fix(desktop): expand workbench panels into tabs (#730)

Tested: cd desktop && bun run test -- --run src/components/workbench/WorkbenchPanel.test.tsx
Tested: cd desktop && bun run test -- --run src/components/layout/ContentRouter.test.tsx
Tested: cd desktop && bun run test -- --run src/stores/tabStore.test.ts
Tested: bun run check:desktop
Confidence: high
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-15 19:41:57 +08:00
parent a4799ae5ea
commit f5a97ac21c
15 changed files with 231 additions and 27 deletions

View File

@ -42,10 +42,16 @@ vi.mock('../../pages/TraceList', () => ({
TraceList: () => <div data-testid="trace-list" />,
}))
vi.mock('../workbench/WorkbenchTab', () => ({
WorkbenchTab: ({ sessionId, tabId }: { sessionId: string; tabId: string }) => (
<div data-testid="workbench-tab">workbench:{sessionId}:{tabId}</div>
),
}))
import { ContentRouter } from './ContentRouter'
import { useTabStore } from '../../stores/tabStore'
describe('ContentRouter terminal tabs', () => {
describe('ContentRouter tab surfaces', () => {
afterEach(() => {
cleanup()
previewBridgeMock.close.mockClear()
@ -148,6 +154,24 @@ describe('ContentRouter terminal tabs', () => {
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
})
it('renders workbench tabs as main content instead of mounting the chat session surface', () => {
useTabStore.setState({
tabs: [{
sessionId: '__workbench__session-1',
title: 'Workbench',
type: 'workbench',
status: 'idle',
workbenchSessionId: 'session-1',
}],
activeTabId: '__workbench__session-1',
})
render(<ContentRouter />)
expect(screen.getByTestId('workbench-tab')).toHaveTextContent('workbench:session-1:__workbench__session-1')
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
})
it('closes the native preview when switching from a chat session to settings', async () => {
useTabStore.setState({
tabs: [

View File

@ -7,6 +7,7 @@ import { Settings } from '../../pages/Settings'
import { TerminalSettings } from '../../pages/TerminalSettings'
import { TraceList } from '../../pages/TraceList'
import { TraceSession } from '../../pages/TraceSession'
import { WorkbenchTab } from '../workbench/WorkbenchTab'
import { previewBridge } from '../../lib/previewBridge'
export function ContentRouter() {
@ -16,7 +17,7 @@ export function ContentRouter() {
const terminalTabs = tabs.filter((tab) => tab.type === 'terminal')
useEffect(() => {
if (activeTabType === 'session') return
if (activeTabType === 'session' || activeTabType === 'workbench') return
void previewBridge.close()
}, [activeTabType])
@ -32,6 +33,11 @@ export function ContentRouter() {
page = traceSessionId ? <TraceSession sessionId={traceSessionId} /> : <EmptySession />
} else if (activeTabType === 'traces') {
page = <TraceList />
} else if (activeTabType === 'workbench') {
const workbenchTab = tabs.find((t) => t.sessionId === activeTabId)
page = workbenchTab?.workbenchSessionId
? <WorkbenchTab tabId={activeTabId} sessionId={workbenchTab.workbenchSessionId} />
: <EmptySession />
} else if (activeTabType !== 'terminal') {
page = <ActiveSession />
}

View File

@ -6,6 +6,7 @@ import {
TERMINAL_TAB_PREFIX,
TRACE_LIST_TAB_ID,
TRACE_TAB_PREFIX,
WORKBENCH_TAB_PREFIX,
useTabStore,
type Tab,
} from '../../stores/tabStore'
@ -44,7 +45,8 @@ function isSessionTabId(tabId: string | null) {
tabId !== SCHEDULED_TAB_ID &&
tabId !== TRACE_LIST_TAB_ID &&
!tabId.startsWith(TERMINAL_TAB_PREFIX) &&
!tabId.startsWith(TRACE_TAB_PREFIX)
!tabId.startsWith(TRACE_TAB_PREFIX) &&
!tabId.startsWith(WORKBENCH_TAB_PREFIX)
}
export function TabBar() {
@ -549,6 +551,9 @@ const TabItem = forwardRef<HTMLDivElement, {
{tab.type === 'terminal' && (
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">terminal</span>
)}
{tab.type === 'workbench' && (
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">view_sidebar</span>
)}
<span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
{tab.title || 'Untitled'}

View File

@ -1,10 +1,16 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../workspace/WorkspacePanel', () => ({
WorkspacePanel: ({ sessionId, embedded }: { sessionId: string; embedded?: boolean }) => (
<div data-testid="workspace-panel" data-embedded={embedded ? 'true' : 'false'}>
WorkspacePanel: ({ sessionId, embedded, forceVisible }: { sessionId: string; embedded?: boolean; forceVisible?: boolean }) => (
<div
data-testid="workspace-panel"
data-embedded={embedded ? 'true' : 'false'}
data-force-visible={forceVisible ? 'true' : 'false'}
>
workspace:{sessionId}
</div>
),
@ -20,12 +26,14 @@ import { WorkbenchPanel } from './WorkbenchPanel'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useTabStore } from '../../stores/tabStore'
const SESSION_ID = 'workbench-session'
beforeEach(() => {
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
useTabStore.setState(useTabStore.getInitialState(), true)
useSettingsStore.setState({ locale: 'en' })
useWorkspacePanelStore.getState().openPanel(SESSION_ID)
})
@ -34,6 +42,7 @@ afterEach(() => {
cleanup()
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
useTabStore.setState(useTabStore.getInitialState(), true)
})
describe('WorkbenchPanel', () => {
@ -95,4 +104,35 @@ describe('WorkbenchPanel', () => {
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(false)
})
it('the expand button promotes the current workbench into a main content tab', () => {
useWorkspacePanelStore.getState().setMode(SESSION_ID, 'browser')
render(<WorkbenchPanel sessionId={SESSION_ID} />)
fireEvent.click(screen.getByRole('button', { name: 'Expand panel' }))
expect(useTabStore.getState().activeTabId).toBe(`__workbench__${SESSION_ID}`)
expect(useTabStore.getState().tabs).toEqual([
{
sessionId: `__workbench__${SESSION_ID}`,
title: 'Workbench',
type: 'workbench',
status: 'idle',
workbenchSessionId: SESSION_ID,
},
])
})
it('renders the tab variant without a nested expand action', () => {
const handleClose = vi.fn()
render(<WorkbenchPanel sessionId={SESSION_ID} variant="tab" onClose={handleClose} />)
expect(screen.queryByRole('button', { name: 'Expand panel' })).not.toBeInTheDocument()
expect(screen.getByTestId('workspace-panel')).toHaveAttribute('data-force-visible', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(handleClose).toHaveBeenCalledTimes(1)
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(true)
})
})

View File

@ -1,15 +1,18 @@
import { FolderOpen, Globe, X } from 'lucide-react'
import { FolderOpen, Globe, Maximize2, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import {
useWorkspacePanelStore,
type WorkbenchMode,
} from '../../stores/workspacePanelStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useTabStore } from '../../stores/tabStore'
import { WorkspacePanel } from '../workspace/WorkspacePanel'
import { BrowserSurface } from '../browser/BrowserSurface'
type WorkbenchPanelProps = {
sessionId: string
variant?: 'panel' | 'tab'
onClose?: () => void
}
const MODE_ITEMS: ReadonlyArray<{
@ -26,12 +29,13 @@ const MODE_ITEMS: ReadonlyArray<{
* browser surface behind a single per-session mode switch (file browser),
* sharing the panel's open state and width via {@link useWorkspacePanelStore}.
*/
export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) {
export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: WorkbenchPanelProps) {
const t = useTranslation()
const mode = useWorkspacePanelStore((state) => state.getMode(sessionId))
const setMode = useWorkspacePanelStore((state) => state.setMode)
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
const ensureBlankBrowser = useBrowserPanelStore((state) => state.ensureBlank)
const isTabVariant = variant === 'tab'
const handleModeSelect = (nextMode: WorkbenchMode) => {
if (nextMode === 'browser') {
@ -40,6 +44,18 @@ export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) {
setMode(sessionId, nextMode)
}
const handleExpand = () => {
useTabStore.getState().openWorkbenchTab(sessionId, t('workbench.tabTitle'))
}
const handleClose = () => {
if (onClose) {
onClose()
return
}
closePanel(sessionId)
}
return (
<div className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)]">
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5">
@ -70,21 +86,34 @@ export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) {
})}
</div>
<button
type="button"
aria-label={t('workbench.close')}
onClick={() => closePanel(sessionId)}
className="ml-auto inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] 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-brand)]/35"
>
<X size={16} strokeWidth={2} aria-hidden="true" />
</button>
<div className="ml-auto flex shrink-0 items-center gap-1">
{!isTabVariant && (
<button
type="button"
aria-label={t('workbench.expand')}
title={t('workbench.expand')}
onClick={handleExpand}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] 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-brand)]/35"
>
<Maximize2 size={15} strokeWidth={2} aria-hidden="true" />
</button>
)}
<button
type="button"
aria-label={t('workbench.close')}
onClick={handleClose}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] 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-brand)]/35"
>
<X size={16} strokeWidth={2} aria-hidden="true" />
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">
{mode === 'browser' ? (
<BrowserSurface sessionId={sessionId} />
) : (
<WorkspacePanel sessionId={sessionId} embedded />
<WorkspacePanel sessionId={sessionId} embedded forceVisible={isTabVariant} />
)}
</div>
</div>

View File

@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useTabStore } from '../../stores/tabStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { WorkbenchPanel } from './WorkbenchPanel'
type WorkbenchTabProps = {
tabId: string
sessionId: string
}
export function WorkbenchTab({ tabId, sessionId }: WorkbenchTabProps) {
const mode = useWorkspacePanelStore((state) => state.getMode(sessionId))
useEffect(() => {
if (mode === 'browser') {
useBrowserPanelStore.getState().ensureBlank(sessionId)
}
}, [mode, sessionId])
return (
<div data-testid="workbench-tab" className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<WorkbenchPanel
sessionId={sessionId}
variant="tab"
onClose={() => useTabStore.getState().closeTab(tabId)}
/>
</div>
)
}

View File

@ -38,6 +38,11 @@ type WorkspacePanelProps = {
* button so the panel header doesn't render a duplicate close control.
*/
embedded?: boolean
/**
* Main-content workbench tabs reuse the same workspace preview UI without
* depending on the right-side panel's open bit.
*/
forceVisible?: boolean
}
type TreeNodeProps = {
@ -912,7 +917,7 @@ function TreeNode({
)
}
export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelProps) {
export function WorkspacePanel({ sessionId, embedded = false, forceVisible = false }: WorkspacePanelProps) {
const t = useTranslation()
const addToast = useUIStore((state) => state.addToast)
const [filterQuery, setFilterQuery] = useState('')
@ -946,6 +951,7 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference)
const chatState = useChatStore((state) => state.sessions[sessionId]?.chatState ?? 'idle')
const shouldRender = forceVisible || isOpen
const refreshLifecycleRef = useRef({
sessionId,
isOpen: false,
@ -986,25 +992,25 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
useEffect(() => {
const previous = refreshLifecycleRef.current
const sessionChanged = previous.sessionId !== sessionId
const opened = isOpen && (sessionChanged || !previous.isOpen)
const opened = shouldRender && (sessionChanged || !previous.isOpen)
const completedTurn =
isOpen &&
shouldRender &&
!sessionChanged &&
previous.chatState !== 'idle' &&
chatState === 'idle'
refreshLifecycleRef.current = { sessionId, isOpen, chatState }
refreshLifecycleRef.current = { sessionId, isOpen: shouldRender, chatState }
const shouldRefreshOnOpen = opened
const shouldRefreshAfterCompletedTurn = completedTurn && chatState === 'idle'
if ((!shouldRefreshOnOpen && !shouldRefreshAfterCompletedTurn) || statusLoading) return
void loadStatus(sessionId)
}, [chatState, isOpen, loadStatus, sessionId, statusLoading])
}, [chatState, loadStatus, sessionId, shouldRender, statusLoading])
useEffect(() => {
if (!isOpen || !isNavigatorVisible || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
if (!shouldRender || !isNavigatorVisible || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
void loadTree(sessionId, '')
}, [activeView, isNavigatorVisible, isOpen, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId])
}, [activeView, isNavigatorVisible, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId, shouldRender])
useEffect(() => {
if (!previewTabContextMenu && !fileContextMenu) return
@ -1028,7 +1034,7 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
}
}, [isNavigatorVisible])
if (!isOpen) return null
if (!shouldRender) return null
const panelWidth = hasPreviewTabs ? width : Math.min(width, 520)
const panelMaxWidth = hasPreviewTabs ? 'min(62%, calc(100% - 328px))' : '36%'

View File

@ -141,7 +141,9 @@ export const en = {
'workbench.modeSwitch': 'Workbench mode',
'workbench.modeWorkspace': 'Files',
'workbench.modeBrowser': 'Browser',
'workbench.expand': 'Expand panel',
'workbench.close': 'Close',
'workbench.tabTitle': 'Workbench',
'workspace.closeTab': 'Close tab',
'workspace.preview': 'Preview',
'workspace.previewEmpty': 'Select a file to preview.',

View File

@ -143,7 +143,9 @@ export const jp: Record<TranslationKey, string> = {
'workbench.modeSwitch': 'ワークベンチモード',
'workbench.modeWorkspace': 'ファイル',
'workbench.modeBrowser': 'ブラウザ',
'workbench.expand': 'パネルを展開',
'workbench.close': '閉じる',
'workbench.tabTitle': 'ワークベンチ',
'workspace.closeTab': 'タブを閉じる',
'workspace.preview': 'プレビュー',
'workspace.previewEmpty': 'プレビューするファイルを選択してください。',

View File

@ -143,7 +143,9 @@ export const kr: Record<TranslationKey, string> = {
'workbench.modeSwitch': '워크벤치 모드',
'workbench.modeWorkspace': '파일',
'workbench.modeBrowser': '브라우저',
'workbench.expand': '패널 펼치기',
'workbench.close': '닫기',
'workbench.tabTitle': '워크벤치',
'workspace.closeTab': '탭 닫기',
'workspace.preview': '미리 보기',
'workspace.previewEmpty': '미리 볼 파일을 선택하세요.',

View File

@ -143,7 +143,9 @@ export const zh: Record<TranslationKey, string> = {
'workbench.modeSwitch': '工作臺模式',
'workbench.modeWorkspace': '檔案',
'workbench.modeBrowser': '瀏覽器',
'workbench.expand': '展開面板',
'workbench.close': '關閉',
'workbench.tabTitle': '工作臺',
'workspace.closeTab': '關閉標籤',
'workspace.preview': '預覽',
'workspace.previewEmpty': '選擇一個檔案進行預覽。',

View File

@ -143,7 +143,9 @@ export const zh: Record<TranslationKey, string> = {
'workbench.modeSwitch': '工作台模式',
'workbench.modeWorkspace': '文件',
'workbench.modeBrowser': '浏览器',
'workbench.expand': '展开面板',
'workbench.close': '关闭',
'workbench.tabTitle': '工作台',
'workspace.closeTab': '关闭标签',
'workspace.preview': '预览',
'workspace.previewEmpty': '选择一个文件进行预览。',

View File

@ -5,6 +5,7 @@ import {
SETTINGS_TAB_ID,
TERMINAL_TAB_PREFIX,
TRACE_TAB_PREFIX,
WORKBENCH_TAB_PREFIX,
useTabStore,
type TabType,
} from '../stores/tabStore'
@ -47,7 +48,8 @@ function isSessionTabState(activeTabId: string | null, activeTabType: TabType |
return activeTabId !== SETTINGS_TAB_ID &&
activeTabId !== SCHEDULED_TAB_ID &&
!activeTabId.startsWith(TERMINAL_TAB_PREFIX) &&
!activeTabId.startsWith(TRACE_TAB_PREFIX)
!activeTabId.startsWith(TRACE_TAB_PREFIX) &&
!activeTabId.startsWith(WORKBENCH_TAB_PREFIX)
}
function getSessionTerminalCwd(session: SessionListItem | undefined) {

View File

@ -44,6 +44,28 @@ describe('tabStore', () => {
expect(useTabStore.getState().activeTabId).toBe(tabId)
})
it('opens one ephemeral workbench tab per source session', () => {
const firstTabId = useTabStore.getState().openWorkbenchTab('session-1', 'Workbench')
const secondTabId = useTabStore.getState().openWorkbenchTab('session-1', 'Workbench')
expect(firstTabId).toBe('__workbench__session-1')
expect(secondTabId).toBe(firstTabId)
expect(useTabStore.getState().tabs).toEqual([
{
sessionId: '__workbench__session-1',
title: 'Workbench',
type: 'workbench',
status: 'idle',
workbenchSessionId: 'session-1',
},
])
expect(useTabStore.getState().activeTabId).toBe('__workbench__session-1')
expect(localStorage.getItem('cc-haha-open-tabs')).toBe(JSON.stringify({
openTabs: [],
activeTabId: null,
}))
})
it('does not let async tab restore overwrite tabs opened while restore is in flight', async () => {
let resolveSessions: (value: unknown) => void = () => {}
vi.mocked(sessionsApi.list).mockReturnValueOnce(new Promise((resolve) => {

View File

@ -10,8 +10,9 @@ export const SCHEDULED_TAB_ID = '__scheduled__'
export const TRACE_LIST_TAB_ID = '__traces__'
export const TERMINAL_TAB_PREFIX = '__terminal__'
export const TRACE_TAB_PREFIX = '__trace__'
export const WORKBENCH_TAB_PREFIX = '__workbench__'
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'trace' | 'traces'
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'trace' | 'traces' | 'workbench'
export type Tab = {
sessionId: string
@ -21,6 +22,7 @@ export type Tab = {
terminalCwd?: string
terminalRuntimeId?: string
traceSessionId?: string
workbenchSessionId?: string
}
type TabPersistence = {
@ -36,6 +38,7 @@ type TabStore = {
openTracesTab: (title?: string) => string
openTraceTab: (sessionId: string, title?: string) => string
openTerminalTab: (cwd?: string, terminalRuntimeId?: string) => string
openWorkbenchTab: (sessionId: string, title?: string) => string
closeTab: (sessionId: string) => void
setActiveTab: (sessionId: string) => void
updateTabTitle: (sessionId: string, title: string) => void
@ -141,6 +144,33 @@ export const useTabStore = create<TabStore>((set, get) => ({
return sessionId
},
openWorkbenchTab: (sessionId, title = 'Workbench') => {
const tabId = `${WORKBENCH_TAB_PREFIX}${sessionId}`
const { tabs } = get()
const existing = tabs.find((tab) => tab.sessionId === tabId)
const tab: Tab = {
sessionId: tabId,
title,
type: 'workbench',
status: 'idle',
workbenchSessionId: sessionId,
}
if (existing) {
set({
tabs: tabs.map((current) => current.sessionId === tabId ? tab : current),
activeTabId: tabId,
})
} else {
set({
tabs: [...tabs, tab],
activeTabId: tabId,
})
}
get().saveTabs()
return tabId
},
closeTab: (sessionId) => {
const { tabs, activeTabId } = get()
const index = tabs.findIndex((t) => t.sessionId === sessionId)
@ -210,7 +240,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
saveTabs: () => {
const { tabs, activeTabId } = get()
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal')
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal' && tab.type !== 'workbench')
const data: TabPersistence = {
openTabs: persistableTabs.map((t) => ({
sessionId: t.sessionId,