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 (
+
+ );
+}
+
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);