feat: add persistent workbar browser tabs and scoped MCP routing

This commit is contained in:
mesalogo
2026-09-10 01:26:56 +08:00
parent 428b4c0d08
commit 80b0b18712
54 changed files with 5446 additions and 1468 deletions
@@ -7,7 +7,18 @@ import {
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceFilePreview } from '../../shared/assistant-contracts'
import type {
AssistantProject,
AssistantTask,
WorkspaceFilePreview
} from '../../shared/assistant-contracts'
import type {
BrowserCreateTabRequest,
BrowserLiveState,
BrowserSetViewportRequest,
BrowserTabId,
BrowserTabSummary
} from '../../shared/contracts'
import {
RightAssistantSidebar,
type AssistantSidebarTab,
@@ -17,15 +28,74 @@ import {
afterEach(cleanup)
const firstBrowserTabId =
'00000000-0000-4000-8000-000000000401' as BrowserTabId
const secondBrowserTabId =
'00000000-0000-4000-8000-000000000402' as BrowserTabId
const browserApi = {
createTab: vi.fn<(request: BrowserCreateTabRequest) => Promise<BrowserTabSummary>>(),
closeTab: vi.fn(async () => undefined),
setViewport: vi.fn<
(request: BrowserSetViewportRequest) => Promise<void>
>(async () => undefined)
}
function browserSummary(
conversationId: string,
tabId: BrowserTabId,
primary: boolean
): BrowserTabSummary {
return {
conversationId,
tabId,
primary,
status: 'ready',
isLoading: false,
canGoBack: false,
createdAt: 1,
updatedAt: 1
}
}
beforeEach(() => {
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1400
})
browserApi.createTab.mockReset()
browserApi.closeTab.mockReset().mockResolvedValue(undefined)
browserApi.setViewport.mockReset().mockResolvedValue(undefined)
const tabsByOwner = new Map<string, BrowserTabId>()
browserApi.createTab.mockImplementation(async ({ conversationId, workbarInstanceId }) => {
const ownershipKey = `${conversationId}:${workbarInstanceId}`
let tabId = tabsByOwner.get(ownershipKey)
if (!tabId) {
tabId = tabsByOwner.size === 0 ? firstBrowserTabId : secondBrowserTabId
tabsByOwner.set(ownershipKey, tabId)
}
return browserSummary(conversationId, tabId, tabsByOwner.size === 1)
})
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: { browser: browserApi }
})
})
function renderSidebar({
const currentProject: AssistantProject = {
id: '00000000-0000-4000-8000-000000000301',
name: '当前项目',
description: '',
rootPath: 'D:\\project',
defaultWorkMode: 'ask',
kind: 'user',
executionSpace: { kind: 'local', rootPath: 'D:\\project' },
status: 'active',
createdAt: '2026-09-09T00:00:00.000Z',
updatedAt: '2026-09-09T00:00:00.000Z'
}
function sidebarElement({
tab = 'tasks',
approvals = [],
artifacts = [],
@@ -36,8 +106,16 @@ function renderSidebar({
})),
onLoadArtifact = vi.fn(async () => undefined),
onLoadWorkspaceFile = vi.fn(),
tasks = [],
activeProject = currentProject,
workspaceProjectId,
restoreFocusRef
restoreFocusRef,
activeConversationId,
browserStates,
onBackBrowser,
onNavigateBrowser,
onReloadBrowser,
onStopLoadingBrowser
}: {
tab?: AssistantSidebarTab
approvals?: PendingSidebarApproval[]
@@ -58,31 +136,46 @@ function renderSidebar({
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
tasks?: AssistantTask[]
activeProject?: AssistantProject | null
workspaceProjectId?: string
restoreFocusRef?: { current: HTMLElement | null }
} = {}): HTMLElement {
render(
activeConversationId?: string
browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
} = {}): React.JSX.Element {
return (
<div>
<main className="workspace" />
<RightAssistantSidebar
approvals={approvals}
activeConversationId={activeConversationId}
artifacts={artifacts}
browserStates={browserStates}
schedules={[]}
tasks={[]}
tasks={tasks}
conversationTitles={new Map()}
currentProject={activeProject ?? undefined}
projectNames={new Map()}
onCreateCustomTask={vi.fn()}
onBackBrowser={onBackBrowser}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={onListWorkspaceDirectory}
onLoadArtifact={onLoadArtifact}
onLoadWorkspaceFile={onLoadWorkspaceFile}
onNavigateBrowser={onNavigateBrowser}
onLoadWorkspaceDiff={vi.fn()}
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
onRefreshChanges={vi.fn(async () => undefined)}
onReloadBrowser={onReloadBrowser}
onRemoveSchedule={vi.fn(async () => undefined)}
onRespondApproval={vi.fn()}
onRunSchedule={vi.fn(async () => undefined)}
onSetScheduleEnabled={vi.fn(async () => undefined)}
onStopLoadingBrowser={onStopLoadingBrowser}
onOpenTask={vi.fn()}
onTabChange={vi.fn()}
open
@@ -92,7 +185,10 @@ function renderSidebar({
/>
</div>
)
}
function renderSidebar(options: Parameters<typeof sidebarElement>[0] = {}): HTMLElement {
render(sidebarElement(options))
return screen.getByRole('complementary', {
name: '助手工作栏'
})
@@ -257,7 +353,7 @@ describe('RightAssistantSidebar resizing', () => {
expect(
screen.getAllByRole('tab').map((tab) => tab.textContent)
).toEqual(['任务中心', '工作区', '浏览器', '成果'])
).toEqual(['任务中心', '工作区', '浏览器 · 未绑定会话', '成果'])
expect(
screen.queryByRole('tab', { name: '预览' })
).not.toBeInTheDocument()
@@ -289,6 +385,16 @@ describe('RightAssistantSidebar resizing', () => {
screen.getByRole('separator', { name: '调整助手工作栏宽度' })
).toHaveAttribute('tabindex', '-1')
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
expect(sidebar).not.toHaveClass('assistant-sidebar--browser-fullscreen')
fireEvent.click(screen.getByRole('tab', { name: /浏览器/u }))
expect(
screen.getByRole('button', { name: '全屏显示浏览器' })
).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(
screen.getByRole('button', { name: '全屏显示浏览器' })
)
fireEvent.keyDown(window, { key: 'Escape' })
expect(sidebar).not.toHaveClass(
'assistant-sidebar--browser-fullscreen'
@@ -332,6 +438,7 @@ describe('RightAssistantSidebar resizing', () => {
conversationId: 'conversation-1',
messageId: 'message-1',
approvalId: 'approval-1',
projectId: currentProject.id,
title: '写入工作区',
description: '更新 release.md',
toolName: 'write_file'
@@ -452,6 +559,102 @@ describe('RightAssistantSidebar resizing', () => {
expect(onListWorkspaceDirectory).toHaveBeenCalledTimes(2)
})
it('scopes approvals by their owning conversation project', () => {
renderSidebar({
approvals: [
{
conversationId: 'conversation-current',
projectId: currentProject.id,
messageId: 'message-current',
approvalId: 'approval-current',
title: '当前项目审批',
description: 'current'
},
{
conversationId: 'conversation-global',
messageId: 'message-global',
approvalId: 'approval-global',
title: '全局审批',
description: 'global'
}
]
})
expect(screen.getByText('当前项目审批')).toBeInTheDocument()
expect(screen.queryByText('全局审批')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '全局' }
)
)
expect(screen.getByText('全局审批')).toBeInTheDocument()
expect(screen.queryByText('当前项目审批')).not.toBeInTheDocument()
expect(screen.getByLabelText('等待审批: 1')).toBeInTheDocument()
})
it('scopes tasks to the current project, global tasks, or all projects and persists the choice', () => {
const createTask = (
id: string,
title: string,
projectId?: string
): AssistantTask => ({
id,
projectId,
title,
instructions: title,
origin: 'schedule',
status: 'running',
createdAt: '2026-09-09T00:00:00.000Z'
})
renderSidebar({
tasks: [
createTask('task-current', '当前项目任务', currentProject.id),
createTask('task-global', '全局任务'),
createTask(
'task-other',
'其他项目任务',
'00000000-0000-4000-8000-000000000302'
)
]
})
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.queryByText('全局任务')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '全局' }
)
)
expect(screen.getByText('全局任务')).toBeInTheDocument()
expect(screen.queryByText('当前项目任务')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '所有项目' }
)
)
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.getByText('全局任务')).toBeInTheDocument()
expect(screen.getByText('其他项目任务')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!))
.toEqual(expect.objectContaining({ taskScope: 'all-projects' }))
})
it('shows a clear current-project empty state when no project is active', () => {
renderSidebar({ activeProject: null })
expect(
screen.getByText(
'没有活动项目。请选择其他任务范围或先打开一个项目。'
)
).toBeInTheDocument()
expect(screen.queryByText('等待审批')).not.toBeInTheDocument()
})
it('keeps preview content and reading position when refreshing fails', async () => {
const onLoadWorkspaceFile = vi.fn().mockResolvedValueOnce({
path: 'README.md', name: 'README.md', content: '# Original heading',
@@ -590,4 +793,342 @@ describe('RightAssistantSidebar resizing', () => {
screen.queryByRole('tab', { name: '预览' })
).not.toBeInTheDocument()
})
it('keeps browser A selected and usable after switching to B, and binds a new browser to B', async () => {
const onNavigateBrowser = vi.fn(async () => undefined)
const onBackBrowser = vi.fn(async () => undefined)
const onReloadBrowser = vi.fn(async () => undefined)
const onStopLoadingBrowser = vi.fn(async () => undefined)
const browserStates = {
'conversation-a': {
[firstBrowserTabId]: {
...browserSummary('conversation-a', firstBrowserTabId, true),
sessionActive: true,
canGoBack: true,
url: 'https://a.example/'
}
},
'conversation-b': {
[secondBrowserTabId]: {
...browserSummary('conversation-b', secondBrowserTabId, true),
sessionActive: true,
url: 'https://b.example/'
}
}
}
const props = {
tab: 'browser' as const,
activeConversationId: 'conversation-a',
browserStates,
onNavigateBrowser,
onBackBrowser,
onReloadBrowser,
onStopLoadingBrowser
}
const view = render(sidebarElement(props))
await waitFor(() =>
expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/')
)
const browserA = screen.getByRole('tab', { name: '浏览器 · conversation-a' })
const ownerA = browserApi.createTab.mock.calls[0]![0].workbarInstanceId
view.rerender(sidebarElement({ ...props, activeConversationId: 'conversation-b' }))
expect(browserA).toBeVisible()
expect(browserA).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/')
expect(browserApi.createTab).toHaveBeenCalledOnce()
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
target: { value: 'https://a.example/next' }
})
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() => expect(onNavigateBrowser).toHaveBeenCalledWith(
'conversation-a', firstBrowserTabId, 'https://a.example/next'
))
fireEvent.click(screen.getByRole('button', { name: '返回' }))
await waitFor(() => {
expect(onBackBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId)
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
})
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
await waitFor(() => {
expect(onReloadBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId)
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
})
view.rerender(sidebarElement({
...props,
activeConversationId: 'conversation-b',
browserStates: {
...browserStates,
'conversation-a': {
[firstBrowserTabId]: { ...browserStates['conversation-a'][firstBrowserTabId]!, isLoading: true }
}
}
}))
fireEvent.click(screen.getByRole('button', { name: '停止加载' }))
await waitFor(() => expect(onStopLoadingBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId))
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!)
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
expect(browserApi.createTab).toHaveBeenLastCalledWith({
conversationId: 'conversation-b',
workbarInstanceId: expect.any(String)
})
expect(browserApi.createTab.mock.calls[1]![0].workbarInstanceId).not.toBe(ownerA)
expect(screen.getByRole('tab', { name: '浏览器 · conversation-b' })).toHaveAttribute('aria-selected', 'true')
expect(browserA).toBeVisible()
await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://b.example/'))
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() => expect(onNavigateBrowser).toHaveBeenLastCalledWith(
'conversation-b', secondBrowserTabId, 'https://b.example/'
))
fireEvent.click(browserA)
expect(browserA).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/')
expect(browserApi.createTab).toHaveBeenCalledTimes(2)
})
it('keeps two browser instances independent and closes only the selected Main tab', async () => {
const conversationId = 'conversation-a'
const onNavigateBrowser = vi.fn(async () => undefined)
const states: Record<string, Record<string, BrowserLiveState>> = {
[conversationId]: {
[firstBrowserTabId]: {
conversationId,
tabId: firstBrowserTabId,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
url: 'https://first.example/',
updatedAt: 1
},
[secondBrowserTabId]: {
conversationId,
tabId: secondBrowserTabId,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
url: 'https://second.example/',
updatedAt: 2
}
}
}
renderSidebar({
tab: 'browser',
activeConversationId: conversationId,
browserStates: states,
onNavigateBrowser
})
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
const firstAddress = screen.getByRole('textbox', {
name: '浏览器地址'
})
await waitFor(() =>
expect(firstAddress).toHaveValue('https://first.example/')
)
fireEvent.change(firstAddress, {
target: { value: 'https://first.example/next' }
})
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() =>
expect(onNavigateBrowser).toHaveBeenCalledWith(
conversationId,
firstBrowserTabId,
'https://first.example/next'
)
)
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(
screen.getByText('浏览器', { selector: 'strong' }).closest('button')!
)
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
const ownershipRequests = browserApi.createTab.mock.calls.map(
([request]) => request
)
expect(ownershipRequests[0]?.conversationId).toBe(conversationId)
expect(ownershipRequests[1]?.conversationId).toBe(conversationId)
expect(ownershipRequests[0]?.workbarInstanceId).not.toBe(
ownershipRequests[1]?.workbarInstanceId
)
const secondAddress = screen.getByRole('textbox', {
name: '浏览器地址'
})
await waitFor(() =>
expect(secondAddress).toHaveValue('https://second.example/')
)
fireEvent.change(secondAddress, {
target: { value: 'https://second.example/next' }
})
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() =>
expect(onNavigateBrowser).toHaveBeenLastCalledWith(
conversationId,
secondBrowserTabId,
'https://second.example/next'
)
)
fireEvent.click(
screen.getByRole('button', { name: /关闭浏览器 · conversation-a · 2/u })
)
await waitFor(() =>
expect(browserApi.closeTab).toHaveBeenCalledWith({
conversationId,
tabId: secondBrowserTabId
})
)
expect(
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
).toBeInTheDocument()
expect(screen.queryByRole('tab', { name: /· 2$/u })).not.toBeInTheDocument()
})
it('restores a browser by stable ownership and releases only its viewport lease', async () => {
const conversationId = 'conversation-restored'
const workbarInstanceId = '10000000-0000-4000-8000-000000000003'
localStorage.setItem(
'goodbuddy.workbar-layout.v1',
JSON.stringify({
instances: [
{
id: '10000000-0000-4000-8000-000000000001',
appId: 'tasks',
title: '任务中心'
},
{
id: '10000000-0000-4000-8000-000000000002',
appId: 'workspace',
title: '工作区'
},
{
id: '10000000-0000-4000-8000-000000000003',
appId: 'browser',
title: '浏览器 · 已恢复',
targetRef: { type: 'conversation', conversationId }
}
],
activeInstanceId: '10000000-0000-4000-8000-000000000001',
expanded: true,
dock: 'right',
widthRatio: 0.3,
taskScope: 'current-project'
})
)
const bounds = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue({
bottom: 500,
height: 400,
left: 900,
right: 1300,
top: 100,
width: 400,
x: 900,
y: 100,
toJSON: () => ({})
})
try {
renderSidebar({ activeConversationId: 'different-conversation' })
expect(browserApi.createTab).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
)
await waitFor(() =>
expect(browserApi.createTab).toHaveBeenCalledWith({
conversationId,
workbarInstanceId
})
)
await waitFor(() =>
expect(browserApi.setViewport).toHaveBeenCalledWith(
expect.objectContaining({
conversationId,
tabId: firstBrowserTabId,
bounds: expect.any(Object),
leaseToken: expect.any(String)
})
)
)
const acquired = browserApi.setViewport.mock.calls.find(
([request]) => 'conversationId' in request
)?.[0]
if (!acquired || !('conversationId' in acquired)) {
throw new Error('Expected an acquired viewport lease')
}
expect(acquired.leaseToken).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
)
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
await waitFor(() =>
expect(browserApi.setViewport).toHaveBeenCalledWith({
leaseToken: acquired.leaseToken
})
)
expect(
browserApi.setViewport.mock.calls.some(
([request]) =>
Object.keys(request).length === 1 &&
request.leaseToken !== acquired.leaseToken
)
).toBe(false)
fireEvent.click(
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
)
await waitFor(() => {
const acquisitions = browserApi.setViewport.mock.calls
.map(([request]) => request)
.filter((request) => 'conversationId' in request)
expect(acquisitions).toHaveLength(2)
expect(acquisitions[1]!.leaseToken).not.toBe(
acquisitions[0]!.leaseToken
)
})
cleanup()
renderSidebar({ activeConversationId: 'different-conversation' })
fireEvent.click(
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
)
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
expect(browserApi.createTab).toHaveBeenLastCalledWith({
conversationId,
workbarInstanceId
})
await waitFor(() =>
expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({
conversationId,
tabId: firstBrowserTabId
})
)
)
} finally {
bounds.mockRestore()
}
})
it('keeps a browser workbar tab when closing its Main tab fails', async () => {
browserApi.closeTab.mockRejectedValueOnce(new Error('Close denied'))
renderSidebar({ tab: 'browser', activeConversationId: 'conversation-a' })
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
fireEvent.click(
screen.getByRole('button', { name: /关闭浏览器 · conversation-a/u })
)
expect(await screen.findByRole('alert')).toHaveTextContent('Close denied')
expect(
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
).toBeInTheDocument()
})
})
+93 -26
View File
@@ -39,12 +39,14 @@ const stylesheet = readFileSync(
function ControlledShell({
initialInstances = DEFAULT_WORKBAR_INSTANCES,
onCreate = vi.fn(),
onClose,
onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
type: 'local'
}))
}: {
initialInstances?: readonly WorkbarTabInstance[]
onCreate?: (request: WorkbarInstanceCreateRequest) => void
onClose?: (instance: WorkbarTabInstance) => boolean | void | Promise<boolean | void>
onTerminalTarget?: () =>
| WorkbarTargetRef
| Promise<WorkbarTargetRef>
@@ -55,19 +57,27 @@ function ControlledShell({
)
return (
<WorkbarShell
activeInstanceId={activeId}
instances={instances}
onActiveInstanceChange={setActiveId}
onCloseInstance={(instance) =>
setInstances((current) =>
current.filter((candidate) => candidate.id !== instance.id)
)
}
onCreateInstance={onCreate}
onResolveTerminalTarget={onTerminalTarget}
renderPanel={(instance) => <p>{instance.title}内容</p>}
/>
<>
<output data-testid="active-workbar-instance">
{activeId ?? 'none'}
</output>
<WorkbarShell
activeInstanceId={activeId}
instances={instances}
onActiveInstanceChange={setActiveId}
onCloseInstance={async (instance) => {
const accepted = await onClose?.(instance)
if (accepted === false) return false
setInstances((current) =>
current.filter((candidate) => candidate.id !== instance.id)
)
return true
}}
onCreateInstance={onCreate}
onResolveTerminalTarget={onTerminalTarget}
renderPanel={(instance) => <p>{instance.title}内容</p>}
/>
</>
)
}
@@ -87,7 +97,7 @@ describe('WorkbarShell', () => {
)
})
it('provides four default single-instance tabs and keeps add outside the tablist', () => {
it('provides fixed defaults plus a browser tab and keeps add outside the tablist', () => {
render(<ControlledShell />)
const tablist = screen.getByRole('tablist', {
@@ -113,6 +123,20 @@ describe('WorkbarShell', () => {
).not.toHaveClass('workbar-shell__tab-item--active')
})
it('does not expose close controls for fixed tabs', () => {
render(<ControlledShell />)
expect(
screen.queryByRole('button', { name: '关闭任务中心' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '关闭工作区' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: '关闭浏览器' })
).toBeInTheDocument()
})
it('opens the catalog, focuses its first choice, and restores add-button focus on Escape', async () => {
render(<ControlledShell />)
const addButton = screen.getByRole('button', {
@@ -196,6 +220,22 @@ describe('WorkbarShell', () => {
)
})
it('allows another browser instance to be requested', () => {
const onCreate = vi.fn()
render(<ControlledShell onCreate={onCreate} />)
fireEvent.click(
screen.getByRole('button', { name: '打开工作栏应用' })
)
fireEvent.click(
screen.getByText('浏览器', { selector: 'strong' }).closest('button')!
)
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ appId: 'browser' })
)
})
it('supports roving Arrow, Home, and End navigation', () => {
render(<ControlledShell />)
const tasks = screen.getByRole('tab', { name: '任务中心' })
@@ -214,19 +254,23 @@ describe('WorkbarShell', () => {
expect(tasks).toHaveFocus()
})
it('closes the active tab, activates its right neighbor, and restores focus', async () => {
render(<ControlledShell />)
const workspace = screen.getByRole('tab', { name: '工作区' })
fireEvent.click(workspace)
it('closes an active closable tab, activates its neighbor, and restores focus', async () => {
render(
<ControlledShell
initialInstances={[...DEFAULT_WORKBAR_INSTANCES, terminalOne]}
/>
)
const browser = screen.getByRole('tab', { name: '浏览器' })
fireEvent.click(browser)
fireEvent.click(
screen.getByRole('button', { name: '关闭工作区' })
screen.getByRole('button', { name: '关闭浏览器' })
)
const browser = screen.getByRole('tab', { name: '浏览器' })
await waitFor(() => expect(browser).toHaveFocus())
expect(browser).toHaveAttribute('aria-selected', 'true')
const results = screen.getByRole('tab', { name: '成果' })
await waitFor(() => expect(results).toHaveFocus())
expect(results).toHaveAttribute('aria-selected', 'true')
expect(
screen.queryByRole('tab', { name: '工作区' })
screen.queryByRole('tab', { name: '浏览器' })
).not.toBeInTheDocument()
})
@@ -234,19 +278,39 @@ describe('WorkbarShell', () => {
render(<ControlledShell />)
const tasks = screen.getByRole('tab', { name: '任务中心' })
fireEvent.click(
screen.getByRole('button', { name: '关闭成果' })
screen.getByRole('button', { name: '关闭浏览器' })
)
await waitFor(() => expect(tasks).toHaveFocus())
expect(tasks).toHaveAttribute('aria-selected', 'true')
})
it('retains an instance when its close operation rejects', async () => {
const onClose = vi.fn(async () => {
throw new Error('Close denied')
})
render(
<ControlledShell
initialInstances={[terminalOne]}
onClose={onClose}
/>
)
fireEvent.click(
screen.getByRole('button', { name: `关闭${terminalOne.title}` })
)
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
expect(screen.getByRole('tab', { name: terminalOne.title }))
.toBeInTheDocument()
})
it('shows the empty state while preserving the add command after the last close', async () => {
render(
<ControlledShell initialInstances={[DEFAULT_WORKBAR_INSTANCES[0]!]} />
<ControlledShell initialInstances={[terminalOne]} />
)
fireEvent.click(
screen.getByRole('button', { name: '关闭任务中心' })
screen.getByRole('button', { name: `关闭${terminalOne.title}` })
)
const addButton = screen.getByRole('button', {
@@ -256,6 +320,9 @@ describe('WorkbarShell', () => {
expect(
screen.getByText('还没有打开的工作栏应用')
).toBeInTheDocument()
expect(screen.getByTestId('active-workbar-instance')).toHaveTextContent(
'none'
)
expect(addButton).toBeVisible()
})
})
+34 -23
View File
@@ -61,7 +61,7 @@ export type WorkbarShellProps = {
activeInstanceId: string | null
renderPanel: (instance: WorkbarTabInstance) => ReactNode
renderTabAdornment?: (instance: WorkbarTabInstance) => ReactNode
onActiveInstanceChange: (instanceId: string) => void
onActiveInstanceChange: (instanceId: string | null) => void
onCreateInstance: (request: WorkbarInstanceCreateRequest) => void
onCloseInstance: (
instance: WorkbarTabInstance
@@ -289,6 +289,12 @@ export function WorkbarShell({
const handleClose = async (
instance: WorkbarTabInstance
): Promise<void> => {
const definition = appDefinitions.find(
(candidate) => candidate.id === instance.appId
)
if (definition?.closable !== true) {
return
}
const closingIndex = instances.findIndex(
(candidate) => candidate.id === instance.id
)
@@ -299,19 +305,18 @@ export function WorkbarShell({
null)
: activeInstance
const closeResult = onCloseInstance(instance)
const closeAccepted =
closeResult &&
typeof (closeResult as PromiseLike<boolean | void>).then ===
'function'
? await closeResult
: closeResult
let closeAccepted: boolean | void
try {
closeAccepted = await onCloseInstance(instance)
} catch {
return
}
if (closeAccepted === false) {
return
}
pendingTabFocusRef.current = nextActive?.id ?? null
if (nextActive && nextActive.id !== activeInstanceId) {
onActiveInstanceChange(nextActive.id)
if (isActive && nextActive?.id !== activeInstanceId) {
onActiveInstanceChange(nextActive?.id ?? null)
}
}
@@ -331,6 +336,10 @@ export function WorkbarShell({
const selected =
!catalogOpen && instance.id === activeInstanceId
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
const closable =
appDefinitions.find(
(definition) => definition.id === instance.appId
)?.closable === true
return (
<div
className={joinClassNames(
@@ -376,19 +385,21 @@ export function WorkbarShell({
</span>
{renderTabAdornment?.(instance)}
</button>
<button
aria-label={t('sidebar.workbar.close', {
title: instance.title
})}
className="workbar-shell__tab-close"
onClick={() => void handleClose(instance)}
title={t('sidebar.workbar.close', {
title: instance.title
})}
type="button"
>
<X aria-hidden="true" />
</button>
{closable ? (
<button
aria-label={t('sidebar.workbar.close', {
title: instance.title
})}
className="workbar-shell__tab-close"
onClick={() => void handleClose(instance)}
title={t('sidebar.workbar.close', {
title: instance.title
})}
type="button"
>
<X aria-hidden="true" />
</button>
) : null}
</div>
)
})}
@@ -208,6 +208,9 @@ export const app = {
switching: 'Switching…',
picker: 'Runtime and model',
directModels: 'Direct models',
defaultDirect: 'Direct · Follow the global default model',
fixedRuntimeModel: 'Fixed Runtime model',
fixedModel: 'Fixed',
deepseekHarnessGroup:
'DeepSeek Harness (Developer preview · OpenAI-compatible)',
manage: 'Manage Runtime and model connections',
@@ -310,6 +310,14 @@ export const workspace = {
startedAt: 'Started {{time}}',
nextRunAt: 'Next run: {{time}}',
notStarted: 'Not run yet',
scope: {
ariaLabel: 'Task scope',
currentProject: 'Current project',
global: 'Global',
allProjects: 'All projects',
noCurrentProject:
'No project is active. Choose another task scope or open a project.'
},
filters: {
ariaLabel: 'Filter tasks',
attention: 'Attention',
@@ -365,6 +373,7 @@ export const workspace = {
},
browser: {
title: 'Live browser',
unbound: 'No conversation',
interact: 'Interact',
interacting: 'Interacting',
close: 'Close browser',
@@ -407,6 +416,8 @@ export const workspace = {
backBrowser: 'Could not go back',
reloadBrowser: 'Could not refresh the web page',
stopLoadingBrowser: 'Could not stop loading the page',
closeBrowser: 'Could not close this browser tab',
browserControlUnavailable: 'Browser tab controls are unavailable',
interactBrowser: 'Could not open the browser interaction window',
stopBrowser: 'Could not close the browser'
}
@@ -195,6 +195,9 @@ export const app = {
switching: '切换中…',
picker: 'Runtime 和模型',
directModels: '直连模型',
defaultDirect: '直连 · 跟随全局默认模型',
fixedRuntimeModel: '固定 Runtime 模型',
fixedModel: '固定',
deepseekHarnessGroup:
'DeepSeek Harness(开发者预览 · OpenAI 兼容)',
manage: '管理 Runtime 和模型连接',
@@ -302,6 +302,13 @@ export const workspace = {
startedAt: '{{time}} 开始',
nextRunAt: '下次运行:{{time}}',
notStarted: '尚未运行',
scope: {
ariaLabel: '任务范围',
currentProject: '当前项目',
global: '全局',
allProjects: '所有项目',
noCurrentProject: '没有活动项目。请选择其他任务范围或先打开一个项目。'
},
filters: {
ariaLabel: '筛选任务',
attention: '待关注',
@@ -355,6 +362,7 @@ export const workspace = {
},
browser: {
title: '实时浏览器',
unbound: '未绑定会话',
interact: '交互',
interacting: '交互中',
close: '关闭浏览器',
@@ -397,6 +405,8 @@ export const workspace = {
backBrowser: '返回上一页失败',
reloadBrowser: '刷新网页失败',
stopLoadingBrowser: '停止加载失败',
closeBrowser: '关闭此浏览器 Tab 失败',
browserControlUnavailable: '浏览器 Tab 控制不可用',
interactBrowser: '打开浏览器交互窗口失败',
stopBrowser: '关闭浏览器失败'
}
+2 -3
View File
@@ -17,7 +17,7 @@ function harnessSettings(
}
describe('DeepSeek Harness runtime selection', () => {
it('uses the configured Chat Completions profile', () => {
it('references the Runtime configuration without copying its profile', () => {
const selection = getRuntimeSelectionForProvider(
'deepseek-harness',
harnessSettings({
@@ -27,8 +27,7 @@ describe('DeepSeek Harness runtime selection', () => {
)
expect(selection).toEqual({
provider: 'deepseek-harness',
profileId: harnessProfileId
provider: 'deepseek-harness'
} satisfies Record<string, string>)
})