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', () => ({
TerminalSettings: ({ active, cwd, onNewTerminal, testId }: { active: boolean; cwd?: string; onNewTerminal: () => void; testId: string }) => (
<div data-active={active ? 'true' : 'false'} data-cwd={cwd ?? ''} data-testid={testId}>
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-runtime-id={runtimeId ?? ''} data-testid={testId}>
<button type="button" onClick={onNewTerminal}>New Terminal</button>
</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-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(<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', () => {
useTabStore.setState({
tabs: [

View File

@ -45,6 +45,7 @@ export function ContentRouter() {
<TerminalSettings
active={active}
cwd={tab.terminalCwd}
runtimeId={tab.terminalRuntimeId ?? tab.sessionId}
workspace
testId={`terminal-host-${tab.sessionId}`}
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', () => ({
TerminalSettings: ({
active,
cwd,
onOpenInTab,
onClose,
runtimeId,
preserveOnUnmount,
testId,
}: {
active?: boolean
cwd?: string
onOpenInTab?: () => void
onClose?: () => void
runtimeId?: string
preserveOnUnmount?: boolean
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={onClose}>Close terminal panel</button>
</div>
@ -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(<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)
: 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 ? (
<div
data-testid="session-terminal-panel"
className="flex shrink-0 flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
style={{ height: terminalPanelHeight }}
className={[
'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
active
active={showTerminalPanel}
docked
cwd={getSessionTerminalCwd(session)}
runtimeId={terminalPanelRuntimeId}
preserveOnUnmount
testId={`session-terminal-host-${activeTabId}`}
onOpenInTab={() => {
useTerminalPanelStore.getState().closePanel(activeTabId)
useTabStore.getState().openTerminalTab(getSessionTerminalCwd(session))
useTabStore.getState().openTerminalTab(getSessionTerminalCwd(session), terminalPanelRuntimeId)
useTerminalPanelStore.getState().detachRuntime(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 { 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(<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', () => {
vi.stubGlobal('navigator', {
...navigator,

View File

@ -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<TerminalStatus, TranslationKey> = {
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<HTMLDivElement | null>(null)
const terminalRef = useRef<XTermTerminal | null>(null)
const fitRef = useRef<XTermFitAddon | null>(null)
const sessionIdRef = useRef<number | null>(null)
const unlistenRef = useRef<Array<() => void>>([])
const [status, setStatus] = useState<TerminalStatus>(() => terminalApi.isAvailable() ? 'idle' : 'unavailable')
const [error, setError] = useState<string | null>(null)
const [shellInfo, setShellInfo] = useState<{ shell: string; cwd: string } | null>(null)
const localRuntimeIdRef = useRef<string | null>(null)
if (!localRuntimeIdRef.current) {
localRuntimeIdRef.current = runtimeId ?? createLocalTerminalRuntimeId()
}
const effectiveRuntimeId = runtimeId ?? localRuntimeIdRef.current
const runtimeRef = useRef<TerminalRuntime | 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 [customShellPath, setCustomShellPath] = useState(desktopTerminal?.customShellPath ?? '')
const [preferencesError, setPreferencesError] = useState<string | null>(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({
<button
type="button"
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"
>
<span className="material-symbols-outlined text-[16px]">mop</span>

View File

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

View File

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