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