diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 4fba6436084..7386f608044 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -305,6 +305,7 @@ export const test = base.extend<{ sandboxBoundaryWindow: Page; readOnlyBoundaryWindow: Page; sessionWorkbarWindow: Page; + artifactPaneWindow: Page; botSettingsWindow: Page; invocableSkillsWindow: Page; settingsProjectsWindow: Page; @@ -389,6 +390,17 @@ export const test = base.extend<{ use, ); }, + artifactPaneWindow: async ({}, use) => { + await withE2eWindow( + { + seed: false, + readinessSelector: '.maka-artifact-list', + e2eFixtureScenario: 'artifact-pane', + locale: 'zh', + }, + use, + ); + }, // Remote access: uses the e2e-fixture workspace so Settings opens on the // channel catalog and main injects deterministic IM onboarding adapters. // The renderer still talks through the real preload/IPC/session authority. diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 3f8971ea8d5..3e298041805 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -1,5 +1,35 @@ import { test, expect } from './fixtures'; +test('generated files use list-to-preview navigation with a compact action menu', async ({ artifactPaneWindow: page }) => { + const pane = page.getByRole('region', { name: '生成文件预览面板' }); + const list = pane.getByRole('listbox', { name: '生成文件列表' }); + const notes = list.getByRole('option', { name: 'notes.md' }); + + await expect(list).toBeVisible(); + await notes.click(); + + await expect(list).toHaveCount(0); + await expect(pane.getByRole('region', { name: '预览 notes.md' })).toBeVisible(); + await expect(pane.getByRole('button', { name: '返回生成文件列表' })).toBeVisible(); + + await pane.getByRole('button', { name: 'notes.md 的更多操作' }).click(); + await expect(page.getByRole('menuitem', { name: '在 Finder 中打开' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: '另存为' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: '复制' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: '删除' })).toBeVisible(); + await page.keyboard.press('Escape'); + + await pane.getByRole('button', { name: '返回生成文件列表' }).click(); + await expect(list).toBeVisible(); + await expect(list).toBeFocused(); + + await list.press('Enter'); + await expect(list).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(list).toBeVisible(); + await expect(list).toBeFocused(); +}); + // Workbar product journey. Narrow max-height and "toggle unmounted without a // session" are pinned in chat-shell-layout-contract (CSS + chrome actions source). test('session tools share one user-controlled workbar that stays mounted across repeated collapse', async ({ sessionWorkbarWindow: page }) => { @@ -13,8 +43,8 @@ test('session tools share one user-controlled workbar that stays mounted across await expect(tabs.getByRole('tab', { name: /浏览器/ })).toHaveCount(0); const launcher = workbar.getByRole('button', { name: '打开工作栏标签' }); await launcher.click(); - await expect(page.getByRole('menuitem', { name: '文件' })).toBeEnabled(); - await page.getByRole('menuitem', { name: '任务' }).click(); + await expect(page.getByRole('menuitem', { name: '生成文件' })).toBeEnabled(); + await page.keyboard.press('Escape'); await expect( page .getByLabel('活跃会话任务') @@ -122,7 +152,7 @@ test('session tools share one user-controlled workbar that stays mounted across await expect(rightWorkbar).toHaveCSS('width', '511px'); await rightWorkbar.getByRole('button', { name: '打开工作栏标签' }).click(); - await page.getByRole('menuitem', { name: '文件' }).click(); + await page.getByRole('menuitem', { name: '生成文件' }).click(); await expect(page.getByText('暂无生成文件')).toBeVisible(); // The record-file row is a fact about the workspace, not the session: it diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 1d635114433..48c1f6ae903 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -171,8 +171,8 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'turn-control-branch-orphan', // PR-UI-RENDER-3a-smoke: registry-driven artifact preview fixtures. // Each shares the standard chat seed + same ARTIFACT_SESSION_ID but - // writes a different single artifact so the ArtifactPane default - // selection deterministically shows the artifact we want. + // writes a different single artifact so opening the only list row reaches + // the intended preview deterministically. 'artifact-preview-image', 'artifact-preview-unsupported', 'artifact-preview-oversize', @@ -462,8 +462,8 @@ function buildE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState | nul case 'artifact-errors': // PR-UI-RENDER-3a-smoke: each preview scenario shares the same // chat session as `artifact-pane`; only the on-disk artifact - // varies. The ArtifactPane selects records[0] by default so the - // single seeded artifact is what gets rendered. + // varies. Each list contains exactly one row so the intended preview is + // deterministic after the normal list-to-preview interaction. case 'artifact-preview-image': case 'artifact-preview-unsupported': case 'artifact-preview-oversize': diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-artifacts.ts b/apps/desktop/src/main/e2e-fixture/scenarios-artifacts.ts index a263b14169e..41b8a55dbb3 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-artifacts.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-artifacts.ts @@ -49,9 +49,8 @@ export function artifactMessages(now: number): StoredMessage[] { export async function writeArtifacts(workspaceRoot: string, now: number, scenario: E2eFixtureScenario): Promise { const root = join(workspaceRoot, 'artifacts'); // PR-UI-RENDER-3a-smoke: dedicated preview scenarios get their - // own short artifact list (single artifact each) so the - // ArtifactPane default selection deterministically picks the one - // the fixture is meant to render. The `sizeBytesOverride` + // own short artifact list (single artifact each) so the normal list row + // deterministically opens the preview the fixture is meant to render. The `sizeBytesOverride` // field bypasses the post-write `stat().size` overwrite so we // can claim 3MB in metadata without writing 3MB to disk. type ArtifactSpec = { diff --git a/apps/desktop/src/renderer/artifact-list-keyboard.ts b/apps/desktop/src/renderer/artifact-list-keyboard.ts index a94ce29fc3e..634f98e9875 100644 --- a/apps/desktop/src/renderer/artifact-list-keyboard.ts +++ b/apps/desktop/src/renderer/artifact-list-keyboard.ts @@ -6,7 +6,8 @@ * a11y review on PR108b: the artifact list MUST be a single tab stop with * ArrowUp/Down navigation + Enter to focus the preview area. Without this * helper the list would either (a) tab through every artifact row, or (b) - * miss arrow-key navigation entirely. + * miss arrow-key navigation entirely. Activation opens the selected file in + * the pane's preview state; the helper stays independent of that view model. * * Each helper takes only the data needed to compute the transition. The * caller is responsible for `event.preventDefault()` so the keys don't @@ -28,7 +29,7 @@ export type ArtifactListAction = * * - ArrowDown / ArrowUp / Home / End → `select` (wraps at ends; if no * selection yet, ArrowDown starts at 0 and ArrowUp at the last item) - * - Enter / Space on a selected row → `activate` (caller focuses preview) + * - Enter / Space on a selected row → `activate` (caller opens preview) * - Escape → `dismiss` (caller closes pane or returns focus to chat) * - Any other key → `noop` (do not preventDefault) * diff --git a/apps/desktop/src/renderer/artifact-pane.tsx b/apps/desktop/src/renderer/artifact-pane.tsx index ecd746ae5c4..c2a02c832a4 100644 --- a/apps/desktop/src/renderer/artifact-pane.tsx +++ b/apps/desktop/src/renderer/artifact-pane.tsx @@ -23,11 +23,13 @@ * binary, and silently base64-stuffing a multi-MB PDF into the clipboard * is a footgun. Both kinds still get「在 Finder 中打开」and「另存为」. * - * Layout: fills the Files tab and reports its authoritative filtered count. + * Layout: fills the Generated files tab and switches between a list and one + * full-panel preview while reporting its authoritative filtered count. */ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; import { AlertTriangle, + ArrowLeft, FileCode, FileImage, FileText, @@ -45,13 +47,13 @@ import { Badge, Banner, Button, + MoreMenu, formatBytes, useMountedRef, useToast, useUiLocale, } from '@maka/ui'; -import { EmptyState as AstryxEmptyState, Toolbar as AstryxToolbar } from '@astryxdesign/core'; -import { Tooltip } from '@astryxdesign/core/Tooltip'; +import { EmptyState as AstryxEmptyState } from '@astryxdesign/core'; import { ArtifactPreview } from './artifact-preview'; import { nextArtifactListAction } from './artifact-list-keyboard'; import { filterUserVisibleArtifacts } from './artifact-visibility'; @@ -67,21 +69,23 @@ export function ArtifactPane(props: { const toast = useToast(); const locale = useUiLocale(); const copy = getArtifactCopy(locale); - const previewEmptyStateCopy = { + const emptyStateCopy = { title: copy.pane.empty, description: copy.pane.emptyHint, }; const [records, setRecords] = useState([]); const [recordsSessionId, setRecordsSessionId] = useState(undefined); const [selectedId, setSelectedId] = useState(null); + const [view, setView] = useState< + { kind: 'list' } | { kind: 'preview'; artifactId: string } + >({ kind: 'list' }); const [listError, setListError] = useState<{ sessionId: string; message: string; } | null>(null); const [pendingArtifactListRetry, setPendingArtifactListRetry] = useState(false); - // Only the delete action still needs drawn state: its handler opens a confirm - // dialog, so it cannot use clickAction (see the toolbar button below). - const [deletingArtifactId, setDeletingArtifactId] = useState(null); + const [artifactActionBusy, setArtifactActionBusy] = useState(false); + const [moreMenuOpen, setMoreMenuOpen] = useState(false); const artifactListRequestSeqRef = useRef(0); const artifactPaneMountedRef = useMountedRef(); const artifactPaneSessionIdRef = useRef(sessionId); @@ -103,6 +107,11 @@ export function ArtifactPane(props: { }; }, []); + useEffect(() => { + setView({ kind: 'list' }); + setSelectedId(null); + }, [sessionId]); + const refresh = useCallback(async () => { const requestSeq = ++artifactListRequestSeqRef.current; if (!sessionId) { @@ -189,35 +198,38 @@ export function ArtifactPane(props: { } }, [activeRecords, records, recordsSessionId, selectedId, sessionId]); - const selected = useMemo( - () => activeRecords.find((record) => record.id === selectedId) ?? null, - [activeRecords, selectedId], + const previewRecord = useMemo( + () => view.kind === 'preview' + ? activeRecords.find((record) => record.id === view.artifactId) ?? null + : null, + [activeRecords, view], ); const listRef = useRef(null); const previewRef = useRef(null); const activeListError = listError && listError.sessionId === sessionId ? listError.message : null; + useEffect(() => { + if (view.kind === 'preview' && recordsSessionId === sessionId && !previewRecord) { + setView({ kind: 'list' }); + requestAnimationFrame(() => listRef.current?.focus()); + } + }, [previewRecord, recordsSessionId, sessionId, view]); + // ---- actions ----------------------------------------------------------- - // The open / save / copy buttons hand this to `clickAction`, which owns - // everything this function used to render by hand: the disabled button, the - // delayed spinner, aria-busy, and the "Loading" announcement. Delete is the - // exception — it confirms first, so it stays on onClick and draws its own - // isLoading (see the button). - // - // What stays here is the part no single button can know: one artifact action - // at a time across the whole pane, including the 在 Finder 中打开 button the - // preview card renders. A ref rather than state, since only delete still - // draws from a pending flag. + // One action at a time across the More menu and the unsupported-preview CTA. + // The ref is the concurrency guard; state only disables the visible menu. async function runArtifactAction(actionKey: string, action: () => Promise) { if (pendingArtifactActionRef.current !== null) return; pendingArtifactActionRef.current = actionKey; + setArtifactActionBusy(true); try { await action(); } finally { if (pendingArtifactActionRef.current === actionKey) { pendingArtifactActionRef.current = null; } + if (artifactPaneMountedRef.current) setArtifactActionBusy(false); } } @@ -323,12 +335,8 @@ export function ArtifactPane(props: { // ---- render ------------------------------------------------------------ - // @kenji a11y gate #1: artifact list is a SINGLE tab stop. ArrowUp/Down + - // Home/End move the selected artifact (preview follows). Enter focuses - // the preview area so a screen-reader user can land there directly. Esc - // returns focus to the chat composer — does NOT swallow the global - // Command Palette / modal Esc handler (the list only listens to Esc when - // its own children have focus). + // The list is one tab stop. Arrow keys move the roving selection; Enter or + // Space opens the selected file in the panel's second, full-height state. function focusComposer() { // Defer to the next frame so the Esc handler doesn't unfocus + refocus // in the same tick. @@ -343,6 +351,18 @@ export function ArtifactPane(props: { focusComposer(); } + function openPreview(artifactId: string) { + setSelectedId(artifactId); + setView({ kind: 'preview', artifactId }); + requestAnimationFrame(() => previewRef.current?.focus()); + } + + function returnToList() { + setMoreMenuOpen(false); + setView({ kind: 'list' }); + requestAnimationFrame(() => listRef.current?.focus()); + } + function handleListKeyDown(event: KeyboardEvent) { const action = nextArtifactListAction({ currentSelectedId: selectedId ?? undefined, @@ -357,10 +377,7 @@ export function ArtifactPane(props: { setSelectedId(action.targetId); break; case 'activate': - setSelectedId(action.targetId); - // Enter on selected row → focus preview surface so the - // screen reader announces the artifact contents. - requestAnimationFrame(() => previewRef.current?.focus()); + openPreview(action.targetId); break; case 'dismiss': dismissPaneToComposer(); @@ -370,36 +387,43 @@ export function ArtifactPane(props: { function handlePaneKeyDown(event: KeyboardEvent) { if (event.key !== 'Escape') return; + if (moreMenuOpen) return; const target = event.target; if (!(target instanceof Node) || !event.currentTarget.contains(target)) return; event.preventDefault(); event.stopPropagation(); - dismissPaneToComposer(); + if (view.kind === 'preview') { + returnToList(); + } else { + dismissPaneToComposer(); + } } return (
{activeListError && ( -
} - endContent={
- - {/* onClick, not clickAction: `deleteArtifact` opens a - confirm dialog first, and clickAction runs inside - startTransition — React holds the state commits that - render the dialog until the action settles, while the - action waits on the dialog. isLoading carries the - spinner, aria-busy, and the disable instead. */} -
} + void runArtifactAction( + `${previewRecord.id}:open`, + () => openInFinder(previewRecord.id), + )} /> - )} + + + ) : null} ); } diff --git a/apps/desktop/src/renderer/artifact-preview.tsx b/apps/desktop/src/renderer/artifact-preview.tsx index f8439f21fd2..1a932219344 100644 --- a/apps/desktop/src/renderer/artifact-preview.tsx +++ b/apps/desktop/src/renderer/artifact-preview.tsx @@ -2,8 +2,8 @@ * Per-kind preview switcher for the ArtifactPane. Routes by `record.kind` * and renders: * - * - `file` → plain text via `readText`, monospace `
`
- *   - `diff`  → unified diff via `readText`, line-tagged for add/del/hunk
+ *   - `file`  → bounded Astryx code or Maka Markdown preview
+ *   - `diff`  → shared structural diff renderer with gutters and syntax
  *   - `html`  → sandboxed `