diff --git a/docs/features/assistant-workbar/browser-tabs-technical-design.md b/docs/features/assistant-workbar/browser-tabs-technical-design.md index 4c890cb5..5983d743 100644 --- a/docs/features/assistant-workbar/browser-tabs-technical-design.md +++ b/docs/features/assistant-workbar/browser-tabs-technical-design.md @@ -144,12 +144,20 @@ Browser Tab。 - 没有活动 Conversation 时创建待绑定实例,不创建无归属 Main Browser Context。 - 主侧栏浏览器快捷按钮优先激活绑定当前 Conversation 的最近使用实例;没有匹配实例时创建 一个。没有活动 Conversation 时激活最近使用的浏览器实例;完全不存在时创建待绑定实例。 -- 每个实例标题至少区分 Conversation 和当前页面;完整目标通过提示和可访问名称提供。 +- New browser instances use a numbered browser label followed by the active Conversation title, when + available. The number avoids existing titles; restoring the layout preserves each saved title. + The address bar shows only that instance's page URL. Binding a numbered instance does not overwrite + its title; the default unbound browser receives the Conversation label when first bound. - 关闭工作栏 Browser Tab 只释放对应 Browser Tab。关闭浏览器上下文是独立危险操作,需要 明确说明会影响该 Conversation 下的全部 Tab 和 Agent 浏览器状态。 ## 8. 持久化与恢复 +`createTab` reuses a live tab only for the same Conversation and workbar instance ID. A new workbar +instance can adopt the primary tab only when its request creates the Conversation context. An existing +primary, including one opened by an Agent, is not adopted by a new instance. New sibling tabs start at +`about:blank` with no navigation history and share the Conversation's cookie partition. + 持久化工作栏实例 ID、顺序、自定义标题和公开 Conversation 绑定,不持久化页面进程、CDP 引用、snapshot、viewport lease、Cookie、Token 或 Main `BrowserTabId`。应用重启后实例显示 未启动状态,首次导航或 Agent 使用时按原绑定创建新 Tab。 diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index b0d68b08..ae097e41 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -90,7 +90,7 @@ Runtime - 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。 - 用户主动打开、关闭、排序和停靠可关闭的面板实例;任务中心和工作区作为基础工作入口默认 - 存在且不可关闭。不可关闭不等于锁定顺序,排序能力由独立属性控制。 + 存在且不可关闭,分别固定在第一、第二位,排序属性为不可重排。 - Task Center 是默认跟随当前项目的单实例 Task 索引。每个 Task 只关联一条 Conversation, 一条 Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通 Conversation、Job、Run 或心跳事项。 @@ -369,8 +369,8 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 ### 7.6 工作区 工作区是默认存在、单实例且不可关闭的基础应用。它默认跟随当前项目;项目或目录不可用时 -保留同一 Tab 并显示修复入口,不能通过关闭 Tab 隐藏范围错误。用户仍可调整该 Tab 的顺序, -除非后续单独引入顺序锁定属性。 +保留同一 Tab 并显示修复入口,不能通过关闭 Tab 隐藏范围错误。任务中心固定在第一位,工作区 +固定在第二位,恢复布局时保持此顺序。 工作区面板允许用户选择: diff --git a/docs/features/assistant-workbar/terminal-tabs-prd.md b/docs/features/assistant-workbar/terminal-tabs-prd.md index 9f297ad0..de3656a2 100644 --- a/docs/features/assistant-workbar/terminal-tabs-prd.md +++ b/docs/features/assistant-workbar/terminal-tabs-prd.md @@ -115,7 +115,7 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或 5. 单实例应用已经打开时,再次选择只聚焦已有实例,不创建重复 Tab。 6. 多实例应用每次选择都创建新实例。 7. 终端从首期开始就是多实例应用。 -8. 任务中心和工作区保持单实例、默认打开且不可关闭;不可关闭不自动禁止排序。 +8. 任务中心和工作区保持单实例、默认打开且不可关闭,分别固定在第一、第二位,`reorderable` 为 `false`。恢复旧布局时也按此规则修正顺序,其余 Tab 保持相对顺序。 9. 浏览器注册为可关闭的多实例应用,升级后保留一个默认实例;成果保持单实例、默认打开且 可关闭。 10. 用户关闭可关闭实例、切换或重排应用 Tab 后,“+”仍保持可见。 @@ -208,14 +208,18 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或 | 应用 | 实例策略 | 默认上下文 | 默认打开 | 必须存在 | 可关闭 | 可排序 | 再次从“+”选择 | | --- | --- | --- | --- | --- | --- | --- | --- | -| 任务中心 | `single` | 当前项目 | 是 | 是 | 否 | 是 | 聚焦已有 Tab | -| 工作区 | `single` | 当前项目 | 是 | 是 | 否 | 是 | 聚焦已有 Tab | +| 任务中心 | `single` | 当前项目 | 是 | 是 | 否 | 否 | 聚焦已有 Tab | +| 工作区 | `single` | 当前项目 | 是 | 是 | 否 | 否 | 聚焦已有 Tab | | 浏览器 | `multiple` | 当前 Conversation | 是,一个实例 | 否 | 是 | 是 | 每次创建新 Tab | | 成果 | `single` | 当前项目 | 是 | 否 | 是 | 是 | 聚焦已有 Tab | | 终端 | `multiple` | 当前项目 | 否 | 否 | 是 | 是 | 每次创建新 Tab | 实例策略、默认状态和可关闭策略都是应用注册数据,不能分散硬编码在多个点击处理器中。 +新建浏览器使用独立编号标题,避开已有名称;关闭后再次新建也不产生重名。恢复布局保留已保存 +的标题。会话名称用于标识归属,不替代浏览器实例名称。新建页面为空白页,同会话 Cookie 共享 +不表示复用旧页面或导航历史。 + ### 7.4 Tab 操作 - 点击或使用方向键切换实例不会暂停或终止后台实例。 diff --git a/src/main/browser/browser-service.test.ts b/src/main/browser/browser-service.test.ts index c7a9fca1..1aee5533 100644 --- a/src/main/browser/browser-service.test.ts +++ b/src/main/browser/browser-service.test.ts @@ -628,6 +628,28 @@ describe('BrowserService', () => { await windowLimited.service.dispose() }) + it('creates a fresh tab instead of adopting a navigated unowned primary, and restores only the same owner', async () => { + const harness = createHarness() + const signal = new AbortController().signal + try { + await harness.service.navigate('conversation', 'https://old.example/', signal, undefined, 21) + const [primary] = harness.service.listTabs('conversation', 21) + const fresh = await harness.service.createTab('conversation', 21, signal, workbarInstanceId) + expect(fresh.tabId).not.toBe(primary!.tabId) + expect(fresh.url).toBeUndefined() + expect(fresh.canGoBack).toBe(false) + expect(harness.slots[1]!.driver.navigate).not.toHaveBeenCalled() + await harness.service.navigate('conversation', 'https://new.example/', signal, fresh.tabId, 21) + const restored = await harness.service.createTab('conversation', 21, signal, workbarInstanceId) + expect(restored.tabId).toBe(fresh.tabId) + expect(restored.url).toBe('https://new.example/') + expect(harness.service.listTabs('conversation', 21)[0]!.url).toBe('https://old.example/') + expect(harness.service.getTabCount('conversation')).toBe(2) + } finally { + await harness.service.dispose() + } + }) + it('returns one live tab for concurrent requests from the same workbar instance', async () => { const gate = deferred() const harness = createHarness({ sessionGate: gate.promise }) diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index 426f417a..19509a02 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -877,7 +877,7 @@ describe('RightAssistantSidebar resizing', () => { 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(screen.getByRole('tab', { name: '浏览器 1 · 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: '前往' })) @@ -891,6 +891,52 @@ describe('RightAssistantSidebar resizing', () => { expect(browserApi.createTab).toHaveBeenCalledTimes(2) }) + it('opens a blank browser from + without inheriting the old address or title', async () => { + const conversationId = 'old-conversation' + renderSidebar({ + tab: 'browser', + activeConversationId: conversationId, + browserStates: { + [conversationId]: { + [firstBrowserTabId]: { + ...browserSummary(conversationId, firstBrowserTabId, true), + sessionActive: true, + url: 'https://old.example/' + } + } + } + }) + await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://old.example/')) + fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' })) + fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2)) + expect(screen.getByRole('tab', { name: '浏览器 1 · old-conversation' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('') + expect(screen.getByRole('button', { name: '返回' })).toBeDisabled() + const requests = browserApi.createTab.mock.calls.map(([request]) => request) + expect(requests[1]?.workbarInstanceId).not.toBe(requests[0]?.workbarInstanceId) + fireEvent.click(screen.getByRole('tab', { name: '浏览器 · old-conversation' })) + expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://old.example/') + + browserApi.createTab.mockImplementation(async ({ conversationId }) => + browserSummary(conversationId, crypto.randomUUID() as BrowserTabId, false) + ) + fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' })) + fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(3)) + expect(screen.getByRole('tab', { name: '浏览器 2 · old-conversation' })).toHaveAttribute('aria-selected', 'true') + fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 1 · old-conversation' })) + await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 1 · old-conversation' })).not.toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' })) + fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(4)) + expect(screen.getByRole('tab', { name: '浏览器 1 · old-conversation' })).toHaveAttribute('aria-selected', 'true') + cleanup() + renderSidebar({ activeConversationId: conversationId }) + expect(screen.getByRole('tab', { name: '浏览器 1 · old-conversation' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: '浏览器 2 · old-conversation' })).toBeVisible() + }) + it('keeps two browser instances independent and closes only the selected Main tab', async () => { const conversationId = 'conversation-a' const onNavigateBrowser = vi.fn(async () => undefined) @@ -976,7 +1022,7 @@ describe('RightAssistantSidebar resizing', () => { ) fireEvent.click( - screen.getByRole('button', { name: /关闭浏览器 · conversation-a · 2/u }) + screen.getByRole('button', { name: /关闭浏览器 1 · conversation-a/u }) ) await waitFor(() => expect(browserApi.closeTab).toHaveBeenCalledWith({ @@ -987,7 +1033,7 @@ describe('RightAssistantSidebar resizing', () => { expect( screen.getByRole('tab', { name: '浏览器 · conversation-a' }) ).toBeInTheDocument() - expect(screen.queryByRole('tab', { name: /· 2$/u })).not.toBeInTheDocument() + expect(screen.queryByRole('tab', { name: '浏览器 1 · conversation-a' })).not.toBeInTheDocument() }) it('restores a browser by stable ownership and releases only its viewport lease', async () => { @@ -1039,7 +1085,7 @@ describe('RightAssistantSidebar resizing', () => { expect(browserApi.createTab).not.toHaveBeenCalled() fireEvent.click( - screen.getByRole('tab', { name: `浏览器 · ${conversationId}` }) + screen.getByRole('tab', { name: '浏览器 · 已恢复' }) ) await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledWith({ @@ -1082,7 +1128,7 @@ describe('RightAssistantSidebar resizing', () => { ).toBe(false) fireEvent.click( - screen.getByRole('tab', { name: `浏览器 · ${conversationId}` }) + screen.getByRole('tab', { name: '浏览器 · 已恢复' }) ) await waitFor(() => { const acquisitions = browserApi.setViewport.mock.calls @@ -1097,7 +1143,7 @@ describe('RightAssistantSidebar resizing', () => { cleanup() renderSidebar({ activeConversationId: 'different-conversation' }) fireEvent.click( - screen.getByRole('tab', { name: `浏览器 · ${conversationId}` }) + screen.getByRole('tab', { name: '浏览器 · 已恢复' }) ) await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2)) expect(browserApi.createTab).toHaveBeenLastCalledWith({ diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index 57b9a98a..195d5858 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -710,13 +710,15 @@ export function RightAssistantSidebar({ ...instance, ...(conversationId ? { - title: `${label} · ${conversationTitles.get(conversationId) ?? conversationId}`, + title: initialLayout + ? instance.title + : `${label} · ${conversationTitles.get(conversationId) ?? conversationId}`, targetRef: { type: 'conversation' as const, conversationId } } - : { title: `${label} · ${t('sidebar.browser.unbound')}` }) + : { title: initialLayout ? instance.title : `${label} · ${t('sidebar.browser.unbound')}` }) } } ) @@ -1055,26 +1057,23 @@ export function RightAssistantSidebar({ conversationId: activeConversationId } : undefined - const sameConversationBrowsers = conversationTarget - ? workbarInstances.filter( - (candidate) => - candidate.appId === 'browser' && - candidate.targetRef?.type === 'conversation' && - candidate.targetRef.conversationId === - conversationTarget.conversationId - ).length - : 0 const conversationTitle = activeConversationId ? conversationTitles.get(activeConversationId) ?? activeConversationId : undefined + let browserNumber = 1 + const browserTitle = (): string => + `${definition.label} ${browserNumber}${conversationTitle ? ` · ${conversationTitle}` : ''}` + while (workbarInstances.some((candidate) => candidate.title === browserTitle())) { + browserNumber += 1 + } const instance: WorkbarTabInstance = { id: crypto.randomUUID(), appId: request.appId, title: request.appId === 'terminal' ? `${definition.label} ${sameTargetTerminals + 1}` - : request.appId === 'browser' && conversationTitle - ? `${definition.label} · ${conversationTitle}${sameConversationBrowsers > 0 ? ` · ${sameConversationBrowsers + 1}` : ''}` + : request.appId === 'browser' + ? browserTitle() : definition.label, ...(request.targetRef || conversationTarget ? { targetRef: request.targetRef ?? conversationTarget } @@ -1124,14 +1123,14 @@ export function RightAssistantSidebar({ throw new Error(t('sidebar.errors.browserControlUnavailable')) } if (instance.targetRef?.type !== 'conversation') { - const conversationTitle = - conversationTitles.get(conversationId) ?? conversationId setWorkbarInstances((current) => current.map((candidate) => candidate.id === instance.id ? { ...candidate, - title: `${t('sidebar.tabs.browser.label')} · ${conversationTitle}`, + ...(candidate.id === DEFAULT_WORKBAR_INSTANCES.find((item) => item.appId === 'browser')?.id + ? { title: `${t('sidebar.tabs.browser.label')} · ${conversationTitles.get(conversationId) ?? conversationId}` } + : {}), targetRef: { type: 'conversation', conversationId } } : candidate @@ -1895,7 +1894,7 @@ export function RightAssistantSidebar({ {schedule - ? t(`task.mode.${schedule.workMode}`) + ? t('task.mode.conversation') : task.workMode ? t(`task.mode.${task.workMode}`) : t('task.mode.unavailable')} diff --git a/src/renderer/src/WorkbarShell.test.tsx b/src/renderer/src/WorkbarShell.test.tsx index edf5d76e..bdc1bdc2 100644 --- a/src/renderer/src/WorkbarShell.test.tsx +++ b/src/renderer/src/WorkbarShell.test.tsx @@ -104,6 +104,8 @@ describe('WorkbarShell', () => { name: '已打开的工作栏应用' }) expect(within(tablist).getAllByRole('tab')).toHaveLength(4) + expect(within(tablist).getAllByRole('tab').map((tab) => tab.textContent)) + .toEqual(DEFAULT_WORKBAR_INSTANCES.map((instance) => instance.title)) expect( within(tablist).queryByRole('button', { name: '打开工作栏应用' @@ -123,6 +125,27 @@ describe('WorkbarShell', () => { ).not.toHaveClass('workbar-shell__tab-item--active') }) + it('pins tasks and workspace in supplied layouts and navigates in displayed order', () => { + const [tasks, workspace, browser, results] = DEFAULT_WORKBAR_INSTANCES + render( + + ) + + const tabs = screen.getAllByRole('tab') + expect(tabs.map((tab) => tab.textContent)).toEqual( + [tasks!, workspace!, terminalOne, results!, browser!].map((instance) => instance.title) + ) + expect(tabs[2]).toHaveAttribute('aria-selected', 'true') + fireEvent.keyDown(tabs[2]!, { key: 'Home' }) + expect(tabs[0]).toHaveFocus() + fireEvent.keyDown(tabs[0]!, { key: 'ArrowRight' }) + expect(tabs[1]).toHaveFocus() + fireEvent.keyDown(tabs[1]!, { key: 'ArrowRight' }) + expect(tabs[2]).toHaveFocus() + }) + it('does not expose close controls for fixed tabs', () => { render() diff --git a/src/renderer/src/WorkbarShell.tsx b/src/renderer/src/WorkbarShell.tsx index afd456eb..95616b1c 100644 --- a/src/renderer/src/WorkbarShell.tsx +++ b/src/renderer/src/WorkbarShell.tsx @@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next' import { WORKBAR_APP_DEFINITIONS, WORKBAR_LIMITS, + orderWorkbarInstances, type WorkbarAppDefinition, type WorkbarAppId, type WorkbarTabInstance, @@ -102,7 +103,7 @@ export function WorkbarShell({ activeInstanceId, appDefinitions = WORKBAR_APP_DEFINITIONS, className, - instances, + instances: unorderedInstances, onActiveInstanceChange, onCloseInstance, onCreateInstance, @@ -111,6 +112,7 @@ export function WorkbarShell({ renderPanel, renderTabAdornment }: WorkbarShellProps): React.JSX.Element { + const instances = orderWorkbarInstances(unorderedInstances) const { t } = useTranslation('workspace') const idPrefix = useId() const addButtonRef = useRef(null) diff --git a/src/renderer/src/i18n/locales/en-US/workspace.ts b/src/renderer/src/i18n/locales/en-US/workspace.ts index 1765666f..d6525b02 100644 --- a/src/renderer/src/i18n/locales/en-US/workspace.ts +++ b/src/renderer/src/i18n/locales/en-US/workspace.ts @@ -435,6 +435,7 @@ export const workspace = { interrupted: 'Interrupted' }, mode: { + conversation: 'Conversation settings', ask: 'Ask', execute: 'Execute', unavailable: 'Mode unavailable' diff --git a/src/renderer/src/i18n/locales/zh-CN/workspace.ts b/src/renderer/src/i18n/locales/zh-CN/workspace.ts index bdc45278..8ac652d5 100644 --- a/src/renderer/src/i18n/locales/zh-CN/workspace.ts +++ b/src/renderer/src/i18n/locales/zh-CN/workspace.ts @@ -424,6 +424,7 @@ export const workspace = { interrupted: '已中断' }, mode: { + conversation: '跟随会话设置', ask: 'Ask', execute: 'Execute', unavailable: '模式不可用' diff --git a/src/shared/workbar-contracts.test.ts b/src/shared/workbar-contracts.test.ts index 0e0d148c..6291bfbb 100644 --- a/src/shared/workbar-contracts.test.ts +++ b/src/shared/workbar-contracts.test.ts @@ -25,8 +25,8 @@ describe('workbar contracts', () => { reorderable })) ).toEqual([ - { id: 'tasks', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true }, - { id: 'workspace', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true }, + { id: 'tasks', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: false }, + { id: 'workspace', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: false }, { id: 'browser', instancePolicy: 'multiple', defaultContext: 'current-conversation', defaultOpen: true, required: false, closable: true, reorderable: true }, { id: 'results', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: false, closable: true, reorderable: true }, { @@ -209,14 +209,46 @@ describe('workbar contracts', () => { expect(normalized?.instances.map(({ id, appId }) => ({ id, appId }))) .toEqual([ { id: taskId, appId: 'tasks' }, + { id: workspaceId, appId: 'workspace' }, { id: browserId, appId: 'browser' }, - { id: secondBrowserId, appId: 'browser' }, - { id: workspaceId, appId: 'workspace' } + { id: secondBrowserId, appId: 'browser' } ]) expect(normalized?.activeInstanceId).toBe(taskId) expect(normalized?.taskScope).toBe('all-projects') }) + it('pins restored tasks and workspace without changing other order or selection', () => { + const workspace = { + id: '00000000-0000-4000-8000-000000000105', + appId: 'workspace', title: 'Workspace' + } as const + const tasks = { id: taskId, appId: 'tasks', title: 'Tasks' } as const + const terminal = { + id: terminalId, appId: 'terminal', title: 'Terminal', + targetRef: { type: 'local' } + } as const + const browser = { + id: '00000000-0000-4000-8000-000000000103', + appId: 'browser', title: 'Browser', + targetRef: { type: 'conversation', conversationId: 'conversation-a' } + } as const + const layout = { + instances: [terminal, workspace, browser, tasks], + activeInstanceId: browser.id, + expanded: true, dock: 'right', widthRatio: 0.4, + taskScope: 'all-projects' + } as const + + const normalized = normalizeWorkbarLayoutPreferences(layout, [tasks, workspace]) + expect(normalized).toEqual({ + ...layout, + instances: [tasks, workspace, terminal, browser] + }) + expect(layout.instances).toEqual([terminal, workspace, browser, tasks]) + expect(normalizeWorkbarLayoutPreferences(normalized, [tasks, workspace])) + .toEqual(normalized) + }) + it('preserves a valid empty layout with a null active instance', () => { expect( normalizeWorkbarLayoutPreferences( diff --git a/src/shared/workbar-contracts.ts b/src/shared/workbar-contracts.ts index 88cf6afb..aecdc244 100644 --- a/src/shared/workbar-contracts.ts +++ b/src/shared/workbar-contracts.ts @@ -107,7 +107,7 @@ export const WORKBAR_APP_DEFINITIONS = [ defaultOpen: true, required: true, closable: false, - reorderable: true, + reorderable: false, availability: { state: 'available' } }, { @@ -121,7 +121,7 @@ export const WORKBAR_APP_DEFINITIONS = [ defaultOpen: true, required: true, closable: false, - reorderable: true, + reorderable: false, availability: { state: 'available' } }, { @@ -317,6 +317,18 @@ export type WorkbarLayoutPreferences = z.infer< typeof workbarLayoutPreferencesSchema > +export function orderWorkbarInstances( + instances: readonly WorkbarTabInstance[] +): WorkbarTabInstance[] { + return [ + ...instances.filter((instance) => instance.appId === 'tasks'), + ...instances.filter((instance) => instance.appId === 'workspace'), + ...instances.filter( + (instance) => instance.appId !== 'tasks' && instance.appId !== 'workspace' + ) + ] +} + export function normalizeWorkbarLayoutPreferences( value: unknown, defaultInstances: readonly WorkbarTabInstance[] @@ -374,7 +386,7 @@ export function normalizeWorkbarLayoutPreferences( ) } - const boundedInstances = instances.slice( + const boundedInstances = orderWorkbarInstances(instances).slice( 0, WORKBAR_LIMITS.maximumOpenInstances ) diff --git a/tests/browser-tabs-electron-e2e.ts b/tests/browser-tabs-electron-e2e.ts index 001819b6..718cc08b 100644 --- a/tests/browser-tabs-electron-e2e.ts +++ b/tests/browser-tabs-electron-e2e.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { createServer } from 'node:http' -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow, webContents } from 'electron' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import type { KnowledgeService } from '../src/main/knowledge/knowledge-service' @@ -13,7 +13,7 @@ const firstWorkbarId = '00000000-0000-4000-8000-000000000901' const secondWorkbarId = '00000000-0000-4000-8000-000000000902' function page(title: string, body = ''): string { - return `

${title}

${body}` + return `${title}

${title}

${body}` } async function main(): Promise { @@ -50,12 +50,22 @@ async function main(): Promise { let client: Client | undefined let token: string | undefined try { + await browser.navigate(conversationId, `${origin}/first`, controller.signal, undefined, window.webContents.id) + const [unownedPrimary] = browser.listTabs(conversationId, window.webContents.id) + assert(unownedPrimary) const first = await browser.createTab( conversationId, window.webContents.id, controller.signal, firstWorkbarId ) + assert.notEqual(first.tabId, unownedPrimary.tabId) + assert.equal(first.url, undefined) + assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, unownedPrimary.tabId, window.webContents.id)), /First tab/u) + await browser.closeTab(conversationId, unownedPrimary.tabId, window.webContents.id) + console.log('New workbar ID did not adopt the existing navigated primary') + await browser.navigate(conversationId, `${origin}/first`, controller.signal, first.tabId, window.webContents.id) + const existingContents = new Set(webContents.getAllWebContents().map((contents) => contents.id)) const second = await browser.createTab( conversationId, window.webContents.id, @@ -63,8 +73,15 @@ async function main(): Promise { secondWorkbarId ) assert.notEqual(first.tabId, second.tabId) + assert.equal(second.url, undefined) + assert.equal(second.canGoBack, false) + const freshContents = webContents.getAllWebContents().filter((contents) => !existingContents.has(contents.id)) + assert.equal(freshContents.length, 1) + assert.equal(freshContents[0]!.getURL(), 'about:blank') + assert.equal(freshContents[0]!.getTitle(), 'about:blank') + assert.equal(freshContents[0]!.navigationHistory.canGoBack(), false) + console.log('Fresh WebContents verified: about:blank, blank-page title, no history') - await browser.navigate(conversationId, `${origin}/first`, controller.signal, first.tabId, window.webContents.id) await browser.navigate(conversationId, `${origin}/second`, controller.signal, second.tabId, window.webContents.id) assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, first.tabId, window.webContents.id)), /First tab/u) assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, second.tabId, window.webContents.id)), /Second tab/u) @@ -72,6 +89,7 @@ async function main(): Promise { await browser.navigate(conversationId, `${origin}/set-cookie`, controller.signal, first.tabId, window.webContents.id) await browser.navigate(conversationId, `${origin}/cookie`, controller.signal, second.tabId, window.webContents.id) assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, second.tabId, window.webContents.id)), /shared-browser-tab=visible/u) + console.log('Independent pages and shared conversation cookies verified') await gateway.start() const usage = browser.acquireTabUsage(conversationId, first.tabId, 'electron-e2e', window.webContents.id)