From 3d3dfdad7600d2e6496b20007ece5d7b3ce727d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 28 Apr 2026 13:35:14 +0800 Subject: [PATCH] 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 --- .../components/layout/ContentRouter.test.tsx | 76 +++++++++++++++++++ .../src/components/layout/ContentRouter.tsx | 57 ++++++++++---- .../src/components/layout/Sidebar.test.tsx | 21 +++++ desktop/src/components/layout/Sidebar.tsx | 10 +++ desktop/src/components/layout/TabBar.test.tsx | 30 ++++++++ desktop/src/components/layout/TabBar.tsx | 37 ++++----- desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/pages/TerminalSettings.test.tsx | 2 + desktop/src/pages/TerminalSettings.tsx | 36 ++++++++- desktop/src/stores/tabStore.ts | 28 ++++++- 11 files changed, 263 insertions(+), 38 deletions(-) create mode 100644 desktop/src/components/layout/ContentRouter.test.tsx diff --git a/desktop/src/components/layout/ContentRouter.test.tsx b/desktop/src/components/layout/ContentRouter.test.tsx new file mode 100644 index 00000000..1c67893e --- /dev/null +++ b/desktop/src/components/layout/ContentRouter.test.tsx @@ -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: () =>
, +})) + +vi.mock('../../pages/ActiveSession', () => ({ + ActiveSession: () =>
, +})) + +vi.mock('../../pages/ScheduledTasks', () => ({ + ScheduledTasks: () =>
, +})) + +vi.mock('../../pages/Settings', () => ({ + Settings: () =>
, +})) + +vi.mock('../../pages/TerminalSettings', () => ({ + TerminalSettings: ({ active, onNewTerminal, testId }: { active: boolean; onNewTerminal: () => void; testId: string }) => ( +
+ +
+ ), +})) + +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() + + 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() + + 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() + 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') + }) +}) diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 6e902210..c79176c1 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -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 + page = + } else if (activeTabType === 'settings') { + page = + } else if (activeTabType === 'scheduled') { + page = + } else if (activeTabType !== 'terminal') { + page = } - // Special tabs - if (activeTabType === 'settings') { - return - } - - if (activeTabType === 'scheduled') { - return - } - - // Session tab — ActiveSession handles both regular and member sessions - return + return ( +
+ {page && ( +
+ {page} +
+ )} + {terminalTabs.map((tab) => { + const active = tab.sessionId === activeTabId + const visible = activeTabType === 'terminal' && active + return ( +
+ useTabStore.getState().openTerminalTab()} + /> +
+ ) + })} +
+ ) } diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index f380c64a..dd665f4d 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -11,6 +11,7 @@ vi.mock('../../i18n', () => ({ const translations: Record = { '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() + + 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')) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index c0add799..9f967e3f 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -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')} + useTabStore.getState().openTerminalTab()} + icon={terminal} + > + {t('sidebar.terminal')} +
{sidebarOpen ? ( diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 79984964..e472e4f6 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -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>) + + await act(async () => { + render() + }) + + expect(screen.getByText('terminal')).toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Close Terminal 1')) + + expect(disconnectSession).not.toHaveBeenCalled() + expect(useTabStore.getState().tabs).toEqual([]) + }) }) diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 87cb0e8f..7da193ae 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -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 = forwardRefschedule )} + {tab.type === 'terminal' && ( + terminal + )} {tab.title || 'Untitled'} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 9bdadb8c..454924c7 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 94c29c67..ee9c0954 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -20,6 +20,7 @@ export const zh: Record = { // ─── Sidebar ────────────────────────────────────── 'sidebar.newSession': '新建会话', 'sidebar.scheduled': '定时任务', + 'sidebar.terminal': '终端', 'sidebar.settings': '设置', 'sidebar.searchPlaceholder': '搜索会话...', 'sidebar.noSessions': '暂无会话', @@ -72,6 +73,7 @@ export const zh: Record = { '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 官方登录页面,授权后自动回到这里。', diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx index 04e72a98..268e4baa 100644 --- a/desktop/src/pages/TerminalSettings.test.tsx +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -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() diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx index e944fb68..afef4605 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -15,7 +15,19 @@ const STATUS_LABEL_KEYS: Record = { 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(null) const terminalRef = useRef(null) @@ -168,13 +180,19 @@ export function TerminalSettings() { } }, [resizeSession, startTerminal]) + useEffect(() => { + if (active) { + requestAnimationFrame(() => resizeSession()) + } + }, [active, resizeSession]) + const clearTerminal = () => { terminalRef.current?.clear() } return ( -
-
+
+

{t('settings.terminal.title')} @@ -184,6 +202,16 @@ export function TerminalSettings() {

+ {onNewTerminal && ( + + )}