diff --git a/docs/design/TASK-OVERLAY-ACCEPTANCE.md b/docs/design/TASK-OVERLAY-ACCEPTANCE.md index 59d41cc4..821ba7fc 100644 --- a/docs/design/TASK-OVERLAY-ACCEPTANCE.md +++ b/docs/design/TASK-OVERLAY-ACCEPTANCE.md @@ -8,23 +8,23 @@ The task workspace retains one mounted content tree through drawer, expanded, an Task confirmations and forms use shared widths and insets, a primary scrolling body, fixed action footers, and quiet Cancel actions. Nested Preview and Conflict Resolver panels become authoring dialogs. Their control bars remain outside the primary scroller. Conflict Resolver's Abort confirmation is inside the shared depth provider, so closing it reactivates the resolver rather than the task behind it. -| Family | Source | Browser geometry evidence | Packaged macOS evidence | -| ------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------- | -| Task root | `TaskDetailPanel`, `UiTaskSurface` | Expanded mode retains section, scroll, board opener | Pending | -| Apply template | `ApplyTemplateDialog` | Light/dark; 1700×900 at 16px, 1180×760 and 900×480 at 20px; fixed footer, parent inert, exact opener | Pending | -| Preview | `PreviewPanel` | Light/dark; 900×480 at 20px; fixed Start control and parent focus restoration | Pending | -| Conflict resolver and Abort | `ConflictResolver` | Light/dark; 900×480 at 20px; fixed Abort, nested inert parents, successive Escape ownership | Pending | -| Task deletion | `TaskDetailsTab` | Light/dark; three sizes; normal/reduced motion; pending dismissal and failed-request recovery | Pending | -| Task stopping | `TaskWorkView` | Every-family checks pending | Pending | -| Attachments, comments, deliverables, observations | Respective task sections | Light/dark; three sizes; normal/reduced motion; pending dismissal and failed-request recovery | Pending | -| Manual time | `TimeTrackingSection` | Light/dark; three sizes; normal/reduced motion; pending dismissal, failed-request recovery, retained draft | Pending | -| Agent stop and readiness override | `AgentPanel` | Every-family checks pending | Pending | -| Approval decisions | `AgentRunTimelinePanel` | Every-family checks pending | Pending | -| Review merge | `ReviewPanel` | Every-family checks pending | Pending | -| Work product versions, editing, artifact preview | `WorkProductsSection`, `ArtifactPreviewModal` | Light/dark; three sizes; normal/reduced motion; editor pending/failure recovery; text preview only | Pending | -| Git PR, merge, worktree removal | `PRDialog`, `WorktreeStatus` | Light/dark; three sizes; normal/reduced motion; pending dismissal, retained drafts, inline failures | Pending | -| Workflows | `WorkflowSection`, `WorkflowStartDialog` | Every-family checks pending | Pending | -| Task metrics export | `ExportDialog` | Every-family checks pending | Pending | +| Family | Source | Browser geometry evidence | Packaged macOS evidence | +| ------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| Task root | `TaskDetailPanel`, `UiTaskSurface` | Expanded mode retains section, scroll, board opener | Pending | +| Apply template | `ApplyTemplateDialog` | Light/dark; 1700×900 at 16px, 1180×760 and 900×480 at 20px; fixed footer, parent inert, exact opener | Pending | +| Preview | `PreviewPanel` | Light/dark; 900×480 at 20px; fixed Start control and parent focus restoration | Pending | +| Conflict resolver and Abort | `ConflictResolver` | Light/dark; 900×480 at 20px; fixed Abort, nested inert parents, successive Escape ownership | Pending | +| Task deletion | `TaskDetailsTab` | Light/dark; three sizes; normal/reduced motion; pending dismissal and failed-request recovery | Pending | +| Task stopping | `TaskWorkView` | Every-family checks pending | Pending | +| Attachments, comments, deliverables, observations | Respective task sections | Light/dark; three sizes; normal/reduced motion; pending dismissal and failed-request recovery | Pending | +| Manual time | `TimeTrackingSection` | Light/dark; three sizes; normal/reduced motion; pending dismissal, failed-request recovery, retained draft | Pending | +| Agent stop and readiness override | `AgentPanel` | Every-family checks pending | Pending | +| Approval decisions | `AgentRunTimelinePanel` | Every-family checks pending | Pending | +| Review merge | `ReviewPanel` | Every-family checks pending | Pending | +| Work product versions, editing, artifact preview | `WorkProductsSection`, `ArtifactPreviewModal` | Light/dark; three sizes; normal/reduced motion; editor pending/failure recovery; text preview only | Pending | +| Git PR, merge, worktree removal | `PRDialog`, `WorktreeStatus` | Light/dark; three sizes; normal/reduced motion; pending dismissal, retained drafts, inline failures | Pending | +| Workflows | `WorkflowSection`, `WorkflowStartDialog` | Every-family checks pending | Pending | +| Task metrics export | `ExportDialog` | Light/dark; three sizes; normal/reduced motion; pending guards, retained filters, failed then successful fixture download | Pending | ## Diagnostic evidence @@ -58,6 +58,16 @@ All three initial pending-request cases failed: PR creation allowed Escape dismi Independent specification and standards reviews found no actionable source issue in this increment. Browser captures were inspected for PR and cleanup at enlarged text/minimum size. Packaged macOS acceptance remains pending for all three. +### Metrics export + +`task-export-popout.spec.ts` opens export from task History/Metrics and tests both themes and motion settings at the three viewport/text combinations. It checks fixed footer geometry and hit targets, viewport containment, nested inert state, guarded Escape/header/backdrop dismissal, disabled filters while exporting, retained filters after failure, and a successful synthetic download on retry with an identical query and exact opener restoration. The retry uses a response with no filename header and verifies the generic fallback name. No real telemetry export is performed. + +The original browser case reproduced dismissal while the request was pending. Export now uses a synchronous submission/dismissal lock, exposes an inline error, and preserves scope/date filters on failure. The error receives focus without native scrolling and is then centered in the primary scroller; focus alone left its bottom edge clipped at minimum size. Browser and component checks verify the focus/scroll behavior. The component regression also tests immediate duplicate submission and restored Cancel availability. + +All four browser cases passed, along with five focused component tests, web typecheck, changed-source lint, and formatting. Light and dark minimum-size captures were inspected. Both independent source review axes cleared the final change. These are browser diagnostics, not packaged macOS or documentation-media acceptance. + +The first download check exposed a separate transport defect: cross-origin responses do not expose `Content-Disposition`, so the app falls back to a generic name despite the server sending a scoped filename. This is tracked in #1447 and is not fixed by the overlay increment. Workflow and native acceptance remain pending. + ## Remaining acceptance Complete every family in both themes, normal and enlarged text, minimum native window size, keyboard entry/dismissal, reduced motion, pending-operation states, and viewport/footer reachability. Rebuild the packaged application with the complete family and inspect native captures. Reconcile the consumer inventory only against that evidence. Final installed-app verification and the maintained documentation screenshots/GIF refresh remain separate, unfinished work. diff --git a/e2e/task-export-popout.spec.ts b/e2e/task-export-popout.spec.ts new file mode 100644 index 00000000..b2e3fb3f --- /dev/null +++ b/e2e/task-export-popout.spec.ts @@ -0,0 +1,140 @@ +import { expect, test } from '@playwright/test'; +import { bypassAuth, cleanupRoutes, deleteTask, seedTestTask } from './helpers/auth'; + +for (const theme of ['light', 'dark']) { + for (const reducedMotion of ['no-preference', 'reduce'] as const) { + test(`task metrics export preserves filters and recovers in ${theme}, motion ${reducedMotion}`, async ({ + page, + }) => { + await bypassAuth(page); + await page.emulateMedia({ reducedMotion }); + await page.addInitScript( + (theme) => localStorage.setItem('veritas-kanban-theme', theme), + theme + ); + const title = `Export fixture ${theme} ${reducedMotion}`; + const task = await seedTestTask(page, { title, type: 'code' }); + await page.route(`**/api/telemetry/events/task/${task.id}`, (route) => + route.fulfill({ + json: [ + { + id: 'evt_fixture', + type: 'run.started', + timestamp: '2026-09-01T10:00:00Z', + taskId: task.id, + agent: 'fixture', + attemptId: 'attempt_fixture', + }, + ], + }) + ); + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const requests: URL[] = []; + await page.route('**/api/telemetry/export?*', async (route) => { + requests.push(new URL(route.request().url())); + if (requests.length === 1) { + await gate; + return route.fulfill({ status: 503, json: { error: 'Fixture export failed' } }); + } + return route.fulfill({ + contentType: 'application/json', + body: '[{"fixture":true}]', + }); + }); + try { + await page.setViewportSize({ width: 1700, height: 900 }); + await page.goto('/'); + await page.getByRole('article', { name: `Task: ${title}` }).press('Enter'); + const workspace = page.getByTestId('task-detail-panel'); + await workspace.getByRole('button', { name: 'History', exact: true }).click(); + await workspace.getByRole('tab', { name: 'Metrics', exact: true }).click(); + const opener = workspace.getByRole('button', { name: 'Export', exact: true }); + await opener.press('Enter'); + const dialog = page.getByRole('dialog', { name: 'Export Metrics' }); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveCSS('opacity', '1'); + await expect(workspace).toHaveAttribute('inert', ''); + await dialog.getByLabel('Format', { exact: true }).click(); + await page.getByRole('option', { name: 'JSON (Programmatic)', exact: true }).click(); + await dialog.getByLabel('From', { exact: true }).fill('2026-09-01'); + await dialog.getByLabel('To', { exact: true }).fill('2026-09-02'); + for (const geometry of [ + { width: 1700, height: 900, fontSize: '16px' }, + { width: 1180, height: 760, fontSize: '20px' }, + { width: 900, height: 480, fontSize: '20px' }, + ]) { + await page.setViewportSize(geometry); + await page.evaluate((size) => { + document.documentElement.style.fontSize = size; + }, geometry.fontSize); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ); + for (const name of ['Close dialog', 'Cancel', 'Export']) { + const action = dialog.getByRole('button', { name, exact: true }); + await expect(action).toBeInViewport({ ratio: 1 }); + await action.click({ trial: true }); + } + const footer = dialog.locator('.vk-overlay-footer'); + const before = await footer.boundingBox(); + await dialog.locator('.vk-overlay-scroll').evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + expect(await footer.boundingBox()).toEqual(before); + const bounds = await dialog.evaluate((element) => ({ + top: element.getBoundingClientRect().top, + bottom: element.getBoundingClientRect().bottom, + overflow: element.scrollWidth - element.clientWidth, + })); + expect(bounds.top).toBeGreaterThanOrEqual(0); + expect(bounds.bottom).toBeLessThanOrEqual(geometry.height); + expect(bounds.overflow).toBe(0); + } + await dialog.getByRole('button', { name: 'Export', exact: true }).click(); + await expect.poll(() => requests.length).toBe(1); + await page.keyboard.press('Escape'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Close dialog' }).click(); + await page.mouse.click(3, 3); + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + for (const label of ['Format', 'Scope', 'Task ID', 'From', 'To']) + await expect(dialog.getByLabel(label, { exact: true })).toBeDisabled(); + release(); + await expect(dialog.getByRole('alert')).toContainText('Fixture export failed'); + await expect(dialog.getByRole('alert')).toBeInViewport({ ratio: 1 }); + await expect(dialog.getByRole('alert')).toBeFocused(); + await expect(dialog.getByLabel('From', { exact: true })).toHaveValue('2026-09-01'); + await expect(dialog.getByLabel('To', { exact: true })).toHaveValue('2026-09-02'); + await expect(dialog.getByLabel('Format', { exact: true })).toHaveValue( + 'JSON (Programmatic)' + ); + await page.screenshot({ path: test.info().outputPath('export-failure.png') }); + const downloadEvent = page.waitForEvent('download'); + await dialog.getByRole('button', { name: 'Export', exact: true }).click(); + const download = await downloadEvent; + // This surface test covers the documented fallback when the response + // has no filename. Cross-origin filename exposure is tracked separately. + expect(download.suggestedFilename()).toBe('telemetry-export.json'); + expect(await download.failure()).toBeNull(); + await expect(dialog).toHaveCount(0); + await expect(opener).toBeFocused(); + expect(requests).toHaveLength(2); + expect(requests[0].search).toBe(requests[1].search); + expect(requests[0].searchParams.get('format')).toBe('json'); + expect(requests[0].searchParams.get('taskId')).toBe(task.id); + expect(requests[0].searchParams.get('from')).toBe('2026-09-01T00:00:00.000Z'); + expect(requests[0].searchParams.get('to')).toBeTruthy(); + } finally { + release(); + await deleteTask(page, String(task.id)).catch(() => {}); + await cleanupRoutes(page).catch(() => {}); + } + }); + } +} diff --git a/web/src/__tests__/dashboard-drilldowns-mantine.test.tsx b/web/src/__tests__/dashboard-drilldowns-mantine.test.tsx index 4d9710f6..9bf49b7b 100644 --- a/web/src/__tests__/dashboard-drilldowns-mantine.test.tsx +++ b/web/src/__tests__/dashboard-drilldowns-mantine.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Stack } from '@mantine/core'; @@ -11,6 +11,7 @@ import { ExportDialog } from '@/components/dashboard/ExportDialog'; import { TasksDrillDown } from '@/components/dashboard/TasksDrillDown'; import { TokensDrillDown } from '@/components/dashboard/TokensDrillDown'; import { createMockProject, createMockTask, renderWithProviders } from './test-utils'; +import * as apiHelpers from '@/lib/api/helpers'; const mocks = vi.hoisted(() => ({ useTasks: vi.fn(), @@ -46,6 +47,7 @@ const projects = [ describe('dashboard Mantine drilldown surfaces', () => { beforeEach(() => { vi.clearAllMocks(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); mocks.useTasks.mockReturnValue({ data: [ createMockTask({ @@ -201,6 +203,49 @@ describe('dashboard Mantine drilldown surfaces', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('guards pending export dismissal and preserves filters after failure', async () => { + let rejectRequest!: (error: Error) => void; + const request = vi.spyOn(apiHelpers, 'apiResponse').mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRequest = reject; + }) + ); + const onOpenChange = vi.fn(); + renderWithProviders(); + const dialog = screen.getByRole('dialog', { name: 'Export Metrics' }); + await waitFor(() => + expect(document.activeElement).toBe( + within(dialog).getByRole('button', { name: 'Close dialog' }) + ) + ); + fireEvent.change(within(dialog).getByLabelText('From'), { target: { value: '2026-09-01' } }); + const submit = within(dialog).getByRole('button', { name: 'Export' }); + fireEvent.click(submit); + fireEvent.click(submit); + fireEvent.keyDown(document.body, { key: 'Escape' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Close dialog' })); + expect(request).toHaveBeenCalledTimes(1); + expect(onOpenChange).not.toHaveBeenCalled(); + expect( + (within(dialog).getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled + ).toBe(true); + expect((within(dialog).getByLabelText('From') as HTMLInputElement).disabled).toBe(true); + await act(async () => rejectRequest(new Error('Fixture export failed'))); + expect(within(dialog).getByRole('alert').textContent).toContain('Fixture export failed'); + expect(document.activeElement).toBe(within(dialog).getByRole('alert')); + expect(within(dialog).getByRole('alert').scrollIntoView).toHaveBeenCalledWith({ + block: 'center', + behavior: 'instant', + }); + expect((within(dialog).getByLabelText('From') as HTMLInputElement).value).toBe('2026-09-01'); + expect( + (within(dialog).getByRole('button', { name: 'Export' }) as HTMLButtonElement).disabled + ).toBe(false); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + it('renders dashboard drilldown content through direct Mantine primitives and preserves selection', async () => { const user = userEvent.setup(); const onTaskClick = vi.fn(); diff --git a/web/src/components/dashboard/ExportDialog.tsx b/web/src/components/dashboard/ExportDialog.tsx index f6a7a2d4..3f38eeef 100644 --- a/web/src/components/dashboard/ExportDialog.tsx +++ b/web/src/components/dashboard/ExportDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core'; import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiAction } from '@/components/ui/UiVocabulary'; @@ -40,9 +40,19 @@ export function ExportDialog({ const [fromDate, setFromDate] = useState(''); const [toDate, setToDate] = useState(''); const [isExporting, setIsExporting] = useState(false); + const [exportError, setExportError] = useState(null); + const exportInFlight = useRef(false); + const errorRef = useRef(null); useEffect(() => { - if (!open) return; + if (exportError) { + errorRef.current?.focus({ preventScroll: true }); + errorRef.current?.scrollIntoView({ block: 'center', behavior: 'instant' }); + } + }, [exportError]); + + useEffect(() => { + if (!open || exportInFlight.current) return; setFormat('csv'); setScope(initialTaskId ? 'task' : initialProject ? 'project' : 'full'); @@ -50,10 +60,19 @@ export function ExportDialog({ setProject(initialProject || ''); setFromDate(''); setToDate(''); + setExportError(null); }, [open, initialTaskId, initialProject]); const handleExport = async () => { + if ( + exportInFlight.current || + (scope === 'task' && !taskId) || + (scope === 'project' && !project) + ) + return; + exportInFlight.current = true; setIsExporting(true); + setExportError(null); try { const params = new URLSearchParams(); @@ -97,13 +116,16 @@ export function ExportDialog({ onOpenChange(false); } catch (error) { - console.error('Export error:', error); + setExportError(error instanceof Error ? error.message : 'Unable to export metrics.'); } finally { + exportInFlight.current = false; setIsExporting(false); } }; - const handleClose = () => onOpenChange(false); + const handleClose = () => { + if (!exportInFlight.current) onOpenChange(false); + }; return ( setFormat((value ?? 'csv') as ExportFormat)} data={[ @@ -137,6 +160,7 @@ export function ExportDialog({ setProject(value ?? '')} placeholder="Select project..." @@ -158,6 +183,7 @@ export function ExportDialog({ ) : ( setProject(e.target.value)} placeholder="Project name" @@ -167,6 +193,7 @@ export function ExportDialog({ {scope === 'task' && ( setTaskId(e.target.value)} placeholder="task_..." @@ -175,6 +202,7 @@ export function ExportDialog({ setFromDate(e.target.value)} @@ -182,13 +210,19 @@ export function ExportDialog({ setToDate(e.target.value)} /> + {exportError && ( + + {exportError} + + )} - + Cancel