feat: Keep terminal workflows inside desktop tabs

The desktop terminal already supported independent PTY sessions, but it only lived inside settings. This change promotes it to a first-class tab workflow so users can open multiple host terminals without leaving the chat-oriented desktop layout.

Constraint: Tauri terminal sessions are process-backed and must stay mounted while switching tabs.
Rejected: Reuse the settings terminal as a navigated page only | it cannot support multiple independent terminal tabs.
Rejected: Hide inactive xterm panes with display none | xterm lost visible output after tab switches during E2E.
Confidence: high
Scope-risk: moderate
Directive: Keep inactive terminal panes mounted and avoid display none unless xterm repaint behavior is reverified.
Tested: bun run test src/components/layout/ContentRouter.test.tsx src/components/layout/Sidebar.test.tsx src/components/layout/TabBar.test.tsx src/pages/TerminalSettings.test.tsx
Tested: bun run lint
Tested: bun run build
Tested: ./scripts/build-macos-arm64.sh
Tested: Computer Use E2E against build-artifacts/macos-arm64 app for multiple terminals, command output retention, tab switching, and terminal cleanup
Not-tested: Intel macOS package
This commit is contained in:
程序员阿江(Relakkes) 2026-04-28 13:35:14 +08:00
parent e0abea6c46
commit 3d3dfdad76
11 changed files with 263 additions and 38 deletions

View File

@ -0,0 +1,76 @@
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../pages/EmptySession', () => ({
EmptySession: () => <div data-testid="empty-session" />,
}))
vi.mock('../../pages/ActiveSession', () => ({
ActiveSession: () => <div data-testid="active-session" />,
}))
vi.mock('../../pages/ScheduledTasks', () => ({
ScheduledTasks: () => <div data-testid="scheduled-tasks" />,
}))
vi.mock('../../pages/Settings', () => ({
Settings: () => <div data-testid="settings-page" />,
}))
vi.mock('../../pages/TerminalSettings', () => ({
TerminalSettings: ({ active, onNewTerminal, testId }: { active: boolean; onNewTerminal: () => void; testId: string }) => (
<div data-active={active ? 'true' : 'false'} data-testid={testId}>
<button type="button" onClick={onNewTerminal}>New Terminal</button>
</div>
),
}))
import { ContentRouter } from './ContentRouter'
import { useTabStore } from '../../stores/tabStore'
describe('ContentRouter terminal tabs', () => {
afterEach(() => {
useTabStore.setState({ tabs: [], activeTabId: null })
})
it('renders the active terminal tab as main content', () => {
useTabStore.setState({
tabs: [{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }],
activeTabId: '__terminal__1',
})
render(<ContentRouter />)
expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-active', 'true')
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
})
it('keeps terminal tabs mounted while chat content is active', () => {
useTabStore.setState({
tabs: [
{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' },
{ sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' },
],
activeTabId: 'session-1',
})
render(<ContentRouter />)
expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-active', 'false')
expect(screen.getByTestId('active-session')).toBeInTheDocument()
})
it('can open another terminal tab from a terminal page', () => {
useTabStore.setState({
tabs: [{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }],
activeTabId: '__terminal__1',
})
render(<ContentRouter />)
fireEvent.click(screen.getByRole('button', { name: 'New Terminal' }))
expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal')).toHaveLength(2)
expect(useTabStore.getState().activeTabId).not.toBe('__terminal__1')
})
})

View File

@ -1,27 +1,56 @@
import type { ReactNode } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { EmptySession } from '../../pages/EmptySession'
import { ActiveSession } from '../../pages/ActiveSession'
import { ScheduledTasks } from '../../pages/ScheduledTasks'
import { Settings } from '../../pages/Settings'
import { TerminalSettings } from '../../pages/TerminalSettings'
export function ContentRouter() {
const activeTabId = useTabStore((s) => s.activeTabId)
const activeTabType = useTabStore((s) => s.tabs.find((t) => t.sessionId === s.activeTabId)?.type)
const tabs = useTabStore((s) => s.tabs)
const activeTabType = tabs.find((t) => t.sessionId === activeTabId)?.type
const terminalTabs = tabs.filter((tab) => tab.type === 'terminal')
// No tabs open — show empty session
let page: ReactNode = null
if (!activeTabId || !activeTabType) {
return <EmptySession />
page = <EmptySession />
} else if (activeTabType === 'settings') {
page = <Settings />
} else if (activeTabType === 'scheduled') {
page = <ScheduledTasks />
} else if (activeTabType !== 'terminal') {
page = <ActiveSession />
}
// Special tabs
if (activeTabType === 'settings') {
return <Settings />
}
if (activeTabType === 'scheduled') {
return <ScheduledTasks />
}
// Session tab — ActiveSession handles both regular and member sessions
return <ActiveSession />
return (
<div className="relative min-h-0 flex-1 overflow-hidden">
{page && (
<div className="absolute inset-0 z-10 flex min-h-0 flex-col overflow-hidden">
{page}
</div>
)}
{terminalTabs.map((tab) => {
const active = tab.sessionId === activeTabId
const visible = activeTabType === 'terminal' && active
return (
<div
key={tab.sessionId}
aria-hidden={!visible}
data-testid={`terminal-tab-panel-${tab.sessionId}`}
className={`absolute inset-0 flex min-h-0 flex-col overflow-hidden ${
visible ? 'z-20 opacity-100' : 'pointer-events-none z-0 opacity-0'
}`}
>
<TerminalSettings
active={active}
workspace
testId={`terminal-host-${tab.sessionId}`}
onNewTerminal={() => useTabStore.getState().openTerminalTab()}
/>
</div>
)
})}
</div>
)
}

View File

@ -11,6 +11,7 @@ vi.mock('../../i18n', () => ({
const translations: Record<string, string> = {
'sidebar.newSession': 'New Session',
'sidebar.scheduled': 'Scheduled',
'sidebar.terminal': 'Terminal',
'sidebar.settings': 'Settings',
'sidebar.searchPlaceholder': 'Search sessions',
'sidebar.noSessions': 'No sessions',
@ -104,6 +105,26 @@ describe('Sidebar', () => {
expect(screen.getByRole('complementary')).not.toHaveAttribute('data-tauri-drag-region')
})
it('opens each terminal click as a first-class app tab', () => {
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Terminal' }))
fireEvent.click(screen.getByRole('button', { name: 'Terminal' }))
const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal')
expect(terminalTabs).toHaveLength(2)
expect(terminalTabs.map((tab) => tab.title)).toEqual(['Terminal 1', 'Terminal 2'])
expect(useTabStore.getState().activeTabId).toBe(terminalTabs[1]!.sessionId)
useTabStore.getState().closeTab(terminalTabs[0]!.sessionId)
useTabStore.getState().openTerminalTab()
expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal').map((tab) => tab.title)).toEqual([
'Terminal 2',
'Terminal 3',
])
})
it('shows a toast when session creation fails', async () => {
createSession.mockRejectedValue(new Error('boom'))

View File

@ -26,6 +26,7 @@ export function Sidebar() {
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
const activeTabId = useTabStore((s) => s.activeTabId)
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type)
const closeTab = useTabStore((s) => s.closeTab)
const disconnectSession = useChatStore((s) => s.disconnectSession)
const [searchQuery, setSearchQuery] = useState('')
@ -202,6 +203,15 @@ export function Sidebar() {
>
{t('sidebar.scheduled')}
</NavItem>
<NavItem
active={activeTabType === 'terminal'}
collapsed={!sidebarOpen}
label={t('sidebar.terminal')}
onClick={() => useTabStore.getState().openTerminalTab()}
icon={<span className="material-symbols-outlined text-[18px]">terminal</span>}
>
{t('sidebar.terminal')}
</NavItem>
</div>
{sidebarOpen ? (

View File

@ -333,4 +333,34 @@ describe('TabBar', () => {
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-2'])
expect(useTabStore.getState().activeTabId).toBe('tab-2')
})
it('closes terminal tabs without disconnecting chat sessions', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const disconnectSession = vi.fn()
useTabStore.setState({
tabs: [
{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' },
],
activeTabId: '__terminal__1',
})
useChatStore.setState({
sessions: {},
disconnectSession,
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
expect(screen.getByText('terminal')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Close Terminal 1'))
expect(disconnectSession).not.toHaveBeenCalled()
expect(useTabStore.getState().tabs).toEqual([])
})
})

View File

@ -77,7 +77,8 @@ export function TabBar() {
const handleClose = (sessionId: string) => {
// Special tabs can always be closed directly
const tab = tabs.find((t) => t.sessionId === sessionId)
if (tab && tab.type !== 'session') {
if (!tab) return
if (tab.type !== 'session') {
closeTab(sessionId)
return
}
@ -101,39 +102,38 @@ export function TabBar() {
const handleCloseOthers = (sessionId: string) => {
setContextMenu(null)
const otherIds = tabs.filter((t) => t.sessionId !== sessionId).map((t) => t.sessionId)
for (const id of otherIds) {
disconnectSession(id)
closeTab(id)
const otherTabs = tabs.filter((t) => t.sessionId !== sessionId)
for (const tab of otherTabs) {
if (tab.type === 'session') disconnectSession(tab.sessionId)
closeTab(tab.sessionId)
}
}
const handleCloseLeft = (sessionId: string) => {
setContextMenu(null)
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
const leftIds = tabs.slice(0, idx).map((t) => t.sessionId)
for (const id of leftIds) {
disconnectSession(id)
closeTab(id)
const leftTabs = tabs.slice(0, idx)
for (const tab of leftTabs) {
if (tab.type === 'session') disconnectSession(tab.sessionId)
closeTab(tab.sessionId)
}
}
const handleCloseRight = (sessionId: string) => {
setContextMenu(null)
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
const rightIds = tabs.slice(idx + 1).map((t) => t.sessionId)
for (const id of rightIds) {
disconnectSession(id)
closeTab(id)
const rightTabs = tabs.slice(idx + 1)
for (const tab of rightTabs) {
if (tab.type === 'session') disconnectSession(tab.sessionId)
closeTab(tab.sessionId)
}
}
const handleCloseAll = () => {
setContextMenu(null)
const allIds = tabs.map((t) => t.sessionId)
for (const id of allIds) {
disconnectSession(id)
closeTab(id)
for (const tab of tabs) {
if (tab.type === 'session') disconnectSession(tab.sessionId)
closeTab(tab.sessionId)
}
}
@ -402,6 +402,9 @@ const TabItem = forwardRef<HTMLDivElement, {
{tab.type === 'scheduled' && (
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">schedule</span>
)}
{tab.type === 'terminal' && (
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">terminal</span>
)}
<span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
{tab.title || 'Untitled'}

View File

@ -18,6 +18,7 @@ export const en = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': 'New session',
'sidebar.scheduled': 'Scheduled',
'sidebar.terminal': 'Terminal',
'sidebar.settings': 'Settings',
'sidebar.searchPlaceholder': 'Search sessions...',
'sidebar.noSessions': 'No sessions yet',
@ -70,6 +71,7 @@ export const en = {
'settings.terminal.status.exited': 'Exited',
'settings.terminal.status.error': 'Error',
'settings.terminal.status.unavailable': 'Unavailable',
'terminal.newTab': 'New Terminal',
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.',

View File

@ -20,6 +20,7 @@ export const zh: Record<TranslationKey, string> = {
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建会话',
'sidebar.scheduled': '定时任务',
'sidebar.terminal': '终端',
'sidebar.settings': '设置',
'sidebar.searchPlaceholder': '搜索会话...',
'sidebar.noSessions': '暂无会话',
@ -72,6 +73,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.terminal.status.exited': '已退出',
'settings.terminal.status.error': '错误',
'settings.terminal.status.unavailable': '不可用',
'terminal.newTab': '新建终端',
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。',

View File

@ -1,6 +1,7 @@
import { act, 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'
const terminalMocks = vi.hoisted(() => {
const terminalInstance = {
@ -54,6 +55,7 @@ import { TerminalSettings } from './TerminalSettings'
describe('TerminalSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
terminalMocks.available = false
terminalMocks.spawn.mockReset()
terminalMocks.write.mockReset()

View File

@ -15,7 +15,19 @@ const STATUS_LABEL_KEYS: Record<TerminalStatus, TranslationKey> = {
unavailable: 'settings.terminal.status.unavailable',
}
export function TerminalSettings() {
type TerminalSettingsProps = {
active?: boolean
onNewTerminal?: () => void
testId?: string
workspace?: boolean
}
export function TerminalSettings({
active = true,
onNewTerminal,
testId = 'settings-terminal-host',
workspace = false,
}: TerminalSettingsProps = {}) {
const t = useTranslation()
const hostRef = useRef<HTMLDivElement | null>(null)
const terminalRef = useRef<XTermTerminal | null>(null)
@ -168,13 +180,19 @@ export function TerminalSettings() {
}
}, [resizeSession, startTerminal])
useEffect(() => {
if (active) {
requestAnimationFrame(() => resizeSession())
}
}, [active, resizeSession])
const clearTerminal = () => {
terminalRef.current?.clear()
}
return (
<div className="flex h-full min-h-[620px] flex-col overflow-hidden">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className={`flex h-full flex-col overflow-hidden ${workspace ? 'min-h-0 bg-[var(--color-surface)] px-5 py-4' : 'min-h-[620px]'}`}>
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
{t('settings.terminal.title')}
@ -184,6 +202,16 @@ export function TerminalSettings() {
</p>
</div>
<div className="flex items-center gap-2">
{onNewTerminal && (
<button
type="button"
onClick={onNewTerminal}
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)]"
>
<span className="material-symbols-outlined text-[16px]">add</span>
{t('terminal.newTab')}
</button>
)}
<button
type="button"
onClick={clearTerminal}
@ -247,7 +275,7 @@ export function TerminalSettings() {
</div>
<div
ref={hostRef}
data-testid="settings-terminal-host"
data-testid={testId}
className="settings-terminal-host h-[calc(100%-2rem)] w-full overflow-hidden p-2"
/>
</div>

View File

@ -5,8 +5,9 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
export const SETTINGS_TAB_ID = '__settings__'
export const SCHEDULED_TAB_ID = '__scheduled__'
export const TERMINAL_TAB_PREFIX = '__terminal__'
export type TabType = 'session' | 'settings' | 'scheduled'
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal'
export type Tab = {
sessionId: string
@ -25,6 +26,7 @@ type TabStore = {
activeTabId: string | null
openTab: (sessionId: string, title: string, type?: TabType) => void
openTerminalTab: () => string
closeTab: (sessionId: string) => void
setActiveTab: (sessionId: string) => void
updateTabTitle: (sessionId: string, title: string) => void
@ -54,6 +56,22 @@ export const useTabStore = create<TabStore>((set, get) => ({
get().saveTabs()
},
openTerminalTab: () => {
const { tabs } = get()
const nextIndex = Math.max(
0,
...tabs
.filter((tab) => tab.type === 'terminal')
.map((tab) => {
const match = /^Terminal (\d+)$/.exec(tab.title)
return match ? Number(match[1]) : 0
}),
) + 1
const sessionId = `${TERMINAL_TAB_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
get().openTab(sessionId, `Terminal ${nextIndex}`, 'terminal')
return sessionId
},
closeTab: (sessionId) => {
const { tabs, activeTabId } = get()
const index = tabs.findIndex((t) => t.sessionId === sessionId)
@ -118,9 +136,12 @@ export const useTabStore = create<TabStore>((set, get) => ({
saveTabs: () => {
const { tabs, activeTabId } = get()
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal')
const data: TabPersistence = {
openTabs: tabs.map((t) => ({ sessionId: t.sessionId, title: t.title, type: t.type })),
activeTabId,
openTabs: persistableTabs.map((t) => ({ sessionId: t.sessionId, title: t.title, type: t.type })),
activeTabId: activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId)
? activeTabId
: (persistableTabs[0]?.sessionId ?? null),
}
try {
localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(data))
@ -142,6 +163,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
.filter((t) => {
// Special tabs are always valid
if (t.type === 'settings' || t.type === 'scheduled') return true
if (t.type === 'terminal') return false
// Session tabs must exist on server
return existingIds.has(t.sessionId)
})