diff --git a/src/browser/components/BackgroundBashOutputDialog/BackgroundBashOutputDialog.tsx b/src/browser/components/BackgroundBashOutputDialog/BackgroundBashOutputDialog.tsx index 739df965732..8c44293a3c4 100644 --- a/src/browser/components/BackgroundBashOutputDialog/BackgroundBashOutputDialog.tsx +++ b/src/browser/components/BackgroundBashOutputDialog/BackgroundBashOutputDialog.tsx @@ -1,14 +1,16 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { CopyButton } from "../CopyButton/CopyButton"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../Dialog/Dialog"; import { DetailContent } from "@/browser/features/Tools/Shared/ToolPrimitives"; import { useAPI } from "@/browser/contexts/API"; import { useBackgroundProcesses } from "@/browser/stores/BackgroundBashStore"; +import { isConnectionLostError } from "@/browser/contexts/connectionLink"; import { appendLiveBashOutputChunk, type LiveBashOutputInternal, } from "@/browser/utils/messages/liveBashOutputBuffer"; import { BASH_TRUNCATE_MAX_TOTAL_BYTES } from "@/common/constants/toolLimits"; +import { getErrorMessage } from "@/common/utils/errors"; const BACKGROUND_BASH_INITIAL_TAIL_BYTES = 64_000; const BACKGROUND_BASH_POLL_INTERVAL_MS = 500; @@ -47,11 +49,25 @@ export const BackgroundBashOutputDialog: React.FC - + {/* Keyed by the process, so another process starts from an empty viewer, while a new API + client (a reconnect) only re-runs the viewer's read and keeps the output it shows. */} + ); +/** The output pane: "Loading…" until the first read, then the output or a placeholder. */ +function outputPaneText(state: { isLoading: boolean; text: string; hasError: boolean }): string { + if (state.isLoading) return "Loading…"; + if (state.text.length > 0) return state.text; + // The error shows above the pane; an empty pane under it reads better than "No output yet". + return state.hasError ? "" : "No output yet"; +} + const BackgroundBashOutputViewer: React.FC<{ workspaceId: string; processId: string }> = ( props ) => { @@ -69,19 +85,24 @@ const BackgroundBashOutputViewer: React.FC<{ workspaceId: string; processId: str const [truncatedStart, setTruncatedStart] = useState(false); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); + // True once a read landed: the viewer then shows output (or "No output yet"), not "Loading…". + const hasReadRef = useRef(false); useEffect(() => { - setOutput(undefined); - setStatus("running"); - setTruncatedStart(false); + // No reset of the shown output here: the first read below replaces it. setError(null); - setIsLoading(true); if (!api) { + // Nothing can load without a client: a fresh viewer shows only the error, not a "Loading…" + // that never ends. A viewer that already read keeps its output. setIsLoading(false); setError("API unavailable"); return; } + // Until the first read lands, the viewer shows "Loading…" (also after an offline start). + if (!hasReadRef.current) { + setIsLoading(true); + } let cancelled = false; @@ -109,8 +130,12 @@ const BackgroundBashOutputViewer: React.FC<{ workspaceId: string; processId: str } setStatus(result.data.status); - if (result.data.truncatedStart) { - setTruncatedStart(true); + // The first read is a fresh tail (the only read that can be truncated at its start), so + // it replaces any output kept from an earlier run (a reconnect); later reads append. + if (offset === undefined) { + setTruncatedStart(result.data.truncatedStart); + setOutput(undefined); + hasReadRef.current = true; } offset = result.data.nextOffset; @@ -133,12 +158,18 @@ const BackgroundBashOutputViewer: React.FC<{ workspaceId: string; processId: str } }; - // A rejected read (e.g. a lost server connection) stops polling with the error shown instead - // of escaping as an unhandled rejection; reopening the dialog starts a fresh read. + // A rejected read stops polling with the error shown instead of escaping as an unhandled + // rejection; reopening the dialog starts a fresh read. Only a lost connection + // (ConnectionLostError) shows no error and keeps the output: the next client polls again. A + // server cancel on a live socket is a plain AbortError, and no new client would ever re-run + // this poll, so it shows its error. run().catch((err: unknown) => { if (cancelled) return; + // A viewer that already read keeps its output for the next client. A fresh one (its first + // read cut off) shows the error, not a "Loading…" with nothing behind it. + if (isConnectionLostError(err) && hasReadRef.current) return; setIsLoading(false); - setError(err instanceof Error ? err.message : String(err)); + setError(getErrorMessage(err)); }); return () => { @@ -184,7 +215,7 @@ const BackgroundBashOutputViewer: React.FC<{ workspaceId: string; processId: str min-h keeps a visible pane for "No output yet" while still letting very short windows shrink it instead of clipping it at the dialog edge. */} - {isLoading ? "Loading…" : text.length > 0 ? text : error ? "" : "No output yet"} + {outputPaneText({ isLoading, text, hasError: error != null })} ); diff --git a/src/browser/components/InstructionsTab/AdditionalSystemContextScratchpad.tsx b/src/browser/components/InstructionsTab/AdditionalSystemContextScratchpad.tsx index 000d8daa0ed..243adb0de1b 100644 --- a/src/browser/components/InstructionsTab/AdditionalSystemContextScratchpad.tsx +++ b/src/browser/components/InstructionsTab/AdditionalSystemContextScratchpad.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ArrowUpRight, BookOpen } from "lucide-react"; import { ChatInputDecoration } from "@/browser/components/ChatPane/ChatInputDecoration"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, useLiveAPI } from "@/browser/contexts/API"; import { type AdditionalSystemContextSnapshot, getAdditionalSystemContextFocusGeneration, @@ -14,6 +14,7 @@ import { useAdditionalSystemContextSnapshot, } from "@/browser/utils/additionalSystemContextStore"; import { focusInstructionsTab } from "@/browser/utils/instructionsTabFocus"; +import { isConnectionLostError } from "@/browser/contexts/connectionLink"; import { cn } from "@/common/lib/utils"; import { getErrorMessage } from "@/common/utils/errors"; @@ -40,6 +41,11 @@ interface ScratchpadState { content: string; enabled: boolean; loading: boolean; + /** + * Editing is off while the text loads, and while there is no live connection to save to (the + * editor keeps showing the text). + */ + disabled: boolean; /** True once the save has been in flight longer than the indicator delay. */ savingVisible: boolean; error: string | null; @@ -47,46 +53,40 @@ interface ScratchpadState { setEnabled: (enabled: boolean) => void; } -export function useChatInstructions(workspaceId: string): ScratchpadState { +/** + * Loads the workspace's instructions into the store; returns whether the first load is pending. + * + * A re-run for the shown workspace (a new API client after a reconnect) keeps the editor as it + * is: it does not flash "Loading…" or take focus from the textarea. A local edit the backend has + * not settled (`dirtyRef`) is not replaced by the load. + */ +function useChatInstructionsLoad( + workspaceId: string, + dirtyRef: { current: boolean }, + setError: (error: string | null) => void +): boolean { const { api } = useAPI(); - const snapshot = useAdditionalSystemContextSnapshot(workspaceId); const [loading, setLoading] = useState(true); - const [savingVisible, setSavingVisible] = useState(false); - const [error, setError] = useState(null); - const dirtyRef = useRef(false); - const mountedRef = useRef(true); - const savingTimerRef = useRef | null>(null); - - const clearSavingTimer = () => { - if (savingTimerRef.current != null) { - clearTimeout(savingTimerRef.current); - savingTimerRef.current = null; - } - }; - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - clearSavingTimer(); - }; - }, []); + // The workspace whose instructions the editor shows (loaded, or failed to load). + const loadedWorkspaceIdRef = useRef(null); useEffect(() => { - dirtyRef.current = false; - setLoading(true); - setSavingVisible(false); - clearSavingTimer(); + if (loadedWorkspaceIdRef.current !== workspaceId) { + loadedWorkspaceIdRef.current = null; + dirtyRef.current = false; + setLoading(true); + } setError(null); if (!api) return; const loadVersion = getAdditionalSystemContextVersion(workspaceId); + // Set by the cleanup, which also runs on unmount. let cancelled = false; api.workspace .getAdditionalSystemContext({ workspaceId }) .then((result) => { - if (cancelled || !mountedRef.current) return; + if (cancelled) return; if (!dirtyRef.current && getAdditionalSystemContextVersion(workspaceId) === loadVersion) { updateAdditionalSystemContextSnapshot(workspaceId, { content: result.content, @@ -94,23 +94,64 @@ export function useChatInstructions(workspaceId: string): ScratchpadState { }); } setLoading(false); + loadedWorkspaceIdRef.current = workspaceId; }) .catch((err) => { - if (cancelled || !mountedRef.current) return; + // A lost connection is not a load error: keep the editor as it is for the next client. + if (cancelled || isConnectionLostError(err)) return; setError(getErrorMessage(err)); setLoading(false); + loadedWorkspaceIdRef.current = workspaceId; }); return () => { cancelled = true; }; - }, [api, workspaceId]); + }, [api, workspaceId, dirtyRef, setError]); + + return loading; +} + +export function useChatInstructions(workspaceId: string): ScratchpadState { + // Saves need a live connection. While Xum reconnects the editor keeps showing the text but is + // read-only, so no edit waits for a later client. + const liveApi = useLiveAPI(); + const snapshot = useAdditionalSystemContextSnapshot(workspaceId); + const [savingVisible, setSavingVisible] = useState(false); + const [error, setError] = useState(null); + // An edit the backend has not settled yet: a load must not replace it. A failed save settles + // it too, so a later reload shows what the backend stored. + const dirtyRef = useRef(false); + const mountedRef = useRef(true); + const savingTimerRef = useRef | null>(null); + const loading = useChatInstructionsLoad(workspaceId, dirtyRef, setError); + + const clearSavingTimer = () => { + if (savingTimerRef.current != null) { + clearTimeout(savingTimerRef.current); + savingTimerRef.current = null; + } + }; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + clearSavingTimer(); + }; + }, []); + + // Another workspace starts without the previous one's "Saving…". + useEffect(() => { + setSavingVisible(false); + clearSavingTimer(); + }, [workspaceId]); const queue = useCallback( (next: AdditionalSystemContextSnapshot) => { + if (!liveApi) return; dirtyRef.current = true; updateAdditionalSystemContextSnapshot(workspaceId, next); - if (!api) return; setError(null); // Only show the "Saving…" message if the save takes more than a beat — // sub-second writes shouldn't flicker the UI on every keystroke. @@ -119,19 +160,20 @@ export function useChatInstructions(workspaceId: string): ScratchpadState { if (mountedRef.current) setSavingVisible(true); savingTimerRef.current = null; }, SAVING_INDICATOR_DELAY_MS); - queueAdditionalSystemContextSave(api, workspaceId, next, { + queueAdditionalSystemContextSave(liveApi, workspaceId, next, { onError: (err) => { if (!mountedRef.current) return; setError(getErrorMessage(err)); }, onIdle: () => { + dirtyRef.current = false; if (!mountedRef.current) return; clearSavingTimer(); setSavingVisible(false); }, }); }, - [api, workspaceId] + [liveApi, workspaceId] ); const setContent = useCallback( @@ -152,6 +194,7 @@ export function useChatInstructions(workspaceId: string): ScratchpadState { content: snapshot.content, enabled: snapshot.enabled, loading, + disabled: loading || liveApi == null, savingVisible, error, setContent, @@ -229,7 +272,7 @@ export function ChatInstructionsEditor(props: ChatInstructionsEditorProps) { props.textareaClassName )} aria-label={`${CHAT_INSTRUCTIONS_LABEL} editor`} - disabled={state.loading} + disabled={state.disabled} />
); @@ -365,6 +438,7 @@ function BudgetOverrideRow(props: BudgetOverrideRowProps) { onChange={(event) => setDraft(event.target.value)} onBlur={(event) => commit(event.currentTarget.value)} className="border-border-medium bg-background-secondary h-8 w-20 text-right text-sm" + disabled={props.disabled} /> } @@ -443,6 +517,7 @@ function TurnCapOverrideRow(props: TurnCapOverrideRowProps) { onChange={(event) => setDraft(event.target.value)} onBlur={(event) => commit(event.currentTarget.value)} className="border-border-medium bg-background-secondary h-8 w-20 text-right text-sm" + disabled={props.disabled} /> } /> @@ -479,6 +554,7 @@ function ExplicitBudgetOverrideRow(props: ExplicitBudgetOverrideRowProps) { checked={props.override ?? false} onChange={(event) => props.onChange(event.target.checked)} className="accent-accent h-4 w-4" + disabled={props.disabled} /> {(props.override ?? false) ? "ON" : "OFF"} diff --git a/src/browser/features/RightSidebar/PlanFileDialog.tsx b/src/browser/features/RightSidebar/PlanFileDialog.tsx index 9139e112dff..1a5ea885fc9 100644 --- a/src/browser/features/RightSidebar/PlanFileDialog.tsx +++ b/src/browser/features/RightSidebar/PlanFileDialog.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import assert from "@/common/utils/assert"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, type APIClient } from "@/browser/contexts/API"; import { MarkdownCore } from "@/browser/features/Messages/MarkdownCore"; import { PlanMarkdownContainer } from "@/browser/features/Messages/MarkdownRenderer"; import { @@ -9,6 +9,7 @@ import { DialogHeader, DialogTitle, } from "@/browser/components/Dialog/Dialog"; +import { isConnectionLostError } from "@/browser/contexts/connectionLink"; import { getErrorMessage } from "@/common/utils/errors"; interface PlanFileDialogProps { @@ -17,106 +18,131 @@ interface PlanFileDialogProps { workspaceId: string; } -export const PlanFileDialog: React.FC = (props) => { +export const PlanFileDialog: React.FC = (props) => ( + + + {/* PostCompactionSection renders this dialog while closed: the body, and so its plan + read, mounts only while the dialog is open. Keyed by workspace, so another workspace + (or reopening the dialog) starts from an empty body, while a new API client (a + reconnect) re-reads the plan in the background and keeps it shown. */} + + + +); + +type PlanRead = + | { status: "ok"; content: string; path: string } + | { status: "error"; error: string } + /** Cut off by a lost connection (ConnectionLostError): not a plan error. */ + | { status: "lost" }; + +async function readPlan(api: APIClient, workspaceId: string): Promise { + try { + const result = await api.workspace.getPlanContent({ workspaceId }); + if (!result.success) { + return { status: "error", error: result.error }; + } + assert(result.data.path.length > 0, "Plan path should be non-empty"); + return { status: "ok", content: result.data.content, path: result.data.path }; + } catch (error: unknown) { + return isConnectionLostError(error) + ? { status: "lost" } + : { status: "error", error: getErrorMessage(error) }; + } +} + +function usePlanFile(workspaceId: string) { const { api } = useAPI(); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [content, setContent] = useState(null); const [path, setPath] = useState(null); + // True once a plan is on screen: offline (no client during a reconnect) then keeps it. + const hasPlanRef = useRef(false); useEffect(() => { - // PostCompactionSection renders this dialog while closed. - // Delay IPC plan-file reads until the user explicitly opens the preview. - if (!props.open) { + assert(workspaceId.trim().length > 0, "workspaceId is required to load plan preview"); + + if (!api) { + if (!hasPlanRef.current) { + setIsLoading(false); + setError("API unavailable"); + } return; } - assert(props.workspaceId.trim().length > 0, "workspaceId is required to load plan preview"); - - setIsLoading(true); + // A read with a client replaces the offline error. Without a plan on screen, it shows + // "Loading plan…" (the dialog may have opened while there was no client). setError(null); - setContent(null); - setPath(null); - - if (!api) { - setIsLoading(false); - setError("API unavailable"); - return; + if (!hasPlanRef.current) { + setIsLoading(true); } let cancelled = false; - const run = async () => { - try { - const result = await api.workspace.getPlanContent({ workspaceId: props.workspaceId }); - - if (cancelled) { - return; - } - - if (!result.success) { - setError(result.error); - setIsLoading(false); - return; - } - - assert(result.data.path.length > 0, "Plan path should be non-empty"); - - setContent(result.data.content); - setPath(result.data.path); - setIsLoading(false); - } catch (error: unknown) { - if (cancelled) { - return; - } - - setError(getErrorMessage(error)); - setIsLoading(false); + void readPlan(api, workspaceId).then((read) => { + // A lost connection (or this effect's own cleanup) keeps what is shown for the next client. + if (cancelled || read.status === "lost") { + return; } - }; - - void run(); + setIsLoading(false); + if (read.status === "error") { + setError(read.error); + return; + } + setError(null); + setContent(read.content); + setPath(read.path); + hasPlanRef.current = true; + }); return () => { cancelled = true; }; - }, [api, props.open, props.workspaceId]); + }, [api, workspaceId]); + + return { isLoading, error, content, path }; +} + +function PlanFileBody(props: { workspaceId: string }) { + const { isLoading, error, content, path } = usePlanFile(props.workspaceId); return ( - - - - - Plan file - {path && ( - - {path} - - )} - - - -
- {error ? ( -
- {error} -
- ) : isLoading ? ( -
Loading plan…
- ) : content !== null ? ( - content.length > 0 ? ( - - - - ) : ( -
Plan file is empty
- ) - ) : ( -
No plan loaded
+ <> + + + Plan file + {path && ( + + {path} + )} -
-
-
+ + + +
+ {error ? ( +
+ {error} +
+ ) : isLoading ? ( +
Loading plan…
+ ) : content !== null ? ( + content.length > 0 ? ( + + {/* Keyed by the text: a reload replaces the plan in place, and Streamdown's + elements re-render only when their source position changes, so an edit that + keeps the positions would otherwise stay stale on screen. */} + + + ) : ( +
Plan file is empty
+ ) + ) : ( +
No plan loaded
+ )} +
+ ); -}; +} diff --git a/src/browser/features/RightSidebar/RightSidebar.reconnect.test.tsx b/src/browser/features/RightSidebar/RightSidebar.reconnect.test.tsx new file mode 100644 index 00000000000..0763c412285 --- /dev/null +++ b/src/browser/features/RightSidebar/RightSidebar.reconnect.test.tsx @@ -0,0 +1,115 @@ +// Bootstrap Happy DOM before react-dom evaluates (see MemoryTab.test.tsx). +import "../../../../tests/ui/dom"; + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { installDom } from "../../../../tests/ui/dom"; +import { useAPI, type APIClient } from "@/browser/contexts/API"; +import { + createConnectionHarness, + GAP_MODES, + type ConnectionHarness, +} from "@/browser/contexts/connection.testHarness"; +import type { TestApiOverrides } from "@/browser/testUtils"; +import { useDesktopAvailable, useLlmDebugLogsEnabled } from "./RightSidebar"; + +/* + * The sidebar drops its Debug and Desktop tabs when the setting (or capability) reads false. + * A dropped connection is not such an answer: the last known value stays, so the tabs stay, + * until the next client reads it again. + */ + +/** What the server answers; a test swaps it (or holds it) before a connect. */ +let answerConfig: () => Promise; +let answerCapability: () => Promise; + +function serverOverrides(): TestApiOverrides { + return { + config: { getConfig: () => answerConfig() }, + desktop: { getCapability: () => answerCapability() }, + } as unknown as TestApiOverrides; +} + +let harness: ConnectionHarness; + +function Wrapper(props: { children: ReactNode }) { + const Connection = harness.Wrapper; + return {props.children}; +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} + +async function drop() { + await harness.drop(); + await flush(); +} + +async function reconnect() { + harness.reconnect(); + await flush(); +} + +/** Never answers: the read stays in flight until a drop cuts it off. */ +function pending(): Promise { + return new Promise(() => undefined); +} + +describe.each([...GAP_MODES])("sidebar tab gates across a dropped connection (%s)", (mode) => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + harness = createConnectionHarness(mode, serverOverrides); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("the debug-logs setting keeps its value through a read the drop cut off", async () => { + answerConfig = () => Promise.resolve({ llmDebugLogs: true }); + const view = renderHook(() => useLlmDebugLogsEnabled(useAPI().api), { wrapper: Wrapper }); + await flush(); + expect(view.result.current).toBe(true); + + await drop(); + expect(view.result.current).toBe(true); + + answerConfig = pending; + await reconnect(); + await drop(); + expect(view.result.current).toBe(true); + + answerConfig = () => Promise.resolve({ llmDebugLogs: false }); + await reconnect(); + expect(view.result.current).toBe(false); + }); + + test("the desktop capability keeps its value through a read the drop cut off", async () => { + answerCapability = () => Promise.resolve({ available: true }); + const view = renderHook(() => useDesktopAvailable(useAPI().api, true, "ws-1"), { + wrapper: Wrapper, + }); + await flush(); + expect(view.result.current).toBe(true); + + await drop(); + expect(view.result.current).toBe(true); + + answerCapability = pending; + await reconnect(); + await drop(); + expect(view.result.current).toBe(true); + + answerCapability = () => Promise.resolve({ available: false }); + await reconnect(); + expect(view.result.current).toBe(false); + }); +}); diff --git a/src/browser/features/RightSidebar/RightSidebar.tsx b/src/browser/features/RightSidebar/RightSidebar.tsx index 26731c423ea..b0fe2e174ad 100644 --- a/src/browser/features/RightSidebar/RightSidebar.tsx +++ b/src/browser/features/RightSidebar/RightSidebar.tsx @@ -22,7 +22,7 @@ import { usePersistedState, } from "@/browser/hooks/usePersistedState"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; -import { useAPI, liveClientOf } from "@/browser/contexts/API"; +import { useAPI, liveClientOf, type APIClient } from "@/browser/contexts/API"; import { useWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { @@ -39,6 +39,7 @@ import type { GoalCreateIntent } from "@/browser/features/RightSidebar/GoalTab"; import { usePopoverError } from "@/browser/hooks/usePopoverError"; import { PopoverError } from "@/browser/components/PopoverError/PopoverError"; import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities"; +import { isConnectionLostError } from "@/browser/contexts/connectionLink"; import { getErrorMessage } from "@/common/utils/errors"; import { showFeedbackToast } from "@/browser/utils/feedbackToast"; import { openSideChat } from "@/browser/utils/chatCommands"; @@ -847,6 +848,115 @@ const RegistryTabPanel: React.FC<{ ); }; +/** + * Whether LLM debug logs are on: null until the config is read, so the layout keeps its Debug + * tab until the setting is known. A toggle (the LLM_DEBUG_LOGS_CHANGED event) wins over a config + * read still in flight. + */ +export function useLlmDebugLogsEnabled(api: APIClient | null): boolean | null { + const [llmDebugLogsEnabled, setLlmDebugLogsEnabled] = React.useState(null); + const debugLogsLocalOverrideRef = React.useRef(false); + + React.useEffect(() => { + // Without a client (a reconnect), keep the last known value: the layout keeps its tabs. + if (!api) { + return; + } + + // Reset local override so a fresh config fetch can set the initial value. + debugLogsLocalOverrideRef.current = false; + let cancelled = false; + + void api.config + .getConfig() + .then((cfg) => { + if (cancelled) { + return; + } + + // If the user toggled debug logs while this config fetch was in flight, + // the event listener has already set the authoritative value. + if (debugLogsLocalOverrideRef.current) { + return; + } + + setLlmDebugLogsEnabled(cfg.llmDebugLogs === true); + }) + .catch((error: unknown) => { + // A lost connection says nothing about the setting: keep the Debug tab for the next client. + if (cancelled || isConnectionLostError(error)) { + return; + } + + setLlmDebugLogsEnabled(false); + }); + + return () => { + cancelled = true; + }; + }, [api]); + + React.useEffect(() => { + const handleLlmDebugLogsChanged = (event: Event) => { + const detail = (event as CustomEvent<{ enabled: boolean }>).detail; + debugLogsLocalOverrideRef.current = true; + setLlmDebugLogsEnabled(detail?.enabled === true); + }; + + window.addEventListener(CUSTOM_EVENTS.LLM_DEBUG_LOGS_CHANGED, handleLlmDebugLogsChanged); + return () => + window.removeEventListener(CUSTOM_EVENTS.LLM_DEBUG_LOGS_CHANGED, handleLlmDebugLogsChanged); + }, []); + + return llmDebugLogsEnabled; +} + +/** + * Whether the workspace can show a Desktop tab: null while unknown, so the layout keeps the tab. + */ +export function useDesktopAvailable( + api: APIClient | null, + desktopExperimentEnabled: boolean, + workspaceId: string +): boolean | null { + const [desktopAvailable, setDesktopAvailable] = React.useState(null); + + React.useEffect(() => { + if (!desktopExperimentEnabled) { + setDesktopAvailable(false); + return; + } + + // A degraded (slow) connection still has a usable api; only a missing api means offline. + // Offline keeps the last known value, so the layout keeps its Desktop tab. + if (!api) { + return; + } + + let cancelled = false; + + void api.desktop + .getCapability({ workspaceId }) + .then((capability) => { + if (!cancelled) { + setDesktopAvailable(capability.available); + } + }) + .catch((error: unknown) => { + // A lost connection is not a missing capability: keep the Desktop tab for the next client. + if (!cancelled && !isConnectionLostError(error)) { + setDesktopAvailable(false); + } + }); + + return () => { + cancelled = true; + }; + }, [api, desktopExperimentEnabled, workspaceId]); + + return desktopAvailable; +} + const RightSidebarComponent: React.FC = ({ workspaceId, workspacePath, @@ -942,9 +1052,8 @@ const RightSidebarComponent: React.FC = ({ // Opening the tab by shortcut leaves focus where it was (usually the chat input), so the // panel claims focus once it renders, like terminals do with autoFocusTerminalSession. const [autoFocusArtifacts, setAutoFocusArtifacts] = React.useState(false); - const [llmDebugLogsEnabled, setLlmDebugLogsEnabled] = React.useState(null); - const [desktopAvailable, setDesktopAvailable] = React.useState(null); - const debugLogsLocalOverrideRef = React.useRef(false); + const llmDebugLogsEnabled = useLlmDebugLogsEnabled(api); + const desktopAvailable = useDesktopAvailable(api, desktopExperimentEnabled, workspaceId); const setDisplayedGoal = async (intent: { // RightSidebar buttons only ever request user-facing transitions; @@ -1045,56 +1154,6 @@ const RightSidebarComponent: React.FC = ({ }); }; - React.useEffect(() => { - if (!api) { - setLlmDebugLogsEnabled(null); - return; - } - - // Reset local override so a fresh config fetch can set the initial value. - debugLogsLocalOverrideRef.current = false; - let cancelled = false; - - void api.config - .getConfig() - .then((cfg) => { - if (cancelled) { - return; - } - - // If the user toggled debug logs while this config fetch was in flight, - // the event listener has already set the authoritative value. - if (debugLogsLocalOverrideRef.current) { - return; - } - - setLlmDebugLogsEnabled(cfg.llmDebugLogs === true); - }) - .catch(() => { - if (cancelled) { - return; - } - - setLlmDebugLogsEnabled(false); - }); - - return () => { - cancelled = true; - }; - }, [api]); - - React.useEffect(() => { - const handleLlmDebugLogsChanged = (event: Event) => { - const detail = (event as CustomEvent<{ enabled: boolean }>).detail; - debugLogsLocalOverrideRef.current = true; - setLlmDebugLogsEnabled(detail?.enabled === true); - }; - - window.addEventListener(CUSTOM_EVENTS.LLM_DEBUG_LOGS_CHANGED, handleLlmDebugLogsChanged); - return () => - window.removeEventListener(CUSTOM_EVENTS.LLM_DEBUG_LOGS_CHANGED, handleLlmDebugLogsChanged); - }, []); - // A tool explicitly requested for new layouts (RIGHT_SIDEBAR_TAB_KEY). Without one, a // workspace starts with just the New tab, which guides the user to the tools. // Not gated on canReviewDiffs: that is false until workspace metadata loads, and a "review" @@ -1224,38 +1283,6 @@ const RightSidebarComponent: React.FC = ({ setLayoutRaw, }); - React.useEffect(() => { - if (!desktopExperimentEnabled) { - setDesktopAvailable(false); - return; - } - - // A degraded (slow) connection still has a usable api; only a missing api means offline. - if (!api) { - setDesktopAvailable(null); - return; - } - - let cancelled = false; - - void api.desktop - .getCapability({ workspaceId }) - .then((capability) => { - if (!cancelled) { - setDesktopAvailable(capability.available); - } - }) - .catch(() => { - if (!cancelled) { - setDesktopAvailable(false); - } - }); - - return () => { - cancelled = true; - }; - }, [api, desktopExperimentEnabled, workspaceId]); - React.useEffect(() => { if (desktopAvailable == null) { return; diff --git a/src/browser/features/RightSidebar/Workflows/WorkflowEmptyState.tsx b/src/browser/features/RightSidebar/Workflows/WorkflowEmptyState.tsx index 52bc6b34921..484a23a3a97 100644 --- a/src/browser/features/RightSidebar/Workflows/WorkflowEmptyState.tsx +++ b/src/browser/features/RightSidebar/Workflows/WorkflowEmptyState.tsx @@ -140,6 +140,20 @@ interface WorkflowEmptyStateProps { // Keyed by scriptPath (unique) rather than descriptor.name, which can collide across skill // workflows that omit meta.name (both normalize to "workflow"). busyScriptPath: string | null; + /** No live connection: nothing can start, so Run and Start are disabled. */ + offline?: boolean; +} + +/** Run is disabled for a blocked script, while another script starts, and while offline. */ +function isRunDisabled( + script: AvailableWorkflow, + busyScriptPath: string | null, + offline: boolean +): boolean { + if (offline || !script.descriptor.executable) { + return true; + } + return busyScriptPath != null && busyScriptPath !== script.scriptPath; } /** @@ -149,6 +163,7 @@ interface WorkflowEmptyStateProps { */ export const WorkflowEmptyState: React.FC = (props) => { const [configuring, setConfiguring] = React.useState(null); + const offline = props.offline === true; return (
@@ -225,9 +240,7 @@ export const WorkflowEmptyState: React.FC = (props) =>
) : run.status === "interrupted" ? ( <> - {canRerun && ( - )} @@ -239,12 +242,12 @@ export const WorkflowRunHeader: React.FC = (props) => { ) : ( <> {canRetry && ( - )} {canRerun && ( - )} diff --git a/src/browser/features/RightSidebar/Workflows/WorkflowsTab.tsx b/src/browser/features/RightSidebar/Workflows/WorkflowsTab.tsx index 075bd355a7b..990b30ed08c 100644 --- a/src/browser/features/RightSidebar/Workflows/WorkflowsTab.tsx +++ b/src/browser/features/RightSidebar/Workflows/WorkflowsTab.tsx @@ -1,7 +1,7 @@ import React from "react"; import { ChevronLeft } from "lucide-react"; -import { useAPI } from "@/browser/contexts/API"; +import { useAPI, useLiveAPI } from "@/browser/contexts/API"; import type { AvailableWorkflow } from "@/common/types/workflow"; import { WorkflowEmptyState } from "./WorkflowEmptyState"; @@ -12,31 +12,37 @@ import { projectWorkflowRun, selectPrimaryWorkflowRun } from "./projectWorkflowR import { useWorkflowRuns } from "./useWorkflowRuns"; /** - * Right-sidebar Workflows tab: a live, scannable view of the workspace's - * durable workflow runs. Focuses the most recent active run by default; the - * run-history list lets the user pin any prior run into focus. + * The empty state's script launcher for one workspace: the runnable scripts, the script being + * started, and the last start error. */ -export const WorkflowsTab: React.FC<{ workspaceId: string }> = (props) => { +function useWorkflowLauncher(workspaceId: string, onStarted: () => void) { const { api } = useAPI(); - const { runs, loading, error } = useWorkflowRuns(props.workspaceId); - const [overrideRunId, setOverrideRunId] = React.useState(null); + // Starting a run needs a live connection. While Xum reconnects, the list stays on screen but + // its Run buttons are disabled (`offline`), so a click never silently does nothing. + const liveApi = useLiveAPI(); const [scripts, setScripts] = React.useState([]); const [busyScriptPath, setBusyScriptPath] = React.useState(null); const [runError, setRunError] = React.useState(null); + // The workspace the launcher state belongs to. + const launcherWorkspaceIdRef = React.useRef(null); // Discover runnable scripts for the empty-state list (one per workspace). React.useEffect(() => { // Reset launcher state first so a workspace switch never shows or launches a stale script // from the previous workspace — including if the new fetch is still pending or fails. - setScripts([]); - setBusyScriptPath(null); - setRunError(null); + // A re-run for the same workspace (a new API client after a reconnect) keeps the list. + if (launcherWorkspaceIdRef.current !== workspaceId) { + launcherWorkspaceIdRef.current = workspaceId; + setScripts([]); + setBusyScriptPath(null); + setRunError(null); + } if (api == null) { return; } let ignore = false; api.workflows - .listScripts({ workspaceId: props.workspaceId }) + .listScripts({ workspaceId }) .then((result) => { if (!ignore) { setScripts(result); @@ -48,24 +54,24 @@ export const WorkflowsTab: React.FC<{ workspaceId: string }> = (props) => { return () => { ignore = true; }; - }, [api, props.workspaceId]); + }, [api, workspaceId]); const runScript = async (script: AvailableWorkflow, args: Record) => { - if (api == null || busyScriptPath != null) { + if (liveApi == null || busyScriptPath != null) { return; } setBusyScriptPath(script.scriptPath); setRunError(null); try { - await api.workflows.start({ - workspaceId: props.workspaceId, + await liveApi.workflows.start({ + workspaceId, scriptPath: script.scriptPath, args, // Background the launch so the call returns immediately; the run then streams // into the tab via the subscription and becomes the focused run. runInBackground: true, }); - setOverrideRunId(null); + onStarted(); } catch (startError) { setRunError(startError instanceof Error ? startError.message : "Failed to start workflow"); } finally { @@ -73,6 +79,22 @@ export const WorkflowsTab: React.FC<{ workspaceId: string }> = (props) => { } }; + return { scripts, busyScriptPath, runError, runScript, offline: liveApi == null }; +} + +/** + * Right-sidebar Workflows tab: a live, scannable view of the workspace's + * durable workflow runs. Focuses the most recent active run by default; the + * run-history list lets the user pin any prior run into focus. + */ +export const WorkflowsTab: React.FC<{ workspaceId: string }> = (props) => { + const { runs, loading, error } = useWorkflowRuns(props.workspaceId); + const [overrideRunId, setOverrideRunId] = React.useState(null); + const { scripts, busyScriptPath, runError, runScript, offline } = useWorkflowLauncher( + props.workspaceId, + () => setOverrideRunId(null) + ); + if (error != null) { return
Failed to load workflows: {error}
; } @@ -89,6 +111,7 @@ export const WorkflowsTab: React.FC<{ workspaceId: string }> = (props) => { scripts={scripts} onRun={(script, args) => void runScript(script, args)} busyScriptPath={busyScriptPath} + offline={offline} /> ); diff --git a/src/browser/features/RightSidebar/Workflows/useWorkflowRuns.ts b/src/browser/features/RightSidebar/Workflows/useWorkflowRuns.ts index 93db8ec6b34..44964c25a1d 100644 --- a/src/browser/features/RightSidebar/Workflows/useWorkflowRuns.ts +++ b/src/browser/features/RightSidebar/Workflows/useWorkflowRuns.ts @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { APIContext, useAPI, type APIClient } from "@/browser/contexts/API"; import { isAbortError } from "@/browser/utils/isAbortError"; @@ -23,6 +23,29 @@ function recency(run: WorkflowRunRecord): number { return Number.isFinite(value) ? value : 0; } +/** + * The state update for one feed event: a snapshot replaces the shown runs, a delta upserts one + * run. Called as the event arrives, so an unknown event throws into the feed's error handling. + */ +function workflowRunsUpdate( + event: WorkflowRunStreamEvent +): (previous: Map) => Map { + switch (event.type) { + case "snapshot": { + const snapshot = new Map(event.runs.map((run) => [run.id, run])); + return () => snapshot; + } + case "run-changed": + return (previous) => { + const next = new Map(previous); + next.set(event.run.id, event.run); + return next; + }; + default: + return assertNever(event); + } +} + /** * Live workflow-run feed for one workspace. Subscribes to `workflows.subscribe` * (snapshot + per-write deltas) and keeps a local id→record map in sync, so the @@ -34,16 +57,24 @@ export function useWorkflowRuns(workspaceId: string): UseWorkflowRunsResult { const [runsById, setRunsById] = useState>(new Map()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // The workspace whose runs are shown. Only another workspace clears them: a re-run for the same + // one (no client during a reconnect, then a new client) keeps them, and the empty state, until + // the new snapshot replaces them. + const shownWorkspaceIdRef = useRef(null); useEffect(() => { - if (!api) { + if (shownWorkspaceIdRef.current !== workspaceId) { + shownWorkspaceIdRef.current = workspaceId; setRunsById(new Map()); - setLoading(false); + setLoading(true); + } + + // Without a client nothing is loaded yet, so `loading` stays as it is: still true before the + // first snapshot of this workspace (the tab must not show its empty state, then flip to runs). + if (!api) { return; } - setRunsById(new Map()); - setLoading(true); setError(null); const controller = new AbortController(); @@ -62,20 +93,9 @@ export function useWorkflowRuns(workspaceId: string): UseWorkflowRunsResult { if (signal.aborted) { break; } - switch (event.type) { - case "snapshot": - setRunsById(new Map(event.runs.map((run) => [run.id, run]))); - setLoading(false); - break; - case "run-changed": - setRunsById((previous) => { - const next = new Map(previous); - next.set(event.run.id, event.run); - return next; - }); - break; - default: - assertNever(event); + setRunsById(workflowRunsUpdate(event)); + if (event.type === "snapshot") { + setLoading(false); } } }; diff --git a/src/browser/features/RightSidebar/dialogsReconnect.test.tsx b/src/browser/features/RightSidebar/dialogsReconnect.test.tsx new file mode 100644 index 00000000000..94da9846383 --- /dev/null +++ b/src/browser/features/RightSidebar/dialogsReconnect.test.tsx @@ -0,0 +1,292 @@ +// Bootstrap Happy DOM before react-dom evaluates (see MemoryTab.test.tsx). +import "../../../../tests/ui/dom"; + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { installDom } from "../../../../tests/ui/dom"; +import { BackgroundBashOutputDialog } from "@/browser/components/BackgroundBashOutputDialog/BackgroundBashOutputDialog"; +import type { APIClient } from "@/browser/contexts/API"; +import { + createConnectionHarness, + GAP_MODES, + type ConnectionHarness, +} from "@/browser/contexts/connection.testHarness"; +import { SubagentTranscriptDialog } from "@/browser/features/Tools/SubagentTranscriptDialog"; +import type { TestApiOverrides } from "@/browser/testUtils"; +import { PlanFileDialog } from "./PlanFileDialog"; + +/* + * A dialog that shows loaded content keeps it across a dropped connection: the new client + * reloads it in the background and replaces it only when the read lands. Opening another + * workspace, task or process still clears it. + */ + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +/** What each server procedure answers; a test swaps them before a reconnect. */ +let answerPlan: () => Promise; +let answerTranscript: () => Promise; +let answerOutput: () => Promise; + +function planResult(content: string) { + return { success: true as const, data: { content, path: "/plans/plan.md" } }; +} + +function transcriptResult(thinkingLevel: "low" | "high") { + return { messages: [], thinkingLevel }; +} + +function outputResult(output: string) { + return { + success: true as const, + data: { status: "exited" as const, output, nextOffset: output.length, truncatedStart: false }, + }; +} + +function serverOverrides(): TestApiOverrides { + return { + workspace: { + getPlanContent: () => answerPlan(), + getSubagentTranscript: () => answerTranscript(), + backgroundBashes: { getOutput: () => answerOutput() }, + }, + } as unknown as TestApiOverrides; +} + +let harness: ConnectionHarness; + +function Wrapper(props: { children: ReactNode }) { + const Connection = harness.Wrapper; + return {props.children}; +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} + +/** Drop the connection and wait until the dialog settled on it. */ +async function drop() { + await harness.drop(); + await flush(); +} + +async function reconnect() { + harness.reconnect(); + await flush(); +} + +function noop() { + return undefined; +} + +describe.each([...GAP_MODES])("dialogs across a dropped connection (%s)", (mode) => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + harness = createConnectionHarness(mode, serverOverrides); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("the plan dialog keeps the shown plan until the new client's read lands", async () => { + answerPlan = () => Promise.resolve(planResult("Plan A")); + const view = render(, { + wrapper: Wrapper, + }); + await view.findByText("Plan A"); + + await drop(); + expect(view.queryByText("Plan A")).not.toBeNull(); + expect(view.queryByText("API unavailable")).toBeNull(); + + // The new client's read is still in flight, and is then cut off by a second drop. + const pending = deferred(); + answerPlan = () => pending.promise; + await reconnect(); + expect(view.queryByText("Plan A")).not.toBeNull(); + expect(view.queryByText("Loading plan…")).toBeNull(); + await drop(); + expect(view.queryByText("Plan A")).not.toBeNull(); + expect(view.queryByTestId("plan-file-dialog-error")).toBeNull(); + + const next = deferred(); + answerPlan = () => next.promise; + await reconnect(); + expect(view.queryByText("Plan A")).not.toBeNull(); + next.resolve(planResult("Plan B")); + await view.findByText("Plan B"); + expect(view.queryByText("Plan A")).toBeNull(); + }); + + test("the transcript dialog keeps the shown transcript until the new client's read lands", async () => { + answerTranscript = () => Promise.resolve(transcriptResult("high")); + const view = render(, { + wrapper: Wrapper, + }); + await view.findByText("thinking: high"); + expect(view.queryByText("Transcript is empty")).not.toBeNull(); + + // Offline may show an error box, but above the transcript, which stays. + await drop(); + expect(view.queryByText("thinking: high")).not.toBeNull(); + expect(view.queryByText("Transcript is empty")).not.toBeNull(); + + const pending = deferred(); + answerTranscript = () => pending.promise; + await reconnect(); + expect(view.queryByText("thinking: high")).not.toBeNull(); + expect(view.queryByText("Transcript is empty")).not.toBeNull(); + await drop(); + expect(view.queryByText("Transcript is empty")).not.toBeNull(); + expect(view.queryByText(/WebSocket|connection/i)).toBeNull(); + + const next = deferred(); + answerTranscript = () => next.promise; + await reconnect(); + next.resolve(transcriptResult("low")); + await view.findByText("thinking: low"); + }); + + test("the background process dialog keeps the shown output until the new client's read lands", async () => { + answerOutput = () => Promise.resolve(outputResult("line A")); + const view = render( + , + { wrapper: Wrapper } + ); + await view.findByText("line A"); + + await drop(); + expect(view.queryByText("line A")).not.toBeNull(); + + const pending = deferred(); + answerOutput = () => pending.promise; + await reconnect(); + expect(view.queryByText("line A")).not.toBeNull(); + expect(view.queryByText("Loading…")).toBeNull(); + await drop(); + expect(view.queryByText("line A")).not.toBeNull(); + expect(view.queryByText(/WebSocket|connection/i)).toBeNull(); + + // The new read is a fresh tail: it replaces the kept output instead of appending to it. + const next = deferred(); + answerOutput = () => next.promise; + await reconnect(); + next.resolve(outputResult("line B")); + await view.findByText("line B"); + expect(view.queryByText(/line A/)).toBeNull(); + }); +}); + +describe.each([...GAP_MODES])("dialogs opened while reconnecting (%s)", (mode) => { + let cleanupDom: (() => void) | null = null; + + beforeEach(async () => { + cleanupDom = installDom(); + harness = createConnectionHarness(mode, serverOverrides); + // The dialog opens during the gap: nothing is shown yet. + await harness.drop(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("the plan dialog shows its loading state once a client reads the plan", async () => { + answerPlan = () => Promise.resolve(planResult("unused")); + const view = render(, { + wrapper: Wrapper, + }); + await flush(); + + const next = deferred(); + answerPlan = () => next.promise; + await reconnect(); + expect(view.queryByText("Loading plan…")).not.toBeNull(); + expect(view.queryByTestId("plan-file-dialog-error")).toBeNull(); + + next.resolve(planResult("Plan A")); + await view.findByText("Plan A"); + }); + + test("the transcript dialog shows its loading state once a client reads the transcript", async () => { + answerTranscript = () => Promise.resolve(transcriptResult("low")); + const view = render(, { + wrapper: Wrapper, + }); + await flush(); + + const next = deferred(); + answerTranscript = () => next.promise; + await reconnect(); + expect(view.queryByText(/Loading transcript/)).not.toBeNull(); + expect(view.queryByText("No transcript loaded")).toBeNull(); + + next.resolve(transcriptResult("high")); + await view.findByText("thinking: high"); + }); +}); + +describe("dialogs opened on other data", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + harness = createConnectionHarness("kept-gap", serverOverrides); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("another workspace clears the shown plan while its own plan loads", async () => { + answerPlan = () => Promise.resolve(planResult("Plan A")); + const view = render(, { + wrapper: Wrapper, + }); + await view.findByText("Plan A"); + + const pending = deferred(); + answerPlan = () => pending.promise; + view.rerender(); + await waitFor(() => expect(view.queryByText("Loading plan…")).not.toBeNull()); + expect(view.queryByText("Plan A")).toBeNull(); + }); + + test("another process clears the shown output while its own output loads", async () => { + answerOutput = () => Promise.resolve(outputResult("line A")); + const view = render( + , + { wrapper: Wrapper } + ); + await view.findByText("line A"); + + const pending = deferred(); + answerOutput = () => pending.promise; + view.rerender( + + ); + await waitFor(() => expect(view.queryByText("Loading…")).not.toBeNull()); + expect(view.queryByText("line A")).toBeNull(); + }); +}); diff --git a/src/browser/features/RightSidebar/feedsReconnect.test.tsx b/src/browser/features/RightSidebar/feedsReconnect.test.tsx new file mode 100644 index 00000000000..c94b40910b8 --- /dev/null +++ b/src/browser/features/RightSidebar/feedsReconnect.test.tsx @@ -0,0 +1,264 @@ +// Bootstrap Happy DOM before react-dom evaluates (see MemoryTab.test.tsx). +import "../../../../tests/ui/dom"; + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { installDom } from "../../../../tests/ui/dom"; +import type { APIClient } from "@/browser/contexts/API"; +import { + createConnectionHarness, + GAP_MODES, + type ConnectionHarness, +} from "@/browser/contexts/connection.testHarness"; +import type { TestApiOverrides } from "@/browser/testUtils"; +import type { DevToolsRunSummary } from "@/common/types/devtools"; +import type { AvailableWorkflow, WorkflowRunRecord } from "@/common/types/workflow"; +import { useDevToolsSubscription } from "./DevToolsTab/useDevToolsSubscription"; +import { useWorkflowRuns } from "./Workflows/useWorkflowRuns"; +import { WorkflowsTab } from "./Workflows/WorkflowsTab"; + +/* + * Live feeds (a snapshot, then deltas) keep what they show across a dropped connection: the new + * client's snapshot replaces it when it arrives. Another workspace still clears it. + */ + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +/** The ids each feed's next snapshot carries; a test swaps them (or holds them) before a connect. */ +let devToolsSnapshot: () => Promise; +let workflowSnapshot: () => Promise; +let scriptNames: () => Promise; + +/** A stream that sends one snapshot, then stays open until the connection drops. */ +function snapshotStream(snapshot: Promise) { + return Promise.resolve( + (async function* () { + yield await snapshot; + await new Promise(() => undefined); + })() + ); +} + +function devToolsRun(id: string): DevToolsRunSummary { + return { id } as unknown as DevToolsRunSummary; +} + +function workflowRun(id: string): WorkflowRunRecord { + return { id, updatedAt: "2026-01-01T00:00:00.000Z" } as unknown as WorkflowRunRecord; +} + +function script(name: string): AvailableWorkflow { + const workflow: AvailableWorkflow = { + descriptor: { name, description: `${name} script`, scope: "project", executable: true }, + scriptPath: `skill://${name}/workflow.js`, + args: [], + }; + return workflow; +} + +function serverOverrides(): TestApiOverrides { + return { + devtools: { + subscribe: () => + snapshotStream( + devToolsSnapshot().then((ids) => ({ type: "snapshot", runs: ids.map(devToolsRun) })) + ), + }, + workflows: { + subscribe: () => + snapshotStream( + workflowSnapshot().then((ids) => ({ type: "snapshot", runs: ids.map(workflowRun) })) + ), + listScripts: () => scriptNames().then((names) => names.map(script)), + }, + } as unknown as TestApiOverrides; +} + +let harness: ConnectionHarness; + +function Wrapper(props: { children: ReactNode }) { + const Connection = harness.Wrapper; + return {props.children}; +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} + +async function drop() { + await harness.drop(); + await flush(); +} + +async function reconnect() { + harness.reconnect(); + await flush(); +} + +function ids(runs: Array<{ id: string }>): string[] { + return runs.map((run) => run.id); +} + +describe.each([...GAP_MODES])("live feeds across a dropped connection (%s)", (mode) => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + harness = createConnectionHarness(mode, serverOverrides); + devToolsSnapshot = () => Promise.resolve(["run-a"]); + workflowSnapshot = () => Promise.resolve(["run-a"]); + scriptNames = () => Promise.resolve(["deploy"]); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("the DevTools feed keeps the shown runs until the new snapshot", async () => { + const view = renderHook(() => useDevToolsSubscription("ws-1"), { wrapper: Wrapper }); + await waitFor(() => expect(ids(view.result.current.runs)).toEqual(["run-a"])); + + await drop(); + expect(ids(view.result.current.runs)).toEqual(["run-a"]); + + const next = deferred(); + devToolsSnapshot = () => next.promise; + await reconnect(); + expect(ids(view.result.current.runs)).toEqual(["run-a"]); + + next.resolve(["run-b"]); + await waitFor(() => expect(ids(view.result.current.runs)).toEqual(["run-b"])); + }); + + test("the workflow feed keeps the shown runs until the new snapshot", async () => { + const view = renderHook(() => useWorkflowRuns("ws-1"), { wrapper: Wrapper }); + await waitFor(() => expect(ids(view.result.current.runs)).toEqual(["run-a"])); + + await drop(); + expect(ids(view.result.current.runs)).toEqual(["run-a"]); + + const next = deferred(); + workflowSnapshot = () => next.promise; + await reconnect(); + expect(ids(view.result.current.runs)).toEqual(["run-a"]); + expect(view.result.current.loading).toBe(false); + + next.resolve(["run-b"]); + await waitFor(() => expect(ids(view.result.current.runs)).toEqual(["run-b"])); + }); + + test("the Workflows tab keeps its empty state and script list while it reloads", async () => { + workflowSnapshot = () => Promise.resolve([]); + const view = render(, { wrapper: Wrapper }); + await view.findByText("deploy"); + + await drop(); + expect(view.queryByText("deploy")).not.toBeNull(); + + const nextRuns = deferred(); + const nextScripts = deferred(); + workflowSnapshot = () => nextRuns.promise; + scriptNames = () => nextScripts.promise; + await reconnect(); + expect(view.queryByText("deploy")).not.toBeNull(); + expect(view.queryByText("Loading workflow runs…")).toBeNull(); + + nextRuns.resolve([]); + nextScripts.resolve(["release"]); + await view.findByText("release"); + expect(view.queryByText("deploy")).toBeNull(); + }); +}); + +describe.each([...GAP_MODES])("the Workflows tab opened while reconnecting (%s)", (mode) => { + let cleanupDom: (() => void) | null = null; + + beforeEach(async () => { + cleanupDom = installDom(); + harness = createConnectionHarness(mode, serverOverrides); + scriptNames = () => Promise.resolve(["deploy"]); + // The tab opens during the gap: no snapshot has landed yet. + await harness.drop(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("shows its loading state, not the empty state, until the first snapshot lands", async () => { + workflowSnapshot = () => Promise.resolve([]); + const view = render(, { wrapper: Wrapper }); + await flush(); + + const next = deferred(); + workflowSnapshot = () => next.promise; + await reconnect(); + expect(view.queryByText("Loading workflow runs…")).not.toBeNull(); + expect(view.queryByText("deploy")).toBeNull(); + + next.resolve([]); + await view.findByText("deploy"); + expect(view.queryByText("Loading workflow runs…")).toBeNull(); + }); +}); + +describe("live feeds of another workspace", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + harness = createConnectionHarness("kept-gap", serverOverrides); + devToolsSnapshot = () => Promise.resolve(["run-a"]); + workflowSnapshot = () => Promise.resolve(["run-a"]); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("another workspace clears the shown runs while its own snapshot loads", async () => { + const devTools = renderHook( + (props: { workspaceId: string }) => useDevToolsSubscription(props.workspaceId), + { + wrapper: Wrapper, + initialProps: { workspaceId: "ws-1" }, + } + ); + const workflows = renderHook( + (props: { workspaceId: string }) => useWorkflowRuns(props.workspaceId), + { + wrapper: Wrapper, + initialProps: { workspaceId: "ws-1" }, + } + ); + await waitFor(() => expect(ids(devTools.result.current.runs)).toEqual(["run-a"])); + await waitFor(() => expect(ids(workflows.result.current.runs)).toEqual(["run-a"])); + + devToolsSnapshot = () => new Promise(() => undefined); + workflowSnapshot = () => new Promise(() => undefined); + devTools.rerender({ workspaceId: "ws-2" }); + workflows.rerender({ workspaceId: "ws-2" }); + await flush(); + expect(ids(devTools.result.current.runs)).toEqual([]); + expect(ids(workflows.result.current.runs)).toEqual([]); + expect(workflows.result.current.loading).toBe(true); + }); +}); diff --git a/src/browser/features/RightSidebar/loadersReconnect.test.tsx b/src/browser/features/RightSidebar/loadersReconnect.test.tsx new file mode 100644 index 00000000000..c0eca4a12c7 --- /dev/null +++ b/src/browser/features/RightSidebar/loadersReconnect.test.tsx @@ -0,0 +1,364 @@ +// Bootstrap Happy DOM before react-dom evaluates (see MemoryTab.test.tsx). +import "../../../../tests/ui/dom"; + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, cleanup, fireEvent, render, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { installDom } from "../../../../tests/ui/dom"; +import { useChatInstructions } from "@/browser/components/InstructionsTab/AdditionalSystemContextScratchpad"; +import { InstructionsTab } from "@/browser/components/InstructionsTab/InstructionsTab"; +import { ProjectMCPOverview } from "@/browser/components/ProjectMCPOverview/ProjectMCPOverview"; +import type { APIClient } from "@/browser/contexts/API"; +import { + createConnectionHarness, + GAP_MODES, + type ConnectionHarness, +} from "@/browser/contexts/connection.testHarness"; +import { RouterProvider } from "@/browser/contexts/RouterContext"; +import { SettingsProvider } from "@/browser/contexts/SettingsContext"; +import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; +import type { TestApiOverrides } from "@/browser/testUtils"; +import { GoalDefaultsSection } from "./GoalDefaultsSection"; +import { useGoalBoard, type UseGoalBoardResult } from "./useGoalBoard"; + +/* + * Panes that load once per workspace or project keep what they show across a dropped + * connection: the new client reloads in the background, a read cut off by the drop changes + * nothing, and only the read that lands replaces the shown data. + */ + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +/** What each server procedure answers; a test swaps them (or holds them) before a connect. */ +let answerBoard: () => Promise; +let answerServers: () => Promise; +let answerInstructions: () => Promise; +let answerChatInstructions: () => Promise; +let answerOverride: () => Promise; +let savedOverrides: unknown[] = []; +let savedChatInstructions: Array<{ content: string; enabled: boolean }> = []; +let failChatSave: Error | null = null; + +function board(goalId: string) { + return { entries: [{ section: "upcoming", goal: { goalId } }] }; +} + +function servers(name: string) { + return { [name]: { transport: "stdio", command: "run", disabled: false } }; +} + +function turnCapOverride(turnCap: number) { + return { defaultBudgetCents: null, defaultTurnCap: turnCap, alwaysRequireExplicitBudget: null }; +} + +function serverOverrides(): TestApiOverrides { + return { + workspace: { + getGoalBoard: () => answerBoard(), + getInstructions: () => answerInstructions(), + getAdditionalSystemContext: () => answerChatInstructions(), + setAdditionalSystemContext: (input: { content: string; enabled: boolean }) => { + if (failChatSave) return Promise.reject(failChatSave); + savedChatInstructions.push({ content: input.content, enabled: input.enabled }); + return Promise.resolve({ content: input.content, enabled: input.enabled }); + }, + goalDefaults: { + get: () => answerOverride(), + set: (input: unknown) => { + savedOverrides.push(input); + return Promise.resolve(); + }, + }, + }, + mcp: { list: () => answerServers() }, + config: { getConfig: () => Promise.resolve({}) }, + } as unknown as TestApiOverrides; +} + +let harness: ConnectionHarness; + +function Wrapper(props: { children: ReactNode }) { + const Connection = harness.Wrapper; + return ( + + + + {props.children} + + + + ); +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} + +async function drop() { + await harness.drop(); + await flush(); +} + +async function reconnect() { + harness.reconnect(); + await flush(); +} + +/** Never answers: the read stays in flight until a drop cuts it off. */ +function pending(): Promise { + return new Promise(() => undefined); +} + +function goalIds(result: { current: UseGoalBoardResult }): string[] { + return result.current.board.entries.map((entry) => entry.goal.goalId); +} + +describe.each([...GAP_MODES])("loaded panes across a dropped connection (%s)", (mode) => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + window.localStorage.clear(); + harness = createConnectionHarness(mode, serverOverrides); + savedOverrides = []; + savedChatInstructions = []; + failChatSave = null; + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("the goal board keeps its goals through a reload the drop cut off", async () => { + answerBoard = () => Promise.resolve(board("goal-a")); + const view = renderHook(() => useGoalBoard("ws-1"), { wrapper: Wrapper }); + await waitFor(() => expect(goalIds(view.result)).toEqual(["goal-a"])); + + await drop(); + expect(goalIds(view.result)).toEqual(["goal-a"]); + + // The new client's read is still in flight when a second drop cuts it off. + answerBoard = pending; + await reconnect(); + await drop(); + expect(goalIds(view.result)).toEqual(["goal-a"]); + + answerBoard = () => Promise.resolve(board("goal-b")); + await reconnect(); + await waitFor(() => expect(goalIds(view.result)).toEqual(["goal-b"])); + }); + + test("the MCP overview keeps its servers through a reload the drop cut off", async () => { + answerServers = () => Promise.resolve(servers("alpha")); + const view = render(, { + wrapper: Wrapper, + }); + await view.findByText("alpha"); + + await drop(); + expect(view.queryByText("alpha")).not.toBeNull(); + + answerServers = pending; + await reconnect(); + await drop(); + expect(view.queryByText("alpha")).not.toBeNull(); + expect(view.queryByText("No MCP servers enabled for this project.")).toBeNull(); + + answerServers = () => Promise.resolve(servers("beta")); + await reconnect(); + await view.findByText("beta"); + }); + + test("the Instructions tab keeps its empty state and an enabled editor while it reloads", async () => { + answerInstructions = () => Promise.resolve({ files: [], totalTokens: 0 }); + answerChatInstructions = () => Promise.resolve({ content: "be brief", enabled: true }); + const view = render(, { wrapper: Wrapper }); + const editor = (await view.findByLabelText("Chat Instructions editor")) as HTMLTextAreaElement; + await waitFor(() => expect(editor.value).toBe("be brief")); + await view.findByText("No instruction files loaded for this workspace."); + expect(editor.disabled).toBe(false); + + await drop(); + const nextInstructions = deferred(); + const nextChatInstructions = deferred(); + answerInstructions = () => nextInstructions.promise; + answerChatInstructions = () => nextChatInstructions.promise; + await reconnect(); + expect(view.queryByText("No instruction files loaded for this workspace.")).not.toBeNull(); + expect(editor.disabled).toBe(false); + expect(view.queryByText("Loading…")).toBeNull(); + + nextInstructions.resolve({ files: [], totalTokens: 0 }); + nextChatInstructions.resolve({ content: "be precise", enabled: true }); + await waitFor(() => expect(editor.value).toBe("be precise")); + }); + + test("the goal defaults keep the override editable while they reload, and an edit wins", async () => { + answerOverride = () => Promise.resolve(turnCapOverride(5)); + const view = render(, { + wrapper: Wrapper, + }); + const turnCap = () => + view.getByLabelText("Workspace default goal turn cap") as HTMLInputElement; + await waitFor(() => expect(turnCap().value).toBe("5")); + + await drop(); + const next = deferred(); + answerOverride = () => next.promise; + await reconnect(); + expect(turnCap().value).toBe("5"); + expect(turnCap().disabled).toBe(false); + expect(view.queryByText("Loading…")).toBeNull(); + + // The user edits while the reload is in flight; its older answer must not undo the edit. + fireEvent.change(turnCap(), { target: { value: "9" } }); + fireEvent.blur(turnCap()); + await waitFor(() => expect(savedOverrides).toHaveLength(1)); + next.resolve(turnCapOverride(7)); + await flush(); + expect(turnCap().value).toBe("9"); + }); +}); + +describe.each([...GAP_MODES])("edits and navigation while reconnecting (%s)", (mode) => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + window.localStorage.clear(); + harness = createConnectionHarness(mode, serverOverrides); + savedOverrides = []; + savedChatInstructions = []; + failChatSave = null; + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("the chat-instructions editor is read-only while offline, and editable again after the reconnect", async () => { + const workspaceId = `ws-offline-edit-${mode}`; + answerInstructions = () => Promise.resolve({ files: [], totalTokens: 0 }); + answerChatInstructions = () => Promise.resolve({ content: "be brief", enabled: true }); + const view = render(, { wrapper: Wrapper }); + const editor = (await view.findByLabelText("Chat Instructions editor")) as HTMLTextAreaElement; + const toggle = view.getByLabelText("Enable Chat Instructions") as HTMLInputElement; + await waitFor(() => expect(editor.value).toBe("be brief")); + expect(editor.disabled).toBe(false); + + await drop(); + expect(editor.value).toBe("be brief"); + expect(editor.disabled).toBe(true); + expect(toggle.disabled).toBe(true); + + await reconnect(); + await waitFor(() => expect(editor.disabled).toBe(false)); + expect(toggle.disabled).toBe(false); + expect(savedChatInstructions).toEqual([]); + }); + + test("after a failed save, a reconnect shows what the server stored", async () => { + const workspaceId = `ws-failed-save-${mode}`; + answerChatInstructions = () => Promise.resolve({ content: "be brief", enabled: true }); + const view = renderHook(() => useChatInstructions(workspaceId), { wrapper: Wrapper }); + await waitFor(() => expect(view.result.current.content).toBe("be brief")); + + failChatSave = new Error("disk full"); + act(() => view.result.current.setContent("be kind")); + await waitFor(() => expect(view.result.current.error).toBe("disk full")); + + await drop(); + await reconnect(); + await waitFor(() => expect(view.result.current.content).toBe("be brief")); + }); + + test("the goal defaults are read-only while offline, and editable again after the reconnect", async () => { + answerOverride = () => Promise.resolve(turnCapOverride(5)); + const view = render(, { + wrapper: Wrapper, + }); + const turnCap = () => + view.getByLabelText("Workspace default goal turn cap") as HTMLInputElement; + const stopOverriding = () => + view.getByRole("button", { name: "Stop overriding Default turn cap" }) as HTMLButtonElement; + await waitFor(() => expect(turnCap().value).toBe("5")); + expect(turnCap().disabled).toBe(false); + + await drop(); + expect(turnCap().value).toBe("5"); + expect(turnCap().disabled).toBe(true); + expect(stopOverriding().disabled).toBe(true); + + await reconnect(); + await waitFor(() => expect(turnCap().disabled).toBe(false)); + expect(stopOverriding().disabled).toBe(false); + expect(savedOverrides).toEqual([]); + }); + + test("the MCP overview never shows one project's servers under another", async () => { + answerServers = () => Promise.resolve(servers("alpha")); + const view = render(, { wrapper: Wrapper }); + await view.findByText("alpha"); + + // Project B's first list is still loading when the connection drops and cuts it off. + answerServers = pending; + view.rerender(); + await flush(); + expect(view.queryByText("alpha")).toBeNull(); + await drop(); + expect(view.queryByText("alpha")).toBeNull(); + + answerServers = () => Promise.resolve(servers("beta")); + await reconnect(); + await view.findByText("beta"); + expect(view.queryByText("alpha")).toBeNull(); + }); +}); + +describe("loaded panes of another workspace", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + window.localStorage.clear(); + harness = createConnectionHarness("kept-gap", serverOverrides); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("another workspace clears the shown override while its own loads", async () => { + answerOverride = () => Promise.resolve(turnCapOverride(5)); + const view = render(, { + wrapper: Wrapper, + }); + await waitFor(() => + expect( + (view.getByLabelText("Workspace default goal turn cap") as HTMLInputElement).value + ).toBe("5") + ); + + answerOverride = pending; + view.rerender(); + await waitFor(() => expect(view.queryByText("Loading…")).not.toBeNull()); + expect(view.queryByLabelText("Workspace default goal turn cap")).toBeNull(); + }); +}); diff --git a/src/browser/features/RightSidebar/offlinePanes.test.tsx b/src/browser/features/RightSidebar/offlinePanes.test.tsx new file mode 100644 index 00000000000..32cd9be9ed3 --- /dev/null +++ b/src/browser/features/RightSidebar/offlinePanes.test.tsx @@ -0,0 +1,369 @@ +// Bootstrap Happy DOM before react-dom evaluates (see MemoryTab.test.tsx). +import "../../../../tests/ui/dom"; + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { installDom } from "../../../../tests/ui/dom"; +import { BackgroundBashOutputDialog } from "@/browser/components/BackgroundBashOutputDialog/BackgroundBashOutputDialog"; +import { ProjectMCPOverview } from "@/browser/components/ProjectMCPOverview/ProjectMCPOverview"; +import type { APIClient } from "@/browser/contexts/API"; +import { + createConnectionHarness, + GAP_MODES, + type ConnectionHarness, +} from "@/browser/contexts/connection.testHarness"; +import { RouterProvider } from "@/browser/contexts/RouterContext"; +import { SettingsProvider } from "@/browser/contexts/SettingsContext"; +import type { TestApiOverrides } from "@/browser/testUtils"; +import type { DevToolsRunSummary } from "@/common/types/devtools"; +import type { GoalBoardSnapshot } from "@/common/types/goal"; +import type { AvailableWorkflow, WorkflowRunRecord } from "@/common/types/workflow"; +import { DevToolsRunCard } from "./DevToolsTab/DevToolsRunCard"; +import { DevToolsTab } from "./DevToolsTab/DevToolsTab"; +import { projectWorkflowRun } from "./Workflows/projectWorkflowRun"; +import { WorkflowRunHeader } from "./Workflows/WorkflowRunHeader"; +import { GoalBoardSections } from "./GoalBoardSections"; +import { WorkflowsTab } from "./Workflows/WorkflowsTab"; + +/* + * Panes keep what they show while Xum reconnects. That must not hide failures: + * - a server cancel on a live socket (a plain AbortError) is an error like any other, not a lost + * connection, since no new client will come to retry it; + * - a control that needs the server does nothing without one, so it says so or is disabled; + * - work cut off by the lost connection runs again on the next client. + */ + +let answerServers: () => Promise; +let answerOutput: () => Promise; +let answerRunDetail: () => Promise; +let mutations: string[] = []; + +/** A server cancel on a live socket: an abort that is not a lost connection. */ +function serverCancel(): Promise { + return Promise.reject(new DOMException("cancelled by the server", "AbortError")); +} + +/** Never answers: the read stays in flight until a drop cuts it off. */ +function pending(): Promise { + return new Promise(() => undefined); +} + +function outputResult(output: string) { + return { + success: true as const, + data: { status: "exited" as const, output, nextOffset: output.length, truncatedStart: false }, + }; +} + +function mutation(name: string) { + return () => { + mutations.push(name); + return Promise.resolve(); + }; +} + +function serverOverrides(): TestApiOverrides { + return { + mcp: { list: () => answerServers() }, + workspace: { + backgroundBashes: { getOutput: () => answerOutput() }, + promoteUpcomingGoal: mutation("promote"), + archiveGoal: mutation("archive"), + reviveArchivedGoal: mutation("revive"), + updateUpcomingGoal: mutation("update"), + goalDefaults: { get: () => Promise.resolve(null) }, + }, + workflows: { + subscribe: () => + Promise.resolve( + (async function* () { + yield { type: "snapshot", runs: [] }; + await new Promise(() => undefined); + })() + ), + listScripts: () => Promise.resolve([workflowScript()]), + start: mutation("start"), + }, + devtools: { + getRunDetail: () => answerRunDetail(), + subscribe: () => + Promise.resolve( + (async function* () { + yield { type: "snapshot", runs: [devToolsRun()] }; + await new Promise(() => undefined); + })() + ), + clear: mutation("clear"), + }, + config: { getConfig: () => Promise.resolve({}) }, + } as unknown as TestApiOverrides; +} + +function workflowScript(): AvailableWorkflow { + const script: AvailableWorkflow = { + descriptor: { + name: "deploy", + description: "deploy script", + scope: "project", + executable: true, + }, + scriptPath: "skill://deploy/workflow.js", + args: [], + }; + return script; +} + +function goalBoard(): GoalBoardSnapshot { + const goal = (goalId: string, objective: string) => + ({ + goalId, + objective, + status: "active", + budgetCents: null, + costCents: 0, + }) as unknown as GoalBoardSnapshot["entries"][number]["goal"]; + return { + entries: [ + { section: "upcoming", goal: goal("11111111-1111-4111-8111-111111111111", "Ship it") }, + { section: "archived", goal: goal("22222222-2222-4222-8222-222222222222", "Old idea") }, + ], + }; +} + +function devToolsRun(): DevToolsRunSummary { + return { + id: "run-1", + firstMessage: "hello", + stepCount: 0, + totalDurationMs: null, + modelId: null, + isInProgress: false, + hasError: false, + } as unknown as DevToolsRunSummary; +} + +function workflowRun(status: WorkflowRunRecord["status"]): WorkflowRunRecord { + return { + id: "wfr_test", + workspaceId: "ws-1", + workflow: { name: "deploy", description: "deploy", scope: "project", executable: true }, + source: "export default function workflow() { return null; }", + sourceHash: "sha256:test", + args: {}, + status, + createdAt: "2026-05-29T00:00:00.000Z", + updatedAt: "2026-05-29T00:00:01.000Z", + events: [], + steps: [], + }; +} + +let harness: ConnectionHarness; + +function Wrapper(props: { children: ReactNode }) { + const Connection = harness.Wrapper; + return ( + + + {props.children} + + + ); +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} + +async function drop() { + await harness.drop(); + await flush(); +} + +async function reconnect() { + harness.reconnect(); + await flush(); +} + +function noop() { + return undefined; +} + +describe.each([...GAP_MODES])("panes kept across a dropped connection (%s)", (mode) => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + window.localStorage.clear(); + harness = createConnectionHarness(mode, serverOverrides); + mutations = []; + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("a server cancel on a live socket shows the MCP load error and ends its spinner", async () => { + answerServers = serverCancel; + const view = render(, { + wrapper: Wrapper, + }); + await flush(); + + expect(view.queryByText("cancelled by the server")).not.toBeNull(); + expect(view.container.querySelector(".animate-spin")).toBeNull(); + }); + + test("a server cancel on a live socket shows the process output error instead of loading", async () => { + answerOutput = serverCancel; + const view = render( + , + { wrapper: Wrapper } + ); + await flush(); + + expect(view.queryByText("cancelled by the server")).not.toBeNull(); + expect(view.queryByText("Loading…")).toBeNull(); + }); + + test("the Workflows tab disables Run while there is no live client", async () => { + const view = render(, { wrapper: Wrapper }); + const run = () => view.getByRole("button", { name: /Run/ }) as HTMLButtonElement; + await view.findByText("deploy"); + expect(run().disabled).toBe(false); + + await drop(); + expect(view.queryByText("deploy")).not.toBeNull(); + expect(run().disabled).toBe(true); + + await reconnect(); + expect(run().disabled).toBe(false); + fireEvent.click(run()); + await waitFor(() => expect(mutations).toEqual(["start"])); + }); + + test("goal-board changes made offline say so, and an edit stays open", async () => { + const view = render( + , + { wrapper: Wrapper } + ); + await flush(); + await drop(); + + fireEvent.click(view.getByRole("button", { name: "Promote Ship it" })); + await view.findByText(/Not connected/); + + fireEvent.click(view.getByRole("button", { name: "Edit Ship it" })); + fireEvent.click(view.getByRole("button", { name: "Save" })); + await flush(); + // The editor stays open: nothing was saved. + expect(view.queryByLabelText("Goal objective")).not.toBeNull(); + + fireEvent.click(view.getByRole("button", { name: /Archived/ })); + fireEvent.click(view.getByRole("button", { name: "Revive Old idea" })); + await waitFor(() => expect(view.queryAllByText(/Not connected/).length).toBe(2)); + expect(mutations).toEqual([]); + }); + + test("a background process opened offline ends its loading state, and loads on the next client", async () => { + await drop(); + answerOutput = () => Promise.resolve(outputResult("line A")); + const view = render( + , + { wrapper: Wrapper } + ); + await flush(); + expect(view.queryByText("Loading…")).toBeNull(); + + const next = Promise.withResolvers(); + answerOutput = () => next.promise; + await reconnect(); + expect(view.queryByText("Loading…")).not.toBeNull(); + next.resolve(outputResult("line A")); + await view.findByText("line A"); + }); + + test("an expanded DevTools run whose detail the drop cut off reads it again on the next client", async () => { + answerRunDetail = pending; + const view = render(, { + wrapper: Wrapper, + }); + fireEvent.click(view.getByRole("button", { name: /hello/ })); + await flush(); + + await drop(); + expect(view.queryByText(/Not connected/)).toBeNull(); + + answerRunDetail = () => Promise.resolve(null); + await reconnect(); + await view.findByText("No steps recorded"); + expect(view.queryByText(/Not connected/)).toBeNull(); + }); + + test("the run header's actions are disabled while there is no live client", async () => { + const live = workflowRun("running"); + const interrupted = workflowRun("interrupted"); + const view = render( + <> + + + , + { wrapper: Wrapper } + ); + const interrupt = () => view.getByRole("button", { name: /Interrupt/ }) as HTMLButtonElement; + const resume = () => view.getByRole("button", { name: /Resume/ }) as HTMLButtonElement; + expect(interrupt().disabled).toBe(false); + expect(resume().disabled).toBe(false); + + await drop(); + expect(interrupt().disabled).toBe(true); + expect(resume().disabled).toBe(true); + + await reconnect(); + expect(interrupt().disabled).toBe(false); + expect(resume().disabled).toBe(false); + }); + + test("DevTools Clear is disabled while there is no live client", async () => { + const view = render(, { wrapper: Wrapper }); + const clear = () => view.getByRole("button", { name: "Clear debug logs" }) as HTMLButtonElement; + await view.findByText("hello"); + expect(clear().disabled).toBe(false); + + await drop(); + expect(view.queryByText("hello")).not.toBeNull(); + expect(clear().disabled).toBe(true); + + await reconnect(); + expect(clear().disabled).toBe(false); + fireEvent.click(clear()); + await waitFor(() => expect(mutations).toEqual(["clear"])); + }); + + test("a DevTools run expanded while reconnecting waits for its detail instead of showing none", async () => { + answerRunDetail = () => Promise.resolve(null); + const view = render(, { + wrapper: Wrapper, + }); + await drop(); + // In the kept gap the kept client's read fails at once with a lost connection. + answerRunDetail = pending; + fireEvent.click(view.getByRole("button", { name: /hello/ })); + await flush(); + expect(view.queryByText("No steps recorded")).toBeNull(); + expect(view.container.querySelector(".animate-spin")).not.toBeNull(); + + answerRunDetail = () => Promise.resolve(null); + await reconnect(); + await view.findByText("No steps recorded"); + }); +}); diff --git a/src/browser/features/RightSidebar/useGoalBoard.ts b/src/browser/features/RightSidebar/useGoalBoard.ts index 8819e639b6c..625a2e5cb75 100644 --- a/src/browser/features/RightSidebar/useGoalBoard.ts +++ b/src/browser/features/RightSidebar/useGoalBoard.ts @@ -1,6 +1,7 @@ import { useCallback, useContext, useEffect, useState } from "react"; -import { APIContext } from "@/browser/contexts/API"; +import { APIContext, type APIClient } from "@/browser/contexts/API"; +import { isConnectionLostError } from "@/browser/contexts/connectionLink"; import type { GoalBoardSnapshot } from "@/common/types/goal"; /** @@ -20,6 +21,23 @@ import type { GoalBoardSnapshot } from "@/common/types/goal"; * tighter coupling could push board snapshots through the activity * channel; today's reads are cheap enough to drive on-demand. */ +const EMPTY_BOARD: GoalBoardSnapshot = { entries: [] }; + +/** + * Reads a workspace's board. A failed read is an empty board, as before, but a read cut off by a + * lost connection resolves to null: the caller keeps the board it shows for the next client. + */ +async function readGoalBoard( + api: APIClient, + workspaceId: string +): Promise { + try { + return (await api.workspace.getGoalBoard({ workspaceId })) ?? EMPTY_BOARD; + } catch (error) { + return isConnectionLostError(error) ? null : EMPTY_BOARD; + } +} + export interface UseGoalBoardResult { board: GoalBoardSnapshot; isLoading: boolean; @@ -46,30 +64,24 @@ export function useGoalBoard( // `useGoalDefaults`. const context = useContext(APIContext); const api = context?.api ?? null; - const [board, setBoard] = useState({ entries: [] }); + const [board, setBoard] = useState(EMPTY_BOARD); const [isLoading, setIsLoading] = useState(true); const [refreshKey, setRefreshKey] = useState(0); useEffect(() => { if (!api || !workspaceId) { - setBoard({ entries: [] }); + // No client (a reconnect) keeps the shown board; no workspace has no board. + setBoard((current) => (workspaceId ? current : EMPTY_BOARD)); setIsLoading(false); return; } let cancelled = false; setIsLoading(true); - void api.workspace - .getGoalBoard({ workspaceId }) - .then((snapshot) => { - if (cancelled) return; - setBoard(snapshot ?? { entries: [] }); - setIsLoading(false); - }) - .catch(() => { - if (cancelled) return; - setBoard({ entries: [] }); - setIsLoading(false); - }); + void readGoalBoard(api, workspaceId).then((snapshot) => { + if (cancelled || snapshot == null) return; + setBoard(snapshot); + setIsLoading(false); + }); return () => { cancelled = true; }; diff --git a/src/browser/features/Tools/SubagentTranscriptDialog.tsx b/src/browser/features/Tools/SubagentTranscriptDialog.tsx index d10b2fc5145..885206e5df8 100644 --- a/src/browser/features/Tools/SubagentTranscriptDialog.tsx +++ b/src/browser/features/Tools/SubagentTranscriptDialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import type { DisplayedMessage, MuxMessage } from "@/common/types/message"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { ChatMuxMessage } from "@/common/orpc/types"; @@ -14,6 +14,7 @@ import { ErrorBox, LoadingDots } from "./Shared/ToolPrimitives"; import { MessageRenderer } from "@/browser/features/Messages/MessageRenderer"; import { ModelDisplay } from "@/browser/features/Messages/ModelDisplay"; import { BashCollapsedSummaryModeProvider } from "./BashCollapsedSummaryModeContext"; +import { isConnectionLostError } from "@/browser/contexts/connectionLink"; import { getErrorMessage } from "@/common/utils/errors"; interface SubagentTranscriptDialogProps { @@ -54,7 +55,10 @@ export const SubagentTranscriptDialog: React.FC = + {/* Keyed by the transcript, so another task starts from an empty viewer, while a new API + client (a reconnect) only re-runs the viewer's read and keeps the transcript shown. */} void; - setThinkingLevel?: (thinkingLevel: ThinkingLevel | undefined) => void; + setModel: (model: string | undefined) => void; + setThinkingLevel: (thinkingLevel: ThinkingLevel | undefined) => void; }> = (props) => { const { api } = useAPI(); @@ -81,9 +85,19 @@ const SubagentTranscriptViewer: React.FC<{ const setModel = props.setModel; const setThinkingLevel = props.setThinkingLevel; - const [isLoading, setIsLoading] = useState(false); + // Loading until the first read lands: a fresh viewer (another task, or the dialog reopened) + // starts empty, and a new API client keeps what the viewer shows. + const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [messages, setMessages] = useState(null); + // True once a transcript is on screen: a re-read (a new API client) then keeps it shown. + const hasTranscriptRef = useRef(false); + + // The header's model and thinking level belong to the transcript this viewer shows. + useEffect(() => { + setModel(undefined); + setThinkingLevel(undefined); + }, [setModel, setThinkingLevel]); useEffect(() => { // TaskToolCall renders this dialog component for each completed task even while closed. @@ -92,11 +106,7 @@ const SubagentTranscriptViewer: React.FC<{ return; } - setIsLoading(true); setError(null); - setMessages(null); - setModel?.(undefined); - setThinkingLevel?.(undefined); if (!api) { setIsLoading(false); @@ -104,6 +114,12 @@ const SubagentTranscriptViewer: React.FC<{ return; } + // Without a transcript on screen, show the loading state (the dialog may have opened while + // there was no client). + if (!hasTranscriptRef.current) { + setIsLoading(true); + } + let cancelled = false; const run = async () => { @@ -116,11 +132,14 @@ const SubagentTranscriptViewer: React.FC<{ if (cancelled) return; setMessages(transcript.messages); - setModel?.(transcript.model); - setThinkingLevel?.(transcript.thinkingLevel); + setModel(transcript.model); + setThinkingLevel(transcript.thinkingLevel); setIsLoading(false); + hasTranscriptRef.current = true; } catch (err: unknown) { - if (cancelled) return; + // A lost connection (or this effect's own cleanup) is not a transcript error: keep what + // is shown for the next client. + if (cancelled || isConnectionLostError(err)) return; setIsLoading(false); setError(getErrorMessage(err)); }