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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-05 19:05:36 +08:00
parent 4e20aac647
commit 35f9f0d0f8
16 changed files with 548 additions and 48 deletions

View File

@ -23,7 +23,7 @@ async function invoke<T>(command: string, args?: Record<string, unknown>): 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<T>(command, args)
}
@ -47,12 +47,12 @@ export const terminalApi = {
},
async onOutput(handler: (payload: TerminalOutputPayload) => void): Promise<Unlisten> {
const events = await import(/* @vite-ignore */ '@tauri-apps/api/event')
const events = await import('@tauri-apps/api/event')
return events.listen<TerminalOutputPayload>('terminal-output', (event) => handler(event.payload))
},
async onExit(handler: (payload: TerminalExitPayload) => void): Promise<Unlisten> {
const events = await import(/* @vite-ignore */ '@tauri-apps/api/event')
const events = await import('@tauri-apps/api/event')
return events.listen<TerminalExitPayload>('terminal-exit', (event) => handler(event.payload))
},
}

View File

@ -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<string>('native-menu-navigate', (event) => {
const target = event.payload as SettingsTab | 'settings'

View File

@ -19,8 +19,8 @@ vi.mock('../../pages/Settings', () => ({
}))
vi.mock('../../pages/TerminalSettings', () => ({
TerminalSettings: ({ active, onNewTerminal, testId }: { active: boolean; onNewTerminal: () => void; testId: string }) => (
<div data-active={active ? 'true' : 'false'} data-testid={testId}>
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}>
<button type="button" onClick={onNewTerminal}>New Terminal</button>
</div>
),
@ -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(<ContentRouter />)
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')
})
})

View File

@ -44,9 +44,10 @@ export function ContentRouter() {
>
<TerminalSettings
active={active}
cwd={tab.terminalCwd}
workspace
testId={`terminal-host-${tab.sessionId}`}
onNewTerminal={() => useTabStore.getState().openTerminalTab()}
onNewTerminal={() => useTabStore.getState().openTerminalTab(tab.terminalCwd)}
/>
</div>
)

View File

@ -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()

View File

@ -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<ReturnType<typeof useChatStore.getState>>)
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<typeof useTabStore.getState>['tabs'][number],
],
activeTabId: 'legacy-session',
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
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<ReturnType<typeof useChatStore.getState>>)
useWorkspacePanelStore.getState().openPanel('tab-1')
useTerminalPanelStore.getState().openPanel('tab-1')
await act(async () => {
render(<TabBar />)
@ -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()
})
})

View File

@ -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<Tab>).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<HTMLDivElement>(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() {
<ToolbarIconButton
icon={<SquareTerminal size={17} strokeWidth={1.9} />}
label={t('tabs.openTerminal')}
onClick={() => useTabStore.getState().openTerminalTab()}
onClick={() => {
if (activeTabId && isActiveSessionTab) {
useTerminalPanelStore.getState().togglePanel(activeTabId)
return
}
useTabStore.getState().openTerminalTab()
}}
active={isTerminalPanelOpen}
/>
{isActiveSessionTab && activeTabId && (
<ToolbarIconButton

View File

@ -98,7 +98,7 @@ export const en = {
// Settings > 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 <args> with claude-haha <args>, 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',

View File

@ -100,7 +100,7 @@ export const zh: Record<TranslationKey, string> = {
// 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<TranslationKey, string> = {
'settings.terminal.status.error': '错误',
'settings.terminal.status.unavailable': '不可用',
'terminal.newTab': '新建终端',
'terminal.openInTab': '在 Tab 中打开',
'terminal.closePanel': '关闭终端面板',
'terminal.resizePanel': '调整终端面板大小',
// Settings > Diagnostics
'settings.diagnostics.title': '诊断',

View File

@ -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<string>('get_server_url')
setBaseUrl(serverUrl)
await waitForHealth(serverUrl)

View File

@ -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
}) => (
<div data-testid={testId} data-cwd={cwd ?? ''}>
<button type="button" onClick={onOpenInTab}>Open in Tab</button>
<button type="button" onClick={onClose}>Close terminal panel</button>
</div>
),
}))
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<typeof useTabStore.getState>['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(<ActiveSession />)
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)
})
})

View File

@ -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 (
<div
role="separator"
aria-label={t('terminal.resizePanel')}
aria-orientation="horizontal"
aria-valuemin={TERMINAL_PANEL_MIN_HEIGHT}
aria-valuemax={TERMINAL_PANEL_MAX_HEIGHT}
aria-valuenow={height}
tabIndex={0}
data-testid="terminal-resize-handle"
onPointerDown={(event) => {
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)]"
>
<div className="mx-3 h-px flex-1 rounded-full bg-[var(--color-border)] transition-colors group-hover:bg-[var(--color-border-focus)] group-focus-visible:bg-[var(--color-border-focus)]" />
</div>
)
}
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 ? (
<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 }}
>
<TerminalResizeHandle />
<TerminalSettings
active
docked
cwd={getSessionTerminalCwd(session)}
testId={`session-terminal-host-${activeTabId}`}
onOpenInTab={() => {
useTerminalPanelStore.getState().closePanel(activeTabId)
useTabStore.getState().openTerminalTab(getSessionTerminalCwd(session))
}}
onClose={() => useTerminalPanelStore.getState().closePanel(activeTabId)}
/>
</div>
) : null}
</div>
{showWorkspacePanel ? (

View File

@ -90,6 +90,7 @@ describe('TerminalSettings', () => {
it('shows a desktop-runtime empty state outside Tauri', () => {
render(<TerminalSettings />)
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(<TerminalSettings cwd="/tmp/current-project" />)
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

View File

@ -17,16 +17,24 @@ const STATUS_LABEL_KEYS: Record<TerminalStatus, TranslationKey> = {
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<HTMLDivElement | null>(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 (
<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={`flex h-full flex-col overflow-hidden ${
docked
? 'min-h-0 bg-[var(--color-surface-container-lowest)] px-3 py-2'
: workspace
? 'min-h-0 bg-[var(--color-surface)] px-5 py-4'
: 'min-h-[620px]'
}`}>
<div className={`${docked ? 'mb-2' : '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)]">
<h2 className={`${docked ? 'text-sm' : 'text-base'} font-semibold text-[var(--color-text-primary)]`}>
{t('settings.terminal.title')}
</h2>
<p className="mt-0.5 max-w-2xl text-sm text-[var(--color-text-tertiary)]">
{t('settings.terminal.description')}
</p>
{!docked && (
<p className="mt-0.5 max-w-2xl text-sm text-[var(--color-text-tertiary)]">
{t('settings.terminal.description')}
</p>
)}
</div>
<div className="flex items-center gap-2">
{onOpenInTab && (
<button
type="button"
onClick={onOpenInTab}
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)]"
>
<span className="material-symbols-outlined text-[16px]">open_in_new</span>
{t('terminal.openInTab')}
</button>
)}
{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)]"
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)]"
>
<span className="material-symbols-outlined text-[16px]">add</span>
{t('terminal.newTab')}
@ -216,7 +246,7 @@ export function TerminalSettings({
type="button"
onClick={clearTerminal}
disabled={!terminalRef.current}
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)] disabled:cursor-not-allowed disabled:opacity-50"
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>
{t('settings.terminal.clear')}
@ -224,15 +254,25 @@ export function TerminalSettings({
<button
type="button"
onClick={() => void startTerminal()}
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--color-text-primary)] px-2.5 text-xs font-medium text-[var(--color-surface)] transition-colors hover:opacity-90"
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--color-text-primary)] px-2.5 text-xs font-medium text-[var(--color-surface)] transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
{t('settings.terminal.restart')}
</button>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label={t('terminal.closePanel')}
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<span className="material-symbols-outlined text-[17px]">close</span>
</button>
)}
</div>
</div>
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<div className={`${docked ? 'mb-2' : 'mb-3'} flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]`}>
<StatusPill status={status} label={t(STATUS_LABEL_KEYS[status])} />
{shellInfo && (
<>
@ -264,7 +304,7 @@ export function TerminalSettings({
</div>
</div>
) : (
<div className="min-h-0 flex-1 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-terminal-border)] bg-[var(--color-terminal-bg)] shadow-[var(--shadow-dropdown)]">
<div className="min-h-0 flex-1 overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-terminal-border)] bg-[var(--color-terminal-bg)] shadow-[var(--shadow-dropdown)]">
<div className="flex h-8 items-center gap-2 border-b border-[var(--color-terminal-border)] bg-[var(--color-terminal-header)] px-3">
<span className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-danger)]" />
<span className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-warning)]" />

View File

@ -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<TabStore>((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<Tab>).type
? { ...tab, type }
: tab,
),
activeTabId: sessionId,
})
} else {
set({
tabs: [...tabs, { sessionId, title, type, status: 'idle' }],
@ -56,7 +64,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
get().saveTabs()
},
openTerminalTab: () => {
openTerminalTab: (cwd) => {
const { tabs } = get()
const nextIndex = Math.max(
0,
@ -68,7 +76,11 @@ export const useTabStore = create<TabStore>((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
},

View File

@ -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<string, TerminalPanelSessionState | undefined>
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<string, TerminalPanelSessionState | undefined>,
sessionId: string,
) {
return panelBySession[sessionId] ?? DEFAULT_PANEL_STATE
}
function removeRecordKey<T>(record: Record<string, T>, 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<TerminalPanelStore>((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),
})),
}))