diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 7fb90456..28b42d76 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -50,7 +50,10 @@ import { shouldObserveRun } from "@/features/ai-chat/utils/run-polling.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; -import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts"; +import { + exportAiChat, + stopRun, +} from "@/features/ai-chat/services/ai-chat-service.ts"; import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts"; import { shouldCollapseOnOutsidePointer, @@ -256,6 +259,39 @@ export default function AiChatWindow() { setLocalStreaming(streaming); }, []); + // #184 Stop wiring. While a detached run is being stopped we SUPPRESS the + // observer merge so the stopping run's still-persisting output does not + // re-stream back into view between the moment the user pressed Stop and the run + // actually settling as 'aborted' server-side. Polling itself keeps running (so + // the terminal transition is still detected) — only the visual merge is gated. + // Cleared when the run is observed terminal (below) or the chat is switched. + const [stoppingRun, setStoppingRun] = useState(false); + // Reset the stopping latch whenever the open chat changes: it is scoped to the + // run of the previously-open chat. + useEffect(() => { + setStoppingRun(false); + }, [activeChatId]); + + // Authoritative stop of the open chat's detached run (the Stop button in + // autonomous mode). Latch "stopping" first (suppresses the re-stream flash), + // then request the server stop — the ONLY thing that ends a detached run; a mere + // local SSE abort is a client disconnect the server ignores. On failure we + // release the latch so the observer resumes (better to show the live run than to + // freeze the view) and surface the error. + const handleServerStop = useCallback( + (chatId: string): void => { + setStoppingRun(true); + void stopRun(chatId).catch(() => { + setStoppingRun(false); + notifications.show({ + message: t("Failed to stop the run"), + color: "red", + }); + }); + }, + [t], + ); + // Poll the latest run of the open chat ONLY when we are a passive observer: // feature on, a chat is open, and we are NOT the local streamer (the streamer // already has the live SSE — polling/merging too would double-render). The @@ -269,9 +305,10 @@ export default function AiChatWindow() { // but only while we are an observer (never when we are the streamer — guards // against a stale poll fighting the live stream). Includes a terminal run so the // final persisted output is shown on reopen. - const observedRow = shouldObserveRun(run, localStreaming) - ? (runData?.message ?? null) - : null; + const observedRow = + shouldObserveRun(run, localStreaming) && !stoppingRun + ? (runData?.message ?? null) + : null; // When the observed run reaches a terminal status, do a final messages refetch // so the persisted final state (token/context badge, export source) is shown, @@ -285,12 +322,24 @@ export default function AiChatWindow() { finalizedRunIdRef.current = null; return; } + // Terminal: a stop we requested has landed (or the run finished on its own), + // so release the stopping latch — the observer merge can now show the final + // persisted (aborted/finished) output without any live re-stream. + // + // But ONLY release once this tab is no longer the streamer. While + // `localStreaming` is true the run query is disabled, so `run` may be the + // PREVIOUS turn's terminal run held in the react-query cache, not the run we + // just asked to stop. Releasing the latch against that stale run would re-open + // the re-stream flash for the current turn the instant we switch to observer + // role. Gating on `!localStreaming` means the latch only clears against a run + // we are actually observing (the current turn's). + if (stoppingRun && !localStreaming) setStoppingRun(false); if (finalizedRunIdRef.current === run.id) return; finalizedRunIdRef.current = run.id; queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId), }); - }, [run, activeChatId, queryClient]); + }, [run, activeChatId, queryClient, stoppingRun, localStreaming]); // The page the user is currently viewing. AiChatWindow lives in a pathless // parent layout route, so useParams() can't see :pageSlug. Match the full @@ -946,6 +995,12 @@ export default function AiChatWindow() { // while we are the streamer. observedRow={observedRow} onStreamingChange={onStreamingChange} + // #184: in autonomous mode the Stop button must hit the authoritative + // server stop (a local SSE abort is a client disconnect the server + // ignores). onServerStop also arms the "stopping" latch above so the + // stopped run's output does not re-stream via the observer merge. + autonomousRunsEnabled={autonomousRunsEnabled} + onServerStop={handleServerStop} /> )} diff --git a/apps/client/src/features/ai-chat/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index c9a0de20..c0fbbad9 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -100,6 +100,16 @@ interface ChatThreadProps { * polling the run while WE are the active streamer (the SSE owns the view) and * resume once we go idle. Called from an effect on every transition. */ onStreamingChange?: (streaming: boolean) => void; + /** #184: whether detached/autonomous agent runs are enabled for this workspace. + * When true the Stop button must additionally hit the AUTHORITATIVE server stop + * (via onServerStop) — aborting only the local SSE is just a client disconnect, + * which the server deliberately ignores, so the detached run would keep going. */ + autonomousRunsEnabled?: boolean; + /** #184: request the server-side stop of this chat's active run (the parent owns + * the endpoint call + the "stopping" latch that keeps observer-polling from + * immediately re-streaming the stopping run's output). Called with the resolved + * chat id when the user presses Stop in autonomous mode. */ + onServerStop?: (chatId: string) => void; } /** @@ -147,6 +157,8 @@ export default function ChatThread({ onServerChatId, observedRow, onStreamingChange, + autonomousRunsEnabled, + onServerStop, }: ChatThreadProps) { const { t } = useTranslation(); @@ -446,6 +458,22 @@ export default function ChatThread({ [setQueue, stop], ); + // Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer + // returns to idle immediately. In AUTONOMOUS mode the turn is a DETACHED run: + // aborting the local SSE is only a client disconnect, which the server ignores, + // so the run would keep executing — we ADDITIONALLY request the authoritative + // server-side stop (the parent owns that call + the "stopping" latch that keeps + // observer-polling from re-streaming the stopping run's output). The chat id is + // read live from chatIdRef (adopted early at the stream's `start` chunk); if it + // is not known yet — a brand-new chat in the first moment of its first turn — + // only the local abort happens (there is no server-side run handle to stop yet). + const handleStop = useCallback(() => { + stop(); + if (autonomousRunsEnabled && chatIdRef.current) { + onServerStop?.(chatIdRef.current); + } + }, [stop, autonomousRunsEnabled, onServerStop]); + // Clear the stopped marker as soon as a new turn begins streaming, and drop any // stale "Send now" interrupt flags. On the legit interrupt path both refs are // already consumed synchronously (onFinish + prepareSendMessagesRequest) before @@ -576,7 +604,7 @@ export default function ChatThread({ sendMessage({ text })} onQueue={enqueue} - onStop={stop} + onStop={handleStop} isStreaming={isStreaming} /> diff --git a/apps/client/src/features/ai-chat/services/ai-chat-service.ts b/apps/client/src/features/ai-chat/services/ai-chat-service.ts index fba0dfad..7dcf8b42 100644 --- a/apps/client/src/features/ai-chat/services/ai-chat-service.ts +++ b/apps/client/src/features/ai-chat/services/ai-chat-service.ts @@ -60,6 +60,21 @@ export async function getAiChatRun( return req.data; } +/** + * Explicitly STOP the active agent run of a chat (#184). This is the ONLY thing + * that ends a DETACHED run — a mere browser disconnect (aborting the local SSE) + * is deliberately ignored server-side, so the client must call this to actually + * stop an autonomous run. Targeted by `chatId` (the server resolves whatever run + * is active on it); owner-gated server-side. Returns `{ stopped }` — false when + * there was nothing active to stop. + */ +export async function stopRun( + chatId: string, +): Promise<{ stopped: boolean }> { + const req = await api.post<{ stopped: boolean }>("/ai-chat/stop", { chatId }); + return req.data; +} + /** * Resolve the chat bound to a document (the current user's most-recent chat * created on that page), or null when there is none. Drives auto-open-on-page. diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx index 6e7cb185..1a0024ac 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx @@ -394,6 +394,10 @@ export default function AiProviderSettings() { useState( workspace?.settings?.ai?.publicShareAssistant ?? false, ); + // #184: detached/autonomous agent runs (settings.ai.autonomousRuns). + const [autonomousRunsEnabled, setAutonomousRunsEnabled] = useState( + workspace?.settings?.ai?.autonomousRuns ?? false, + ); const [chatToggleLoading, setChatToggleLoading] = useState(false); const [searchToggleLoading, setSearchToggleLoading] = useState(false); const [dictationToggleLoading, setDictationToggleLoading] = useState(false); @@ -403,6 +407,8 @@ export default function AiProviderSettings() { publicShareAssistantToggleLoading, setPublicShareAssistantToggleLoading, ] = useState(false); + const [autonomousRunsToggleLoading, setAutonomousRunsToggleLoading] = + useState(false); // Whether a key is currently stored server-side (drives the placeholder). const [hasApiKey, setHasApiKey] = useState(false); @@ -730,6 +736,37 @@ export default function AiProviderSettings() { } } + // Optimistic toggle for detached/autonomous agent runs + // (settings.ai.autonomousRuns). When on, a chat turn becomes a server-side run + // that survives a browser disconnect and can be reconnected to / live-followed; + // only an explicit Stop ends it. Off by default; single-instance-only in phase 1. + async function handleToggleAutonomousRuns(value: boolean) { + setAutonomousRunsToggleLoading(true); + const previous = autonomousRunsEnabled; + setAutonomousRunsEnabled(value); + try { + const updated = await updateWorkspace({ autonomousRuns: value }); + setWorkspace({ + ...updated, + settings: { + ...updated.settings, + ai: { ...updated.settings?.ai, autonomousRuns: value }, + }, + }); + notifications.show({ message: t("Updated successfully") }); + } catch (err) { + setAutonomousRunsEnabled(previous); + const message = (err as { response?: { data?: { message?: string } } }) + ?.response?.data?.message; + notifications.show({ + message: message ?? t("Failed to update data"), + color: "red", + }); + } finally { + setAutonomousRunsToggleLoading(false); + } + } + // Admins only — match the previous behavior. if (!isAdmin) { return ( @@ -960,6 +997,31 @@ export default function AiProviderSettings() { {...form.getInputProps("publicShareAssistantRoleId")} /> + {/* Detached/autonomous agent runs: a chat turn becomes a server-side run + that survives a browser disconnect; only an explicit Stop ends it. + Single-instance-only in phase 1. */} + + + + {t("Autonomous agent runs")} + + + {t( + "Keep an agent turn running server-side even if the browser disconnects; reconnect and follow it on reopen. Single-instance deployments only.", + )} + + + + handleToggleAutonomousRuns(e.currentTarget.checked) + } + /> + +