diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts index daec3f5aa5c..614d17fcc63 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts @@ -1,5 +1,6 @@ import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useRef, useState } from "react"; +import { rejectTerminalSessionReady } from "renderer/lib/terminal/session-readiness"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { isTerminalAttachCanceledMessage } from "../attach-cancel"; import { coldRestoreState } from "../state"; @@ -9,6 +10,7 @@ import type { TerminalStreamEvent, } from "../types"; import { scrollToBottom } from "../utils"; +import * as v1TerminalCache from "../v1-terminal-cache"; export interface UseTerminalColdRestoreOptions { paneId: string; @@ -208,6 +210,14 @@ export function useTerminalColdRestore({ pendingInitialStateRef.current = result; maybeApplyInitialState(); + // FORK NOTE: now that handleStartShell has a real backend + // session, mark the v1 cache + session-readiness waiters as + // ready. useTerminalLifecycle.ts intentionally defers this + // for the cold-restore path so that a tab-switch remount + // does not take the isReattach fast-path before a real + // shell exists. + v1TerminalCache.markSessionReady(paneId); + setIsRestoredMode(false); coldRestoreState.delete(paneId); @@ -226,6 +236,10 @@ export function useTerminalColdRestore({ setConnectionError(error.message || "Failed to start shell"); setIsRestoredMode(false); coldRestoreState.delete(paneId); + rejectTerminalSessionReady( + paneId, + new Error(error.message || "Failed to start shell"), + ); isStreamReadyRef.current = true; flushPendingEvents(); }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index d2c16329b39..4b9487b9b18 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -7,7 +7,6 @@ import { writeCommandInPane } from "renderer/lib/terminal/launch-command"; import type { DetectedLink } from "renderer/lib/terminal/links"; import { clearTerminalSessionReady, - markTerminalSessionReady, rejectTerminalSessionReady, } from "renderer/lib/terminal/session-readiness"; import { electronTrpcClient } from "renderer/lib/trpc-client"; @@ -256,9 +255,20 @@ export function useTerminalLifecycle({ // Only treat as reattach when the stream is still alive (subscription ≠ null). // If the stream died while the tab was hidden (onError sets subscription=null), // we must go through the full create/attach path to restart it. + // + // FORK NOTE: Also block the fast-path while the pane is in cold-restore + // mode. Cold restore returns `isColdRestore: true` without creating a + // backend session, but the onSuccess handler below still marks the + // cache as `streamReady=true`. On the next mount (e.g. after a tab + // switch unmount) the fast-path would otherwise skip createOrAttach + // entirely, leaving a stream with no backend → user typing is silently + // dropped by `electronTrpcClient.terminal.write`. Forcing the full + // attach path here lets `handleStartShell` run and spawn a real shell. + const hasPendingColdRestore = coldRestoreState.has(paneId); const isReattach = cachedBeforeCreate?.streamReady === true && - cachedBeforeCreate.subscription !== null; + cachedBeforeCreate.subscription !== null && + !hasPendingColdRestore; if (DEBUG_TERMINAL) { console.log(`[Terminal] isReattach=${isReattach} paneId=${paneId}`); } @@ -602,13 +612,14 @@ export function useTerminalLifecycle({ setConnectionError(null); clearPaneInitialDataRef.current(paneId); - // Start the cache-owned stream subscription now that the - // backend session exists, and mark it ready so events - // flow through the component's registered handler. - v1TerminalCache.startStream(paneId); - v1TerminalCache.setStreamReady(paneId); - markTerminalSessionReady(paneId); - + // FORK NOTE: Do NOT mark the cache as streamReady here + // yet. Cold-restore responses come back without a real + // backend session, so we must defer startStream / + // setStreamReady / markTerminalSessionReady until + // handleStartShell spawns an actual shell. Marking + // readiness up front caused tab-switch remounts to enter + // the isReattach fast-path with no backend, silently + // dropping every user keystroke. const storedColdRestore = coldRestoreState.get(paneId); if (storedColdRestore?.isRestored) { setIsRestoredMode(true); @@ -640,6 +651,10 @@ export function useTerminalLifecycle({ return; } + // Real backend session is live — safe to start the stream + // subscription and unblock waiters. + v1TerminalCache.markSessionReady(paneId); + pendingInitialStateRef.current = result; maybeApplyInitialState(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts index ae61b530d6d..58881426eeb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts @@ -2,6 +2,7 @@ import type { Unsubscribable } from "@trpc/server/observable"; import type { FitAddon } from "@xterm/addon-fit"; import type { SearchAddon } from "@xterm/addon-search"; import type { Terminal as XTerm } from "@xterm/xterm"; +import { markTerminalSessionReady } from "renderer/lib/terminal/session-readiness"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { DEBUG_TERMINAL } from "./config"; import { type CreateTerminalOptions, createTerminalInWrapper } from "./helpers"; @@ -266,6 +267,21 @@ export function setStreamReady(paneId: string): void { } } +/** + * Mark a pane as session-ready: start the tRPC stream, flip the cache's + * `streamReady` flag, and resolve any {@link waitForTerminalSessionReady} + * waiters in one step. + * + * FORK NOTE: centralizes the three-call sequence so the cold-restore and + * normal attach paths can't drift — see useTerminalLifecycle.ts and + * useTerminalColdRestore.ts. + */ +export function markSessionReady(paneId: string): void { + startStream(paneId); + setStreamReady(paneId); + markTerminalSessionReady(paneId); +} + /** * Register event handlers from the mounted Terminal component. * Returns any lifecycle events (exit, error, disconnect) that were