mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
9746a6893b
commit
76a4ca5d30
@ -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: [
|
||||
|
||||
@ -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)}
|
||||
|
||||
107
desktop/src/lib/terminalRuntime.ts
Normal file
107
desktop/src/lib/terminalRuntime.ts
Normal 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()
|
||||
}
|
||||
@ -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}`)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user