From c52d87053292f356254acac50e013b2f19e85dcf Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Mon, 26 Oct 2026 23:54:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(buddy):=20=E6=94=AF=E6=8C=81=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E6=A0=87=E7=AD=BE=E5=9B=BA=E5=AE=9A=E4=B8=8E?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/__tests__/workbench.e2e.mjs | 85 ++++++++++++++++++- apps/buddy/src/i18n/locales/en-US/tasks.ts | 7 ++ apps/buddy/src/i18n/locales/zh-CN/tasks.ts | 7 ++ .../model/context-panel/readContextTab.ts | 2 +- .../model/context-panel/taskContextPanel.ts | 2 + .../useTaskResourcePanelClose.spec.ts | 55 ++++++++++++ .../context-panel/useContextPanelTabs.ts | 14 +-- .../context-panel/useTaskContextPanel.ts | 1 + .../context-panel/useTaskResourcePanel.ts | 25 ++++++ .../context-panel/DesktopTaskContextPanel.vue | 4 +- .../DesktopTaskResourcePanel.vue | 12 +-- .../browser/WorkbenchResourcePanel.vue | 29 +++++-- .../workbench/browser/useResourcePanelMenu.ts | 75 ++++++++++++++++ 13 files changed, 298 insertions(+), 20 deletions(-) create mode 100644 apps/buddy/src/workbench/browser/useResourcePanelMenu.ts diff --git a/.playwright/scripts/__tests__/workbench.e2e.mjs b/.playwright/scripts/__tests__/workbench.e2e.mjs index 652a3768a..509197178 100644 --- a/.playwright/scripts/__tests__/workbench.e2e.mjs +++ b/.playwright/scripts/__tests__/workbench.e2e.mjs @@ -49,7 +49,8 @@ test('builtin working copies back up continued edits, veto closure and recover a await expect.poll(readBackup).toBe(beforeUndo) await expect(editor.locator('.view-lines')).toContainText('Second edit while dirty.') expect(await fs.readFile(document, 'utf8')).toBe('Original document.\n') - await page.getByTestId('task-context-panel').getByRole('button', { name: '关闭文件', exact: true }).click() + await page.getByTestId('task-context-panel').getByRole('tab', { name: '文件', exact: true }).click({ button: 'right' }) + await page.locator('.n-dropdown-menu:visible').getByText('关闭全部', { exact: true }).click() const close = page.locator('.n-dialog').filter({ hasText: '关闭前保存更改?' }) await close.getByRole('button', { name: '取消', exact: true }).click() await expect(close).toBeHidden() @@ -346,3 +347,85 @@ test('sidebar resizing and collapsing preserve the current draft and persist who await expect(restarted.page.locator('.desktop-chat-composer__prosemirror:visible')).toHaveText('Keep this draft while changing the layout.') expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) }) + +test('resource tab menus pin, close by position and preserve pins across restart', async ({ buddy }, testInfo) => { + const instance = await buddy.createInstance('resource-tab-menu') + let { app, page, diagnostics } = await instance.launch() + await expect(page.getByRole('textbox', { name: '对话消息', exact: true })).toBeVisible() + await page.keyboard.press('Control+Shift+P') + await page.getByPlaceholder('输入命令名称').fill('浏览器') + await page.getByPlaceholder('输入命令名称').press('Enter') + const panel = () => page.getByTestId('task-context-panel') + const tabs = () => panel().getByRole('tab') + const menu = () => page.locator('.n-dropdown-menu:visible') + async function add() { + const count = await tabs().count() + await page.getByTestId('context-add-tab').click() + await page.getByRole('menuitem', { name: '浏览器', exact: true }).click() + await expect(tabs()).toHaveCount(count + 1) + } + async function action(index, label) { + await tabs().nth(index).click({ button: 'right' }) + await expect(menu().getByText(label, { exact: true }).locator('..')).not.toHaveClass(/disabled/) + await menu().getByText(label, { exact: true }).click() + } + await expect(tabs()).toHaveCount(1) + await add() + await add() + await add() + const ids = await tabs().evaluateAll(elements => elements.map(element => element.id)) + await action(1, '固定') + await expect(tabs().first()).toHaveAttribute('id', ids[1]) + await expect(panel().locator('[data-pinned="true"]')).toHaveCount(1) + await expect(tabs().last()).toHaveAttribute('aria-selected', 'true') + await tabs().nth(2).click({ button: 'right' }) + await expect(menu().locator('.n-dropdown-option-body__label')).toHaveText(['固定', '关闭', '关闭其他', '关闭左侧', '关闭右侧', '关闭全部']) + await testInfo.attach('resource-tab-menu', { body: await page.screenshot({ animations: 'disabled' }), contentType: 'image/png' }) + await expect(menu().getByText('关闭左侧', { exact: true }).locator('..')).not.toHaveClass(/disabled/) + await menu().getByText('关闭左侧', { exact: true }).click() + await expect(tabs()).toHaveCount(3) + expect(await tabs().evaluateAll(elements => elements.map(element => element.id))).toEqual([ids[1], ids[2], ids[3]]) + await action(1, '关闭右侧') + await expect(tabs()).toHaveCount(2) + expect(await tabs().evaluateAll(elements => elements.map(element => element.id))).toEqual([ids[1], ids[2]]) + await add() + await action(1, '关闭其他') + await expect(tabs()).toHaveCount(2) + await action(1, '关闭全部') + await expect(tabs()).toHaveCount(1) + await expect(tabs().first()).toHaveAttribute('id', ids[1]) + await tabs().first().focus() + await page.keyboard.press('Shift+F10') + await expect(menu().getByText('取消固定', { exact: true })).toBeVisible() + for (const label of ['关闭其他', '关闭左侧', '关闭右侧', '关闭全部']) + await expect(menu().getByText(label, { exact: true }).locator('..')).toHaveClass(/disabled/) + for (const theme of ['light', 'dark']) { + await page.evaluate(theme => window.lexoraDesktop.themes.request({ action: 'preference', preference: { id: `lexora.themes.classic-${theme}` } }), theme) + await expect(page.locator('html')).toHaveAttribute('data-buddy-theme', theme) + await testInfo.attach(`resource-tab-menu-${theme}`, { body: await page.screenshot({ animations: 'disabled' }), contentType: 'image/png' }) + } + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(980, 640)) + await page.keyboard.press('Escape') + await tabs().first().click({ button: 'right' }) + await expect(menu().getByText('关闭全部', { exact: true })).toBeInViewport() + await testInfo.attach('resource-tab-menu-narrow', { body: await page.screenshot({ animations: 'disabled' }), contentType: 'image/png' }) + await page.keyboard.press('Escape') + await expect(menu()).toBeHidden() + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + await instance.stop() + ;({ page, diagnostics } = await instance.launch()) + const toggle = page.getByTestId('context-panel-toggle') + await expect(toggle).toBeVisible() + if (await toggle.getAttribute('aria-expanded') === 'false') + await toggle.click() + await expect(panel().locator('[data-pinned="true"]')).toHaveCount(1) + await action(0, '取消固定') + await expect(panel().locator('[data-pinned="true"]')).toHaveCount(0) + await action(0, '固定') + await panel().getByRole('button', { name: '取消固定', exact: true }).click() + await expect(tabs()).toHaveCount(1) + await expect(panel().locator('[data-pinned="true"]')).toHaveCount(0) + await panel().getByRole('button', { name: '关闭新标签页', exact: true }).click() + await expect(tabs()).toHaveCount(0) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) diff --git a/apps/buddy/src/i18n/locales/en-US/tasks.ts b/apps/buddy/src/i18n/locales/en-US/tasks.ts index 27d67f68a..aed7f1a51 100644 --- a/apps/buddy/src/i18n/locales/en-US/tasks.ts +++ b/apps/buddy/src/i18n/locales/en-US/tasks.ts @@ -178,6 +178,13 @@ export default { 'desktop.context.browserCertificateFailed': 'The page’s security certificate could not be verified. The connection was blocked.', 'desktop.context.browserNavigationBlocked': 'Access to this address or resource was blocked. Check the address and permissions.', 'desktop.context.browserPageUnavailable': 'The page stopped responding or closed unexpectedly. Please try again.', + 'desktop.context.pin': 'Pin', + 'desktop.context.unpin': 'Unpin', + 'desktop.context.close': 'Close', + 'desktop.context.closeOthers': 'Close Others', + 'desktop.context.closeLeft': 'Close to the Left', + 'desktop.context.closeRight': 'Close to the Right', + 'desktop.context.closeAll': 'Close All', 'desktop.context.closeTab': 'Close {name}', 'desktop.context.changedFiles': '{count} files', 'desktop.context.changedFilesLabel': 'Files changed in this run', diff --git a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts index ea083f7c6..755f37f72 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts @@ -176,6 +176,13 @@ export default { 'desktop.context.browserCertificateFailed': '网页的安全证书验证失败,连接已被阻止。', 'desktop.context.browserNavigationBlocked': '此地址或资源的访问已被阻止,请检查地址与访问权限。', 'desktop.context.browserPageUnavailable': '网页已停止响应或意外退出,请重试。', + 'desktop.context.pin': '固定', + 'desktop.context.unpin': '取消固定', + 'desktop.context.close': '关闭', + 'desktop.context.closeOthers': '关闭其他', + 'desktop.context.closeLeft': '关闭左侧', + 'desktop.context.closeRight': '关闭右侧', + 'desktop.context.closeAll': '关闭全部', 'desktop.context.closeTab': '关闭{name}', 'desktop.context.changedFiles': '{count} 个文件', 'desktop.context.changedFilesLabel': '本轮变更文件', diff --git a/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts b/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts index ac011d9b0..cf72bc96e 100644 --- a/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts +++ b/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts @@ -8,7 +8,7 @@ import { z } from 'zod' const identity = z.string().min(1).max(256) const scopeSchema = z.union([z.literal('independent'), z.literal('workspace'), z.templateLiteral(['task:', z.string().min(1)]), z.templateLiteral(['draft:', z.string().min(1)])]) const selectionSchema = z.tuple([z.union([scopeSchema, z.templateLiteral(['space:', z.string().min(1)])]), identity]) -const base = z.object({ id: identity, scope: scopeSchema, source: contextPanelSourceSchema.optional() }) +const base = z.object({ id: identity, scope: scopeSchema, source: contextPanelSourceSchema.optional(), pinned: z.boolean().optional() }) const tabSchema = z.discriminatedUnion('kind', [ base.extend({ kind: z.literal('browser'), conversationId: identity.nullable(), browserKey: identity.optional() }), base.extend({ kind: z.literal('files'), target: spaceFileTargetSchema, treeTarget: z.object({ path: z.string().max(4096), kind: z.enum(['file', 'directory']) }).optional(), rootName: z.string() }), diff --git a/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts b/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts index d587d47ea..756a1754d 100644 --- a/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts +++ b/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts @@ -17,6 +17,7 @@ export function contextPanelSelectionScope(mode: DesktopContextPanelMode, scope: } interface ContextTabSource { + pinned?: boolean scope: ContextPanelScope source?: ContextPanelSource } @@ -63,6 +64,7 @@ export interface TaskViewContextTab extends ContextTabSource { } export interface ContextPanelTab { + pinned?: boolean dirty?: boolean id: string title: string diff --git a/apps/buddy/src/modules/tasks/state/context-panel/__tests__/useTaskResourcePanelClose.spec.ts b/apps/buddy/src/modules/tasks/state/context-panel/__tests__/useTaskResourcePanelClose.spec.ts index 2bb4b0d39..319148eb8 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/__tests__/useTaskResourcePanelClose.spec.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/__tests__/useTaskResourcePanelClose.spec.ts @@ -61,6 +61,61 @@ function artifact(artifactId = 'report'): LocalArtifact { } describe('resource panel close', () => { + it('keeps pins when reopening resources and restoring snapshots, and isolates bulk close to visible tabs', async () => { + const activeConversationId = shallowRef('task-a') + const f = fixture({ activeConversationId }) + f.panel.openView('first', 'First') + f.panel.openView('pinned', 'Pinned') + f.panel.setTabPinned('pinned', true) + f.panel.openView('pinned', 'Updated') + expect(f.panel.tabs.value.map(tab => tab.id)).toEqual(['pinned', 'first']) + activeConversationId.value = 'task-b' + f.panel.openView('hidden', 'Hidden') + activeConversationId.value = 'task-a' + const restored = fixture() + restored.panel.restoreSnapshot(JSON.parse(JSON.stringify(f.panel.snapshot()))) + expect(restored.panel.tabs.value.map(tab => [tab.id, !!tab.pinned])).toEqual([['pinned', true], ['first', false]]) + await restored.panel.closeTabs(['pinned', 'first', 'hidden']) + expect(restored.panel.allTabs.value.map(tab => tab.id)).toEqual(['pinned', 'hidden']) + expect(await restored.panel.closeTab('pinned')).toBe(true) + expect(restored.panel.allTabs.value.map(tab => tab.id)).toEqual(['hidden']) + }) + + it('stops a batch at a cancelled save and preserves the remaining views', async () => { + const f = fixture({ closeView: async id => id !== 'dirty' }) + for (const id of ['clean', 'dirty', 'after']) + f.panel.openView(id, id) + await f.panel.closeTabs(['clean', 'dirty', 'after']) + expect(f.panel.tabs.value.map(tab => tab.id)).toEqual(['dirty', 'after']) + }) + + it('preserves tabs opened or pinned while a batch waits for a close', async () => { + const gate = deferred() + const f = fixture({ closeView: () => gate.promise }) + for (const id of ['first', 'second']) + f.panel.openView(id, id) + const closing = f.panel.closeTabs(['first', 'second']) + f.panel.setTabPinned('second', true) + f.panel.openView('new', 'New') + gate.resolve(true) + await closing + expect(f.panel.tabs.value.map(tab => tab.id)).toEqual(['second', 'new']) + }) + + it('stops the remaining batch after switching away and back to the scope', async () => { + const gate = deferred() + const activeConversationId = shallowRef('task-a') + const f = fixture({ activeConversationId, closeView: () => gate.promise }) + for (const id of ['first', 'second']) + f.panel.openView(id, id) + const closing = f.panel.closeTabs(['first', 'second']) + activeConversationId.value = 'task-b' + activeConversationId.value = 'task-a' + gate.resolve(true) + await closing + expect(f.panel.tabs.value.map(tab => tab.id)).toEqual(['second']) + }) + it.each(['space', 'independent'] as const)('records a user close with the original source only after the last visible tab closes in %s mode', async (mode) => { const f = fixture({ mode: shallowRef(mode), diff --git a/apps/buddy/src/modules/tasks/state/context-panel/useContextPanelTabs.ts b/apps/buddy/src/modules/tasks/state/context-panel/useContextPanelTabs.ts index ff19de1a0..b20f132bc 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/useContextPanelTabs.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/useContextPanelTabs.ts @@ -50,9 +50,8 @@ export function useContextPanelTabs(options: { } function scopeTabs(scope: ContextPanelSelectionScope) { - if (scope === 'independent') - return resources.value - return resources.value.filter(tab => scope.startsWith('space:') ? selectionFor(tab.scope, 'space') === scope : tab.scope === scope) + const visible = scope === 'independent' ? resources.value : resources.value.filter(tab => scope.startsWith('space:') ? selectionFor(tab.scope, 'space') === scope : tab.scope === scope) + return visible.toSorted((a, b) => Number(!!b.pinned) - Number(!!a.pinned)) } function selectedTab(scope: ContextPanelSelectionScope) { @@ -69,7 +68,7 @@ export function useContextPanelTabs(options: { if (discardedScopes.has(tab.scope)) return resources.value = resources.value.some(item => item.id === tab.id) - ? resources.value.map(item => item.id === tab.id ? tab : item) + ? resources.value.map(item => item.id === tab.id ? { ...tab, pinned: item.pinned } : item) : [...resources.value, tab] if (activate) selections.set(selectionFor(tab.scope), tab.id) @@ -94,6 +93,11 @@ export function useContextPanelTabs(options: { resources.value = resources.value.map(transform) } + function setPinned(id: string, pinned: boolean) { + if (tabs.value.some(tab => tab.id === id)) + update(tab => tab.id === id ? { ...tab, pinned } : tab) + } + function retain(predicate: (tab: TaskContextTab) => boolean) { for (const tab of resources.value) { if (!predicate(tab)) @@ -130,5 +134,5 @@ export function useContextPanelTabs(options: { } } - return { snapshot, restoreSelections, activeTab: readonly(activeTab), tabs: readonly(tabs), resources: readonly(resources), scope: readonly(scope), selectionScope: readonly(selectionScope), select, put, close, update, retain, discard, discardDraft, adoptDraft } + return { snapshot, restoreSelections, activeTab: readonly(activeTab), tabs: readonly(tabs), resources: readonly(resources), scope: readonly(scope), selectionScope: readonly(selectionScope), select, put, close, setPinned, update, retain, discard, discardDraft, adoptDraft } } diff --git a/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts b/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts index 8e0be3487..aecd94101 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts @@ -201,6 +201,7 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) { discardDraft: store.discardDraft, addBrowser, closeTab: store.close, + setTabPinned: store.setPinned, discardConversation: (id: string) => store.discard(taskContextPanelScope(id)), hasTab, openArtifact, diff --git a/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts b/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts index a1c616807..6d5b80eac 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/useTaskResourcePanel.ts @@ -152,6 +152,30 @@ export function useTaskResourcePanel(options: TaskResourcePanelOptions) { return accepted.promise } + async function closeTabs(tabIds: readonly string[]): Promise { + const requested = new Set(tabIds) + const targets = taskContext.tabs.value.filter(tab => requested.has(tab.id) && !tab.pinned) + let cancelled = false + const stop = watch(taskContext.selectionScope, () => cancelled = true, { flush: 'sync' }) + try { + for (const target of targets) { + if (disposed || cancelled) + break + const tab = taskContext.tabs.value.find(tab => tab.id === target.id) + if (!tab || tab.pinned) + continue + if (!await closeTab(tab.id)) + break + } + } + catch (error) { + options.onError(error) + } + finally { + stop() + } + } + async function closeResource(tabId: string): Promise { const tab = taskContext.allTabs.value.find(item => item.id === tabId) if (!tab) @@ -350,6 +374,7 @@ export function useTaskResourcePanel(options: TaskResourcePanelOptions) { canPreviewFile, previewFile, closeTab, + closeTabs, discardDraft: (id: string) => { operation.value += 1 interaction += 1 diff --git a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue index 8d6ad5c91..c794f9c45 100644 --- a/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue +++ b/apps/buddy/src/modules/tasks/widgets/context-panel/DesktopTaskContextPanel.vue @@ -16,13 +16,15 @@ defineProps<{ const emit = defineEmits<{ add: [id: string] closeTab: [tabId: string] + closeTabs: [tabIds: readonly string[]] + pinTab: [tabId: string, pinned: boolean] selectTab: [tabId: string] }>() defineSlots<{ 'default'?: () => unknown, 'header-actions'?: () => unknown, 'toolbar'?: () => unknown }>()