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({