From 606954cc08d5a101669ad0686f8810e70e525da0 Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Sat, 10 Oct 2026 15:21:09 +0800 Subject: [PATCH 1/7] feat(web): polish preview workspace and unify preview chrome Support empty groups, scoped tab batch closing, tab-owned title editing and status hints. Unify compact menu density and preview header actions, align fullscreen controls, and move summaries into header popovers. Includes regression tests and architecture documentation, rebased onto current main without repeating the changes merged in #287. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/web/e2e/layers-activation.spec.ts | 8 +- apps/web/e2e/menu-styles.spec.ts | 9 +- apps/web/e2e/overlay-panels.spec.ts | 36 + apps/web/e2e/preview-groups.spec.ts | 185 +++++ apps/web/e2e/preview-tab-scrollbar.spec.ts | 662 ++++++++++++++++++ apps/web/e2e/preview-title-rename.spec.ts | 400 +++++++++++ .../src/components/Common/DropdownMenu.tsx | 21 +- .../Common/InlineEditableTitle.test.tsx | 33 +- .../components/Common/InlineEditableTitle.tsx | 16 +- apps/web/src/components/Common/menuStyles.ts | 6 +- .../components/Nodes/AiSummaryBanner.test.tsx | 55 -- .../src/components/Nodes/AiSummaryBanner.tsx | 73 -- .../components/Nodes/AiSummaryButton.test.tsx | 100 +++ .../src/components/Nodes/AiSummaryButton.tsx | 111 +++ .../components/Nodes/FloatingDragHandle.tsx | 4 +- .../components/Nodes/NodePreviewContent.tsx | 4 +- .../Nodes/PreviewHeaderButton.test.tsx | 63 ++ .../components/Nodes/PreviewHeaderButton.tsx | 34 + .../components/Nodes/PreviewHeaderSlot.tsx | 2 + .../Nodes/image/ImagePreview.test.tsx | 5 + .../components/Nodes/image/ImagePreview.tsx | 20 +- .../src/components/Nodes/note/NotePreview.tsx | 10 +- .../components/Nodes/office/OfficePreview.tsx | 10 +- .../src/components/Nodes/pdf/PDFPreview.tsx | 19 +- .../src/components/Nodes/web/WebPreview.tsx | 20 +- .../Panels/Canvas/CanvasToolbar.test.tsx | 7 +- .../Panels/ChatPanel/AcpConnectionBadge.tsx | 110 ++- .../Panels/ChatPanel/AgentSelector.tsx | 14 +- ...tedNodeRefs.tsx => ChatContextSources.tsx} | 0 .../components/Panels/ChatPanel/ChatInput.tsx | 2 +- .../ChatPanel/ChatPanel.titles.test.tsx | 383 ++++++++-- .../Panels/ChatPanel/agentMenu.test.tsx | 39 ++ .../src/components/Panels/ChatPanel/index.tsx | 214 ++++-- .../ExpandedNodePanel.test.tsx | 11 +- .../ExpandedNodePanel/ExpandedNodePanel.tsx | 138 ++-- .../components/Panels/Header/CanvasHeader.tsx | 8 +- .../PreviewWorkspace/PreviewGroup.test.tsx | 62 +- .../Panels/PreviewWorkspace/PreviewGroup.tsx | 102 ++- .../PreviewWorkspace/PreviewRenderer.tsx | 39 +- .../Panels/PreviewWorkspace/PreviewTab.tsx | 457 +++++++++--- .../PreviewWorkspace/PreviewTabStrip.tsx | 178 +++-- .../PreviewWorkspace.test.tsx | 516 ++++++++++++-- .../PreviewWorkspace/PreviewWorkspace.tsx | 61 +- .../PreviewWorkspacePanel.tsx | 8 +- .../Panels/PreviewWorkspace/UrlPreview.tsx | 13 +- .../PreviewWorkspace/previewTabStrip.css | 110 +++ apps/web/src/i18n/resources/en/common.json | 14 +- apps/web/src/i18n/resources/zh-CN/common.json | 14 +- .../playground/QuestionNewDirections.tsx | 4 +- .../src/store/previewWorkspace/model.test.ts | 178 ++++- apps/web/src/store/previewWorkspace/model.ts | 75 +- .../previewWorkspace/persistence.test.ts | 25 +- .../src/store/previewWorkspace/persistence.ts | 14 +- .../src/store/previewWorkspace/store.test.ts | 30 + apps/web/src/store/previewWorkspace/store.ts | 21 + docs/architecture/preview-workspace.md | 39 +- docs/architecture/web-architecture.md | 12 +- 57 files changed, 4039 insertions(+), 765 deletions(-) create mode 100644 apps/web/e2e/preview-groups.spec.ts create mode 100644 apps/web/e2e/preview-tab-scrollbar.spec.ts create mode 100644 apps/web/e2e/preview-title-rename.spec.ts delete mode 100644 apps/web/src/components/Nodes/AiSummaryBanner.test.tsx delete mode 100644 apps/web/src/components/Nodes/AiSummaryBanner.tsx create mode 100644 apps/web/src/components/Nodes/AiSummaryButton.test.tsx create mode 100644 apps/web/src/components/Nodes/AiSummaryButton.tsx create mode 100644 apps/web/src/components/Nodes/PreviewHeaderButton.test.tsx create mode 100644 apps/web/src/components/Nodes/PreviewHeaderButton.tsx rename apps/web/src/components/Panels/ChatPanel/{SelectedNodeRefs.tsx => ChatContextSources.tsx} (100%) create mode 100644 apps/web/src/components/Panels/PreviewWorkspace/previewTabStrip.css diff --git a/apps/web/e2e/layers-activation.spec.ts b/apps/web/e2e/layers-activation.spec.ts index 2dfefc7a8..f6278a1cc 100644 --- a/apps/web/e2e/layers-activation.spec.ts +++ b/apps/web/e2e/layers-activation.spec.ts @@ -1803,12 +1803,12 @@ test.describe('mouse header controls', () => { }; }), ).toEqual({ - height: collapsed ? 40 : 48, + height: collapsed ? 40 : 44, paddingLeft: collapsed ? '8px' : '12px', paddingRight: collapsed ? '8px' : '12px', - paddingTop: collapsed ? '0px' : '4px', - paddingBottom: collapsed ? '0px' : '4px', - titleTopInset: collapsed ? 6 : 10, + paddingTop: '0px', + paddingBottom: '0px', + titleTopInset: collapsed ? 6 : 8, gap: '2px', logoWidth: 24, logoHeight: 24, diff --git a/apps/web/e2e/menu-styles.spec.ts b/apps/web/e2e/menu-styles.spec.ts index 1cfd65f8a..8401cfa9d 100644 --- a/apps/web/e2e/menu-styles.spec.ts +++ b/apps/web/e2e/menu-styles.spec.ts @@ -19,11 +19,11 @@ test('shared menus keep default metrics, states and behaviors without toolbar ov await expect(row).toHaveCSS('font-size', '13px'); await expect(row).toHaveCSS('line-height', '20px'); await expect(row).toHaveCSS('font-weight', '400'); - await expect(row).toHaveCSS('padding', '6px 8px'); + await expect(row).toHaveCSS('padding', '4px 8px'); await expect(row).toHaveCSS('border-radius', '6px'); - await expect(row).toHaveCSS('min-height', '32px'); + await expect(row).toHaveCSS('min-height', '28px'); const panel = row.locator('xpath=ancestor::*[@data-floating-chrome][1]'); - await expect(panel).toHaveCSS('padding', '6px 4px'); + await expect(panel).toHaveCSS('padding', '4px'); await expect(panel).toHaveCSS('border-radius', '8px'); for (const icon of await row.locator('svg').all()) { await expect(icon).toHaveCSS('width', '14px'); @@ -34,6 +34,7 @@ test('shared menus keep default metrics, states and behaviors without toolbar ov await page.getByRole('button', { name: 'Select sample' }).click(); const selected = page.getByRole('option', { name: /First option/ }); await checkRow(selected); + await expect(selected).toHaveCSS('height', '28px'); await expect(selected).toHaveAttribute('aria-selected', 'true'); const label = page.getByText('New destination', { exact: true }); await expect(label).toHaveCSS('font-size', '12px'); @@ -72,6 +73,8 @@ test('shared menus keep default metrics, states and behaviors without toolbar ov await page.getByRole('button', { name: 'Menu sample' }).click(); const action = page.getByRole('menuitem', { name: 'Action Ctrl+2' }); await checkRow(action); + await expect(action).toHaveCSS('height', '28px'); + await expect(action.getByText('Ctrl+2')).toHaveCSS('margin-left', '8px'); await checkRow(page.getByRole('menuitem', { name: 'Link', exact: true })); await expect( page.getByRole('menuitem', { name: 'Link', exact: true }), diff --git a/apps/web/e2e/overlay-panels.spec.ts b/apps/web/e2e/overlay-panels.spec.ts index d7036b4b4..c777a1ce5 100644 --- a/apps/web/e2e/overlay-panels.spec.ts +++ b/apps/web/e2e/overlay-panels.spec.ts @@ -352,6 +352,42 @@ test('toolbar stays visible without overlap and updates while a panel remains fo await expect(toolbar).toBeVisible(); }); +test('fullscreen rail aligns its layer control with Preview header controls', async ({ + page, +}) => { + await openNewCanvas(page); + await page.keyboard.press('Escape'); + await createChatPreview(page); + await settlePanels(page); + const fullscreen = page.getByTestId('toggle-preview-fullscreen'); + await fullscreen.click(); + const rail = page.locator('[data-fullscreen-header-rail]'); + const toggle = rail.getByRole('button', { + name: 'Show layers panel', + exact: true, + }); + await expect(toggle).toBeVisible(); + await expect(page.locator('[data-canvas-root]')).toHaveCount(0); + const left = await toggle.boundingBox(); + const right = await fullscreen.boundingBox(); + if (!left || !right) + throw new Error('Fullscreen header controls are missing'); + expect(left.height).toBe(28); + expect(left.height).toBe(right.height); + expect(left.y).toBeCloseTo(right.y, 1); + const railBounds = await rail.boundingBox(); + if (!railBounds) throw new Error('Fullscreen rail is missing'); + expect(left.y + left.height / 2 - railBounds.y).toBe(22); + await toggle.click(); + await page + .getByRole('button', { name: 'Collapse layers panel', exact: true }) + .click(); + await expect(toggle).toBeVisible(); + await fullscreen.click(); + await expect(rail).toHaveCount(0); + await expect(page.locator('[data-canvas-root]')).toBeVisible(); +}); + test('narrow layouts keep a full-size Canvas and bounded panels', async ({ page, }, testInfo) => { diff --git a/apps/web/e2e/preview-groups.spec.ts b/apps/web/e2e/preview-groups.spec.ts new file mode 100644 index 000000000..41ba69604 --- /dev/null +++ b/apps/web/e2e/preview-groups.spec.ts @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test } from '@playwright/test'; + +import { openNewCanvas } from './helpers'; + +import type * as WorkspaceStore from '../src/store/previewWorkspace/store'; + +test.use({ hasTouch: false, viewport: { width: 1600, height: 1000 } }); + +test('empty splits persist and canvas double-clicks follow the last interacted group', async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem( + 'huabu-sketch-tools', + JSON.stringify({ state: { inputModePreference: 'mouse' }, version: 0 }), + ); + }); + await openNewCanvas(page); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Select (S)', exact: true }).click(); + const canvasId = new URL(page.url()).pathname.split('/').pop(); + const positions = await page + .locator('.react-flow__viewport') + .evaluate((viewport) => { + const pane = document.querySelector('.react-flow__pane'); + if (!pane) throw new Error('Canvas pane is missing'); + const bounds = pane.getBoundingClientRect(); + const transform = new DOMMatrix(getComputedStyle(viewport).transform); + return [180, 550].map((y) => ({ + x: (250 - bounds.left - transform.e) / transform.a, + y: (y - bounds.top - transform.f) / transform.d, + })); + }); + const response = await page.request.post(`/api/canvas/${canvasId}/execute`, { + data: { + commands: [ + { + type: 'CREATE_NODES', + nodes: positions.map((position, index) => ({ + nodeType: 'note', + data: { + label: index === 0 ? 'Alpha' : 'Beta', + content: index === 0 ? 'Alpha' : 'Beta', + }, + position, + size: { width: 300, height: 200 }, + })), + }, + ], + originator: { source: 'agent', threadId: 'e2e-preview-groups' }, + }, + }); + expect(response.ok(), await response.text()).toBe(true); + const nodes = page.locator('.react-flow__node-note'); + await expect(nodes).toHaveCount(2); + const firstId = await nodes.nth(0).getAttribute('data-id'); + const secondId = await nodes.nth(1).getAttribute('data-id'); + if (!firstId || !secondId) throw new Error('Notes must have stable IDs'); + const snapshot = () => + page.evaluate(async () => { + const path = '/src/store/previewWorkspace/store.ts'; + const { usePreviewWorkspaceStore } = (await import( + path + )) as typeof WorkspaceStore; + return usePreviewWorkspaceStore.getState().workspace; + }); + + await nodes.nth(0).dblclick({ position: { x: 15, y: 15 } }); + await expect(page.getByRole('tab')).toHaveCount(1); + await page + .getByRole('button', { + name: 'Split: create an empty group on the right', + exact: true, + }) + .click(); + const strips = page.getByRole('tablist', { name: 'Open previews' }); + await expect(strips).toHaveCount(2); + await expect(strips.nth(0).getByRole('tab')).toHaveCount(1); + await expect(strips.nth(1).getByRole('tab')).toHaveCount(0); + const split = await snapshot(); + expect(split.activeGroupId).toBe(split.groups[1].id); + + await page.reload(); + await expect(strips).toHaveCount(2); + await expect(strips.nth(1).getByRole('tab')).toHaveCount(0); + expect((await snapshot()).activeGroupId).toBe(split.groups[1].id); + await page + .getByRole('button', { name: 'Close empty group', exact: true }) + .click(); + await expect(strips).toHaveCount(1); + await expect(strips.getByRole('tab')).toHaveCount(1); + expect((await snapshot()).groups[0].id).toBe(split.groups[0].id); + await page + .getByRole('button', { + name: 'Split: create an empty group on the right', + exact: true, + }) + .click(); + await expect(strips).toHaveCount(2); + await page + .locator(`.react-flow__node[data-id="${secondId}"]`) + .dblclick({ position: { x: 15, y: 15 } }); + await expect(strips.nth(1).getByRole('tab')).toHaveCount(1); + let workspace = await snapshot(); + expect(workspace.tabs[workspace.groups[1].tabIds[0]].target).toMatchObject({ + nodeId: secondId, + }); + + await page + .locator(`.react-flow__node[data-id="${firstId}"]`) + .dblclick({ position: { x: 15, y: 15 } }); + workspace = await snapshot(); + expect(workspace.activeGroupId).toBe(workspace.groups[0].id); + expect(Object.keys(workspace.tabs)).toHaveLength(2); + await strips.nth(1).getByRole('tab').click({ button: 'right' }); + await page + .getByRole('menuitem', { + name: 'Close all tabs in this group', + exact: true, + }) + .click(); + await expect(strips).toHaveCount(1); + await page + .getByRole('button', { + name: 'Split: create an empty group on the right', + exact: true, + }) + .click(); + await expect(strips).toHaveCount(2); + await strips.nth(0).getByRole('tab').focus(); + await page.keyboard.press('Space'); + await expect(page.getByTestId('preview-tab-drag-overlay')).toBeVisible(); + await expect( + page + .getByRole('status') + .filter({ hasText: 'was moved over droppable area' }), + ).toHaveCount(1); + await page.keyboard.press('ArrowRight'); + await expect(page.getByTestId('preview-tab-append-indicator')).toBeVisible(); + await page.keyboard.press('Space'); + await expect(strips.nth(0).getByRole('tab')).toHaveCount(0); + await expect(strips.nth(1).getByRole('tab')).toHaveCount(1); + const survivingId = (await snapshot()).groups[1].id; + const closeEmpty = page.getByRole('button', { + name: 'Close empty group', + exact: true, + }); + await closeEmpty.focus(); + await closeEmpty.press('Enter'); + await expect(strips).toHaveCount(1); + expect((await snapshot()).groups[0].id).toBe(survivingId); + await expect( + page.getByRole('button', { name: 'Close empty group', exact: true }), + ).toHaveCount(0); + await page + .getByRole('button', { + name: 'Split: create an empty group on the right', + exact: true, + }) + .click(); + await expect(strips).toHaveCount(2); + await expect( + page.getByRole('button', { + name: 'Move tab to the other group', + exact: true, + }), + ).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'Merge groups', exact: true }), + ).toHaveCount(0); + await page.getByRole('tabpanel').nth(1).click(); + await page + .locator(`.react-flow__node[data-id="${secondId}"]`) + .dblclick({ position: { x: 15, y: 15 } }); + await expect(strips.nth(1).getByRole('tab')).toHaveCount(1); + await strips + .nth(1) + .getByRole('button', { name: /^Close / }) + .click(); + await expect(strips).toHaveCount(1); + await expect(strips.getByRole('tab')).toHaveCount(1); +}); diff --git a/apps/web/e2e/preview-tab-scrollbar.spec.ts b/apps/web/e2e/preview-tab-scrollbar.spec.ts new file mode 100644 index 000000000..4bbc138e9 --- /dev/null +++ b/apps/web/e2e/preview-tab-scrollbar.spec.ts @@ -0,0 +1,662 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test } from '@playwright/test'; + +import type * as PreviewHeaderButtonModule from '../src/components/Nodes/PreviewHeaderButton'; +import type * as HeaderModule from '../src/components/Panels/Header/CanvasHeader'; +import type * as StripModule from '../src/components/Panels/PreviewWorkspace/PreviewTabStrip'; +import type * as CanvasStoreModule from '../src/store/canvasStore'; +import type { PreviewTab } from '../src/store/previewWorkspace/model'; +import type * as PreviewModelModule from '../src/store/previewWorkspace/model'; +import type * as ReactModule from 'react'; +import type * as ReactDOMModule from 'react-dom/client'; +import type * as RouterModule from 'react-router-dom'; + +test.use({ hasTouch: false }); + +test.beforeEach(async ({ page }) => { + await page.goto('/setup'); + await page.evaluate(async () => { + const dependencyUrl = (file: string) => { + const resource = performance + .getEntriesByType('resource') + .find((entry) => + new URL(entry.name).pathname.endsWith(`/deps/${file}.js`), + ); + if (!resource) throw new Error(`Vite has not loaded ${file}`); + return resource.name; + }; + const stripPath = + '/src/components/Panels/PreviewWorkspace/PreviewTabStrip.tsx'; + const storePath = '/src/store/canvasStore.ts'; + const headerPath = '/src/components/Panels/Header/CanvasHeader.tsx'; + const modelPath = '/src/store/previewWorkspace/model.ts'; + const previewButtonPath = '/src/components/Nodes/PreviewHeaderButton.tsx'; + const [ + { default: React }, + { default: ReactDOM }, + { PreviewTabStrip }, + { default: canvasStore }, + { CanvasHeader }, + { MemoryRouter }, + { closeTabs }, + { PreviewHeaderButton }, + ] = await Promise.all([ + import(dependencyUrl('react')) as Promise<{ + default: typeof ReactModule; + }>, + import(dependencyUrl('react-dom_client')) as Promise<{ + default: typeof ReactDOMModule; + }>, + import(stripPath) as Promise, + import(storePath) as Promise, + import(headerPath) as Promise, + import(dependencyUrl('react-router-dom')) as Promise, + import(modelPath) as Promise, + import(previewButtonPath) as Promise, + ]); + const nodes = [ + { + id: 'note', + type: 'note', + label: 'IPR 反馈与后续行动:核心问题和下一步计划', + }, + { id: 'pdf', type: 'pdf', label: '项目研究资料与参考案例汇总' }, + { id: 'office', type: 'office', label: '团队协作方案与产品设计说明' }, + { id: 'text', type: 'text', label: '工作空间体验优化设计方案与讨论记录' }, + ]; + canvasStore.setState({ + nodes: nodes.map(({ id, type, label }) => ({ + id, + type, + position: { x: 0, y: 0 }, + data: { label, ...(type === 'office' ? { format: 'docx' } : {}) }, + })), + }); + const host = document.createElement('div'); + host.dataset.testid = 'scrollbar-fixture'; + Object.assign(host.style, { + position: 'fixed', + top: '20px', + left: '20px', + width: '360px', + zIndex: '100', + }); + document.body.append(host); + const noop = () => {}; + function Fixture() { + const [tabs, setTabs] = React.useState([ + ...nodes.map( + ({ id }, index): PreviewTab => ({ + id: `tab-${index}`, + target: { kind: 'node', canvasId: 'scrollbar-test', nodeId: id }, + transient: index === 1, + lastActiveSeq: index, + }), + ), + { + id: 'tab-4', + target: { + kind: 'url', + canvasId: 'scrollbar-test', + url: 'https://preview.example.com/reference?topic=research#summary', + }, + transient: false, + lastActiveSeq: 4, + }, + ]); + const [activeTabId, setActiveTabId] = React.useState('tab-0'); + return React.createElement(PreviewTabStrip, { + groupId: 'scrollbar-test', + tabs, + activeTabId, + onActivate: setActiveTabId, + onClose: (id) => { + setTabs((current) => current.filter((tab) => tab.id !== id)); + setActiveTabId('tab-0'); + }, + onCloseTabs: (id, scope) => { + const groupId = 'scrollbar-test'; + const closed = closeTabs( + { + tabs: Object.fromEntries(tabs.map((tab) => [tab.id, tab])), + groups: [ + { id: groupId, tabIds: tabs.map((tab) => tab.id), activeTabId }, + ], + activeGroupId: groupId, + splitRatio: 0.5, + activationSeq: tabs.length, + }, + id, + scope, + ); + setTabs(closed.groups[0].tabIds.map((tabId) => closed.tabs[tabId])); + setActiveTabId(closed.groups[0].activeTabId ?? ''); + }, + onPromote: (id) => + setTabs((current) => + current.map((tab) => + tab.id === id ? { ...tab, transient: false } : tab, + ), + ), + onNewChat: () => { + const id = `new-${tabs.length}`; + setTabs((current) => [ + ...current, + { + id, + target: { + kind: 'chat', + canvasId: 'scrollbar-test', + threadId: id, + }, + transient: false, + lastActiveSeq: current.length, + }, + ]); + setActiveTabId(id); + }, + tabDropIndicator: null, + isFullscreen: false, + onToggleFullscreen: noop, + onCollapse: noop, + }); + } + ReactDOM.createRoot(host).render( + React.createElement( + React.Fragment, + null, + React.createElement(Fixture), + React.createElement( + 'div', + { + 'data-testid': 'preview-header-button-fixture', + style: { position: 'fixed', top: 160, left: 20 }, + }, + React.createElement( + PreviewHeaderButton, + { title: 'Preview header action' }, + React.createElement('svg', { 'aria-hidden': true }), + ), + ), + React.createElement( + 'div', + { + 'data-testid': 'left-header-fixture', + style: { position: 'fixed', top: 100, left: 20, width: 300 }, + }, + React.createElement( + MemoryRouter, + null, + React.createElement( + CanvasHeader, + { compact: true, onToggle: noop }, + 'IPR-R1-Follow-Up', + ), + ), + ), + ), + ); + }); + await expect( + page.getByTestId('scrollbar-fixture').getByRole('tab'), + ).toHaveCount(5); +}); + +test('preview header actions keep compact geometry and muted color', async ({ + page, +}) => { + const button = page + .getByTestId('preview-header-button-fixture') + .getByRole('button', { + name: 'Preview header action', + }); + await expect(button).toHaveCSS('width', '24px'); + await expect(button).toHaveCSS('height', '24px'); + await expect(button.locator('svg')).toHaveCSS('width', '13px'); + await expect(button.locator('svg')).toHaveCSS('height', '13px'); + expect( + await button.evaluate((el) => { + const probe = document.createElement('span'); + probe.className = 'text-fg-muted'; + el.append(probe); + const matches = + getComputedStyle(el).color === getComputedStyle(probe).color; + probe.remove(); + return matches; + }), + ).toBe(true); + await button.hover(); + const tooltip = page.getByRole('tooltip', { name: 'Preview header action' }); + await expect(tooltip).toBeVisible(); + const buttonBox = await button.boundingBox(); + const tooltipBox = await tooltip.boundingBox(); + if (!buttonBox || !tooltipBox) + throw new Error('Preview action and tooltip must have layout boxes'); + expect(tooltipBox.y).toBeGreaterThanOrEqual(buttonBox.y + buttonBox.height); +}); + +test('context menu closes scoped batches and skips disabled actions', async ({ + page, +}) => { + const host = page.getByTestId('scrollbar-fixture'); + const tabs = host.getByRole('tab'); + await tabs.nth(1).click({ button: 'right' }); + await page + .getByRole('menuitem', { name: 'Close tabs to the right', exact: true }) + .click(); + await expect(tabs).toHaveCount(2); + await expect(tabs.nth(1)).toHaveAttribute('data-preview-tab-id', 'tab-1'); + await tabs.nth(1).click({ button: 'right' }); + await page + .getByRole('menuitem', { name: 'Close other tabs', exact: true }) + .click(); + await expect(tabs).toHaveCount(1); + await expect(tabs.first()).toHaveAttribute('data-preview-tab-id', 'tab-1'); + await tabs.first().click({ button: 'right' }); + await expect( + page.getByRole('menuitem', { name: 'Close other tabs', exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole('menuitem', { + name: 'Close tabs to the right', + exact: true, + }), + ).toBeDisabled(); + const close = page.getByRole('menuitem', { name: 'Close', exact: true }); + await close.focus(); + await page.keyboard.press('ArrowDown'); + const closeGroup = page.getByRole('menuitem', { + name: 'Close all tabs in this group', + exact: true, + }); + await expect(closeGroup).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(tabs).toHaveCount(0); +}); + +test('headers share a 44px band with a bottom rule only on Preview', async ({ + page, +}) => { + const left = page.getByTestId('left-header-fixture').locator('header'); + const right = page.getByTestId('scrollbar-fixture').getByRole('tablist'); + const leftMetrics = await left.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const rule = getComputedStyle(element, '::after'); + const button = element.querySelector('button'); + if (!button) throw new Error('Header control missing'); + const control = button.getBoundingClientRect(); + return { + height: rect.height, + center: control.top + control.height / 2 - rect.top, + ruleContent: rule.content, + borderBottomWidth: getComputedStyle(element).borderBottomWidth, + }; + }); + const rightMetrics = await right.evaluate((element) => { + const header = element.parentElement; + if (!header) throw new Error('Preview header missing'); + const rect = header.getBoundingClientRect(); + const rule = getComputedStyle(header, '::after'); + const button = header.querySelector('button'); + if (!button) throw new Error('Preview control missing'); + const control = button.getBoundingClientRect(); + return { + height: rect.height, + center: control.top + control.height / 2 - rect.top, + ruleHeight: rule.height, + ruleColor: rule.backgroundColor, + }; + }); + expect(leftMetrics.height).toBe(rightMetrics.height); + expect(leftMetrics.center).toBe(rightMetrics.center); + expect(leftMetrics.height).toBe(44); + expect(leftMetrics.center).toBe(22); + expect(leftMetrics.ruleContent).toBe('none'); + expect(leftMetrics.borderBottomWidth).toBe('0px'); + expect(rightMetrics.ruleHeight).toBe('1px'); + await expect(right.getByRole('tab').first()).toHaveCSS( + 'border-radius', + '8px', + ); +}); + +test('scrollbar appears only on hover or keyboard focus without moving centered pills', async ({ + page, +}) => { + const host = page.getByTestId('scrollbar-fixture'); + const strip = host.getByRole('tablist'); + const metrics = () => + strip.evaluate((element) => { + const tab = element.querySelector('[role="tab"]'); + const control = tab?.querySelector('button'); + const outer = element.parentElement; + if (!tab || !control || !outer) { + throw new Error('Preview tab strip is missing its tab or controls'); + } + const rect = element.getBoundingClientRect(); + const controlRect = control.getBoundingClientRect(); + return { + outerHeight: outer.getBoundingClientRect().height, + gutter: element.offsetHeight - element.clientHeight, + scrollbarHeight: getComputedStyle(element, '::-webkit-scrollbar') + .height, + scrollbarWidth: getComputedStyle(element).scrollbarWidth, + overflows: element.scrollWidth > element.clientWidth, + tabHeight: tab.getBoundingClientRect().height, + viewportHeight: element.clientHeight, + thumbColor: getComputedStyle(element, '::-webkit-scrollbar-thumb') + .backgroundColor, + tabCenter: + tab.getBoundingClientRect().top + + tab.getBoundingClientRect().height / 2 - + rect.top, + controlCenter: controlRect.top + controlRect.height / 2 - rect.top, + scrollTop: element.scrollTop, + controlsFit: + controlRect.top >= rect.top && + controlRect.bottom <= rect.top + element.clientHeight, + }; + }); + const narrow = await metrics(); + expect(narrow.outerHeight).toBe(44); + expect(narrow.scrollbarHeight).toBe('4px'); + expect(narrow.scrollbarWidth).toBe('auto'); + expect(narrow.gutter).toBeLessThanOrEqual(4); + expect(narrow.overflows).toBe(true); + expect(narrow.tabHeight).toBe(28); + expect(narrow.tabCenter).toBe(22); + expect(narrow.controlCenter).toBe(22); + expect(narrow.scrollTop).toBe(0); + expect(narrow.thumbColor).toBe('rgba(0, 0, 0, 0)'); + expect(narrow.controlsFit).toBe(true); + await strip.hover(); + const hovered = await metrics(); + expect(hovered.thumbColor).not.toBe(narrow.thumbColor); + expect(hovered.tabCenter).toBe(narrow.tabCenter); + expect(hovered.controlCenter).toBe(narrow.controlCenter); + expect(hovered.gutter).toBe(narrow.gutter); + await strip.getByRole('tab').first().click(); + await page.mouse.move(800, 500); + await expect + .poll(async () => (await metrics()).thumbColor) + .toBe(narrow.thumbColor); + await strip.evaluate((element) => { + element.scrollLeft = 100; + }); + expect(await strip.evaluate((element) => element.scrollLeft)).toBe(100); + await host.evaluate((element) => { + element.style.width = '1800px'; + }); + const wide = await metrics(); + expect(wide.overflows).toBe(false); + expect(wide.gutter).toBe(0); + expect(wide.outerHeight).toBe(44); + expect(wide.tabHeight).toBe(28); + expect(wide.tabCenter).toBe(22); + expect(wide.controlCenter).toBe(22); + expect(wide.controlsFit).toBe(true); +}); + +test('tabs shrink before overflowing and reserve readable titles and fixed controls', async ({ + page, +}) => { + const host = page.getByTestId('scrollbar-fixture'); + const strip = host.getByRole('tablist'); + const sizes = () => + strip.getByRole('tab').evaluateAll((tabs) => + tabs.map((tab) => { + const title = tab.querySelector( + '[data-testid="preview-tab-title"]', + ); + const actions = tab.querySelector( + '[data-testid="preview-tab-actions"]', + ); + if (!title || !actions) + throw new Error('Tab title or actions are missing'); + return { + width: tab.getBoundingClientRect().width, + titleWidth: title.getBoundingClientRect().width, + actionsWidth: actions.getBoundingClientRect().width, + fontSize: parseFloat(getComputedStyle(title).fontSize), + buttonWidths: Array.from( + actions.querySelectorAll('button'), + (button) => button.getBoundingClientRect().width, + ), + }; + }), + ); + await host.evaluate((element) => { + element.style.width = '1800px'; + }); + const wide = await sizes(); + await host.evaluate((element) => { + element.style.width = '1000px'; + }); + const medium = await sizes(); + expect(medium[0].width).toBeGreaterThan(medium[2].width); + expect(medium[2].width).toBeLessThan(wide[2].width); + expect( + await strip.evaluate( + (element) => element.scrollWidth <= element.clientWidth, + ), + ).toBe(true); + await host.evaluate((element) => { + element.style.width = '360px'; + }); + const narrow = await sizes(); + expect( + await strip.evaluate( + (element) => element.scrollWidth > element.clientWidth, + ), + ).toBe(true); + for (const [index, tab] of narrow.entries()) { + expect(tab.titleWidth).toBeGreaterThanOrEqual( + (index === 0 ? 6 : 4) * tab.fontSize - 1, + ); + expect(tab.actionsWidth).toBe(wide[index].actionsWidth); + expect(tab.buttonWidths.every((width) => width >= 24)).toBe(true); + } +}); + +test('tooltips include full titles and types on hover and keyboard focus', async ({ + page, +}) => { + const host = page.getByTestId('scrollbar-fixture'); + const tabs = host.getByRole('tab'); + await tabs.first().hover(); + await expect(page.getByRole('tooltip')).toContainText( + 'IPR 反馈与后续行动:核心问题和下一步计划', + ); + await expect(page.getByRole('tooltip')).toContainText('Note'); + await page.mouse.move(800, 500); + await expect(page.getByRole('tooltip')).toHaveCount(0); + await tabs.first().click(); + await page.mouse.move(800, 500); + await tabs.first().press('ArrowRight'); + await expect(tabs.nth(1)).toBeFocused(); + expect( + await host + .getByRole('tablist') + .evaluate( + (element) => + getComputedStyle(element, '::-webkit-scrollbar-thumb') + .backgroundColor, + ), + ).not.toBe('rgba(0, 0, 0, 0)'); + await expect(page.getByRole('tooltip')).toContainText( + '项目研究资料与参考案例汇总', + ); + await expect(page.getByRole('tooltip')).toContainText('PDF'); + await expect(page.getByRole('tooltip')).toContainText('Temporary preview'); + const tooltip = page.getByRole('tooltip'); + await expect(tooltip.getByTestId('preview-tooltip-title')).toHaveCSS( + 'font-size', + '13px', + ); + await expect(tooltip.getByTestId('preview-tooltip-title')).toHaveCSS( + 'font-weight', + '600', + ); + await expect(tooltip.getByTestId('preview-tooltip-meta')).toHaveCSS( + 'font-size', + '11px', + ); + await expect(tooltip.getByTestId('preview-tooltip-meta')).toHaveCSS( + 'opacity', + '0.8', + ); + await expect(tooltip.getByTestId('preview-tooltip-hint')).toHaveCSS( + 'font-size', + '11px', + ); + await expect(tooltip.getByTestId('preview-tooltip-hint')).toHaveCSS( + 'opacity', + '0.7', + ); + await tabs.nth(1).press('ArrowRight'); + await expect(page.getByRole('tooltip')).toContainText('Word'); + await tabs.nth(2).press('End'); + await expect(tabs.last()).toBeFocused(); + await expect(page.getByRole('tooltip')).toContainText( + 'https://preview.example.com/reference?topic=research#summary', + ); + await expect(page.getByRole('tooltip')).toContainText('Website'); +}); + +test('native scrolling, activation, resize, promotion and closing remain usable', async ({ + page, +}) => { + const host = page.getByTestId('scrollbar-fixture'); + const strip = host.getByRole('tablist'); + const tabs = strip.getByRole('tab'); + await strip.hover(); + await page.mouse.wheel(150, 0); + await expect + .poll(() => strip.evaluate((element) => element.scrollLeft)) + .toBeGreaterThan(0); + await host.evaluate((element) => { + element.style.width = '1000px'; + }); + await tabs.last().click(); + await host.evaluate((element) => { + element.style.width = '360px'; + }); + const activeIsVisible = () => + strip.evaluate((element) => { + const active = element.querySelector('[aria-selected="true"]'); + if (!active) throw new Error('No active tab'); + const tab = active.getBoundingClientRect(); + const viewport = element.getBoundingClientRect(); + return tab.left >= viewport.left - 1 && tab.right <= viewport.right + 1; + }); + await expect.poll(activeIsVisible).toBe(true); + await tabs.last().press('Home'); + await expect(tabs.first()).toBeFocused(); + await expect.poll(activeIsVisible).toBe(true); + await tabs.first().press('ArrowRight'); + await tabs.nth(1).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Keep tab', exact: true }).click(); + await expect(tabs.nth(1)).not.toHaveClass(/italic/); + await expect(tabs.nth(1).getByRole('button')).toHaveCount(1); + await tabs + .nth(1) + .getByRole('button', { name: 'Close 项目研究资料与参考案例汇总' }) + .click(); + await expect(tabs).toHaveCount(4); + await host + .getByRole('button', { name: 'New conversation', exact: true }) + .click(); + await expect(tabs).toHaveCount(5); + await expect(tabs.last()).toHaveAttribute('aria-selected', 'true'); + await expect.poll(activeIsVisible).toBe(true); +}); + +test('active actions stay visible and inactive actions reveal without shifting titles', async ({ + page, +}) => { + const host = page.getByTestId('scrollbar-fixture'); + await host.evaluate((element) => { + element.style.width = '1000px'; + }); + const tabs = host.getByRole('tab'); + const activeActions = tabs.first().getByTestId('preview-tab-actions'); + const temporary = tabs.nth(1); + const actions = temporary.getByTestId('preview-tab-actions'); + const title = temporary.getByTestId('preview-tab-title'); + await page.mouse.move(1200, 600); + await expect(activeActions).toHaveCSS('opacity', '1'); + await expect(actions).toHaveCSS('opacity', '0'); + await expect(actions).toHaveCSS('pointer-events', 'none'); + const titleBefore = await title.boundingBox(); + const tabBefore = await temporary.boundingBox(); + expect(titleBefore).not.toBeNull(); + expect(tabBefore).not.toBeNull(); + if (!titleBefore || !tabBefore) throw new Error('Tab geometry missing'); + expect( + tabBefore.x + tabBefore.width - titleBefore.x - titleBefore.width, + ).toBe(8); + await expect(title).toHaveCSS('mask-image', 'none'); + await temporary.hover(); + await expect(actions).toHaveCSS('opacity', '1'); + await expect(actions).toHaveCSS('pointer-events', 'auto'); + await expect(activeActions).toHaveCSS('opacity', '1'); + expect(await title.boundingBox()).toEqual(titleBefore); + expect(await temporary.boundingBox()).toEqual(tabBefore); + expect( + await title.evaluate((element) => getComputedStyle(element).maskImage), + ).not.toBe('none'); + await temporary.click(); + await page.mouse.move(1200, 600); + await expect(actions).toHaveCSS('opacity', '1'); + await expect(activeActions).toHaveCSS('opacity', '0'); + await temporary.press('Home'); + await expect(tabs.first()).toBeFocused(); + await expect(activeActions).toHaveCSS('opacity', '1'); + await tabs.first().press('ArrowRight'); + await expect(temporary).toBeFocused(); + await expect(actions).toHaveCSS('opacity', '1'); + await temporary.press('Tab'); + const close = temporary.getByRole('button', { name: /^Close / }); + await expect(close).toBeFocused(); + await expect(actions).toHaveCSS('opacity', '1'); + await expect(temporary.getByRole('button')).toHaveCount(1); + await temporary.focus(); + await temporary.press('Shift+F10'); + const pin = page.getByRole('menuitem', { name: 'Keep tab', exact: true }); + await expect(pin).toBeFocused(); + await pin.press('Enter'); + await expect(temporary).not.toHaveClass(/italic/); +}); + +test.describe('touch tab actions', () => { + test.use({ hasTouch: true, isMobile: true }); + + test('keeps Close visible and supports double-tap promotion without hover', async ({ + page, + }) => { + const host = page.getByTestId('scrollbar-fixture'); + await host.evaluate((element) => { + element.style.width = '1000px'; + }); + expect( + await page.evaluate(() => matchMedia('(hover: hover)').matches), + ).toBe(false); + const tabs = host.getByRole('tab'); + const temporary = tabs.nth(1); + const actions = temporary.getByTestId('preview-tab-actions'); + await expect(actions).toHaveCSS('opacity', '1'); + await expect(actions).toHaveCSS('pointer-events', 'auto'); + await expect(temporary.getByRole('button')).toHaveCount(1); + const bounds = await temporary.boundingBox(); + if (!bounds) throw new Error('Temporary tab must be measurable'); + const x = bounds.x + 24; + const y = bounds.y + bounds.height / 2; + await page.touchscreen.tap(x, y); + await page.touchscreen.tap(x, y); + await expect(temporary).not.toHaveClass(/italic/); + await temporary.getByRole('button', { name: /^Close / }).tap(); + await expect(tabs).toHaveCount(4); + }); +}); diff --git a/apps/web/e2e/preview-title-rename.spec.ts b/apps/web/e2e/preview-title-rename.spec.ts new file mode 100644 index 000000000..28a1ce763 --- /dev/null +++ b/apps/web/e2e/preview-title-rename.spec.ts @@ -0,0 +1,400 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test } from '@playwright/test'; + +import { openNewCanvas } from './helpers'; + +import type * as CanvasStore from '../src/store/canvasStore'; +import type * as ChatStore from '../src/store/chatStore'; +import type * as PreviewActions from '../src/store/previewWorkspace/actions'; +import type * as WorkspaceStore from '../src/store/previewWorkspace/store'; + +test.use({ hasTouch: false, viewport: { width: 1600, height: 1000 } }); + +test('Chat status overlays its icon, shares the tab tooltip and retains visible error recovery', async ({ + page, +}) => { + let failMetadata = true; + let metadataReads = 0; + await page.route('**/api/acp/threads/*/cached-meta**', (route) => { + metadataReads += 1; + return failMetadata + ? route.fulfill({ status: 503, json: { message: 'Cache unavailable' } }) + : route.fulfill({ + json: { + source: 'thread', + sessionMeta: { + availableModes: [], + currentModeId: null, + availableModels: [], + currentModelId: null, + configOptions: [], + selections: {}, + sessionInfo: null, + usage: null, + updatedAt: 1, + }, + }, + }); + }); + await openNewCanvas(page); + await page.evaluate(async () => { + const canvasPath = '/src/store/canvasStore.ts'; + const chatPath = '/src/store/chatStore.ts'; + const actionsPath = '/src/store/previewWorkspace/actions.ts'; + const [{ default: canvas }, { useChatStore }, { openPreviewNode }] = + await Promise.all([ + import(canvasPath) as Promise, + import(chatPath) as Promise, + import(actionsPath) as Promise, + ]); + useChatStore.getState().setHistoryLoaded('status-thread', true); + canvas.setState({ + nodes: [ + { + id: 'status-question', + type: 'question', + position: { x: 0, y: 0 }, + data: { + label: 'Status question', + labelSource: 'user', + threadId: 'status-thread', + status: 'done', + bindingState: 'bound', + agentBinding: { + kind: 'external', + profileId: 'status-profile', + alias: 'Status Agent', + }, + }, + }, + { + id: 'status-note', + type: 'text', + position: { x: 400, y: 0 }, + data: { label: 'Ordinary text', content: 'No Agent status here' }, + }, + ], + }); + openPreviewNode('status-question', { transient: false }); + }); + const tab = page.getByRole('tab', { + name: 'Status question (question)', + exact: true, + }); + const panel = page.getByRole('tabpanel'); + const dot = tab.locator('[data-acp-connection-status]'); + await expect(dot).toHaveAttribute('data-acp-connection-status', 'failed'); + await expect(panel.locator('[data-acp-connection-status]')).toHaveCount(0); + await expect(panel.locator('[data-preview-active="true"] .h-9')).toHaveCount( + 0, + ); + const alert = panel + .getByRole('alert') + .filter({ hasText: 'Cache unavailable' }); + await expect(alert).toBeVisible(); + const initialReads = metadataReads; + await tab.hover(); + const tooltip = page.getByRole('tooltip'); + await expect(tooltip).toHaveCount(1); + await expect(tooltip).toContainText('Load failed'); + await expect(tooltip).not.toContainText('Status Agent'); + await expect(tooltip).not.toContainText('Cache unavailable'); + expect(metadataReads).toBe(initialReads); + const iconBox = await tab.getByTestId('preview-tab-icon').boundingBox(); + const dotBox = await dot.boundingBox(); + if (!iconBox || !dotBox) throw new Error('Status icon must be measurable'); + expect(dotBox.width).toBe(6); + expect(dotBox.height).toBe(6); + expect(dotBox.x).toBeGreaterThan(iconBox.x + iconBox.width / 2); + expect(dotBox.y).toBeGreaterThan(iconBox.y + iconBox.height / 2); + expect(dotBox.x + dotBox.width).toBeCloseTo(iconBox.x + iconBox.width + 2, 0); + expect(dotBox.y + dotBox.height).toBeCloseTo( + iconBox.y + iconBox.height + 2, + 0, + ); + failMetadata = false; + await alert.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(dot).toHaveAttribute('data-acp-connection-status', 'connected'); + await expect(alert).toHaveCount(0); + expect(metadataReads).toBe(initialReads + 1); + await page.mouse.move(0, 0); + await page.keyboard.press('Tab'); + await tab.focus(); + await expect(tooltip).toContainText('Normal'); + await expect(tooltip.getByTestId('preview-tooltip-meta')).toContainText( + 'Normal', + ); + await expect(tooltip.getByTestId('preview-tooltip-title')).toHaveText( + 'Status question', + ); + await expect(tooltip).not.toContainText('Status Agent'); + await expect(tooltip).not.toContainText('live connection'); + await expect(tooltip).toContainText('Status question'); + await expect(tab).toHaveAttribute('aria-describedby', /.+/); + + await page.evaluate(async () => { + const path = '/src/store/previewWorkspace/actions.ts'; + const { openPreviewNode } = (await import(path)) as typeof PreviewActions; + openPreviewNode('status-note', { transient: false }); + }); + await expect( + page.getByRole('tab', { name: 'Ordinary text (text)', exact: true }), + ).toHaveAttribute('aria-selected', 'true'); + await expect( + page.locator('[role="tab"] [data-acp-connection-status]'), + ).toHaveCount(0); + await tab.click(); + await expect(dot).toHaveAttribute('data-acp-connection-status', 'connected'); +}); + +test('tab-owned titles retain content actions and durable rename without duplicate title chrome', async ({ + page, +}) => { + await openNewCanvas(page); + const canvasId = new URL(page.url()).pathname.split('/').pop(); + const response = await page.request.post(`/api/canvas/${canvasId}/execute`, { + data: { + commands: [ + { + type: 'CREATE_NODES', + nodes: [ + { + nodeType: 'note', + data: { + label: 'Alpha title', + labelSource: 'user', + content: 'Note body', + }, + position: { x: 0, y: 0 }, + size: { width: 300, height: 200 }, + }, + { + nodeType: 'text', + data: { + label: 'Beta title', + labelSource: 'user', + content: 'Text body', + }, + position: { x: 400, y: 0 }, + size: { width: 300, height: 200 }, + }, + ], + }, + ], + originator: { source: 'agent', threadId: 'e2e-preview-title-rename' }, + }, + }); + + expect(response.ok(), await response.text()).toBe(true); + await expect(page.locator('.react-flow__node-note')).toHaveCount(1); + await expect(page.locator('.react-flow__node-text')).toHaveCount(1); + await page.evaluate(async () => { + const storePath = '/src/store/canvasStore.ts'; + const actionsPath = '/src/store/previewWorkspace/actions.ts'; + const [{ default: canvasStore }, { openPreviewNode }] = await Promise.all([ + import(storePath) as Promise, + import(actionsPath) as Promise, + ]); + const nodes = canvasStore.getState().nodes; + const note = nodes.find((node) => node.type === 'note'); + const text = nodes.find((node) => node.type === 'text'); + if (!note || !text) throw new Error('Preview fixture nodes are missing'); + openPreviewNode(note.id, { transient: false }); + openPreviewNode(text.id, { transient: false }); + }); + const alpha = page.getByRole('tab', { + name: 'Alpha title (note)', + exact: true, + }); + const beta = page.getByRole('tab', { + name: 'Beta title (text)', + exact: true, + }); + const panel = page.getByRole('tabpanel'); + const header = panel + .locator('[data-preview-active="true"]') + .getByTestId('expanded-node-header'); + await expect(beta).toHaveAttribute('aria-selected', 'true'); + await expect( + panel.getByRole('button', { name: 'Rename node', exact: true }), + ).toHaveCount(0); + await expect(header).toBeHidden(); + await beta.focus(); + await beta.press('F2'); + const input = page.getByRole('tab').getByRole('textbox', { + name: 'Rename node', + exact: true, + }); + await expect(input).toBeFocused(); + await expect(input).toHaveValue('Beta title'); + await expect(header).toBeHidden(); + await expect(beta.getByRole('textbox')).toBeFocused(); + await input.press('Escape'); + await expect(beta).toBeFocused(); + await expect(header).toBeHidden(); + + await beta.press('Shift+F10'); + await expect( + page.getByRole('menuitem', { name: /Rename node/ }), + ).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(beta).toBeFocused(); + await expect(page.getByRole('menuitem')).toHaveCount(0); + await alpha.click({ button: 'right' }); + const rename = page.getByRole('menuitem', { name: /Rename node/ }); + await expect(rename).toBeFocused(); + await expect(page.getByRole('menuitem').locator('svg')).toHaveCount(0); + const tabBounds = await alpha.boundingBox(); + const menuBounds = await page.getByRole('menu').boundingBox(); + if (!tabBounds || !menuBounds) throw new Error('Missing tab menu geometry'); + expect(menuBounds.y).toBeGreaterThanOrEqual(tabBounds.y + tabBounds.height); + expect(menuBounds.y - tabBounds.y - tabBounds.height).toBeLessThan(20); + expect(Math.abs(menuBounds.x - tabBounds.x)).toBeLessThan(12); + await rename.click(); + await expect(alpha).toHaveAttribute('aria-selected', 'true'); + await expect(input).toBeFocused(); + await expect(input).toHaveValue('Alpha title'); + await input.fill('Durable new title'); + await input.press('Enter'); + const renamed = page.getByRole('tab', { + name: 'Durable new title (note)', + exact: true, + }); + await expect(renamed).toBeFocused(); + await expect(input).toHaveCount(0); + await expect(header).toBeVisible(); + await expect( + header.locator('[data-preview-header-actions] button'), + ).not.toHaveCount(0); + await expect(header).not.toContainText('Durable new title'); + await header + .getByRole('button', { name: 'Switch to raw markdown editor' }) + .click(); + await expect( + header.getByRole('button', { name: 'Switch to rich text editor' }), + ).toBeVisible(); + await page.reload(); + await expect( + page.getByRole('tab', { name: 'Durable new title (note)', exact: true }), + ).toBeVisible(); +}); + +test('Web and PDF summaries share their real content toolbar and do not move the document', async ({ + page, +}) => { + await page.route('https://preview.example.test/**', (route) => + route.fulfill({ + contentType: 'text/html', + body: '

Local web fixture

', + }), + ); + await page.route('**/api/web/page?**', (route) => + route.fulfill({ + json: { + kind: 'url', + src: 'https://preview.example.test/', + embeddable: true, + }, + }), + ); + await openNewCanvas(page); + await page.evaluate(async () => { + const canvasPath = '/src/store/canvasStore.ts'; + const workspacePath = '/src/store/previewWorkspace/store.ts'; + const actionsPath = '/src/store/previewWorkspace/actions.ts'; + const [ + { default: canvas }, + { usePreviewWorkspaceStore }, + { openPreviewNode }, + ] = await Promise.all([ + import(canvasPath) as Promise, + import(workspacePath) as Promise, + import(actionsPath) as Promise, + ]); + const canvasId = canvas.getState().canvasId; + canvas.setState({ + nodes: [ + { + id: 'web-fixture', + type: 'web', + position: { x: 0, y: 0 }, + data: { + label: 'Web fixture', + labelSource: 'user', + src: 'https://preview.example.test/', + summary: 'Web summary content', + keywords: ['Web keyword'], + }, + }, + { + id: 'pdf-fixture', + type: 'pdf', + position: { x: 450, y: 0 }, + data: { + label: 'PDF fixture', + labelSource: 'user', + src: '', + summary: 'PDF summary content', + keywords: ['PDF keyword'], + }, + }, + ], + edges: [], + }); + openPreviewNode('web-fixture', { transient: false }); + usePreviewWorkspaceStore + .getState() + .openPreviewTarget( + { kind: 'node', nodeId: 'pdf-fixture', canvasId }, + { openToSide: true }, + ); + }); + await expect(page.getByRole('tabpanel')).toHaveCount(2); + const web = page.getByRole('tabpanel').nth(0); + const pdf = page.getByRole('tabpanel').nth(1); + await expect( + web.getByRole('button', { name: 'Reload page', exact: true }), + ).toBeVisible(); + await expect( + web.getByRole('button', { + name: 'Open page in external browser', + exact: true, + }), + ).toBeVisible(); + await expect( + pdf.getByRole('button', { name: 'Select area to capture', exact: true }), + ).toBeVisible(); + await expect( + pdf.getByRole('button', { name: 'Highlight Text', exact: true }), + ).toBeVisible(); + for (const [index, name] of ['Web', 'PDF'].entries()) { + const panel = page.getByRole('tabpanel').nth(index); + const header = panel.getByTestId('expanded-node-header'); + await expect(header).toHaveCount(1); + const summary = header.getByRole('button', { + name: 'AI Summary', + exact: true, + }); + await expect(summary).toHaveText(''); + await summary.click({ trial: true }); + const body = panel.locator('[data-search-scope="node"] > div').last(); + const before = await body.boundingBox(); + await summary.click(); + const dialog = page.getByRole('dialog', { + name: 'AI Summary', + exact: true, + }); + await expect(dialog).toContainText(`${name} summary content`); + await expect(dialog).toContainText(`${name} keyword`); + expect(await body.boundingBox()).toEqual(before); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(summary).toBeFocused(); + await summary.click(); + await dialog.getByRole('button', { name: 'Close AI summary' }).click(); + await expect(dialog).toHaveCount(0); + await expect(summary).toBeVisible(); + } +}); diff --git a/apps/web/src/components/Common/DropdownMenu.tsx b/apps/web/src/components/Common/DropdownMenu.tsx index 3673ae9a4..cc2b4e0ef 100644 --- a/apps/web/src/components/Common/DropdownMenu.tsx +++ b/apps/web/src/components/Common/DropdownMenu.tsx @@ -132,6 +132,7 @@ export const DropdownMenuLink: React.FC = ({ // ─── DropdownMenu (container) ───────────────────────────────────────────────── type DropdownMenuProps = { + 'aria-describedby'?: string; floating?: boolean; placement?: PopoverProps['placement']; onOpenAutoFocus?: PopoverProps['onOpenAutoFocus']; @@ -139,6 +140,7 @@ type DropdownMenuProps = { trigger: ReactElement<{ onClick?: (e: React.MouseEvent) => void; 'aria-expanded'?: boolean; + 'aria-describedby'?: string; }>; /** Menu content — typically `` elements and dividers. */ children: ReactNode; @@ -146,6 +148,8 @@ type DropdownMenuProps = { className?: string; /** Offset from the trigger edge (px). Defaults to `{ x: 0, y: 4 }`. */ offset?: Partial<{ x: number; y: number }>; + /** Layout classes for the trigger wrapper, without changing menu styling. */ + triggerWrapperClassName?: string; /** * Which edge of the trigger to align the panel to. * `"bottom-left"` (default) opens below, left-aligned. @@ -182,6 +186,7 @@ type DropdownMenuProps = { * ``` */ export const DropdownMenu: React.FC = ({ + 'aria-describedby': describedBy, floating = false, placement, onOpenAutoFocus, @@ -189,6 +194,7 @@ export const DropdownMenu: React.FC = ({ children, className, offset, + triggerWrapperClassName, align = 'bottom-left', open: controlledOpen, onOpenChange, @@ -223,7 +229,7 @@ export const DropdownMenu: React.FC = ({ justDismissedRef.current = false; if (reason === 'escape' && document.activeElement === document.body) { triggerRef.current - ?.querySelector('button') + ?.querySelector('[role="tab"], button') ?.focus({ preventScroll: true }); } }); @@ -258,6 +264,13 @@ export const DropdownMenu: React.FC = ({ }, [align, isRight, isTop, opensSideways]); const clonedTrigger = cloneElement(trigger, { + ...(describedBy + ? { + 'aria-describedby': [trigger.props['aria-describedby'], describedBy] + .filter(Boolean) + .join(' '), + } + : {}), onClick: (event) => { if (typeof trigger.props.onClick === 'function') { trigger.props.onClick(event); @@ -271,7 +284,9 @@ export const DropdownMenu: React.FC = ({ return ( <> -
{clonedTrigger}
+
+ {clonedTrigger} +
{isOpen && ( = ({