diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index ae6b329e54..41a0ece2de 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -349,6 +349,61 @@ test('titlebar workbar action restores an existing tool instead of the picker', expect(Math.abs(restoredToggleBox!.x - safeAreaToggleBox!.x)).toBeLessThanOrEqual(1); }); +test('registered Workbar descriptors render singleton and dynamic panels', async ({ + window: page, +}) => { + await createSession(page, 'create descriptor registry session'); + await page.getByRole('button', { name: '展开任务工作栏' }).click(); + + const launcher = page.getByRole('list', { name: '打开工具' }); + const openLauncher = async () => { + await page.getByRole('button', { name: '打开工作栏标签' }).first().click(); + await expect(launcher).toBeVisible(); + }; + const registeredActions = [ + /侧边对话.*在不打断主任务的情况下追问和只读探索/, + /变更.*查看当前 Git 工作区变化/, + /终端.*查看当前任务的终端运行和实时输出/, + /浏览器.*打开内置浏览器并保留当前页面/, + /生成文件.*浏览当前任务生成的文件/, + /待办.*查看和维护这个任务的待办台账/, + /工作看板.*记录和管理暂缓事项/, + /追踪.*检查任务调用、工具与耗时记录/, + ] as const; + await expect(launcher).toBeVisible(); + await expect(launcher.getByRole('button')).toHaveCount(registeredActions.length); + for (const name of registeredActions) { + await expect(launcher.getByRole('button', { name })).toBeVisible(); + } + + await launcher.getByRole('button', { name: registeredActions[5] }).click(); + await expect(page.getByRole('region', { name: '任务待办' })).toBeVisible(); + await openLauncher(); + await launcher.getByRole('button', { name: registeredActions[5] }).click(); + await expect(page.getByRole('tab', { name: '待办' })).toHaveCount(1); + + await openLauncher(); + await launcher.getByRole('button', { name: registeredActions[2] }).click(); + const firstTerminal = page.getByRole('region', { name: '任务终端' }); + await expect(firstTerminal).toBeVisible(); + const firstTerminalRef = await firstTerminal.getAttribute('data-terminal-ref'); + expect(firstTerminalRef).toBeTruthy(); + await openLauncher(); + await launcher.getByRole('button', { name: registeredActions[2] }).click(); + const secondTerminal = page.locator('.maka-session-terminal-panel:visible'); + await expect(secondTerminal).toBeVisible(); + await expect(page.getByRole('tab', { name: /^终端(?: \d+)?$/ })).toHaveCount(2); + expect(await secondTerminal.getAttribute('data-terminal-ref')).not.toBe(firstTerminalRef); + + await openLauncher(); + await launcher.getByRole('button', { name: registeredActions[0] }).click(); + await expect(page.locator('.maka-quote-companion')).toBeVisible(); + await openLauncher(); + await launcher.getByRole('button', { name: registeredActions[0] }).click(); + await expect(page.getByRole('tab', { name: /^侧边对话(?: \d+)?$/ })).toHaveCount(2); + await expect(page.locator('.maka-quote-companion')).toHaveCount(2); +}); + test('Git changes re-read the workspace after the app regains focus', async ({ gitReviewWindow, }) => { diff --git a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts index 3865055dd7..ddecbc2386 100644 --- a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts @@ -22,6 +22,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; +import { WORKBAR_TOOL_DEFINITIONS } from '../../renderer/features/workbar/testing.js'; const desktopRoot = resolve( fileURLToPath(new URL('../../../', import.meta.url)), @@ -43,6 +44,37 @@ function sourceFiles(root: string): string[] { } describe('Workbar feature boundary', () => { + it('registers one exhaustive surface descriptor for every Workbar tab kind', () => { + const surface = readFileSync( + join(featureRoot, 'ui', 'workbar-surface.tsx'), + 'utf8', + ); + const registeredKinds = [...surface.matchAll( + /\n (?:'[^']+'|[a-z]+): defineWorkbarSurfaceDescriptor\(\s*'([^']+)',\s*\{/g, + )].map((match) => match[1]); + + assert.deepEqual( + registeredKinds, + WORKBAR_TOOL_DEFINITIONS.map((definition) => definition.kind), + ); + assert.equal( + surface.includes( + 'const WORKBAR_SURFACE_DESCRIPTOR_BY_KIND: WorkbarSurfaceDescriptorsByKind', + ), + true, + ); + assert.equal( + surface.includes( + 'WORKBAR_TOOL_DEFINITIONS.map(\n (definition) => WORKBAR_SURFACE_DESCRIPTOR_BY_KIND[definition.kind]', + ), + true, + ); + assert.equal( + surface.includes('const descriptor = workbarSurfaceDescriptor(tab.kind);'), + true, + ); + }); + it('contains no Desktop global bridge or shell/process imports', () => { const violations: string[] = []; for (const path of sourceFiles(featureRoot)) { diff --git a/apps/desktop/src/renderer/features/workbar/model/workbar-tool-definitions.ts b/apps/desktop/src/renderer/features/workbar/model/workbar-tool-definitions.ts index 09de048b1f..b7168992a5 100644 --- a/apps/desktop/src/renderer/features/workbar/model/workbar-tool-definitions.ts +++ b/apps/desktop/src/renderer/features/workbar/model/workbar-tool-definitions.ts @@ -27,6 +27,7 @@ export interface WorkbarToolDefinition { readonly labelKey: SessionWorkbarTabKind; readonly icon: | 'activity' + | 'clipboard' | 'folder' | 'git-branch' | 'globe' @@ -102,7 +103,7 @@ const WORKBAR_TOOL_DEFINITION_BY_KIND = { 'work-board': { kind: 'work-board', labelKey: 'work-board', - icon: 'list-todo', + icon: 'clipboard', persisted: true, singleton: true, defaultPlacement: 'right', diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx index 43ee14343f..6dc9642f60 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx @@ -86,6 +86,11 @@ import { sessionWorkbarTabsToRight, terminalRefFromWorkbarTab, } from '../model/workbar-tabs'; +import { + WORKBAR_TOOL_DEFINITIONS, + workbarToolDefinition, + type WorkbarToolDefinition, +} from '../model/workbar-tool-definitions'; import { useSessionTodo } from '../tools/tasks/use-session-todo'; import { WorkbarToggle } from './workbar-toggle'; import { WorkBoardPanel } from '../../../work-board-panel.js'; @@ -119,6 +124,104 @@ const SessionTerminalPanel = lazy(() => })), ); +type WorkbarCopy = ReturnType['workbar']; + +export interface WorkbarSurfaceProps { + sessionId: string; + projectId?: string | null; + projectAliases?: readonly string[]; + hidden: boolean; + onDismissPanel: (placement: SessionWorkbarPlacement) => void; + panelsState: SessionWorkbarPanelsState; + rightCollapsed: boolean; + bottomOpen: boolean; + onActivateTab: (placement: SessionWorkbarPlacement, tabId: string) => void; + onCloseTab: (placement: SessionWorkbarPlacement, tab: SessionWorkbarTab) => void; + onCloseTabs: ( + placement: SessionWorkbarPlacement, + tabs: readonly SessionWorkbarTab[], + ) => void; + onReorderTab: ( + placement: SessionWorkbarPlacement, + tabId: string, + targetTabId: string, + ) => void; + onMoveTab: ( + placement: SessionWorkbarPlacement, + tabId: string, + direction: 'left' | 'right', + ) => void; + onMoveTabToPanel: (tabId: string, target: SessionWorkbarPlacement) => void; + onPinTab: (tabId: string) => void; + onOpenLauncher: (placement: SessionWorkbarPlacement) => void; + onRequestOpenTab: ( + placement: SessionWorkbarPlacement, + kind: SessionWorkbarTabKind, + ) => void; + quotes?: readonly QuoteCompanionPanelState[]; + onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; + onRemoveQuote?: (target: CompanionQuoteTarget) => void; + onForkVisibilityChange?: (event: CompanionForkVisibilityEvent) => void; + onContentStateChange?: (panelId: string, hasContent: boolean) => void; + onInitialPromptStarted?: (panelId: string) => void; + onPromptAccepted?: (panelId: string, prompt: string) => void; + onActivityStateChange?: (panelId: string, active: boolean) => void; + activeSideChatPanelIds?: ReadonlySet; + sourceSession?: SessionSummary; + modelChoices?: readonly ChatModelChoice[]; + confirmBypass: () => Promise; +} + +interface WorkbarSurfaceRenderContext { + active: boolean; + copy: WorkbarCopy; + placement: SessionWorkbarPlacement; + props: WorkbarSurfaceProps; + sessionTodo: ReturnType; + setArtifactCount: (count: number) => void; + tab: SessionWorkbarTab; +} + +interface WorkbarSurfaceDescriptor { + readonly kind: Kind; + readonly icon: typeof Activity; + readonly label: ( + tab: SessionWorkbarTab, + tabs: readonly SessionWorkbarTab[], + copy: WorkbarCopy, + ) => string; + readonly launcherLabel: (copy: WorkbarCopy) => string; + readonly launcherDescription: (copy: WorkbarCopy) => string; + readonly panelClassName?: string; + readonly render: (context: WorkbarSurfaceRenderContext) => ReactNode; +} + +type WorkbarSurfaceDescriptorsByKind = { + readonly [Kind in SessionWorkbarTabKind]: WorkbarSurfaceDescriptor; +}; + +const WORKBAR_ICON_BY_NAME = { + activity: Activity, + clipboard: Clipboard, + folder: FolderOpen, + 'git-branch': GitBranch, + globe: Globe, + 'list-todo': ListTodo, + 'message-circle-question': MessageCircleQuestion, + terminal: Terminal, +} satisfies Record; + +function defineWorkbarSurfaceDescriptor( + kind: Kind, + descriptor: Omit, 'icon' | 'kind'>, +): WorkbarSurfaceDescriptor { + return { + ...descriptor, + kind, + icon: WORKBAR_ICON_BY_NAME[workbarToolDefinition(kind).icon], + }; +} + function WorkbarPanelLoading(props: { label: string }) { return (
@@ -171,39 +274,160 @@ function TabCount(props: { count: number }) { return ; } +const WORKBAR_SURFACE_DESCRIPTOR_BY_KIND: WorkbarSurfaceDescriptorsByKind = { + 'side-chat': defineWorkbarSurfaceDescriptor('side-chat', { + label: (tab, tabs, copy) => { + if (tab.title?.trim()) return tab.title.trim(); + const index = + tab.ordinal ?? + tabs.filter((candidate) => candidate.kind === 'side-chat').findIndex( + (candidate) => candidate.id === tab.id, + ) + 1; + return index <= 1 ? copy.sideChat : copy.sideChatNumbered(index); + }, + launcherLabel: (copy) => copy.sideChat, + launcherDescription: (copy) => copy.launcher.sideChat, + panelClassName: 'maka-quote-workbar-panel', + render: ({ active, props, tab }) => { + const panelId = tab.id.slice('side-chat:'.length); + const quote = props.quotes?.find((candidate) => candidate.id === panelId); + if (!quote) return null; + return ( + {})} + onRemoveQuote={props.onRemoveQuote} + onForkVisibilityChange={props.onForkVisibilityChange} + onContentStateChange={props.onContentStateChange} + onInitialPromptStarted={props.onInitialPromptStarted} + onPromptAccepted={props.onPromptAccepted} + onActivityStateChange={props.onActivityStateChange} + /> + ); + }, + }), + review: defineWorkbarSurfaceDescriptor('review', { + label: (_tab, _tabs, copy) => copy.review, + launcherLabel: (copy) => copy.review, + launcherDescription: (copy) => copy.launcher.review, + render: ({ active, copy, props }) => ( + }> + + + ), + }), + terminal: defineWorkbarSurfaceDescriptor('terminal', { + label: (tab, _tabs, copy) => + tab.ordinal && tab.ordinal > 1 + ? copy.terminalNumbered(tab.ordinal) + : copy.terminal, + launcherLabel: (copy) => copy.terminal, + launcherDescription: (copy) => copy.launcher.terminal, + render: ({ active, copy, props, tab }) => ( + }> + + + ), + }), + browser: defineWorkbarSurfaceDescriptor('browser', { + label: (_tab, _tabs, copy) => copy.browser, + launcherLabel: (copy) => copy.browser, + launcherDescription: (copy) => copy.launcher.browser, + render: ({ active, copy, props }) => ( + }> + + ), + }), + files: defineWorkbarSurfaceDescriptor('files', { + label: (_tab, _tabs, copy) => copy.files, + launcherLabel: (copy) => copy.files, + launcherDescription: (copy) => copy.launcher.files, + render: ({ copy, placement, props, setArtifactCount }) => ( + }> + props.onDismissPanel(placement)} + /> + + ), + }), + tasks: defineWorkbarSurfaceDescriptor('tasks', { + label: (_tab, _tabs, copy) => copy.tasks, + launcherLabel: (copy) => copy.tasks, + launcherDescription: (copy) => copy.launcher.tasks, + render: ({ sessionTodo }) => ( + + ), + }), + 'work-board': defineWorkbarSurfaceDescriptor('work-board', { + label: (_tab, _tabs, copy) => copy.workBoard, + launcherLabel: (copy) => copy.workBoard, + launcherDescription: (copy) => copy.launcher.workBoard, + render: ({ props }) => ( + + ), + }), + inspector: defineWorkbarSurfaceDescriptor('inspector', { + label: (_tab, _tabs, copy) => copy.inspector, + launcherLabel: (copy) => copy.inspector, + launcherDescription: (copy) => copy.launcher.inspector, + render: ({ active, copy, props }) => ( + }> + + + ), + }), +}; + +type RegisteredWorkbarSurfaceDescriptor = + (typeof WORKBAR_SURFACE_DESCRIPTOR_BY_KIND)[SessionWorkbarTabKind]; + +const WORKBAR_SURFACE_DESCRIPTORS: readonly RegisteredWorkbarSurfaceDescriptor[] = + WORKBAR_TOOL_DEFINITIONS.map( + (definition) => WORKBAR_SURFACE_DESCRIPTOR_BY_KIND[definition.kind], + ); + +function workbarSurfaceDescriptor( + kind: SessionWorkbarTabKind, +): RegisteredWorkbarSurfaceDescriptor { + return WORKBAR_SURFACE_DESCRIPTOR_BY_KIND[kind]; +} + function tabLabel( tab: SessionWorkbarTab, tabs: readonly SessionWorkbarTab[], - copy: ReturnType['workbar'], + copy: WorkbarCopy, ): string { - switch (tab.kind) { - case 'review': - return copy.review; - case 'terminal': - return tab.ordinal && tab.ordinal > 1 - ? copy.terminalNumbered(tab.ordinal) - : copy.terminal; - case 'tasks': - return copy.tasks; - case 'work-board': - return copy.workBoard; - case 'browser': - return copy.browser; - case 'files': - return copy.files; - case 'inspector': - return copy.inspector; - case 'side-chat': - { - if (tab.title?.trim()) return tab.title.trim(); - const index = - tab.ordinal ?? - tabs.filter((candidate) => candidate.kind === 'side-chat').findIndex( - (candidate) => candidate.id === tab.id, - ) + 1; - return index <= 1 ? copy.sideChat : copy.sideChatNumbered(index); - } - } + return workbarSurfaceDescriptor(tab.kind).label(tab, tabs, copy); } function tabIcon(tab: SessionWorkbarTab, active: boolean): ReactNode { @@ -216,22 +440,7 @@ function tabIcon(tab: SessionWorkbarTab, active: boolean): ReactNode { /> ); } - const Icon = - tab.kind === 'review' - ? GitBranch - : tab.kind === 'terminal' - ? Terminal - : tab.kind === 'tasks' - ? ListTodo - : tab.kind === 'work-board' - ? Clipboard - : tab.kind === 'browser' - ? Globe - : tab.kind === 'files' - ? FolderOpen - : tab.kind === 'inspector' - ? Activity - : MessageCircleQuestion; + const Icon = workbarSurfaceDescriptor(tab.kind).icon; return