From 35f9f0d0f82be8c3907f9cb8b341b14a318e9f3c 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, 5 May 2026 19:05:36 +0800 Subject: [PATCH] feat: make project terminals open where users work Desktop terminal access should behave like an IDE: active project sessions open a bottom panel in the session working directory while keeping a full terminal tab available for dedicated use. The panel has constrained resizing and cleanup so session tab state remains isolated, and terminal guidance points users to the bundled claude-haha command for extension setup. Constraint: Desktop bundles the user-facing CLI as claude-haha while claude-sidecar remains internal Rejected: Always opening a standalone terminal tab | loses the current project context and diverges from common IDE behavior Rejected: Exposing claude-sidecar in terminal guidance | it is an internal launcher, not the supportable user command Confidence: high Scope-risk: moderate Directive: Keep bottom terminals keyed by session id and pass session workDir/projectPath into spawned terminals Tested: bun run check:desktop Tested: git diff --check Tested: Computer Use E2E against built macOS app during implementation Not-tested: bun run quality:pr is blocked by existing branch policy requiring allow-cli-core-change approval --- desktop/src/api/terminal.ts | 6 +- desktop/src/components/layout/AppShell.tsx | 5 +- .../components/layout/ContentRouter.test.tsx | 10 +- .../src/components/layout/ContentRouter.tsx | 3 +- desktop/src/components/layout/Sidebar.tsx | 2 +- desktop/src/components/layout/TabBar.test.tsx | 41 ++++- desktop/src/components/layout/TabBar.tsx | 53 +++++-- desktop/src/i18n/locales/en.ts | 5 +- desktop/src/i18n/locales/zh.ts | 5 +- desktop/src/lib/desktopRuntime.ts | 2 +- desktop/src/pages/ActiveSession.test.tsx | 128 +++++++++++++++- desktop/src/pages/ActiveSession.tsx | 141 +++++++++++++++++- desktop/src/pages/TerminalSettings.test.tsx | 15 ++ desktop/src/pages/TerminalSettings.tsx | 66 ++++++-- desktop/src/stores/tabStore.ts | 20 ++- desktop/src/stores/terminalPanelStore.ts | 94 ++++++++++++ 16 files changed, 548 insertions(+), 48 deletions(-) create mode 100644 desktop/src/stores/terminalPanelStore.ts diff --git a/desktop/src/api/terminal.ts b/desktop/src/api/terminal.ts index cd09a6fa..0daf87ee 100644 --- a/desktop/src/api/terminal.ts +++ b/desktop/src/api/terminal.ts @@ -23,7 +23,7 @@ async function invoke(command: string, args?: Record): Promi if (!isTauriRuntime()) { throw new Error('Terminal is available in the desktop app runtime.') } - const api = await import(/* @vite-ignore */ '@tauri-apps/api/core') + const api = await import('@tauri-apps/api/core') return api.invoke(command, args) } @@ -47,12 +47,12 @@ export const terminalApi = { }, async onOutput(handler: (payload: TerminalOutputPayload) => void): Promise { - const events = await import(/* @vite-ignore */ '@tauri-apps/api/event') + const events = await import('@tauri-apps/api/event') return events.listen('terminal-output', (event) => handler(event.payload)) }, async onExit(handler: (payload: TerminalExitPayload) => void): Promise { - const events = await import(/* @vite-ignore */ '@tauri-apps/api/event') + const events = await import('@tauri-apps/api/event') return events.listen('terminal-exit', (event) => handler(event.payload)) }, } diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index a1af3279..3ec36cd5 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -6,7 +6,7 @@ import { UpdateChecker } from '../shared/UpdateChecker' import { useSettingsStore } from '../../stores/settingsStore' import { useUIStore, type SettingsTab } from '../../stores/uiStore' import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts' -import { initializeDesktopServerUrl } from '../../lib/desktopRuntime' +import { initializeDesktopServerUrl, isTauriRuntime } from '../../lib/desktopRuntime' import { TabBar } from './TabBar' import { StartupErrorView } from './StartupErrorView' import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore' @@ -55,8 +55,9 @@ export function AppShell() { // Listen for macOS native menu navigation events (About / Settings) useEffect(() => { + if (!isTauriRuntime()) return let unlisten: (() => void) | undefined - import(/* @vite-ignore */ '@tauri-apps/api/event') + import('@tauri-apps/api/event') .then(({ listen }) => listen('native-menu-navigate', (event) => { const target = event.payload as SettingsTab | 'settings' diff --git a/desktop/src/components/layout/ContentRouter.test.tsx b/desktop/src/components/layout/ContentRouter.test.tsx index 1c67893e..f1993d2f 100644 --- a/desktop/src/components/layout/ContentRouter.test.tsx +++ b/desktop/src/components/layout/ContentRouter.test.tsx @@ -19,8 +19,8 @@ vi.mock('../../pages/Settings', () => ({ })) vi.mock('../../pages/TerminalSettings', () => ({ - TerminalSettings: ({ active, onNewTerminal, testId }: { active: boolean; onNewTerminal: () => void; testId: string }) => ( -
+ TerminalSettings: ({ active, cwd, onNewTerminal, testId }: { active: boolean; cwd?: string; onNewTerminal: () => void; testId: string }) => ( +
), @@ -36,13 +36,14 @@ describe('ContentRouter terminal tabs', () => { it('renders the active terminal tab as main content', () => { useTabStore.setState({ - tabs: [{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }], + tabs: [{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle', terminalCwd: '/tmp/project' }], activeTabId: '__terminal__1', }) render() 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.queryByTestId('active-session')).not.toBeInTheDocument() }) @@ -63,7 +64,7 @@ describe('ContentRouter terminal tabs', () => { it('can open another terminal tab from a terminal page', () => { useTabStore.setState({ - tabs: [{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' }], + tabs: [{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle', terminalCwd: '/tmp/project' }], activeTabId: '__terminal__1', }) @@ -72,5 +73,6 @@ describe('ContentRouter terminal tabs', () => { expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal')).toHaveLength(2) expect(useTabStore.getState().activeTabId).not.toBe('__terminal__1') + expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === useTabStore.getState().activeTabId)?.terminalCwd).toBe('/tmp/project') }) }) diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index c79176c1..14afe106 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -44,9 +44,10 @@ export function ContentRouter() { > useTabStore.getState().openTerminalTab()} + onNewTerminal={() => useTabStore.getState().openTerminalTab(tab.terminalCwd)} />
) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index c8433899..c20a822c 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -102,7 +102,7 @@ export function Sidebar() { useEffect(() => { if (!isTauri) return - import(/* @vite-ignore */ '@tauri-apps/api/window') + import('@tauri-apps/api/window') .then(({ getCurrentWindow }) => { const win = getCurrentWindow() startDraggingRef.current = () => win.startDragging() diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 1239d821..12a1f028 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -74,12 +74,14 @@ describe('TabBar', () => { const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') + const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore') useTabStore.setState({ tabs: [], activeTabId: null }) useChatStore.setState({ sessions: {}, } as Partial>) useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) + useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true) delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__ }) @@ -394,10 +396,11 @@ describe('TabBar', () => { expect(useTabStore.getState().tabs).toEqual([]) }) - it('opens a terminal tab from the toolbar', async () => { + it('opens the bottom terminal panel from the toolbar for an active session', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') + const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore') useTabStore.setState({ tabs: [ @@ -417,8 +420,35 @@ describe('TabBar', () => { fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' })) const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal') - expect(terminalTabs).toHaveLength(1) - expect(useTabStore.getState().activeTabId).toBe(terminalTabs[0]?.sessionId) + expect(terminalTabs).toHaveLength(0) + expect(useTerminalPanelStore.getState().isPanelOpen('tab-1')).toBe(true) + }) + + it('treats legacy session tabs without a type as bottom-panel terminal targets', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'legacy-session', title: 'Legacy Session', status: 'idle' } as ReturnType['tabs'][number], + ], + activeTabId: 'legacy-session', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' })) + + expect(useTabStore.getState().tabs.some((tab) => tab.type === 'terminal')).toBe(false) + expect(useTerminalPanelStore.getState().isPanelOpen('legacy-session')).toBe(true) }) it('toggles the workspace panel for the active session from the toolbar', async () => { @@ -478,11 +508,12 @@ describe('TabBar', () => { expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument() }) - it('clears workspace panel state when closing a session tab', async () => { + it('clears session panel state when closing a session tab', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') + const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore') useTabStore.setState({ tabs: [ @@ -495,6 +526,7 @@ describe('TabBar', () => { disconnectSession: vi.fn(), } as Partial>) useWorkspacePanelStore.getState().openPanel('tab-1') + useTerminalPanelStore.getState().openPanel('tab-1') await act(async () => { render() @@ -503,5 +535,6 @@ describe('TabBar', () => { fireEvent.click(screen.getByLabelText('Close First Session')) expect(useWorkspacePanelStore.getState().panelBySession['tab-1']).toBeUndefined() + expect(useTerminalPanelStore.getState().panelBySession['tab-1']).toBeUndefined() }) }) diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 4b52bbdc..2eaface7 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -1,7 +1,14 @@ import { forwardRef, useRef, useState, useEffect, useCallback } from 'react' -import { useTabStore, type Tab } from '../../stores/tabStore' +import { + SCHEDULED_TAB_ID, + SETTINGS_TAB_ID, + TERMINAL_TAB_PREFIX, + useTabStore, + type Tab, +} from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' +import { useTerminalPanelStore } from '../../stores/terminalPanelStore' import { useTranslation } from '../../i18n' import { WindowControls, showWindowControls } from './WindowControls' import { Folder, FolderOpen, SquareTerminal } from 'lucide-react' @@ -10,6 +17,21 @@ const TAB_WIDTH = 180 const DRAG_START_THRESHOLD = 4 const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) +function isSessionTab(tab: Tab | null) { + if (!tab) return false + const tabType = (tab as Partial).type + if (tabType === 'session') return true + if (tabType) return false + return isSessionTabId(tab.sessionId) +} + +function isSessionTabId(tabId: string | null) { + if (!tabId) return false + return tabId !== SETTINGS_TAB_ID && + tabId !== SCHEDULED_TAB_ID && + !tabId.startsWith(TERMINAL_TAB_PREFIX) +} + export function TabBar() { const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) @@ -17,10 +39,13 @@ export function TabBar() { const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null - const isActiveSessionTab = activeTab?.type === 'session' + const isActiveSessionTab = isSessionTab(activeTab) || isSessionTabId(activeTabId) const isWorkspacePanelOpen = useWorkspacePanelStore((state) => activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, ) + const isTerminalPanelOpen = useTerminalPanelStore((state) => + activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, + ) const moveTab = useTabStore((s) => s.moveTab) const scrollRef = useRef(null) @@ -40,7 +65,7 @@ export function TabBar() { useEffect(() => { if (!isTauri) return - import(/* @vite-ignore */ '@tauri-apps/api/window') + import('@tauri-apps/api/window') .then(({ getCurrentWindow }) => { const win = getCurrentWindow() startDraggingRef.current = () => win.startDragging() @@ -82,8 +107,9 @@ export function TabBar() { } const closeTabWithCleanup = useCallback((tab: Tab) => { - if (tab.type === 'session') { + if (isSessionTab(tab)) { useWorkspacePanelStore.getState().clearSession(tab.sessionId) + useTerminalPanelStore.getState().clearSession(tab.sessionId) } closeTab(tab.sessionId) }, [closeTab]) @@ -92,7 +118,7 @@ export function TabBar() { // Special tabs can always be closed directly const tab = tabs.find((t) => t.sessionId === sessionId) if (!tab) return - if (tab.type !== 'session') { + if (!isSessionTab(tab)) { closeTabWithCleanup(tab) return } @@ -118,7 +144,7 @@ export function TabBar() { setContextMenu(null) const otherTabs = tabs.filter((t) => t.sessionId !== sessionId) for (const tab of otherTabs) { - if (tab.type === 'session') disconnectSession(tab.sessionId) + if (isSessionTab(tab)) disconnectSession(tab.sessionId) closeTabWithCleanup(tab) } } @@ -128,7 +154,7 @@ export function TabBar() { const idx = tabs.findIndex((t) => t.sessionId === sessionId) const leftTabs = tabs.slice(0, idx) for (const tab of leftTabs) { - if (tab.type === 'session') disconnectSession(tab.sessionId) + if (isSessionTab(tab)) disconnectSession(tab.sessionId) closeTabWithCleanup(tab) } } @@ -138,7 +164,7 @@ export function TabBar() { const idx = tabs.findIndex((t) => t.sessionId === sessionId) const rightTabs = tabs.slice(idx + 1) for (const tab of rightTabs) { - if (tab.type === 'session') disconnectSession(tab.sessionId) + if (isSessionTab(tab)) disconnectSession(tab.sessionId) closeTabWithCleanup(tab) } } @@ -146,7 +172,7 @@ export function TabBar() { const handleCloseAll = () => { setContextMenu(null) for (const tab of tabs) { - if (tab.type === 'session') disconnectSession(tab.sessionId) + if (isSessionTab(tab)) disconnectSession(tab.sessionId) closeTabWithCleanup(tab) } } @@ -281,7 +307,14 @@ export function TabBar() { } label={t('tabs.openTerminal')} - onClick={() => useTabStore.getState().openTerminalTab()} + onClick={() => { + if (activeTabId && isActiveSessionTab) { + useTerminalPanelStore.getState().togglePanel(activeTabId) + return + } + useTabStore.getState().openTerminalTab() + }} + active={isTerminalPanelOpen} /> {isActiveSessionTab && activeTabId && ( Terminal 'settings.terminal.title': 'Terminal', - 'settings.terminal.description': 'Run host-machine commands for plugin, skill, and MCP setup without leaving the desktop app.', + 'settings.terminal.description': 'Run host-machine commands for plugin, skill, and MCP setup. The desktop app includes claude-haha; replace documented claude with claude-haha , for example: claude-haha plugin install ... or claude-haha mcp add ...', 'settings.terminal.clear': 'Clear', 'settings.terminal.restart': 'Restart', 'settings.terminal.windowTitle': 'Host shell', @@ -111,6 +111,9 @@ export const en = { 'settings.terminal.status.error': 'Error', 'settings.terminal.status.unavailable': 'Unavailable', 'terminal.newTab': 'New Terminal', + 'terminal.openInTab': 'Open in Tab', + 'terminal.closePanel': 'Close terminal panel', + 'terminal.resizePanel': 'Resize terminal panel', // Settings > Diagnostics 'settings.diagnostics.title': 'Diagnostics', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 83ccd44e..a38f95a8 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -100,7 +100,7 @@ export const zh: Record = { // Settings > Terminal 'settings.terminal.title': '终端', - 'settings.terminal.description': '直接运行宿主机命令,用于安装插件、Skills、MCP 等需要命令行的扩展。', + 'settings.terminal.description': '直接运行宿主机命令,用于安装插件、Skills、MCP 等扩展。桌面端已内置 claude-haha;文档里的 claude <参数> 可替换成 claude-haha <参数>,例如 claude-haha plugin install ... 或 claude-haha mcp add ...', 'settings.terminal.clear': '清屏', 'settings.terminal.restart': '重启', 'settings.terminal.windowTitle': '宿主机 Shell', @@ -113,6 +113,9 @@ export const zh: Record = { 'settings.terminal.status.error': '错误', 'settings.terminal.status.unavailable': '不可用', 'terminal.newTab': '新建终端', + 'terminal.openInTab': '在 Tab 中打开', + 'terminal.closePanel': '关闭终端面板', + 'terminal.resizePanel': '调整终端面板大小', // Settings > Diagnostics 'settings.diagnostics.title': '诊断', diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index b6227f95..630aec95 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -20,7 +20,7 @@ export async function initializeDesktopServerUrl() { } try { - const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core') + const { invoke } = await import('@tauri-apps/api/core') const serverUrl = await invoke('get_server_url') setBaseUrl(serverUrl) await waitForHealth(serverUrl) diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index ac37f76f..44d9b585 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, within } from '@testing-library/react' +import { createEvent, fireEvent, render, screen, within } from '@testing-library/react' import '@testing-library/jest-dom' import { act } from 'react' @@ -29,6 +29,25 @@ vi.mock('../components/workspace/WorkspacePanel', () => ({ ), })) +vi.mock('./TerminalSettings', () => ({ + TerminalSettings: ({ + cwd, + onOpenInTab, + onClose, + testId, + }: { + cwd?: string + onOpenInTab?: () => void + onClose?: () => void + testId: string + }) => ( +
+ + +
+ ), +})) + import { ActiveSession } from './ActiveSession' import { useChatStore } from '../stores/chatStore' import { useCLITaskStore } from '../stores/cliTaskStore' @@ -37,6 +56,12 @@ import { useTabStore } from '../stores/tabStore' import { useTeamStore } from '../stores/teamStore' import { useWorkspacePanelStore } from '../stores/workspacePanelStore' import { WORKSPACE_PANEL_DEFAULT_WIDTH } from '../stores/workspacePanelStore' +import { useTerminalPanelStore } from '../stores/terminalPanelStore' +import { + TERMINAL_PANEL_DEFAULT_HEIGHT, + TERMINAL_PANEL_MAX_HEIGHT, + TERMINAL_PANEL_MIN_HEIGHT, +} from '../stores/terminalPanelStore' afterEach(() => { vi.useRealTimers() @@ -45,6 +70,7 @@ afterEach(() => { useChatStore.setState({ sessions: {} }) useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null }) useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) + useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true) }) describe('ActiveSession task polling', () => { @@ -367,4 +393,104 @@ describe('ActiveSession task polling', () => { expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument() expect(screen.getByTestId('message-list')).toBeInTheDocument() }) + + it('renders a bottom terminal panel in the current session cwd and can promote it to a tab', async () => { + const sessionId = 'terminal-session' + + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Terminal 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/packages/app', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'Terminal Session', status: 'idle' } as ReturnType['tabs'][number]], + 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() + + const panel = screen.getByTestId('session-terminal-panel') + const resizeHandle = screen.getByTestId('terminal-resize-handle') + const host = screen.getByTestId(`session-terminal-host-${sessionId}`) + + expect(panel).toHaveStyle({ height: `${TERMINAL_PANEL_DEFAULT_HEIGHT}px` }) + expect(host).toHaveAttribute('data-cwd', '/tmp/project-root/packages/app') + expect(resizeHandle).toHaveAttribute('aria-valuemin', `${TERMINAL_PANEL_MIN_HEIGHT}`) + expect(resizeHandle).toHaveAttribute('aria-valuemax', `${TERMINAL_PANEL_MAX_HEIGHT}`) + + act(() => { + fireEvent.keyDown(resizeHandle, { key: 'ArrowUp' }) + }) + expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_DEFAULT_HEIGHT + 24) + + await act(async () => { + const pointerDown = createEvent.pointerDown(resizeHandle) + Object.defineProperty(pointerDown, 'button', { value: 0 }) + Object.defineProperty(pointerDown, 'clientY', { value: 300 }) + fireEvent(resizeHandle, pointerDown) + }) + + await act(async () => { + const pointerMove = new Event('pointermove') + Object.defineProperty(pointerMove, 'clientY', { value: 260 }) + window.dispatchEvent(pointerMove) + window.dispatchEvent(new Event('pointerup')) + }) + expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_DEFAULT_HEIGHT + 64) + + act(() => { + fireEvent.keyDown(resizeHandle, { key: 'End' }) + }) + expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_MAX_HEIGHT) + + act(() => { + fireEvent.keyDown(resizeHandle, { key: 'Home' }) + }) + expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_MIN_HEIGHT) + + act(() => { + fireEvent.doubleClick(resizeHandle) + }) + expect(useTerminalPanelStore.getState().height).toBe(TERMINAL_PANEL_DEFAULT_HEIGHT) + + fireEvent.click(screen.getByRole('button', { name: 'Open in Tab' })) + + const terminalTab = useTabStore.getState().tabs.find((tab) => tab.type === 'terminal') + expect(useTerminalPanelStore.getState().isPanelOpen(sessionId)).toBe(false) + expect(terminalTab?.terminalCwd).toBe('/tmp/project-root/packages/app') + expect(useTabStore.getState().activeTabId).toBe(terminalTab?.sessionId) + }) }) diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 34a181ea..8ceb5fb1 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -1,10 +1,22 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { useTabStore } from '../stores/tabStore' +import { + SCHEDULED_TAB_ID, + SETTINGS_TAB_ID, + TERMINAL_TAB_PREFIX, + useTabStore, + type TabType, +} from '../stores/tabStore' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { useCLITaskStore } from '../stores/cliTaskStore' import { useTeamStore } from '../stores/teamStore' import { useWorkspacePanelStore } from '../stores/workspacePanelStore' +import { + TERMINAL_PANEL_DEFAULT_HEIGHT, + TERMINAL_PANEL_MAX_HEIGHT, + TERMINAL_PANEL_MIN_HEIGHT, + useTerminalPanelStore, +} from '../stores/terminalPanelStore' import { useTranslation } from '../i18n' import { MessageList } from '../components/chat/MessageList' import { ChatInput } from '../components/chat/ChatInput' @@ -12,12 +24,30 @@ import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermis import { SessionTaskBar } from '../components/chat/SessionTaskBar' import { WorkspacePanel } from '../components/workspace/WorkspacePanel' import { TeamStatusBar } from '../components/teams/TeamStatusBar' +import { TerminalSettings } from './TerminalSettings' +import type { SessionListItem } from '../types/session' const TASK_POLL_INTERVAL_MS = 1000 const WORKSPACE_RESIZE_STEP = 32 +const TERMINAL_RESIZE_STEP = 24 const CHAT_COLUMN_WITH_WORKSPACE_CLASS = 'min-w-[320px] flex-1 border-r border-[var(--color-border)] bg-[var(--color-surface)]' +function isSessionTabState(activeTabId: string | null, activeTabType: TabType | null | undefined) { + if (!activeTabId) return false + if (activeTabType === 'session') return true + if (activeTabType) return false + return activeTabId !== SETTINGS_TAB_ID && + activeTabId !== SCHEDULED_TAB_ID && + !activeTabId.startsWith(TERMINAL_TAB_PREFIX) +} + +function getSessionTerminalCwd(session: SessionListItem | undefined) { + if (!session) return undefined + if (session.workDir && session.workDirExists !== false) return session.workDir + return session.projectPath || undefined +} + function WorkspaceResizeHandle() { const t = useTranslation() const width = useWorkspacePanelStore((state) => state.width) @@ -87,6 +117,86 @@ function WorkspaceResizeHandle() { ) } +function TerminalResizeHandle() { + const t = useTranslation() + const height = useTerminalPanelStore((state) => state.height) + const setHeight = useTerminalPanelStore((state) => state.setHeight) + const [dragState, setDragState] = useState<{ startY: number; startHeight: number } | null>(null) + const dragStateRef = useRef(dragState) + + useEffect(() => { + dragStateRef.current = dragState + }, [dragState]) + + useEffect(() => { + if (!dragState) return + + const handlePointerMove = (event: PointerEvent) => { + const current = dragStateRef.current + if (!current) return + setHeight(current.startHeight + current.startY - event.clientY) + } + + const handlePointerUp = () => { + setDragState(null) + } + + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + window.addEventListener('pointercancel', handlePointerUp) + + return () => { + document.body.style.cursor = '' + document.body.style.userSelect = '' + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + window.removeEventListener('pointercancel', handlePointerUp) + } + }, [dragState, setHeight]) + + return ( +
{ + if (event.button !== 0) return + event.preventDefault() + setDragState({ startY: event.clientY, startHeight: height }) + }} + onKeyDown={(event) => { + if (event.key === 'ArrowUp') { + event.preventDefault() + setHeight(height + TERMINAL_RESIZE_STEP) + } + if (event.key === 'ArrowDown') { + event.preventDefault() + setHeight(height - TERMINAL_RESIZE_STEP) + } + if (event.key === 'Home') { + event.preventDefault() + setHeight(TERMINAL_PANEL_MIN_HEIGHT) + } + if (event.key === 'End') { + event.preventDefault() + setHeight(TERMINAL_PANEL_MAX_HEIGHT) + } + }} + onDoubleClick={() => setHeight(TERMINAL_PANEL_DEFAULT_HEIGHT)} + className="group flex h-2.5 shrink-0 cursor-row-resize items-center bg-[var(--color-surface)] outline-none focus-visible:bg-[var(--color-surface-container)]" + > +
+
+ ) +} + export function ActiveSession() { const activeTabId = useTabStore((s) => s.activeTabId) const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null) @@ -105,10 +215,16 @@ export function ActiveSession() { const activeTeam = useTeamStore((s) => s.activeTeam) const isMemberSession = !!memberInfo const showWorkspacePanel = useWorkspacePanelStore((state) => - activeTabId && activeTabType === 'session' && !isMemberSession + activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession ? state.isPanelOpen(activeTabId) : false, ) + const showTerminalPanel = useTerminalPanelStore((state) => + activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession + ? state.isPanelOpen(activeTabId) + : false, + ) + const terminalPanelHeight = useTerminalPanelStore((state) => state.height) useEffect(() => { if (activeTabId && !isMemberSession) { @@ -313,6 +429,27 @@ export function ActiveSession() { variant={isEmpty && !isMemberSession && !showWorkspacePanel ? 'hero' : 'default'} compact={showWorkspacePanel} /> + + {showTerminalPanel && activeTabId ? ( +
+ + { + useTerminalPanelStore.getState().closePanel(activeTabId) + useTabStore.getState().openTerminalTab(getSessionTerminalCwd(session)) + }} + onClose={() => useTerminalPanelStore.getState().closePanel(activeTabId)} + /> +
+ ) : null}
{showWorkspacePanel ? ( diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx index 268e4baa..a2a68c31 100644 --- a/desktop/src/pages/TerminalSettings.test.tsx +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -90,6 +90,7 @@ describe('TerminalSettings', () => { it('shows a desktop-runtime empty state outside Tauri', () => { render() + expect(screen.getByText(/claude-haha/)).toBeInTheDocument() expect(screen.getByText('Desktop runtime required')).toBeInTheDocument() expect(terminalMocks.spawn).not.toHaveBeenCalled() }) @@ -108,6 +109,20 @@ describe('TerminalSettings', () => { expect(terminalMocks.fitInstance.fit).toHaveBeenCalled() }) + it('starts in the provided cwd when embedded in a project session', async () => { + terminalMocks.available = true + + render() + + await waitFor(() => { + expect(terminalMocks.spawn).toHaveBeenCalledWith({ + cols: 80, + rows: 24, + cwd: '/tmp/current-project', + }) + }) + }) + it('writes matching terminal output events into xterm', async () => { terminalMocks.available = true let outputHandler: ((payload: { session_id: number; data: string }) => void) | undefined diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx index afef4605..ff623074 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -17,16 +17,24 @@ const STATUS_LABEL_KEYS: Record = { type TerminalSettingsProps = { active?: boolean + cwd?: string onNewTerminal?: () => void + onOpenInTab?: () => void + onClose?: () => void testId?: string workspace?: boolean + docked?: boolean } export function TerminalSettings({ active = true, + cwd, onNewTerminal, + onOpenInTab, + onClose, testId = 'settings-terminal-host', workspace = false, + docked = false, }: TerminalSettingsProps = {}) { const t = useTranslation() const hostRef = useRef(null) @@ -142,7 +150,11 @@ export function TerminalSettings({ }) try { - const result = await terminalApi.spawn({ cols: terminal.cols, rows: terminal.rows }) + const result = await terminalApi.spawn({ + cols: terminal.cols, + rows: terminal.rows, + ...(cwd ? { cwd } : {}), + }) sessionIdRef.current = result.session_id setShellInfo({ shell: result.shell, cwd: result.cwd }) setStatus('running') @@ -156,7 +168,7 @@ export function TerminalSettings({ setError(err instanceof Error ? err.message : String(err)) setStatus('error') } - }, [resizeSession]) + }, [cwd, resizeSession]) useEffect(() => { if (!terminalApi.isAvailable()) return @@ -191,22 +203,40 @@ export function TerminalSettings({ } return ( -
-
+
+
-

+

{t('settings.terminal.title')}

-

- {t('settings.terminal.description')} -

+ {!docked && ( +

+ {t('settings.terminal.description')} +

+ )}
+ {onOpenInTab && ( + + )} {onNewTerminal && ( + {onClose && ( + + )}
-
+
{shellInfo && ( <> @@ -264,7 +304,7 @@ export function TerminalSettings({
) : ( -
+
diff --git a/desktop/src/stores/tabStore.ts b/desktop/src/stores/tabStore.ts index 0d7a023e..925ffc9c 100644 --- a/desktop/src/stores/tabStore.ts +++ b/desktop/src/stores/tabStore.ts @@ -14,6 +14,7 @@ export type Tab = { title: string type: TabType status: 'idle' | 'running' | 'error' + terminalCwd?: string } type TabPersistence = { @@ -26,7 +27,7 @@ type TabStore = { activeTabId: string | null openTab: (sessionId: string, title: string, type?: TabType) => void - openTerminalTab: () => string + openTerminalTab: (cwd?: string) => string closeTab: (sessionId: string) => void setActiveTab: (sessionId: string) => void updateTabTitle: (sessionId: string, title: string) => void @@ -46,7 +47,14 @@ export const useTabStore = create((set, get) => ({ const { tabs } = get() const existing = tabs.find((t) => t.sessionId === sessionId) if (existing) { - set({ activeTabId: sessionId }) + set({ + tabs: tabs.map((tab) => + tab.sessionId === sessionId && !(tab as Partial).type + ? { ...tab, type } + : tab, + ), + activeTabId: sessionId, + }) } else { set({ tabs: [...tabs, { sessionId, title, type, status: 'idle' }], @@ -56,7 +64,7 @@ export const useTabStore = create((set, get) => ({ get().saveTabs() }, - openTerminalTab: () => { + openTerminalTab: (cwd) => { const { tabs } = get() const nextIndex = Math.max( 0, @@ -68,7 +76,11 @@ export const useTabStore = create((set, get) => ({ }), ) + 1 const sessionId = `${TERMINAL_TAB_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - get().openTab(sessionId, `Terminal ${nextIndex}`, 'terminal') + set({ + tabs: [...tabs, { sessionId, title: `Terminal ${nextIndex}`, type: 'terminal', status: 'idle', terminalCwd: cwd }], + activeTabId: sessionId, + }) + get().saveTabs() return sessionId }, diff --git a/desktop/src/stores/terminalPanelStore.ts b/desktop/src/stores/terminalPanelStore.ts new file mode 100644 index 00000000..ccdac5c8 --- /dev/null +++ b/desktop/src/stores/terminalPanelStore.ts @@ -0,0 +1,94 @@ +import { create } from 'zustand' + +export const TERMINAL_PANEL_DEFAULT_HEIGHT = 300 +export const TERMINAL_PANEL_MIN_HEIGHT = 220 +export const TERMINAL_PANEL_MAX_HEIGHT = 560 + +type TerminalPanelSessionState = { + isOpen: boolean +} + +type TerminalPanelStore = { + panelBySession: Record + height: number + + isPanelOpen: (sessionId: string) => boolean + openPanel: (sessionId: string) => void + closePanel: (sessionId: string) => void + togglePanel: (sessionId: string) => void + setHeight: (height: number) => void + clearSession: (sessionId: string) => void +} + +const DEFAULT_PANEL_STATE: TerminalPanelSessionState = { + isOpen: false, +} + +function getSessionPanelState( + panelBySession: Record, + sessionId: string, +) { + return panelBySession[sessionId] ?? DEFAULT_PANEL_STATE +} + +function removeRecordKey(record: Record, key: string) { + if (!(key in record)) return record + const { [key]: _removed, ...rest } = record + return rest +} + +export function clampTerminalPanelHeight(height: number) { + if (!Number.isFinite(height)) return TERMINAL_PANEL_DEFAULT_HEIGHT + const rounded = Math.round(height) + return Math.min(TERMINAL_PANEL_MAX_HEIGHT, Math.max(TERMINAL_PANEL_MIN_HEIGHT, rounded)) +} + +export const useTerminalPanelStore = create((set, get) => ({ + panelBySession: {}, + height: TERMINAL_PANEL_DEFAULT_HEIGHT, + + isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen, + + openPanel: (sessionId) => + set((state) => ({ + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...getSessionPanelState(state.panelBySession, sessionId), + isOpen: true, + }, + }, + })), + + closePanel: (sessionId) => + set((state) => ({ + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...getSessionPanelState(state.panelBySession, sessionId), + isOpen: false, + }, + }, + })), + + togglePanel: (sessionId) => + set((state) => { + const panel = getSessionPanelState(state.panelBySession, sessionId) + return { + panelBySession: { + ...state.panelBySession, + [sessionId]: { + ...panel, + isOpen: !panel.isOpen, + }, + }, + } + }), + + setHeight: (height) => set({ height: clampTerminalPanelHeight(height) }), + + clearSession: (sessionId) => + set((state) => ({ + panelBySession: removeRecordKey(state.panelBySession, sessionId), + })), +}))