mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
4e20aac647
commit
35f9f0d0f8
@ -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))
|
||||
},
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '诊断',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]" />
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
|
||||
94
desktop/src/stores/terminalPanelStore.ts
Normal file
94
desktop/src/stores/terminalPanelStore.ts
Normal 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),
|
||||
})),
|
||||
}))
|
||||
Loading…
x
Reference in New Issue
Block a user