fix: pin workbar tab order and preserve browser titles
This commit is contained in:
@@ -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<void>()
|
||||
const harness = createHarness({ sessionGate: gate.promise })
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
<span>
|
||||
{schedule
|
||||
? t(`task.mode.${schedule.workMode}`)
|
||||
? t('task.mode.conversation')
|
||||
: task.workMode
|
||||
? t(`task.mode.${task.workMode}`)
|
||||
: t('task.mode.unavailable')}
|
||||
|
||||
@@ -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(
|
||||
<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', () => {
|
||||
render(<ControlledShell />)
|
||||
|
||||
|
||||
@@ -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<HTMLButtonElement>(null)
|
||||
|
||||
@@ -435,6 +435,7 @@ export const workspace = {
|
||||
interrupted: 'Interrupted'
|
||||
},
|
||||
mode: {
|
||||
conversation: 'Conversation settings',
|
||||
ask: 'Ask',
|
||||
execute: 'Execute',
|
||||
unavailable: 'Mode unavailable'
|
||||
|
||||
@@ -424,6 +424,7 @@ export const workspace = {
|
||||
interrupted: '已中断'
|
||||
},
|
||||
mode: {
|
||||
conversation: '跟随会话设置',
|
||||
ask: 'Ask',
|
||||
execute: 'Execute',
|
||||
unavailable: '模式不可用'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user