feat: add persistent workbar browser tabs and scoped MCP routing
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: '关闭浏览器失败'
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user