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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-25 22:33:22 +08:00
parent 9746a6893b
commit 76a4ca5d30
10 changed files with 408 additions and 88 deletions

View File

@ -19,8 +19,8 @@ vi.mock('../../pages/Settings', () => ({
})) }))
vi.mock('../../pages/TerminalSettings', () => ({ 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 }) => (
<div data-active={active ? 'true' : 'false'} data-cwd={cwd ?? ''} data-testid={testId}> <div data-active={active ? 'true' : 'false'} data-cwd={cwd ?? ''} data-runtime-id={runtimeId ?? ''} data-testid={testId}>
<button type="button" onClick={onNewTerminal}>New Terminal</button> <button type="button" onClick={onNewTerminal}>New Terminal</button>
</div> </div>
), ),
@ -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-active', 'true')
expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-cwd', '/tmp/project') 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() 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(<ContentRouter />)
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', () => { it('keeps terminal tabs mounted while chat content is active', () => {
useTabStore.setState({ useTabStore.setState({
tabs: [ tabs: [

View File

@ -45,6 +45,7 @@ export function ContentRouter() {
<TerminalSettings <TerminalSettings
active={active} active={active}
cwd={tab.terminalCwd} cwd={tab.terminalCwd}
runtimeId={tab.terminalRuntimeId ?? tab.sessionId}
workspace workspace
testId={`terminal-host-${tab.sessionId}`} testId={`terminal-host-${tab.sessionId}`}
onNewTerminal={() => useTabStore.getState().openTerminalTab(tab.terminalCwd)} onNewTerminal={() => useTabStore.getState().openTerminalTab(tab.terminalCwd)}

View File

@ -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<string, TerminalRuntime>()
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<Pick<TerminalRuntime, 'terminal' | 'fit' | 'nativeSessionId' | 'status' | 'error' | 'shellInfo'>>,
) {
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()
}

View File

@ -39,17 +39,29 @@ vi.mock('../components/workspace/WorkspacePanel', () => ({
vi.mock('./TerminalSettings', () => ({ vi.mock('./TerminalSettings', () => ({
TerminalSettings: ({ TerminalSettings: ({
active,
cwd, cwd,
onOpenInTab, onOpenInTab,
onClose, onClose,
runtimeId,
preserveOnUnmount,
testId, testId,
}: { }: {
active?: boolean
cwd?: string cwd?: string
onOpenInTab?: () => void onOpenInTab?: () => void
onClose?: () => void onClose?: () => void
runtimeId?: string
preserveOnUnmount?: boolean
testId: string testId: string
}) => ( }) => (
<div data-testid={testId} data-cwd={cwd ?? ''}> <div
data-testid={testId}
data-active={active ? 'true' : 'false'}
data-cwd={cwd ?? ''}
data-preserve-on-unmount={preserveOnUnmount ? 'true' : 'false'}
data-runtime-id={runtimeId ?? ''}
>
<button type="button" onClick={onOpenInTab}>Open in Tab</button> <button type="button" onClick={onOpenInTab}>Open in Tab</button>
<button type="button" onClick={onClose}>Close terminal panel</button> <button type="button" onClick={onClose}>Close terminal panel</button>
</div> </div>
@ -840,6 +852,8 @@ describe('ActiveSession task polling', () => {
expect(panel).toHaveStyle({ height: `${TERMINAL_PANEL_DEFAULT_HEIGHT}px` }) expect(panel).toHaveStyle({ height: `${TERMINAL_PANEL_DEFAULT_HEIGHT}px` })
expect(host).toHaveAttribute('data-cwd', '/tmp/project-root/packages/app') 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-valuemin', `${TERMINAL_PANEL_MIN_HEIGHT}`)
expect(resizeHandle).toHaveAttribute('aria-valuemax', `${TERMINAL_PANEL_MAX_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') const terminalTab = useTabStore.getState().tabs.find((tab) => tab.type === 'terminal')
expect(useTerminalPanelStore.getState().isPanelOpen(sessionId)).toBe(false) expect(useTerminalPanelStore.getState().isPanelOpen(sessionId)).toBe(false)
expect(useTerminalPanelStore.getState().getPanelRuntimeId(sessionId)).toBeUndefined()
expect(terminalTab?.terminalCwd).toBe('/tmp/project-root/packages/app') expect(terminalTab?.terminalCwd).toBe('/tmp/project-root/packages/app')
expect(terminalTab?.terminalRuntimeId).toBe(`__session_terminal__${sessionId}`)
expect(useTabStore.getState().activeTabId).toBe(terminalTab?.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(<ActiveSession />)
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}`)
})
}) })

View File

@ -286,6 +286,11 @@ export function ActiveSession() {
? state.isPanelOpen(activeTabId) ? state.isPanelOpen(activeTabId)
: false, : false,
) )
const terminalPanelRuntimeId = useTerminalPanelStore((state) =>
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
? state.panelBySession[activeTabId]?.runtimeId
: undefined,
)
const terminalPanelHeight = useTerminalPanelStore((state) => state.height) const terminalPanelHeight = useTerminalPanelStore((state) => state.height)
useEffect(() => { useEffect(() => {
@ -501,21 +506,27 @@ export function ActiveSession() {
compact={showWorkspacePanel} compact={showWorkspacePanel}
/> />
{showTerminalPanel && activeTabId ? ( {terminalPanelRuntimeId && activeTabId ? (
<div <div
data-testid="session-terminal-panel" data-testid="session-terminal-panel"
className="flex shrink-0 flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]" className={[
style={{ height: terminalPanelHeight }} 'flex shrink-0 flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]',
showTerminalPanel ? '' : 'hidden',
].join(' ')}
style={{ height: showTerminalPanel ? terminalPanelHeight : 0 }}
> >
<TerminalResizeHandle /> {showTerminalPanel && <TerminalResizeHandle />}
<TerminalSettings <TerminalSettings
active active={showTerminalPanel}
docked docked
cwd={getSessionTerminalCwd(session)} cwd={getSessionTerminalCwd(session)}
runtimeId={terminalPanelRuntimeId}
preserveOnUnmount
testId={`session-terminal-host-${activeTabId}`} testId={`session-terminal-host-${activeTabId}`}
onOpenInTab={() => { onOpenInTab={() => {
useTerminalPanelStore.getState().closePanel(activeTabId) useTerminalPanelStore.getState().closePanel(activeTabId)
useTabStore.getState().openTerminalTab(getSessionTerminalCwd(session)) useTabStore.getState().openTerminalTab(getSessionTerminalCwd(session), terminalPanelRuntimeId)
useTerminalPanelStore.getState().detachRuntime(activeTabId)
}} }}
onClose={() => useTerminalPanelStore.getState().closePanel(activeTabId)} onClose={() => useTerminalPanelStore.getState().closePanel(activeTabId)}
/> />

View File

@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../stores/settingsStore' import { useSettingsStore } from '../stores/settingsStore'
import { destroyTerminalRuntime } from '../lib/terminalRuntime'
const terminalMocks = vi.hoisted(() => { const terminalMocks = vi.hoisted(() => {
const terminalInstance = { const terminalInstance = {
@ -160,6 +161,25 @@ describe('TerminalSettings', () => {
expect(terminalMocks.terminalInstance.write).not.toHaveBeenCalledWith('ignored\r\n') 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(<TerminalSettings runtimeId="shared-runtime" preserveOnUnmount />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalledTimes(1))
first.unmount()
expect(terminalMocks.kill).not.toHaveBeenCalled()
render(<TerminalSettings runtimeId="shared-runtime" />)
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', () => { it('shows Windows-only startup shell controls in settings mode', () => {
vi.stubGlobal('navigator', { vi.stubGlobal('navigator', {
...navigator, ...navigator,

View File

@ -1,6 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { useTranslation, type TranslationKey } from '../i18n'
import { terminalApi } from '../api/terminal' import { terminalApi } from '../api/terminal'
import { useSettingsStore } from '../stores/settingsStore' import { useSettingsStore } from '../stores/settingsStore'
@ -8,8 +6,16 @@ import { Dropdown } from '../components/shared/Dropdown'
import { Input } from '../components/shared/Input' import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button' import { Button } from '../components/shared/Button'
import type { DesktopTerminalStartupShell } from '../types/settings' import type { DesktopTerminalStartupShell } from '../types/settings'
import {
type TerminalStatus = 'idle' | 'starting' | 'running' | 'exited' | 'error' | 'unavailable' attachTerminalRuntime,
createLocalTerminalRuntimeId,
destroyTerminalRuntime,
getTerminalRuntime,
subscribeTerminalRuntime,
updateTerminalRuntime,
type TerminalRuntime,
type TerminalStatus,
} from '../lib/terminalRuntime'
const STATUS_LABEL_KEYS: Record<TerminalStatus, TranslationKey> = { const STATUS_LABEL_KEYS: Record<TerminalStatus, TranslationKey> = {
idle: 'settings.terminal.status.idle', idle: 'settings.terminal.status.idle',
@ -30,6 +36,8 @@ type TerminalSettingsProps = {
workspace?: boolean workspace?: boolean
docked?: boolean docked?: boolean
showPreferences?: boolean showPreferences?: boolean
runtimeId?: string
preserveOnUnmount?: boolean
} }
export function TerminalSettings({ export function TerminalSettings({
@ -42,18 +50,27 @@ export function TerminalSettings({
workspace = false, workspace = false,
docked = false, docked = false,
showPreferences = false, showPreferences = false,
runtimeId,
preserveOnUnmount = false,
}: TerminalSettingsProps = {}) { }: TerminalSettingsProps = {}) {
const t = useTranslation() const t = useTranslation()
const desktopTerminal = useSettingsStore((state) => state.desktopTerminal) const desktopTerminal = useSettingsStore((state) => state.desktopTerminal)
const setDesktopTerminal = useSettingsStore((state) => state.setDesktopTerminal) const setDesktopTerminal = useSettingsStore((state) => state.setDesktopTerminal)
const hostRef = useRef<HTMLDivElement | null>(null) const hostRef = useRef<HTMLDivElement | null>(null)
const terminalRef = useRef<XTermTerminal | null>(null) const localRuntimeIdRef = useRef<string | null>(null)
const fitRef = useRef<XTermFitAddon | null>(null) if (!localRuntimeIdRef.current) {
const sessionIdRef = useRef<number | null>(null) localRuntimeIdRef.current = runtimeId ?? createLocalTerminalRuntimeId()
const unlistenRef = useRef<Array<() => void>>([]) }
const [status, setStatus] = useState<TerminalStatus>(() => terminalApi.isAvailable() ? 'idle' : 'unavailable') const effectiveRuntimeId = runtimeId ?? localRuntimeIdRef.current
const [error, setError] = useState<string | null>(null) const runtimeRef = useRef<TerminalRuntime | null>(null)
const [shellInfo, setShellInfo] = useState<{ shell: string; cwd: string } | null>(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<DesktopTerminalStartupShell>(desktopTerminal?.startupShell ?? 'system') const [startupShell, setStartupShell] = useState<DesktopTerminalStartupShell>(desktopTerminal?.startupShell ?? 'system')
const [customShellPath, setCustomShellPath] = useState(desktopTerminal?.customShellPath ?? '') const [customShellPath, setCustomShellPath] = useState(desktopTerminal?.customShellPath ?? '')
const [preferencesError, setPreferencesError] = useState<string | null>(null) const [preferencesError, setPreferencesError] = useState<string | null>(null)
@ -61,6 +78,10 @@ export function TerminalSettings({
const [preferencesSaving, setPreferencesSaving] = useState(false) const [preferencesSaving, setPreferencesSaving] = useState(false)
const isWindows = typeof navigator !== 'undefined' && /Win/i.test(navigator.platform || navigator.userAgent) const isWindows = typeof navigator !== 'undefined' && /Win/i.test(navigator.platform || navigator.userAgent)
useEffect(() => {
return subscribeTerminalRuntime(runtime, () => forceRuntimeUpdate((value) => value + 1))
}, [runtime])
useEffect(() => { useEffect(() => {
setStartupShell(desktopTerminal?.startupShell ?? 'system') setStartupShell(desktopTerminal?.startupShell ?? 'system')
setCustomShellPath(desktopTerminal?.customShellPath ?? '') setCustomShellPath(desktopTerminal?.customShellPath ?? '')
@ -101,40 +122,41 @@ export function TerminalSettings({
], [t]) ], [t])
const resizeSession = useCallback(() => { const resizeSession = useCallback(() => {
const terminal = terminalRef.current const terminal = runtime.terminal
const fit = fitRef.current const fit = runtime.fit
const sessionId = sessionIdRef.current const sessionId = runtime.nativeSessionId
if (!terminal || !fit) return if (!terminal || !fit) return
fit.fit() fit.fit()
if (sessionId) { if (sessionId) {
void terminalApi.resize(sessionId, terminal.cols, terminal.rows).catch(() => {}) void terminalApi.resize(sessionId, terminal.cols, terminal.rows).catch(() => {})
} }
}, []) }, [runtime])
const startTerminal = useCallback(async () => { const startTerminal = useCallback(async () => {
if (!terminalApi.isAvailable()) { if (!terminalApi.isAvailable()) {
setStatus('unavailable') updateTerminalRuntime(runtime, { status: 'unavailable' })
return return
} }
const host = hostRef.current const host = hostRef.current
if (!host) return if (!host) return
setError(null) updateTerminalRuntime(runtime, { error: null, status: 'starting', shellInfo: null })
setStatus('starting')
setShellInfo(null)
const existing = sessionIdRef.current const existing = runtime.nativeSessionId
if (existing) { if (existing) {
await terminalApi.kill(existing).catch(() => {}) await terminalApi.kill(existing).catch(() => {})
sessionIdRef.current = null runtime.nativeSessionId = null
} }
unlistenRef.current.forEach((unlisten) => unlisten()) runtime.dataDisposable?.dispose()
unlistenRef.current = [] runtime.dataDisposable = null
runtime.unlisteners.forEach((unlisten) => unlisten())
runtime.unlisteners = []
terminalRef.current?.dispose() runtime.terminal?.dispose()
fitRef.current = null runtime.terminal = null
runtime.fit = null
host.innerHTML = '' host.innerHTML = ''
const [{ Terminal }, { FitAddon }] = await Promise.all([ const [{ Terminal }, { FitAddon }] = await Promise.all([
@ -175,30 +197,31 @@ export function TerminalSettings({
const fit = new FitAddon() const fit = new FitAddon()
terminal.loadAddon(fit) terminal.loadAddon(fit)
terminal.open(host) terminal.open(host)
terminalRef.current = terminal updateTerminalRuntime(runtime, { terminal, fit })
fitRef.current = fit
fit.fit() fit.fit()
const outputUnlisten = await terminalApi.onOutput((payload) => { const outputUnlisten = await terminalApi.onOutput((payload) => {
if (payload.session_id === sessionIdRef.current) { if (payload.session_id === runtime.nativeSessionId) {
terminal.write(payload.data) terminal.write(payload.data)
} }
}) })
const exitUnlisten = await terminalApi.onExit((payload) => { const exitUnlisten = await terminalApi.onExit((payload) => {
if (payload.session_id !== sessionIdRef.current) return if (payload.session_id !== runtime.nativeSessionId) return
setStatus('exited') updateTerminalRuntime(runtime, { status: 'exited' })
const signal = payload.signal ? `, ${payload.signal}` : '' const signal = payload.signal ? `, ${payload.signal}` : ''
terminal.writeln(`\r\n[process exited: ${payload.code}${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) => { runtime.dataDisposable = terminal.onData((data) => {
const sessionId = sessionIdRef.current const sessionId = runtime.nativeSessionId
if (sessionId) { if (sessionId) {
void terminalApi.write(sessionId, data).catch((err) => { void terminalApi.write(sessionId, data).catch((err) => {
setError(err instanceof Error ? err.message : String(err)) updateTerminalRuntime(runtime, {
setStatus('error') error: err instanceof Error ? err.message : String(err),
status: 'error',
})
}) })
} }
}) })
@ -209,42 +232,46 @@ export function TerminalSettings({
rows: terminal.rows, rows: terminal.rows,
...(cwd ? { cwd } : {}), ...(cwd ? { cwd } : {}),
}) })
sessionIdRef.current = result.session_id updateTerminalRuntime(runtime, {
setShellInfo({ shell: result.shell, cwd: result.cwd }) nativeSessionId: result.session_id,
setStatus('running') shellInfo: { shell: result.shell, cwd: result.cwd },
status: 'running',
})
resizeSession() resizeSession()
} catch (err) { } catch (err) {
outputUnlisten() outputUnlisten()
exitUnlisten() exitUnlisten()
terminal.dispose() terminal.dispose()
terminalRef.current = null updateTerminalRuntime(runtime, {
fitRef.current = null terminal: null,
setError(err instanceof Error ? err.message : String(err)) fit: null,
setStatus('error') error: err instanceof Error ? err.message : String(err),
status: 'error',
})
} }
}, [cwd, resizeSession]) }, [cwd, resizeSession, runtime])
useEffect(() => { useEffect(() => {
if (!terminalApi.isAvailable()) return 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()) const observer = new ResizeObserver(() => resizeSession())
if (hostRef.current) observer.observe(hostRef.current) if (hostRef.current) observer.observe(hostRef.current)
return () => { return () => {
observer.disconnect() observer.disconnect()
const sessionId = sessionIdRef.current if (!preserveOnUnmount) {
if (sessionId) { destroyTerminalRuntime(runtime.id)
void terminalApi.kill(sessionId).catch(() => {})
} }
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(() => { useEffect(() => {
if (active) { if (active) {
@ -253,7 +280,7 @@ export function TerminalSettings({
}, [active, resizeSession]) }, [active, resizeSession])
const clearTerminal = () => { const clearTerminal = () => {
terminalRef.current?.clear() runtime.terminal?.clear()
} }
const savePreferences = async () => { const savePreferences = async () => {
@ -329,7 +356,7 @@ export function TerminalSettings({
<button <button
type="button" type="button"
onClick={clearTerminal} onClick={clearTerminal}
disabled={!terminalRef.current} disabled={!runtime.terminal}
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:opacity-50" className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:opacity-50"
> >
<span className="material-symbols-outlined text-[16px]">mop</span> <span className="material-symbols-outlined text-[16px]">mop</span>

View File

@ -19,4 +19,20 @@ describe('tabStore', () => {
}) })
expect(useTabStore.getState().activeTabId).toBe('session-1') expect(useTabStore.getState().activeTabId).toBe('session-1')
}) })
it('stores a promoted terminal runtime id on new terminal tabs', () => {
const tabId = useTabStore.getState().openTerminalTab('/tmp/project', '__session_terminal__session-1')
expect(useTabStore.getState().tabs).toEqual([
{
sessionId: tabId,
title: 'Terminal 1',
type: 'terminal',
status: 'idle',
terminalCwd: '/tmp/project',
terminalRuntimeId: '__session_terminal__session-1',
},
])
expect(useTabStore.getState().activeTabId).toBe(tabId)
})
}) })

View File

@ -1,6 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import { sessionsApi } from '../api/sessions' import { sessionsApi } from '../api/sessions'
import { dropSession as dropVirtualHeightSession } from '../components/chat/virtualHeightCache' import { dropSession as dropVirtualHeightSession } from '../components/chat/virtualHeightCache'
import { destroyTerminalRuntime } from '../lib/terminalRuntime'
const TAB_STORAGE_KEY = 'cc-haha-open-tabs' const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
@ -16,6 +17,7 @@ export type Tab = {
type: TabType type: TabType
status: 'idle' | 'running' | 'error' status: 'idle' | 'running' | 'error'
terminalCwd?: string terminalCwd?: string
terminalRuntimeId?: string
} }
type TabPersistence = { type TabPersistence = {
@ -28,7 +30,7 @@ type TabStore = {
activeTabId: string | null activeTabId: string | null
openTab: (sessionId: string, title: string, type?: TabType) => void openTab: (sessionId: string, title: string, type?: TabType) => void
openTerminalTab: (cwd?: string) => string openTerminalTab: (cwd?: string, terminalRuntimeId?: string) => string
closeTab: (sessionId: string) => void closeTab: (sessionId: string) => void
setActiveTab: (sessionId: string) => void setActiveTab: (sessionId: string) => void
updateTabTitle: (sessionId: string, title: string) => void updateTabTitle: (sessionId: string, title: string) => void
@ -69,7 +71,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
get().saveTabs() get().saveTabs()
}, },
openTerminalTab: (cwd) => { openTerminalTab: (cwd, terminalRuntimeId) => {
const { tabs } = get() const { tabs } = get()
const nextIndex = Math.max( const nextIndex = Math.max(
0, 0,
@ -82,7 +84,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
) + 1 ) + 1
const sessionId = `${TERMINAL_TAB_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const sessionId = `${TERMINAL_TAB_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
set({ set({
tabs: [...tabs, { sessionId, title: `Terminal ${nextIndex}`, type: 'terminal', status: 'idle', terminalCwd: cwd }], tabs: [...tabs, { sessionId, title: `Terminal ${nextIndex}`, type: 'terminal', status: 'idle', terminalCwd: cwd, terminalRuntimeId }],
activeTabId: sessionId, activeTabId: sessionId,
}) })
get().saveTabs() get().saveTabs()
@ -109,6 +111,10 @@ export const useTabStore = create<TabStore>((set, get) => ({
set({ tabs: newTabs, activeTabId: newActiveId }) set({ tabs: newTabs, activeTabId: newActiveId })
get().saveTabs() get().saveTabs()
const closedTab = tabs[index]
if (closedTab?.type === 'terminal') {
destroyTerminalRuntime(closedTab.terminalRuntimeId ?? closedTab.sessionId)
}
dropVirtualHeightSession(sessionId) dropVirtualHeightSession(sessionId)
}, },

View File

@ -1,11 +1,14 @@
import { create } from 'zustand' import { create } from 'zustand'
import { destroyTerminalRuntime } from '../lib/terminalRuntime'
export const TERMINAL_PANEL_DEFAULT_HEIGHT = 300 export const TERMINAL_PANEL_DEFAULT_HEIGHT = 300
export const TERMINAL_PANEL_MIN_HEIGHT = 220 export const TERMINAL_PANEL_MIN_HEIGHT = 220
export const TERMINAL_PANEL_MAX_HEIGHT = 560 export const TERMINAL_PANEL_MAX_HEIGHT = 560
export const SESSION_TERMINAL_RUNTIME_PREFIX = '__session_terminal__'
type TerminalPanelSessionState = { type TerminalPanelSessionState = {
isOpen: boolean isOpen: boolean
runtimeId?: string
} }
type TerminalPanelStore = { type TerminalPanelStore = {
@ -18,12 +21,18 @@ type TerminalPanelStore = {
togglePanel: (sessionId: string) => void togglePanel: (sessionId: string) => void
setHeight: (height: number) => void setHeight: (height: number) => void
clearSession: (sessionId: string) => void clearSession: (sessionId: string) => void
detachRuntime: (sessionId: string) => void
getPanelRuntimeId: (sessionId: string) => string | undefined
} }
const DEFAULT_PANEL_STATE: TerminalPanelSessionState = { const DEFAULT_PANEL_STATE: TerminalPanelSessionState = {
isOpen: false, isOpen: false,
} }
function createSessionTerminalRuntimeId(sessionId: string) {
return `${SESSION_TERMINAL_RUNTIME_PREFIX}${sessionId}`
}
function getSessionPanelState( function getSessionPanelState(
panelBySession: Record<string, TerminalPanelSessionState | undefined>, panelBySession: Record<string, TerminalPanelSessionState | undefined>,
sessionId: string, sessionId: string,
@ -50,26 +59,33 @@ export const useTerminalPanelStore = create<TerminalPanelStore>((set, get) => ({
isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen, isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen,
openPanel: (sessionId) => openPanel: (sessionId) =>
set((state) => ({ set((state) => {
panelBySession: { const panel = getSessionPanelState(state.panelBySession, sessionId)
...state.panelBySession, return {
[sessionId]: { panelBySession: {
...getSessionPanelState(state.panelBySession, sessionId), ...state.panelBySession,
isOpen: true, [sessionId]: {
...panel,
isOpen: true,
runtimeId: panel.runtimeId ?? createSessionTerminalRuntimeId(sessionId),
},
}, },
}, }
})), }),
closePanel: (sessionId) => closePanel: (sessionId) =>
set((state) => ({ set((state) => {
panelBySession: { const panel = getSessionPanelState(state.panelBySession, sessionId)
...state.panelBySession, return {
[sessionId]: { panelBySession: {
...getSessionPanelState(state.panelBySession, sessionId), ...state.panelBySession,
isOpen: false, [sessionId]: {
...panel,
isOpen: false,
},
}, },
}, }
})), }),
togglePanel: (sessionId) => togglePanel: (sessionId) =>
set((state) => { set((state) => {
@ -80,6 +96,7 @@ export const useTerminalPanelStore = create<TerminalPanelStore>((set, get) => ({
[sessionId]: { [sessionId]: {
...panel, ...panel,
isOpen: !panel.isOpen, isOpen: !panel.isOpen,
runtimeId: panel.runtimeId ?? createSessionTerminalRuntimeId(sessionId),
}, },
}, },
} }
@ -88,7 +105,31 @@ export const useTerminalPanelStore = create<TerminalPanelStore>((set, get) => ({
setHeight: (height) => set({ height: clampTerminalPanelHeight(height) }), setHeight: (height) => set({ height: clampTerminalPanelHeight(height) }),
clearSession: (sessionId) => clearSession: (sessionId) =>
set((state) => ({ set((state) => {
panelBySession: removeRecordKey(state.panelBySession, sessionId), const runtimeId = state.panelBySession[sessionId]?.runtimeId
})), if (runtimeId) {
destroyTerminalRuntime(runtimeId)
}
return {
panelBySession: removeRecordKey(state.panelBySession, sessionId),
}
}),
detachRuntime: (sessionId) =>
set((state) => {
const panel = state.panelBySession[sessionId]
if (!panel) return state
return {
panelBySession: {
...state.panelBySession,
[sessionId]: {
...panel,
isOpen: false,
runtimeId: undefined,
},
},
}
}),
getPanelRuntimeId: (sessionId) => get().panelBySession[sessionId]?.runtimeId,
})) }))