From 76a4ca5d307910b710f0c5ae1daf72ca5c04644a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 25 May 2026 22:33:22 +0800 Subject: [PATCH] fix: preserve desktop terminal sessions across panel moves Users can be typing in the docked terminal when they hide the panel or promote it into a tab. The old component-owned lifecycle treated those UI moves as terminal teardown and spawned a fresh shell afterward. This moves terminal ownership into a small runtime registry keyed by panel or tab identity, keeps docked terminals mounted while hidden, and transfers the runtime id to the terminal tab when promoted. Closing the owning tab or session still releases the PTY. Constraint: Keep native PTY behavior unchanged and fix this in the desktop React lifecycle layer. Rejected: Persist terminal tabs through localStorage | runtime PTYs are process-local and should not be restored after app restart. Confidence: high Scope-risk: moderate Directive: Do not tie terminal process lifetime to panel visibility; only explicit owning-surface close/restart should destroy it. Tested: cd desktop && bun run test -- src/pages/TerminalSettings.test.tsx src/pages/ActiveSession.test.tsx src/components/layout/ContentRouter.test.tsx src/stores/tabStore.test.ts Tested: bun run check:desktop Not-tested: Manual Tauri window PTY smoke. --- .../components/layout/ContentRouter.test.tsx | 23 ++- .../src/components/layout/ContentRouter.tsx | 1 + desktop/src/lib/terminalRuntime.ts | 107 +++++++++++++ desktop/src/pages/ActiveSession.test.tsx | 74 ++++++++- desktop/src/pages/ActiveSession.tsx | 23 ++- desktop/src/pages/TerminalSettings.test.tsx | 20 +++ desktop/src/pages/TerminalSettings.tsx | 141 +++++++++++------- desktop/src/stores/tabStore.test.ts | 16 ++ desktop/src/stores/tabStore.ts | 12 +- desktop/src/stores/terminalPanelStore.ts | 79 +++++++--- 10 files changed, 408 insertions(+), 88 deletions(-) create mode 100644 desktop/src/lib/terminalRuntime.ts diff --git a/desktop/src/components/layout/ContentRouter.test.tsx b/desktop/src/components/layout/ContentRouter.test.tsx index dc0b464d..a4a218e8 100644 --- a/desktop/src/components/layout/ContentRouter.test.tsx +++ b/desktop/src/components/layout/ContentRouter.test.tsx @@ -19,8 +19,8 @@ vi.mock('../../pages/Settings', () => ({ })) vi.mock('../../pages/TerminalSettings', () => ({ - TerminalSettings: ({ active, cwd, onNewTerminal, testId }: { active: boolean; cwd?: string; onNewTerminal: () => void; testId: string }) => ( -
+ TerminalSettings: ({ active, cwd, onNewTerminal, runtimeId, testId }: { active: boolean; cwd?: string; onNewTerminal: () => void; runtimeId?: string; testId: string }) => ( +
), @@ -45,9 +45,28 @@ describe('ContentRouter terminal tabs', () => { expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-active', 'true') expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-cwd', '/tmp/project') + expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-runtime-id', '__terminal__1') expect(screen.queryByTestId('active-session')).not.toBeInTheDocument() }) + it('uses a promoted docked runtime when rendering a terminal tab', () => { + useTabStore.setState({ + tabs: [{ + sessionId: '__terminal__1', + title: 'Terminal 1', + type: 'terminal', + status: 'idle', + terminalCwd: '/tmp/project', + terminalRuntimeId: '__session_terminal__session-1', + }], + activeTabId: '__terminal__1', + }) + + render() + + expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-runtime-id', '__session_terminal__session-1') + }) + it('keeps terminal tabs mounted while chat content is active', () => { useTabStore.setState({ tabs: [ diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 14afe106..19446e64 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -45,6 +45,7 @@ export function ContentRouter() { useTabStore.getState().openTerminalTab(tab.terminalCwd)} diff --git a/desktop/src/lib/terminalRuntime.ts b/desktop/src/lib/terminalRuntime.ts new file mode 100644 index 00000000..81063a56 --- /dev/null +++ b/desktop/src/lib/terminalRuntime.ts @@ -0,0 +1,107 @@ +import type { Terminal as XTermTerminal, IDisposable } from '@xterm/xterm' +import type { FitAddon as XTermFitAddon } from '@xterm/addon-fit' +import { terminalApi } from '../api/terminal' + +export type TerminalStatus = 'idle' | 'starting' | 'running' | 'exited' | 'error' | 'unavailable' + +export type TerminalShellInfo = { + shell: string + cwd: string +} + +export type TerminalRuntime = { + id: string + terminal: XTermTerminal | null + fit: XTermFitAddon | null + nativeSessionId: number | null + unlisteners: Array<() => void> + dataDisposable: IDisposable | null + status: TerminalStatus + error: string | null + shellInfo: TerminalShellInfo | null + listeners: Set<() => void> +} + +const runtimes = new Map() +let localRuntimeCounter = 0 + +export function createLocalTerminalRuntimeId() { + localRuntimeCounter += 1 + return `local-terminal-${localRuntimeCounter}` +} + +export function getTerminalRuntime(id: string, initialStatus: TerminalStatus): TerminalRuntime { + const existing = runtimes.get(id) + if (existing) return existing + + const runtime: TerminalRuntime = { + id, + terminal: null, + fit: null, + nativeSessionId: null, + unlisteners: [], + dataDisposable: null, + status: initialStatus, + error: null, + shellInfo: null, + listeners: new Set(), + } + runtimes.set(id, runtime) + return runtime +} + +export function updateTerminalRuntime( + runtime: TerminalRuntime, + patch: Partial>, +) { + Object.assign(runtime, patch) + notifyTerminalRuntime(runtime) +} + +export function notifyTerminalRuntime(runtime: TerminalRuntime) { + runtime.listeners.forEach((listener) => listener()) +} + +export function subscribeTerminalRuntime(runtime: TerminalRuntime, listener: () => void) { + runtime.listeners.add(listener) + return () => { + runtime.listeners.delete(listener) + } +} + +export function attachTerminalRuntime(runtime: TerminalRuntime, host: HTMLElement) { + const terminal = runtime.terminal + if (!terminal) return + + const element = terminal.element + if (element) { + if (element.parentElement !== host) { + host.replaceChildren(element) + } + } else { + host.innerHTML = '' + terminal.open(host) + } + runtime.fit?.fit() +} + +export function destroyTerminalRuntime(id: string) { + const runtime = runtimes.get(id) + if (!runtime) return + runtimes.delete(id) + + const sessionId = runtime.nativeSessionId + runtime.nativeSessionId = null + if (sessionId) { + void terminalApi.kill(sessionId).catch(() => {}) + } + + runtime.dataDisposable?.dispose() + runtime.dataDisposable = null + runtime.unlisteners.forEach((unlisten) => unlisten()) + runtime.unlisteners = [] + runtime.terminal?.dispose() + runtime.terminal = null + runtime.fit = null + runtime.listeners.clear() +} diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 1b0ebd4a..25313b27 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -39,17 +39,29 @@ vi.mock('../components/workspace/WorkspacePanel', () => ({ vi.mock('./TerminalSettings', () => ({ TerminalSettings: ({ + active, cwd, onOpenInTab, onClose, + runtimeId, + preserveOnUnmount, testId, }: { + active?: boolean cwd?: string onOpenInTab?: () => void onClose?: () => void + runtimeId?: string + preserveOnUnmount?: boolean testId: string }) => ( -
+
@@ -840,6 +852,8 @@ describe('ActiveSession task polling', () => { expect(panel).toHaveStyle({ height: `${TERMINAL_PANEL_DEFAULT_HEIGHT}px` }) expect(host).toHaveAttribute('data-cwd', '/tmp/project-root/packages/app') + expect(host).toHaveAttribute('data-active', 'true') + expect(host).toHaveAttribute('data-preserve-on-unmount', 'true') expect(resizeHandle).toHaveAttribute('aria-valuemin', `${TERMINAL_PANEL_MIN_HEIGHT}`) expect(resizeHandle).toHaveAttribute('aria-valuemax', `${TERMINAL_PANEL_MAX_HEIGHT}`) @@ -885,7 +899,65 @@ describe('ActiveSession task polling', () => { const terminalTab = useTabStore.getState().tabs.find((tab) => tab.type === 'terminal') expect(useTerminalPanelStore.getState().isPanelOpen(sessionId)).toBe(false) + expect(useTerminalPanelStore.getState().getPanelRuntimeId(sessionId)).toBeUndefined() expect(terminalTab?.terminalCwd).toBe('/tmp/project-root/packages/app') + expect(terminalTab?.terminalRuntimeId).toBe(`__session_terminal__${sessionId}`) expect(useTabStore.getState().activeTabId).toBe(terminalTab?.sessionId) }) + + it('keeps the docked terminal mounted when the panel is hidden', async () => { + const sessionId = 'terminal-hide-session' + + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Terminal Hide Session', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 1, + projectPath: '/tmp/project-root', + workDir: '/tmp/project-root', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'Terminal Hide Session', type: 'session', status: 'idle' }], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + useTerminalPanelStore.getState().openPanel(sessionId) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Close terminal panel' })) + + expect(useTerminalPanelStore.getState().isPanelOpen(sessionId)).toBe(false) + expect(screen.getByTestId('session-terminal-panel')).toHaveClass('hidden') + expect(screen.getByTestId(`session-terminal-host-${sessionId}`)).toHaveAttribute('data-active', 'false') + expect(screen.getByTestId(`session-terminal-host-${sessionId}`)).toHaveAttribute('data-runtime-id', `__session_terminal__${sessionId}`) + }) }) diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 3c0e8381..5652ed03 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -286,6 +286,11 @@ export function ActiveSession() { ? state.isPanelOpen(activeTabId) : false, ) + const terminalPanelRuntimeId = useTerminalPanelStore((state) => + activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout + ? state.panelBySession[activeTabId]?.runtimeId + : undefined, + ) const terminalPanelHeight = useTerminalPanelStore((state) => state.height) useEffect(() => { @@ -501,21 +506,27 @@ export function ActiveSession() { compact={showWorkspacePanel} /> - {showTerminalPanel && activeTabId ? ( + {terminalPanelRuntimeId && activeTabId ? (
- + {showTerminalPanel && } { useTerminalPanelStore.getState().closePanel(activeTabId) - useTabStore.getState().openTerminalTab(getSessionTerminalCwd(session)) + useTabStore.getState().openTerminalTab(getSessionTerminalCwd(session), terminalPanelRuntimeId) + useTerminalPanelStore.getState().detachRuntime(activeTabId) }} onClose={() => useTerminalPanelStore.getState().closePanel(activeTabId)} /> diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx index 1942d2fc..b4bcf76f 100644 --- a/desktop/src/pages/TerminalSettings.test.tsx +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSettingsStore } from '../stores/settingsStore' +import { destroyTerminalRuntime } from '../lib/terminalRuntime' const terminalMocks = vi.hoisted(() => { const terminalInstance = { @@ -160,6 +161,25 @@ describe('TerminalSettings', () => { expect(terminalMocks.terminalInstance.write).not.toHaveBeenCalledWith('ignored\r\n') }) + it('can preserve and reattach a running terminal runtime across unmounts', async () => { + terminalMocks.available = true + + const first = render() + await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalledTimes(1)) + + first.unmount() + expect(terminalMocks.kill).not.toHaveBeenCalled() + + render() + + await waitFor(() => { + expect(terminalMocks.terminalInstance.open).toHaveBeenCalledTimes(2) + }) + expect(terminalMocks.spawn).toHaveBeenCalledTimes(1) + + destroyTerminalRuntime('shared-runtime') + }) + it('shows Windows-only startup shell controls in settings mode', () => { vi.stubGlobal('navigator', { ...navigator, diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx index 6fab568f..36fcd44f 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -1,6 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { Terminal as XTermTerminal } from '@xterm/xterm' -import type { FitAddon as XTermFitAddon } from '@xterm/addon-fit' import { useTranslation, type TranslationKey } from '../i18n' import { terminalApi } from '../api/terminal' import { useSettingsStore } from '../stores/settingsStore' @@ -8,8 +6,16 @@ import { Dropdown } from '../components/shared/Dropdown' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import type { DesktopTerminalStartupShell } from '../types/settings' - -type TerminalStatus = 'idle' | 'starting' | 'running' | 'exited' | 'error' | 'unavailable' +import { + attachTerminalRuntime, + createLocalTerminalRuntimeId, + destroyTerminalRuntime, + getTerminalRuntime, + subscribeTerminalRuntime, + updateTerminalRuntime, + type TerminalRuntime, + type TerminalStatus, +} from '../lib/terminalRuntime' const STATUS_LABEL_KEYS: Record = { idle: 'settings.terminal.status.idle', @@ -30,6 +36,8 @@ type TerminalSettingsProps = { workspace?: boolean docked?: boolean showPreferences?: boolean + runtimeId?: string + preserveOnUnmount?: boolean } export function TerminalSettings({ @@ -42,18 +50,27 @@ export function TerminalSettings({ workspace = false, docked = false, showPreferences = false, + runtimeId, + preserveOnUnmount = false, }: TerminalSettingsProps = {}) { const t = useTranslation() const desktopTerminal = useSettingsStore((state) => state.desktopTerminal) const setDesktopTerminal = useSettingsStore((state) => state.setDesktopTerminal) const hostRef = useRef(null) - const terminalRef = useRef(null) - const fitRef = useRef(null) - const sessionIdRef = useRef(null) - const unlistenRef = useRef void>>([]) - const [status, setStatus] = useState(() => terminalApi.isAvailable() ? 'idle' : 'unavailable') - const [error, setError] = useState(null) - const [shellInfo, setShellInfo] = useState<{ shell: string; cwd: string } | null>(null) + const localRuntimeIdRef = useRef(null) + if (!localRuntimeIdRef.current) { + localRuntimeIdRef.current = runtimeId ?? createLocalTerminalRuntimeId() + } + const effectiveRuntimeId = runtimeId ?? localRuntimeIdRef.current + const runtimeRef = useRef(null) + if (!runtimeRef.current || runtimeRef.current.id !== effectiveRuntimeId) { + runtimeRef.current = getTerminalRuntime(effectiveRuntimeId, terminalApi.isAvailable() ? 'idle' : 'unavailable') + } + const runtime = runtimeRef.current + const [, forceRuntimeUpdate] = useState(0) + const status = runtime.status + const error = runtime.error + const shellInfo = runtime.shellInfo const [startupShell, setStartupShell] = useState(desktopTerminal?.startupShell ?? 'system') const [customShellPath, setCustomShellPath] = useState(desktopTerminal?.customShellPath ?? '') const [preferencesError, setPreferencesError] = useState(null) @@ -61,6 +78,10 @@ export function TerminalSettings({ const [preferencesSaving, setPreferencesSaving] = useState(false) const isWindows = typeof navigator !== 'undefined' && /Win/i.test(navigator.platform || navigator.userAgent) + useEffect(() => { + return subscribeTerminalRuntime(runtime, () => forceRuntimeUpdate((value) => value + 1)) + }, [runtime]) + useEffect(() => { setStartupShell(desktopTerminal?.startupShell ?? 'system') setCustomShellPath(desktopTerminal?.customShellPath ?? '') @@ -101,40 +122,41 @@ export function TerminalSettings({ ], [t]) const resizeSession = useCallback(() => { - const terminal = terminalRef.current - const fit = fitRef.current - const sessionId = sessionIdRef.current + const terminal = runtime.terminal + const fit = runtime.fit + const sessionId = runtime.nativeSessionId if (!terminal || !fit) return fit.fit() if (sessionId) { void terminalApi.resize(sessionId, terminal.cols, terminal.rows).catch(() => {}) } - }, []) + }, [runtime]) const startTerminal = useCallback(async () => { if (!terminalApi.isAvailable()) { - setStatus('unavailable') + updateTerminalRuntime(runtime, { status: 'unavailable' }) return } const host = hostRef.current if (!host) return - setError(null) - setStatus('starting') - setShellInfo(null) + updateTerminalRuntime(runtime, { error: null, status: 'starting', shellInfo: null }) - const existing = sessionIdRef.current + const existing = runtime.nativeSessionId if (existing) { await terminalApi.kill(existing).catch(() => {}) - sessionIdRef.current = null + runtime.nativeSessionId = null } - unlistenRef.current.forEach((unlisten) => unlisten()) - unlistenRef.current = [] + runtime.dataDisposable?.dispose() + runtime.dataDisposable = null + runtime.unlisteners.forEach((unlisten) => unlisten()) + runtime.unlisteners = [] - terminalRef.current?.dispose() - fitRef.current = null + runtime.terminal?.dispose() + runtime.terminal = null + runtime.fit = null host.innerHTML = '' const [{ Terminal }, { FitAddon }] = await Promise.all([ @@ -175,30 +197,31 @@ export function TerminalSettings({ const fit = new FitAddon() terminal.loadAddon(fit) terminal.open(host) - terminalRef.current = terminal - fitRef.current = fit + updateTerminalRuntime(runtime, { terminal, fit }) fit.fit() const outputUnlisten = await terminalApi.onOutput((payload) => { - if (payload.session_id === sessionIdRef.current) { + if (payload.session_id === runtime.nativeSessionId) { terminal.write(payload.data) } }) const exitUnlisten = await terminalApi.onExit((payload) => { - if (payload.session_id !== sessionIdRef.current) return - setStatus('exited') + if (payload.session_id !== runtime.nativeSessionId) return + updateTerminalRuntime(runtime, { status: 'exited' }) const signal = payload.signal ? `, ${payload.signal}` : '' terminal.writeln(`\r\n[process exited: ${payload.code}${signal}]`) - sessionIdRef.current = null + updateTerminalRuntime(runtime, { nativeSessionId: null }) }) - unlistenRef.current = [outputUnlisten, exitUnlisten] + runtime.unlisteners = [outputUnlisten, exitUnlisten] - terminal.onData((data) => { - const sessionId = sessionIdRef.current + runtime.dataDisposable = terminal.onData((data) => { + const sessionId = runtime.nativeSessionId if (sessionId) { void terminalApi.write(sessionId, data).catch((err) => { - setError(err instanceof Error ? err.message : String(err)) - setStatus('error') + updateTerminalRuntime(runtime, { + error: err instanceof Error ? err.message : String(err), + status: 'error', + }) }) } }) @@ -209,42 +232,46 @@ export function TerminalSettings({ rows: terminal.rows, ...(cwd ? { cwd } : {}), }) - sessionIdRef.current = result.session_id - setShellInfo({ shell: result.shell, cwd: result.cwd }) - setStatus('running') + updateTerminalRuntime(runtime, { + nativeSessionId: result.session_id, + shellInfo: { shell: result.shell, cwd: result.cwd }, + status: 'running', + }) resizeSession() } catch (err) { outputUnlisten() exitUnlisten() terminal.dispose() - terminalRef.current = null - fitRef.current = null - setError(err instanceof Error ? err.message : String(err)) - setStatus('error') + updateTerminalRuntime(runtime, { + terminal: null, + fit: null, + error: err instanceof Error ? err.message : String(err), + status: 'error', + }) } - }, [cwd, resizeSession]) + }, [cwd, resizeSession, runtime]) useEffect(() => { if (!terminalApi.isAvailable()) return - void startTerminal() + if (runtime.terminal) { + if (hostRef.current) { + attachTerminalRuntime(runtime, hostRef.current) + } + resizeSession() + } else { + void startTerminal() + } const observer = new ResizeObserver(() => resizeSession()) if (hostRef.current) observer.observe(hostRef.current) return () => { observer.disconnect() - const sessionId = sessionIdRef.current - if (sessionId) { - void terminalApi.kill(sessionId).catch(() => {}) + if (!preserveOnUnmount) { + destroyTerminalRuntime(runtime.id) } - terminalRef.current?.dispose() - terminalRef.current = null - fitRef.current = null - unlistenRef.current.forEach((unlisten) => unlisten()) - unlistenRef.current = [] - sessionIdRef.current = null } - }, [resizeSession, startTerminal]) + }, [preserveOnUnmount, resizeSession, runtime, startTerminal]) useEffect(() => { if (active) { @@ -253,7 +280,7 @@ export function TerminalSettings({ }, [active, resizeSession]) const clearTerminal = () => { - terminalRef.current?.clear() + runtime.terminal?.clear() } const savePreferences = async () => { @@ -329,7 +356,7 @@ export function TerminalSettings({