fix: pin workbar tab order and preserve browser titles
This commit is contained in:
@@ -144,12 +144,20 @@ Browser Tab。
|
|||||||
- 没有活动 Conversation 时创建待绑定实例,不创建无归属 Main Browser Context。
|
- 没有活动 Conversation 时创建待绑定实例,不创建无归属 Main Browser Context。
|
||||||
- 主侧栏浏览器快捷按钮优先激活绑定当前 Conversation 的最近使用实例;没有匹配实例时创建
|
- 主侧栏浏览器快捷按钮优先激活绑定当前 Conversation 的最近使用实例;没有匹配实例时创建
|
||||||
一个。没有活动 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。关闭浏览器上下文是独立危险操作,需要
|
- 关闭工作栏 Browser Tab 只释放对应 Browser Tab。关闭浏览器上下文是独立危险操作,需要
|
||||||
明确说明会影响该 Conversation 下的全部 Tab 和 Agent 浏览器状态。
|
明确说明会影响该 Conversation 下的全部 Tab 和 Agent 浏览器状态。
|
||||||
|
|
||||||
## 8. 持久化与恢复
|
## 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
|
持久化工作栏实例 ID、顺序、自定义标题和公开 Conversation 绑定,不持久化页面进程、CDP
|
||||||
引用、snapshot、viewport lease、Cookie、Token 或 Main `BrowserTabId`。应用重启后实例显示
|
引用、snapshot、viewport lease、Cookie、Token 或 Main `BrowserTabId`。应用重启后实例显示
|
||||||
未启动状态,首次导航或 Agent 使用时按原绑定创建新 Tab。
|
未启动状态,首次导航或 Agent 使用时按原绑定创建新 Tab。
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ Runtime
|
|||||||
|
|
||||||
- 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。
|
- 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。
|
||||||
- 用户主动打开、关闭、排序和停靠可关闭的面板实例;任务中心和工作区作为基础工作入口默认
|
- 用户主动打开、关闭、排序和停靠可关闭的面板实例;任务中心和工作区作为基础工作入口默认
|
||||||
存在且不可关闭。不可关闭不等于锁定顺序,排序能力由独立属性控制。
|
存在且不可关闭,分别固定在第一、第二位,排序属性为不可重排。
|
||||||
- Task Center 是默认跟随当前项目的单实例 Task 索引。每个 Task 只关联一条 Conversation,
|
- Task Center 是默认跟随当前项目的单实例 Task 索引。每个 Task 只关联一条 Conversation,
|
||||||
一条 Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通
|
一条 Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通
|
||||||
Conversation、Job、Run 或心跳事项。
|
Conversation、Job、Run 或心跳事项。
|
||||||
@@ -369,8 +369,8 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
|||||||
### 7.6 工作区
|
### 7.6 工作区
|
||||||
|
|
||||||
工作区是默认存在、单实例且不可关闭的基础应用。它默认跟随当前项目;项目或目录不可用时
|
工作区是默认存在、单实例且不可关闭的基础应用。它默认跟随当前项目;项目或目录不可用时
|
||||||
保留同一 Tab 并显示修复入口,不能通过关闭 Tab 隐藏范围错误。用户仍可调整该 Tab 的顺序,
|
保留同一 Tab 并显示修复入口,不能通过关闭 Tab 隐藏范围错误。任务中心固定在第一位,工作区
|
||||||
除非后续单独引入顺序锁定属性。
|
固定在第二位,恢复布局时保持此顺序。
|
||||||
|
|
||||||
工作区面板允许用户选择:
|
工作区面板允许用户选择:
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
|
|||||||
5. 单实例应用已经打开时,再次选择只聚焦已有实例,不创建重复 Tab。
|
5. 单实例应用已经打开时,再次选择只聚焦已有实例,不创建重复 Tab。
|
||||||
6. 多实例应用每次选择都创建新实例。
|
6. 多实例应用每次选择都创建新实例。
|
||||||
7. 终端从首期开始就是多实例应用。
|
7. 终端从首期开始就是多实例应用。
|
||||||
8. 任务中心和工作区保持单实例、默认打开且不可关闭;不可关闭不自动禁止排序。
|
8. 任务中心和工作区保持单实例、默认打开且不可关闭,分别固定在第一、第二位,`reorderable` 为 `false`。恢复旧布局时也按此规则修正顺序,其余 Tab 保持相对顺序。
|
||||||
9. 浏览器注册为可关闭的多实例应用,升级后保留一个默认实例;成果保持单实例、默认打开且
|
9. 浏览器注册为可关闭的多实例应用,升级后保留一个默认实例;成果保持单实例、默认打开且
|
||||||
可关闭。
|
可关闭。
|
||||||
10. 用户关闭可关闭实例、切换或重排应用 Tab 后,“+”仍保持可见。
|
10. 用户关闭可关闭实例、切换或重排应用 Tab 后,“+”仍保持可见。
|
||||||
@@ -208,14 +208,18 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
|
|||||||
|
|
||||||
| 应用 | 实例策略 | 默认上下文 | 默认打开 | 必须存在 | 可关闭 | 可排序 | 再次从“+”选择 |
|
| 应用 | 实例策略 | 默认上下文 | 默认打开 | 必须存在 | 可关闭 | 可排序 | 再次从“+”选择 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| 任务中心 | `single` | 当前项目 | 是 | 是 | 否 | 是 | 聚焦已有 Tab |
|
| 任务中心 | `single` | 当前项目 | 是 | 是 | 否 | 否 | 聚焦已有 Tab |
|
||||||
| 工作区 | `single` | 当前项目 | 是 | 是 | 否 | 是 | 聚焦已有 Tab |
|
| 工作区 | `single` | 当前项目 | 是 | 是 | 否 | 否 | 聚焦已有 Tab |
|
||||||
| 浏览器 | `multiple` | 当前 Conversation | 是,一个实例 | 否 | 是 | 是 | 每次创建新 Tab |
|
| 浏览器 | `multiple` | 当前 Conversation | 是,一个实例 | 否 | 是 | 是 | 每次创建新 Tab |
|
||||||
| 成果 | `single` | 当前项目 | 是 | 否 | 是 | 是 | 聚焦已有 Tab |
|
| 成果 | `single` | 当前项目 | 是 | 否 | 是 | 是 | 聚焦已有 Tab |
|
||||||
| 终端 | `multiple` | 当前项目 | 否 | 否 | 是 | 是 | 每次创建新 Tab |
|
| 终端 | `multiple` | 当前项目 | 否 | 否 | 是 | 是 | 每次创建新 Tab |
|
||||||
|
|
||||||
实例策略、默认状态和可关闭策略都是应用注册数据,不能分散硬编码在多个点击处理器中。
|
实例策略、默认状态和可关闭策略都是应用注册数据,不能分散硬编码在多个点击处理器中。
|
||||||
|
|
||||||
|
新建浏览器使用独立编号标题,避开已有名称;关闭后再次新建也不产生重名。恢复布局保留已保存
|
||||||
|
的标题。会话名称用于标识归属,不替代浏览器实例名称。新建页面为空白页,同会话 Cookie 共享
|
||||||
|
不表示复用旧页面或导航历史。
|
||||||
|
|
||||||
### 7.4 Tab 操作
|
### 7.4 Tab 操作
|
||||||
|
|
||||||
- 点击或使用方向键切换实例不会暂停或终止后台实例。
|
- 点击或使用方向键切换实例不会暂停或终止后台实例。
|
||||||
|
|||||||
@@ -628,6 +628,28 @@ describe('BrowserService', () => {
|
|||||||
await windowLimited.service.dispose()
|
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 () => {
|
it('returns one live tab for concurrent requests from the same workbar instance', async () => {
|
||||||
const gate = deferred<void>()
|
const gate = deferred<void>()
|
||||||
const harness = createHarness({ sessionGate: gate.promise })
|
const harness = createHarness({ sessionGate: gate.promise })
|
||||||
|
|||||||
@@ -877,7 +877,7 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
workbarInstanceId: expect.any(String)
|
workbarInstanceId: expect.any(String)
|
||||||
})
|
})
|
||||||
expect(browserApi.createTab.mock.calls[1]![0].workbarInstanceId).not.toBe(ownerA)
|
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()
|
expect(browserA).toBeVisible()
|
||||||
await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://b.example/'))
|
await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://b.example/'))
|
||||||
fireEvent.click(screen.getByRole('button', { name: '前往' }))
|
fireEvent.click(screen.getByRole('button', { name: '前往' }))
|
||||||
@@ -891,6 +891,52 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
expect(browserApi.createTab).toHaveBeenCalledTimes(2)
|
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 () => {
|
it('keeps two browser instances independent and closes only the selected Main tab', async () => {
|
||||||
const conversationId = 'conversation-a'
|
const conversationId = 'conversation-a'
|
||||||
const onNavigateBrowser = vi.fn(async () => undefined)
|
const onNavigateBrowser = vi.fn(async () => undefined)
|
||||||
@@ -976,7 +1022,7 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('button', { name: /关闭浏览器 · conversation-a · 2/u })
|
screen.getByRole('button', { name: /关闭浏览器 1 · conversation-a/u })
|
||||||
)
|
)
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(browserApi.closeTab).toHaveBeenCalledWith({
|
expect(browserApi.closeTab).toHaveBeenCalledWith({
|
||||||
@@ -987,7 +1033,7 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
|
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
|
||||||
).toBeInTheDocument()
|
).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 () => {
|
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()
|
expect(browserApi.createTab).not.toHaveBeenCalled()
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
|
screen.getByRole('tab', { name: '浏览器 · 已恢复' })
|
||||||
)
|
)
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(browserApi.createTab).toHaveBeenCalledWith({
|
expect(browserApi.createTab).toHaveBeenCalledWith({
|
||||||
@@ -1082,7 +1128,7 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
).toBe(false)
|
).toBe(false)
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
|
screen.getByRole('tab', { name: '浏览器 · 已恢复' })
|
||||||
)
|
)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const acquisitions = browserApi.setViewport.mock.calls
|
const acquisitions = browserApi.setViewport.mock.calls
|
||||||
@@ -1097,7 +1143,7 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
cleanup()
|
cleanup()
|
||||||
renderSidebar({ activeConversationId: 'different-conversation' })
|
renderSidebar({ activeConversationId: 'different-conversation' })
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
|
screen.getByRole('tab', { name: '浏览器 · 已恢复' })
|
||||||
)
|
)
|
||||||
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
|
||||||
expect(browserApi.createTab).toHaveBeenLastCalledWith({
|
expect(browserApi.createTab).toHaveBeenLastCalledWith({
|
||||||
|
|||||||
@@ -710,13 +710,15 @@ export function RightAssistantSidebar({
|
|||||||
...instance,
|
...instance,
|
||||||
...(conversationId
|
...(conversationId
|
||||||
? {
|
? {
|
||||||
title: `${label} · ${conversationTitles.get(conversationId) ?? conversationId}`,
|
title: initialLayout
|
||||||
|
? instance.title
|
||||||
|
: `${label} · ${conversationTitles.get(conversationId) ?? conversationId}`,
|
||||||
targetRef: {
|
targetRef: {
|
||||||
type: 'conversation' as const,
|
type: 'conversation' as const,
|
||||||
conversationId
|
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
|
conversationId: activeConversationId
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const sameConversationBrowsers = conversationTarget
|
|
||||||
? workbarInstances.filter(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.appId === 'browser' &&
|
|
||||||
candidate.targetRef?.type === 'conversation' &&
|
|
||||||
candidate.targetRef.conversationId ===
|
|
||||||
conversationTarget.conversationId
|
|
||||||
).length
|
|
||||||
: 0
|
|
||||||
const conversationTitle = activeConversationId
|
const conversationTitle = activeConversationId
|
||||||
? conversationTitles.get(activeConversationId) ?? activeConversationId
|
? conversationTitles.get(activeConversationId) ?? activeConversationId
|
||||||
: undefined
|
: undefined
|
||||||
|
let browserNumber = 1
|
||||||
|
const browserTitle = (): string =>
|
||||||
|
`${definition.label} ${browserNumber}${conversationTitle ? ` · ${conversationTitle}` : ''}`
|
||||||
|
while (workbarInstances.some((candidate) => candidate.title === browserTitle())) {
|
||||||
|
browserNumber += 1
|
||||||
|
}
|
||||||
const instance: WorkbarTabInstance = {
|
const instance: WorkbarTabInstance = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
appId: request.appId,
|
appId: request.appId,
|
||||||
title:
|
title:
|
||||||
request.appId === 'terminal'
|
request.appId === 'terminal'
|
||||||
? `${definition.label} ${sameTargetTerminals + 1}`
|
? `${definition.label} ${sameTargetTerminals + 1}`
|
||||||
: request.appId === 'browser' && conversationTitle
|
: request.appId === 'browser'
|
||||||
? `${definition.label} · ${conversationTitle}${sameConversationBrowsers > 0 ? ` · ${sameConversationBrowsers + 1}` : ''}`
|
? browserTitle()
|
||||||
: definition.label,
|
: definition.label,
|
||||||
...(request.targetRef || conversationTarget
|
...(request.targetRef || conversationTarget
|
||||||
? { targetRef: request.targetRef ?? conversationTarget }
|
? { targetRef: request.targetRef ?? conversationTarget }
|
||||||
@@ -1124,14 +1123,14 @@ export function RightAssistantSidebar({
|
|||||||
throw new Error(t('sidebar.errors.browserControlUnavailable'))
|
throw new Error(t('sidebar.errors.browserControlUnavailable'))
|
||||||
}
|
}
|
||||||
if (instance.targetRef?.type !== 'conversation') {
|
if (instance.targetRef?.type !== 'conversation') {
|
||||||
const conversationTitle =
|
|
||||||
conversationTitles.get(conversationId) ?? conversationId
|
|
||||||
setWorkbarInstances((current) =>
|
setWorkbarInstances((current) =>
|
||||||
current.map((candidate) =>
|
current.map((candidate) =>
|
||||||
candidate.id === instance.id
|
candidate.id === instance.id
|
||||||
? {
|
? {
|
||||||
...candidate,
|
...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 }
|
targetRef: { type: 'conversation', conversationId }
|
||||||
}
|
}
|
||||||
: candidate
|
: candidate
|
||||||
@@ -1895,7 +1894,7 @@ export function RightAssistantSidebar({
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{schedule
|
{schedule
|
||||||
? t(`task.mode.${schedule.workMode}`)
|
? t('task.mode.conversation')
|
||||||
: task.workMode
|
: task.workMode
|
||||||
? t(`task.mode.${task.workMode}`)
|
? t(`task.mode.${task.workMode}`)
|
||||||
: t('task.mode.unavailable')}
|
: t('task.mode.unavailable')}
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ describe('WorkbarShell', () => {
|
|||||||
name: '已打开的工作栏应用'
|
name: '已打开的工作栏应用'
|
||||||
})
|
})
|
||||||
expect(within(tablist).getAllByRole('tab')).toHaveLength(4)
|
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(
|
expect(
|
||||||
within(tablist).queryByRole('button', {
|
within(tablist).queryByRole('button', {
|
||||||
name: '打开工作栏应用'
|
name: '打开工作栏应用'
|
||||||
@@ -123,6 +125,27 @@ describe('WorkbarShell', () => {
|
|||||||
).not.toHaveClass('workbar-shell__tab-item--active')
|
).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(
|
||||||
|
<ControlledShell
|
||||||
|
initialInstances={[terminalOne, workspace!, results!, tasks!, browser!]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
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', () => {
|
it('does not expose close controls for fixed tabs', () => {
|
||||||
render(<ControlledShell />)
|
render(<ControlledShell />)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import {
|
import {
|
||||||
WORKBAR_APP_DEFINITIONS,
|
WORKBAR_APP_DEFINITIONS,
|
||||||
WORKBAR_LIMITS,
|
WORKBAR_LIMITS,
|
||||||
|
orderWorkbarInstances,
|
||||||
type WorkbarAppDefinition,
|
type WorkbarAppDefinition,
|
||||||
type WorkbarAppId,
|
type WorkbarAppId,
|
||||||
type WorkbarTabInstance,
|
type WorkbarTabInstance,
|
||||||
@@ -102,7 +103,7 @@ export function WorkbarShell({
|
|||||||
activeInstanceId,
|
activeInstanceId,
|
||||||
appDefinitions = WORKBAR_APP_DEFINITIONS,
|
appDefinitions = WORKBAR_APP_DEFINITIONS,
|
||||||
className,
|
className,
|
||||||
instances,
|
instances: unorderedInstances,
|
||||||
onActiveInstanceChange,
|
onActiveInstanceChange,
|
||||||
onCloseInstance,
|
onCloseInstance,
|
||||||
onCreateInstance,
|
onCreateInstance,
|
||||||
@@ -111,6 +112,7 @@ export function WorkbarShell({
|
|||||||
renderPanel,
|
renderPanel,
|
||||||
renderTabAdornment
|
renderTabAdornment
|
||||||
}: WorkbarShellProps): React.JSX.Element {
|
}: WorkbarShellProps): React.JSX.Element {
|
||||||
|
const instances = orderWorkbarInstances(unorderedInstances)
|
||||||
const { t } = useTranslation('workspace')
|
const { t } = useTranslation('workspace')
|
||||||
const idPrefix = useId()
|
const idPrefix = useId()
|
||||||
const addButtonRef = useRef<HTMLButtonElement>(null)
|
const addButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ export const workspace = {
|
|||||||
interrupted: 'Interrupted'
|
interrupted: 'Interrupted'
|
||||||
},
|
},
|
||||||
mode: {
|
mode: {
|
||||||
|
conversation: 'Conversation settings',
|
||||||
ask: 'Ask',
|
ask: 'Ask',
|
||||||
execute: 'Execute',
|
execute: 'Execute',
|
||||||
unavailable: 'Mode unavailable'
|
unavailable: 'Mode unavailable'
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ export const workspace = {
|
|||||||
interrupted: '已中断'
|
interrupted: '已中断'
|
||||||
},
|
},
|
||||||
mode: {
|
mode: {
|
||||||
|
conversation: '跟随会话设置',
|
||||||
ask: 'Ask',
|
ask: 'Ask',
|
||||||
execute: 'Execute',
|
execute: 'Execute',
|
||||||
unavailable: '模式不可用'
|
unavailable: '模式不可用'
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ describe('workbar contracts', () => {
|
|||||||
reorderable
|
reorderable
|
||||||
}))
|
}))
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{ id: 'tasks', 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: true },
|
{ 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: '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 },
|
{ 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 })))
|
expect(normalized?.instances.map(({ id, appId }) => ({ id, appId })))
|
||||||
.toEqual([
|
.toEqual([
|
||||||
{ id: taskId, appId: 'tasks' },
|
{ id: taskId, appId: 'tasks' },
|
||||||
|
{ id: workspaceId, appId: 'workspace' },
|
||||||
{ id: browserId, appId: 'browser' },
|
{ id: browserId, appId: 'browser' },
|
||||||
{ id: secondBrowserId, appId: 'browser' },
|
{ id: secondBrowserId, appId: 'browser' }
|
||||||
{ id: workspaceId, appId: 'workspace' }
|
|
||||||
])
|
])
|
||||||
expect(normalized?.activeInstanceId).toBe(taskId)
|
expect(normalized?.activeInstanceId).toBe(taskId)
|
||||||
expect(normalized?.taskScope).toBe('all-projects')
|
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', () => {
|
it('preserves a valid empty layout with a null active instance', () => {
|
||||||
expect(
|
expect(
|
||||||
normalizeWorkbarLayoutPreferences(
|
normalizeWorkbarLayoutPreferences(
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const WORKBAR_APP_DEFINITIONS = [
|
|||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
required: true,
|
required: true,
|
||||||
closable: false,
|
closable: false,
|
||||||
reorderable: true,
|
reorderable: false,
|
||||||
availability: { state: 'available' }
|
availability: { state: 'available' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -121,7 +121,7 @@ export const WORKBAR_APP_DEFINITIONS = [
|
|||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
required: true,
|
required: true,
|
||||||
closable: false,
|
closable: false,
|
||||||
reorderable: true,
|
reorderable: false,
|
||||||
availability: { state: 'available' }
|
availability: { state: 'available' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -317,6 +317,18 @@ export type WorkbarLayoutPreferences = z.infer<
|
|||||||
typeof workbarLayoutPreferencesSchema
|
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(
|
export function normalizeWorkbarLayoutPreferences(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
defaultInstances: readonly WorkbarTabInstance[]
|
defaultInstances: readonly WorkbarTabInstance[]
|
||||||
@@ -374,7 +386,7 @@ export function normalizeWorkbarLayoutPreferences(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundedInstances = instances.slice(
|
const boundedInstances = orderWorkbarInstances(instances).slice(
|
||||||
0,
|
0,
|
||||||
WORKBAR_LIMITS.maximumOpenInstances
|
WORKBAR_LIMITS.maximumOpenInstances
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { createServer } from 'node:http'
|
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 { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||||
import type { KnowledgeService } from '../src/main/knowledge/knowledge-service'
|
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'
|
const secondWorkbarId = '00000000-0000-4000-8000-000000000902'
|
||||||
|
|
||||||
function page(title: string, body = ''): string {
|
function page(title: string, body = ''): string {
|
||||||
return `<!doctype html><html><body><h1>${title}</h1>${body}</body></html>`
|
return `<!doctype html><html><head><title>${title}</title></head><body><h1>${title}</h1>${body}</body></html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -50,12 +50,22 @@ async function main(): Promise<void> {
|
|||||||
let client: Client | undefined
|
let client: Client | undefined
|
||||||
let token: string | undefined
|
let token: string | undefined
|
||||||
try {
|
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(
|
const first = await browser.createTab(
|
||||||
conversationId,
|
conversationId,
|
||||||
window.webContents.id,
|
window.webContents.id,
|
||||||
controller.signal,
|
controller.signal,
|
||||||
firstWorkbarId
|
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(
|
const second = await browser.createTab(
|
||||||
conversationId,
|
conversationId,
|
||||||
window.webContents.id,
|
window.webContents.id,
|
||||||
@@ -63,8 +73,15 @@ async function main(): Promise<void> {
|
|||||||
secondWorkbarId
|
secondWorkbarId
|
||||||
)
|
)
|
||||||
assert.notEqual(first.tabId, second.tabId)
|
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)
|
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, 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)
|
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<void> {
|
|||||||
await browser.navigate(conversationId, `${origin}/set-cookie`, controller.signal, first.tabId, window.webContents.id)
|
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)
|
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)
|
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()
|
await gateway.start()
|
||||||
const usage = browser.acquireTabUsage(conversationId, first.tabId, 'electron-e2e', window.webContents.id)
|
const usage = browser.acquireTabUsage(conversationId, first.tabId, 'electron-e2e', window.webContents.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user