From c9c7f19b698c7f51a12f3f1098a2bee72671f605 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Fri, 14 Aug 2026 19:47:39 -0700 Subject: [PATCH] fix(thread): track connection state and stabilize session startup - Show connection progress in thread UI - Guard async launch cleanup and startup/close races - Restrict steering to active structured turns - Refresh localized terminal labels and regression tests --- src/renderer/actions/threadActions.test.ts | 3 + src/renderer/actions/threadActions.ts | 1 + .../thread/ChatPane/ChatPane.test.tsx | 15 +++ .../components/thread/ChatPane/ChatPane.tsx | 8 +- .../thread/ChatPane/ChatTurnElapsed.tsx | 24 +++++ .../thread/ThreadComposerSection.test.tsx | 19 ++++ .../thread/ThreadComposerSection.tsx | 5 +- .../thread/ThreadHeaderStatus.test.tsx | 21 +++++ .../components/thread/ThreadHeaderStatus.tsx | 23 ++++- .../components/thread/ThreadView.test.tsx | 59 ++++++++++++ src/renderer/components/thread/ThreadView.tsx | 15 ++- .../components/thread/threadComposerSubmit.ts | 8 +- src/renderer/locales/de/messages.po | 10 ++ src/renderer/locales/en/messages.po | 10 ++ src/renderer/locales/es/messages.po | 10 ++ src/renderer/locales/fr/messages.po | 10 ++ src/renderer/locales/ja/messages.po | 10 ++ src/renderer/locales/ko/messages.po | 10 ++ src/renderer/locales/pl/messages.po | 10 ++ src/renderer/locales/pt-BR/messages.po | 10 ++ src/renderer/locales/ru/messages.po | 10 ++ src/renderer/locales/tr/messages.po | 10 ++ src/renderer/locales/uk/messages.po | 10 ++ src/renderer/locales/vi/messages.po | 10 ++ src/renderer/locales/zh-CN/messages.po | 10 ++ src/renderer/state/appStore.test.ts | 14 +++ src/renderer/state/slices/launchSlice.ts | 21 +++++ .../runtime/threadSession/steerCoordinator.ts | 6 +- .../threadSessionManager.startClose.test.ts | 93 +++++++++++++++++++ .../runtime/threadSessionManager.ts | 24 ++++- 30 files changed, 472 insertions(+), 17 deletions(-) diff --git a/src/renderer/actions/threadActions.test.ts b/src/renderer/actions/threadActions.test.ts index 50c6f0ec6..15194835d 100644 --- a/src/renderer/actions/threadActions.test.ts +++ b/src/renderer/actions/threadActions.test.ts @@ -370,6 +370,7 @@ describe("threadActions", () => { const reopened = useAppStore.getState().threads[0]; expect(reopened?.status).toBe("launching"); expect(reopened?.attention).toBe("none"); + expect(useAppStore.getState().connectingThreadIds[thread.id]).toBeUndefined(); expect(useAppStore.getState().pendingThreadLaunches[thread.id]).toBe(""); }); @@ -389,6 +390,7 @@ describe("threadActions", () => { const reopened = useAppStore.getState().threads[0]; expect(reopened?.status).toBe("idle"); expect(reopened?.attention).toBe("none"); + expect(useAppStore.getState().connectingThreadIds[thread.id]).toEqual(expect.any(String)); expect(useAppStore.getState().pendingThreadLaunches[thread.id]).toBe(""); }); @@ -409,6 +411,7 @@ describe("threadActions", () => { // Transport split is in performInitialThreadLaunch (remote client vs bridge). expect(useAppStore.getState().threads[0]?.status).toBe("idle"); + expect(useAppStore.getState().connectingThreadIds[thread.id]).toEqual(expect.any(String)); expect(useAppStore.getState().pendingThreadLaunches[thread.id]).toBe(""); }); diff --git a/src/renderer/actions/threadActions.ts b/src/renderer/actions/threadActions.ts index 7181fba08..061670799 100644 --- a/src/renderer/actions/threadActions.ts +++ b/src/renderer/actions/threadActions.ts @@ -340,6 +340,7 @@ export function reopenStoredThread(threadId: string): void { ...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}), canResumeWithConfig: thread.canResumeWithConfig || thread.sessionRef !== undefined, }); + if (isGuiReconnect) store.beginThreadConnecting(thread.id); }); // Local and remote share this queue; performInitialThreadLaunch picks the // host (local bridge vs remote client.startThread). diff --git a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx index 02118c28b..a98411b2e 100644 --- a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx +++ b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx @@ -253,6 +253,7 @@ describe("ChatPane", () => { fileCheckpointsByThread: {}, fileCheckpointTurnsByThread: {}, provisioningWorktreeThreadIds: {}, + connectingThreadIds: {}, })); }); @@ -283,6 +284,20 @@ describe("ChatPane", () => { expect(screen.queryByText("Creating worktree…")).not.toBeInTheDocument(); }); + it("shows connecting without starting a working timer during GUI reconnect", () => { + const thread = { ...makeThread(), status: "idle" as const }; + useAppStore.setState({ + threads: [thread], + connectingThreadIds: { [thread.id]: "connection-1" }, + }); + + renderChatPane(thread); + + expect(screen.getByText("Connecting…")).toBeInTheDocument(); + expect(screen.queryByText(/^Working for/)).not.toBeInTheDocument(); + expect(screen.queryByText("No messages yet")).not.toBeInTheDocument(); + }); + it("loads the next persisted page when LegendList reaches the start", async () => { const thread = makeThread(); seedAssistantMessage(thread.id, "Latest answer"); diff --git a/src/renderer/components/thread/ChatPane/ChatPane.tsx b/src/renderer/components/thread/ChatPane/ChatPane.tsx index ff4afddc7..87cb8813a 100644 --- a/src/renderer/components/thread/ChatPane/ChatPane.tsx +++ b/src/renderer/components/thread/ChatPane/ChatPane.tsx @@ -33,6 +33,7 @@ import { ChatFindBar, type ScrollToIndex } from "@/renderer/components/find/Chat import { ChatPaneActionsContext, type ChatPaneActions } from "./chatPaneActionsContext"; import { ChatScrollControls, type ChatScrollControlsHandle } from "./ChatScrollControls"; import { + ChatConnectingFooter, ChatTurnElapsedFooter, ChatWorktreeProvisioningFooter, type TurnTiming, @@ -293,6 +294,7 @@ export function ChatPane(props: ChatPaneProps) { const isWorktreeProvisioning = useAppStore( (s) => s.provisioningWorktreeThreadIds[threadId] === true && status === "launching", ); + const isConnecting = useAppStore((s) => s.connectingThreadIds[threadId] !== undefined); // Detached background work keeps the thread doing real work after the // foreground turn settles. Treat that as "still working" for the tail-loader // timer (so it keeps ticking "Working for ...") without touching `status` - @@ -333,7 +335,7 @@ export function ChatPane(props: ChatPaneProps) { // request before that round-trip completes, leaving status stuck at // `needs_approval` even though the user has already answered. const isTurnPaused = hasOpenRuntimeRequest; - const showEmptyHint = isEmpty && !isLive; + const showEmptyHint = isEmpty && !isLive && !isConnecting; // The tail loader displays the most recent completed turn's frozen elapsed // time when the thread is idle and no newer timeline row exists. Once an // optimistic next prompt is appended, keep the completed indicator inline at @@ -399,6 +401,8 @@ export function ChatPane(props: ChatPaneProps) { footer={ isWorktreeProvisioning ? ( + ) : isConnecting ? ( + ) : showTailLoader && tailTurn ? ( ) : null @@ -456,7 +460,7 @@ export function ChatPane(props: ChatPaneProps) { layoutChangeToken={layoutChangeToken} tailEntryId={timelineEntries.at(-1)?.id ?? null} threadId={threadId} - tailLoaderVisible={isWorktreeProvisioning || showTailLoader} + tailLoaderVisible={isWorktreeProvisioning || isConnecting || showTailLoader} initialScrollSettled={isInitialScrollSettled} initialScrollRevealDelayMs={props.initialScrollRevealDelayMs ?? 0} virtualScrollToBottomRef={virtualScrollToBottomRef} diff --git a/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx b/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx index 92811d434..173e9e2fa 100644 --- a/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx +++ b/src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx @@ -52,6 +52,30 @@ export function ChatWorktreeProvisioningFooter() { ); } +export function ChatConnectingFooter() { + const { t } = useLingui(); + const textRef = useRef(null); + const text = t`Connecting…`; + useShimmerRef(textRef, true); + + return ( +
+ +
+ + {text} + +
+
+
+ ); +} + function WorkingFor({ turn, isPaused }: { turn: TurnTiming; isPaused: boolean }) { if (turn.endedAt !== null) { return ; diff --git a/src/renderer/components/thread/ThreadComposerSection.test.tsx b/src/renderer/components/thread/ThreadComposerSection.test.tsx index 840ea6c46..53161731e 100644 --- a/src/renderer/components/thread/ThreadComposerSection.test.tsx +++ b/src/renderer/components/thread/ThreadComposerSection.test.tsx @@ -223,6 +223,7 @@ describe("ThreadComposerSection", () => { runtimeItemsByIdByThread: {}, runtimeRequestsByThread: {}, pendingSteerByThreadId: {}, + connectingThreadIds: {}, pendingComposerFocusThreadId: null, threadDraftContents: {}, provisioningWorktreeThreadIds: {}, @@ -920,6 +921,24 @@ describe("ThreadComposerSection", () => { ); }); + it("does not submit or steer while a stored GUI session is reconnecting", async () => { + useAppStore.setState({ connectingThreadIds: { [guiThread.id]: "connection-1" } }); + const onSubmitInput = vi + .fn<(prompt: string, segments?: unknown) => Promise>() + .mockResolvedValue(undefined); + renderComposer({ onSubmitInput }); + + const input = screen.getByRole("textbox"); + input.appendChild(document.createTextNode("wait for connection")); + fireEvent.input(input); + fireEvent.click(screen.getByText("send")); + await act(async () => Promise.resolve()); + + expect(onSubmitInput).not.toHaveBeenCalled(); + expect(bridgeMock.setPendingSteer).not.toHaveBeenCalled(); + expect(input).toHaveTextContent("wait for connection"); + }); + it("restores approval requests and composer text when auto-deny before submit fails", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); runtimeActions.resolveThreadServerRequest.mockRejectedValueOnce(new Error("deny failed")); diff --git a/src/renderer/components/thread/ThreadComposerSection.tsx b/src/renderer/components/thread/ThreadComposerSection.tsx index 5d5abbb59..bb7571dd0 100644 --- a/src/renderer/components/thread/ThreadComposerSection.tsx +++ b/src/renderer/components/thread/ThreadComposerSection.tsx @@ -169,6 +169,7 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread (state) => state.provisioningWorktreeThreadIds[thread.id] === true && thread.status === "launching", ); + const isConnecting = useAppStore((state) => state.connectingThreadIds[thread.id] !== undefined); const { t } = useLingui(); const [prompt, setPrompt] = useState(""); const [hasContent, setHasContent] = useState(false); @@ -352,6 +353,7 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread thread.status === "working"; const canSubmitServerInput = isServerControlled && + !isConnecting && thread.sessionRef !== undefined && (thread.status === "idle" || thread.status === "needs_reply" || @@ -428,7 +430,8 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread const canInterruptStructuredTurn = canShowRuntimeChrome && thread.status === "working"; const pendingSteer = useAppStore((s) => s.pendingSteerByThreadId[thread.id]); const visiblePendingSteer = useDelayedPendingSteer(pendingSteer); - const usesPendingSteerPath = !usesTerminalPresentation && thread.status === "working"; + const usesPendingSteerPath = + !isConnecting && !usesTerminalPresentation && thread.status === "working"; const runtimeRequests = useAppStore((s) => s.runtimeRequestsByThread[thread.id]); const activeRuntimeRequest = canShowRuntimeChrome ? runtimeRequests?.[0] : undefined; const approvalDenyOption = activeRuntimeRequest diff --git a/src/renderer/components/thread/ThreadHeaderStatus.test.tsx b/src/renderer/components/thread/ThreadHeaderStatus.test.tsx index 5b1fb3756..090113bb8 100644 --- a/src/renderer/components/thread/ThreadHeaderStatus.test.tsx +++ b/src/renderer/components/thread/ThreadHeaderStatus.test.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Thread } from "@/shared/contracts"; +import { useAppStore } from "@/renderer/state/appStore"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; import { ThreadHeaderStatusButton } from "./ThreadHeaderStatus"; @@ -50,6 +51,7 @@ function makeThread(status: "idle" | "finished"): Thread { describe("ThreadHeaderStatusButton", () => { beforeEach(() => { + useAppStore.setState({ connectingThreadIds: {} }); useThreadHasBackgroundActivityMock.mockReset(); useThreadHasBackgroundActivityMock.mockReturnValue(true); }); @@ -75,4 +77,23 @@ describe("ThreadHeaderStatusButton", () => { expect(useThreadHasBackgroundActivityMock).toHaveBeenCalledOnce(); }, ); + + it("shows connecting while a stored GUI session reconnects", () => { + const thread = makeThread("idle"); + useAppStore.setState({ connectingThreadIds: { [thread.id]: "connection-1" } }); + + const { getByRole, getByText } = render( + , + ); + + expect( + getByRole("button", { name: "Claude: Connecting…. Hover for status details." }), + ).toBeInTheDocument(); + expect(getByText("Connecting…")).toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/thread/ThreadHeaderStatus.tsx b/src/renderer/components/thread/ThreadHeaderStatus.tsx index c47f606f5..577bf8f15 100644 --- a/src/renderer/components/thread/ThreadHeaderStatus.tsx +++ b/src/renderer/components/thread/ThreadHeaderStatus.tsx @@ -5,14 +5,16 @@ import type { Thread, ThreadStatusSource } from "@/shared/contracts"; import { ProviderIcon } from "@/renderer/components/providers/ProviderIcon"; import { getStatusTone } from "@/renderer/components/providers/statusTone"; import { useThreadHasBackgroundActivity } from "@/renderer/hooks/uiSelectors"; +import { useAppStore } from "@/renderer/state/appStore"; import { useThread } from "@/renderer/state/useThread"; import type { TranslateFn } from "@/renderer/i18n/i18n"; export function threadRuntimeStatusLabel( thread: Thread, t: TranslateFn, - opts?: { hasBackgroundActivity?: boolean }, + opts?: { hasBackgroundActivity?: boolean; isConnecting?: boolean }, ): string { + if (opts?.isConnecting) return t(msg`Connecting…`); const { status, attention } = thread; if (status === "launching") return t(msg`Launching…`); if (status === "inactive") return t(msg`Inactive`); @@ -71,10 +73,14 @@ function ThreadStatusSupportDetail({ source }: { source: ThreadStatusSource | un } } -function ThreadHeaderStatusTooltipBody(props: { thread: Thread; hasBackgroundActivity: boolean }) { - const { thread, hasBackgroundActivity } = props; +function ThreadHeaderStatusTooltipBody(props: { + thread: Thread; + hasBackgroundActivity: boolean; + isConnecting: boolean; +}) { + const { thread, hasBackgroundActivity, isConnecting } = props; const { t } = useLingui(); - const runtime = threadRuntimeStatusLabel(thread, t, { hasBackgroundActivity }); + const runtime = threadRuntimeStatusLabel(thread, t, { hasBackgroundActivity, isConnecting }); const source = thread.threadStatusSource; const isServer = source === "server"; const errorMessage = thread.status === "error" ? thread.errorMessage?.trim() : undefined; @@ -124,8 +130,14 @@ export function ThreadHeaderStatusButton(props: { const { t } = useLingui(); const thread = useThread(props.threadId) ?? props.fallbackThread; const hasBackgroundActivity = useThreadHasBackgroundActivity(props.threadId); + const isConnecting = useAppStore( + (state) => state.connectingThreadIds[props.threadId] !== undefined, + ); const agentLabel = props.agentLabel ?? props.fallbackAgentKind; - const statusLabel = threadRuntimeStatusLabel(thread, t, { hasBackgroundActivity }); + const statusLabel = threadRuntimeStatusLabel(thread, t, { + hasBackgroundActivity, + isConnecting, + }); return ( @@ -156,6 +168,7 @@ export function ThreadHeaderStatusButton(props: { diff --git a/src/renderer/components/thread/ThreadView.test.tsx b/src/renderer/components/thread/ThreadView.test.tsx index 69965a6e1..67e2fa7fb 100644 --- a/src/renderer/components/thread/ThreadView.test.tsx +++ b/src/renderer/components/thread/ThreadView.test.tsx @@ -96,6 +96,7 @@ describe("ThreadView", () => { runtimeItemsByIdByThread: {}, runtimeRequestsByThread: {}, provisioningWorktreeThreadIds: {}, + connectingThreadIds: {}, }); }); @@ -351,6 +352,64 @@ describe("ThreadView", () => { await waitFor(() => expect(bridge.startThread).toHaveBeenCalled()); }); + it("clears the renderer reconnect flag after a stored GUI session connects", async () => { + const thread: Thread = { + id: "thread-gui-reconnect", + projectId: "project-1", + title: "Reconnecting chat thread", + agentKind: "codex", + config: { model: "gpt-5.4" }, + status: "idle", + attention: "none", + canResumeWithConfig: true, + sessionRef: { + providerSessionId: "session-gui-reconnect", + discoveredAt: new Date().toISOString(), + }, + archived: false, + done: false, + starred: false, + presentationMode: "gui", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + useAppStore.setState({ + threads: [thread], + connectingThreadIds: { [thread.id]: "connection-1" }, + }); + + renderThreadView({ + thread, + agentStatus: { + kind: "codex", + label: "Codex", + installed: true, + authState: "authenticated", + capabilities: { + models: [{ id: "gpt-5.4", label: "5.4" }], + efforts: ["low"], + modelEfforts: {}, + modes: ["agent"], + approvalPolicies: [{ id: "on-request", label: "On Request" }], + sandboxModes: [{ id: "read-only", label: "Read Only" }], + supportsResume: true, + supportsDirectInput: true, + liveInputMode: "server", + presentationMode: "gui", + settingDefs: [], + }, + }, + projectLocation: { kind: "windows", path: "C:\\repo" }, + pendingLaunchPrompt: "", + onLaunchConsumed: () => undefined, + }); + + await waitFor(() => expect(bridge.startThread).toHaveBeenCalled()); + await waitFor(() => { + expect(useAppStore.getState().connectingThreadIds[thread.id]).toBeUndefined(); + }); + }); + it("forwards launch rejection messages to the launch failure callback", async () => { bridge.startThread.mockRejectedValueOnce(new Error("launcher boom")); const onLaunchConsumed = vi.fn<() => void>(); diff --git a/src/renderer/components/thread/ThreadView.tsx b/src/renderer/components/thread/ThreadView.tsx index 46a092a73..70e7f103a 100644 --- a/src/renderer/components/thread/ThreadView.tsx +++ b/src/renderer/components/thread/ThreadView.tsx @@ -235,6 +235,7 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) { launchRequestRef.current = launchKey; onLaunchConsumed?.(); + const connectionToken = useAppStore.getState().connectingThreadIds[thread.id]; void (async () => { await performInitialThreadLaunch({ @@ -247,10 +248,16 @@ export const ThreadView = memo(function ThreadView(props: ThreadViewProps) { : {}), initialSize: launchTerminalSize, }); - })().catch((error) => { - launchRequestRef.current = null; - onLaunchFailed?.(formatLaunchError(error, t`Thread failed to start.`)); - }); + })() + .catch((error) => { + launchRequestRef.current = null; + onLaunchFailed?.(formatLaunchError(error, t`Thread failed to start.`)); + }) + .finally(() => { + if (connectionToken) { + useAppStore.getState().finishThreadConnecting(thread.id, connectionToken); + } + }); }, [ t, onLaunchConsumed, diff --git a/src/renderer/components/thread/threadComposerSubmit.ts b/src/renderer/components/thread/threadComposerSubmit.ts index f4ef2c68f..dcb6992aa 100644 --- a/src/renderer/components/thread/threadComposerSubmit.ts +++ b/src/renderer/components/thread/threadComposerSubmit.ts @@ -41,7 +41,8 @@ export interface ComposerSubmitContext { presentationMode: ThreadPresentationMode; usesTerminalPresentation: boolean; canSubmit: boolean; - /** GUI thread with a working turn — stage the prompt as a pending steer. */ + /** Renderer routing hint for a GUI thread that appears to be working. The + * supervisor rechecks the live session after any pending startup completes. */ usesPendingSteerPath: boolean; needsFocusBeforeInput: boolean; activeRuntimeRequest: OpenRuntimeRequest | undefined; @@ -181,7 +182,10 @@ export function submitComposerPrompt(segments: PromptSegment[], ctx: ComposerSub }); }; - // GUI threads + working status → stage as pending steer (replace-latest). + // GUI threads + working status → request a pending steer (replace-latest). + // This renderer status can be optimistic; the supervisor waits out a + // reconnect and drains the prompt as a normal turn when the live session is + // authoritatively idle. // The supervisor fires the cancel and drains the slot when the in-flight // turn returns with `cancelled` stopReason. No optimistic chat paint — // the strip above the composer is the visual confirmation; the real diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index e43e301c8..8d5bdde48 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -2963,7 +2963,9 @@ msgstr "Verbinden..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "Verbindet…" @@ -4827,6 +4829,10 @@ msgstr "Dateien" msgid "Files for {0}" msgstr "Dateien für {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Dateien für {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11391,6 +11397,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal für {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal für {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index cadb8dba0..74a7b6cd0 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -2963,7 +2963,9 @@ msgstr "Connecting..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "Connecting…" @@ -4827,6 +4829,10 @@ msgstr "Files" msgid "Files for {0}" msgstr "Files for {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Files for {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11391,6 +11397,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal for {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal for {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index 2261774ea..81db8d4c6 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -2963,7 +2963,9 @@ msgstr "Conectando..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "Conectando…" @@ -4827,6 +4829,10 @@ msgstr "Archivos" msgid "Files for {0}" msgstr "Archivos de {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Archivos de {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11391,6 +11397,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal para {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal para {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index 9a4702294..d0b099bd0 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -2963,7 +2963,9 @@ msgstr "Connexion..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "Connexion…" @@ -4827,6 +4829,10 @@ msgstr "Fichiers" msgid "Files for {0}" msgstr "Fichiers pour {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Fichiers pour {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11390,6 +11396,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal pour {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal pour {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index ac55883c6..a42f43e1c 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -2962,7 +2962,9 @@ msgstr "接続中..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "接続中…" @@ -4826,6 +4828,10 @@ msgstr "ファイル" msgid "Files for {0}" msgstr "{0}のファイル" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "{projectName}のファイル" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11389,6 +11395,10 @@ msgstr "ターミナル" msgid "Terminal for {0}" msgstr "{0}用のターミナル" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "{projectName}用のターミナル" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 719d9831d..4fcea4108 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -2963,7 +2963,9 @@ msgstr "연결 중..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "연결 중…" @@ -4827,6 +4829,10 @@ msgstr "파일" msgid "Files for {0}" msgstr "{0}에 대한 파일" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "{projectName}에 대한 파일" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11391,6 +11397,10 @@ msgstr "터미널" msgid "Terminal for {0}" msgstr "{0}용 터미널" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "{projectName}용 터미널" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index 6e18ecd93..6ba1c963f 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -2963,7 +2963,9 @@ msgstr "Łączenie..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "Łączenie…" @@ -4827,6 +4829,10 @@ msgstr "Pliki" msgid "Files for {0}" msgstr "Pliki dla {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Pliki dla {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11391,6 +11397,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal dla {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal dla {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index 72fe82347..fff4a8e0f 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -2963,7 +2963,9 @@ msgstr "Conectando..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "Conectando…" @@ -4827,6 +4829,10 @@ msgstr "Arquivos" msgid "Files for {0}" msgstr "Arquivos para {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Arquivos para {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11391,6 +11397,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Terminal para {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Terminal para {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index 97a264410..73ea085c2 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -2963,7 +2963,9 @@ msgstr "Подключение..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "Подключение…" @@ -4827,6 +4829,10 @@ msgstr "Файлы" msgid "Files for {0}" msgstr "Файлы для {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Файлы для {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11391,6 +11397,10 @@ msgstr "Терминал" msgid "Terminal for {0}" msgstr "Терминал для {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Терминал для {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index 8fa9aef05..af0781633 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -2963,7 +2963,9 @@ msgstr "Bağlanıyor..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "Bağlanıyor…" @@ -4827,6 +4829,10 @@ msgstr "Dosyalar" msgid "Files for {0}" msgstr "{0} için dosyalar" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "{projectName} için dosyalar" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11391,6 +11397,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "{0} için terminal" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "{projectName} için terminal" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index c00eada7c..b5cc11ba3 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -2963,7 +2963,9 @@ msgstr "Підключення..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "Підключення…" @@ -4827,6 +4829,10 @@ msgstr "Файли" msgid "Files for {0}" msgstr "Файли для {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Файли для {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11391,6 +11397,10 @@ msgstr "Термінал" msgid "Terminal for {0}" msgstr "Термінал для {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Термінал для {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index e2969944f..d32176498 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -2963,7 +2963,9 @@ msgstr "Đang kết nối..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "Đang kết nối…" @@ -4827,6 +4829,10 @@ msgstr "Tập tin" msgid "Files for {0}" msgstr "Tập tin cho {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "Tập tin cho {projectName}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11391,6 +11397,10 @@ msgstr "Terminal" msgid "Terminal for {0}" msgstr "Thiết bị đầu cuối cho {0}" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "Thiết bị đầu cuối cho {projectName}" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index c52f21dab..4577232d9 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -2963,7 +2963,9 @@ msgstr "连接中..." #: src/mobile/views/DesktopsView.tsx #: src/renderer/components/common/RemoteServerStatusDot.tsx +#: src/renderer/components/thread/ChatPane/ChatTurnElapsed.tsx #: src/renderer/components/thread/ThreadDraftView.tsx +#: src/renderer/components/thread/ThreadHeaderStatus.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx msgid "Connecting…" msgstr "正在连接…" @@ -4827,6 +4829,10 @@ msgstr "文件" msgid "Files for {0}" msgstr "{0}的文件" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Files for {projectName}" +msgstr "{projectName}的文件" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Files matching these globs are hidden from the @file mention search." @@ -11390,6 +11396,10 @@ msgstr "终端" msgid "Terminal for {0}" msgstr "{0}的终端" +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Terminal for {projectName}" +msgstr "{projectName}的终端" + #: src/mobile/TerminalAccessory.tsx #: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts msgid "Terminal input" diff --git a/src/renderer/state/appStore.test.ts b/src/renderer/state/appStore.test.ts index f43595cbb..b728216cb 100644 --- a/src/renderer/state/appStore.test.ts +++ b/src/renderer/state/appStore.test.ts @@ -19,11 +19,25 @@ describe("appStore runtime config sync", () => { threads: [], pendingLaunchUserMessageItemIds: {}, provisioningWorktreeThreadIds: {}, + connectingThreadIds: {}, view: { kind: "home" }, })); usePanelStore.getState().setGitHubActionsContext(null); }); + it("keeps a newer reconnect marker when an older launch finishes", () => { + const firstToken = useAppStore.getState().beginThreadConnecting("thread-1"); + const secondToken = useAppStore.getState().beginThreadConnecting("thread-1"); + + useAppStore.getState().finishThreadConnecting("thread-1", firstToken); + + expect(useAppStore.getState().connectingThreadIds["thread-1"]).toBe(secondToken); + + useAppStore.getState().finishThreadConnecting("thread-1", secondToken); + + expect(useAppStore.getState().connectingThreadIds["thread-1"]).toBeUndefined(); + }); + it("applies resolved runtime config onto the stored thread", () => { const project = useAppStore.getState().addProject({ kind: "windows", diff --git a/src/renderer/state/slices/launchSlice.ts b/src/renderer/state/slices/launchSlice.ts index 0bd6276b6..3f4fdb235 100644 --- a/src/renderer/state/slices/launchSlice.ts +++ b/src/renderer/state/slices/launchSlice.ts @@ -5,6 +5,9 @@ export interface LaunchSlice { pendingThreadLaunches: Record; pendingLaunchSegments: Record; pendingLaunchUserMessageItemIds: Record; + /** Renderer-only reconnect state. Kept separate from `ThreadStatus` so an + * empty reconnect does not manufacture an active/completed turn. */ + connectingThreadIds: Record; queueThreadLaunch: ( threadId: string, prompt: string, @@ -12,12 +15,15 @@ export interface LaunchSlice { userMessageItemId?: string, ) => void; consumeThreadLaunch: (threadId: string) => void; + beginThreadConnecting: (threadId: string) => string; + finishThreadConnecting: (threadId: string, token: string) => void; } export const createLaunchSlice: SliceCreator = (set) => ({ pendingThreadLaunches: {}, pendingLaunchSegments: {}, pendingLaunchUserMessageItemIds: {}, + connectingThreadIds: {}, queueThreadLaunch: (threadId, prompt, segments, userMessageItemId) => set((state) => ({ pendingThreadLaunches: { @@ -53,4 +59,19 @@ export const createLaunchSlice: SliceCreator = (set) => ({ state.pendingLaunchUserMessageItemIds; return { pendingThreadLaunches, pendingLaunchSegments, pendingLaunchUserMessageItemIds }; }), + beginThreadConnecting: (threadId) => { + const token = crypto.randomUUID(); + set((state) => ({ + connectingThreadIds: { ...state.connectingThreadIds, [threadId]: token }, + })); + return token; + }, + finishThreadConnecting: (threadId, token) => + set((state) => { + // A stale launch completion must not clear a newer reconnect for the + // same persisted thread id. + if (state.connectingThreadIds[threadId] !== token) return {}; + const { [threadId]: _removed, ...connectingThreadIds } = state.connectingThreadIds; + return { connectingThreadIds }; + }), }); diff --git a/src/supervisor/runtime/threadSession/steerCoordinator.ts b/src/supervisor/runtime/threadSession/steerCoordinator.ts index 3fe5dc69d..29571602b 100644 --- a/src/supervisor/runtime/threadSession/steerCoordinator.ts +++ b/src/supervisor/runtime/threadSession/steerCoordinator.ts @@ -214,7 +214,11 @@ export class SteerCoordinator { }; // Capability-based: non-interrupting steer enqueues onto the running turn // (subagents survive, no watchdog); others use the interrupt-drain path. - if (session.structuredSession.steerTurn) { + // A renderer can request this path from optimistic `working` state while + // the supervisor is still reconnecting. Native steering is valid only for + // an authoritatively live turn; idle/needs-reply/error must drain as a + // normal turn instead. + if (session.status === "working" && session.structuredSession.steerTurn) { this.steerStructuredTurn(session, turn); return; } diff --git a/src/supervisor/runtime/threadSessionManager.startClose.test.ts b/src/supervisor/runtime/threadSessionManager.startClose.test.ts index 840060896..22607ec6e 100644 --- a/src/supervisor/runtime/threadSessionManager.startClose.test.ts +++ b/src/supervisor/runtime/threadSessionManager.startClose.test.ts @@ -201,6 +201,99 @@ describe("ThreadSessionManager provider-session routing", () => { }); describe("ThreadSessionManager start guards", () => { + it("waits for a reconnect before delivering input to the new live session", async () => { + const activation = deferred(); + const structuredSession = createStructuredSession(activation.promise); + structuredSession.startTurn = vi.fn>( + async () => undefined, + ); + const adapter = createAdapter("codex", structuredSession); + const manager = createManager("codex", adapter); + const start = manager.startThread({ + threadId: "reconnecting-input", + projectLocation: { kind: "windows", path: "C:\\repo" }, + agentKind: "codex", + config: { model: "codex/model" }, + prompt: "", + initialSize: { cols: 80, rows: 24 }, + sessionRef: { + providerSessionId: "ses_existing", + discoveredAt: "2026-08-15T00:00:00.000Z", + }, + presentationMode: "gui", + }); + await vi.waitFor(() => expect(structuredSession.activate).toHaveBeenCalledOnce()); + + const delivered = vi.fn<() => void>(); + const send = manager + .sendThreadInput({ + threadId: "reconnecting-input", + prompt: "send after reconnect", + config: { model: "codex/model" }, + }) + .then(delivered); + await Promise.resolve(); + expect(delivered).not.toHaveBeenCalled(); + + activation.resolve(); + await start; + await send; + expect(structuredSession.startTurn).toHaveBeenCalledWith( + "send after reconnect", + { model: "codex/model" }, + undefined, + { userMessageItemId: expect.stringMatching(/^user-/) }, + ); + }); + + it("reclassifies a premature reconnect steer from authoritative idle state", async () => { + const activation = deferred(); + const structuredSession = createStructuredSession(activation.promise); + structuredSession.startTurn = vi.fn>( + async () => undefined, + ); + structuredSession.interruptTurn = vi.fn>( + async () => undefined, + ); + structuredSession.steerTurn = vi.fn>( + async () => undefined, + ); + const adapter = createAdapter("codex", structuredSession); + const manager = createManager("codex", adapter); + const start = manager.startThread({ + threadId: "reconnecting-steer", + projectLocation: { kind: "windows", path: "C:\\repo" }, + agentKind: "codex", + config: { model: "codex/model" }, + prompt: "", + initialSize: { cols: 80, rows: 24 }, + sessionRef: { + providerSessionId: "ses_existing", + discoveredAt: "2026-08-15T00:00:00.000Z", + }, + presentationMode: "gui", + }); + await vi.waitFor(() => expect(structuredSession.activate).toHaveBeenCalledOnce()); + + const steer = manager.setPendingSteer({ + threadId: "reconnecting-steer", + prompt: "normal turn after reconnect", + config: { model: "codex/model" }, + }); + activation.resolve(); + await start; + await steer; + + expect(structuredSession.startTurn).toHaveBeenCalledWith( + "normal turn after reconnect", + { model: "codex/model" }, + undefined, + { userMessageItemId: expect.stringMatching(/^user-/) }, + ); + expect(structuredSession.interruptTurn).not.toHaveBeenCalled(); + expect(structuredSession.steerTurn).not.toHaveBeenCalled(); + }); + it("lets the IPC boundary exclusively own a structured GUI factory failure", async () => { captureSupervisorException.mockClear(); const structuredSession = createStructuredSession(Promise.resolve()); diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index 465ba241a..0de8365da 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -504,7 +504,7 @@ export class ThreadSessionManager { } async sendThreadInput(payload: SendThreadInputPayload): Promise { - const session = this.sessions.get(payload.threadId); + const session = await this.findSessionAfterPendingStart(payload.threadId); if (!session) { if (this.recentlyRemovedThreadIds.has(payload.threadId)) return; throw new Error(`Unknown thread session: ${payload.threadId}`); @@ -862,7 +862,10 @@ export class ThreadSessionManager { * Drain is automatic on cancelled-stopReason via `maybeDrainPendingSteer`. */ async setPendingSteer(payload: SetPendingSteerPayload): Promise { - const session = this.requireSession(payload.threadId); + const session = await this.findSessionAfterPendingStart(payload.threadId); + if (!session) { + throw new Error(`Unknown thread session: ${payload.threadId}`); + } if (payload.segments === undefined) { await this.steerCoordinator.setPendingSteer(session, payload); return; @@ -1107,6 +1110,23 @@ export class ThreadSessionManager { return session; } + /** + * Input can race an in-progress reconnect before `spawnPipeline` publishes + * the new SessionRuntime. Wait for that thread's serialized start instead of + * surfacing a false "Unknown thread session" error. Callers still decide + * normal-turn vs steer from the authoritative session status after startup. + */ + private async findSessionAfterPendingStart( + threadId: string, + ): Promise { + const live = this.sessions.get(threadId); + if (live) return live; + const pendingStart = this.startLocks.get(threadId); + if (!pendingStart) return undefined; + await pendingStart; + return this.sessions.get(threadId); + } + private rememberRemovedThread(threadId: string): void { this.recentlyRemovedThreadIds.delete(threadId); this.recentlyRemovedThreadIds.add(threadId);