From 25f5cb4aa0ebe7f453384df17999b9321dd90d27 Mon Sep 17 00:00:00 2001 From: Ling Test Date: Tue, 27 Oct 2026 23:48:59 +0800 Subject: [PATCH 1/8] feat(daemon,web): document deliverables build route + interactive spreadsheet viewer ux-gap-roadmap-01 + 12. Deliverables: versioned DeckSpec/DocSpec/WorkbookSpec contracts with bounded parsing; daemon builders via pptxgenjs/docx/exceljs writing real .pptx/.docx/.xlsx files into the workspace deliverables directory through POST /deliverables/build. Spreadsheet viewer: sync sheet-name extraction from the workbook zip for the preview envelope, a workspace-bound windowed /previews/spreadsheet route (formulas are served for reference and never evaluated), and a web SpreadsheetGrid with sheet tabs, sticky headers, cell selection, formula bar and load-more windowing so large workbooks never load whole. Unit tests: builder round-trips (pptx zip structure + speaker notes, docx content, xlsx re-read via exceljs), spec validation, window clamping, extraction windows, API routes incl. workspace-bound escape rejection. Playwright spec with screenshot evidence covers sheet tab switching, cell selection and the formula bar. --- apps/web/e2e/spreadsheet-viewer.spec.ts | 145 +++ .../right-dock/DockFileItemDetail.tsx | 4 +- apps/web/src/i18n/locales/en-US.ts | 5 + apps/web/src/i18n/locales/zh-CN.ts | 5 + .../conversation/preview/ArtifactPreview.tsx | 22 +- .../preview/ArtifactPreviewDrawer.tsx | 5 +- .../conversation/preview/SpreadsheetGrid.tsx | 151 +++ apps/web/src/styles.css | 118 ++ packages/contracts/src/artifacts.ts | 4 + packages/contracts/src/deliverables.ts | 157 +++ packages/contracts/src/index.ts | 1 + packages/daemon/package.json | 16 +- .../src/features/deliverables/builders.ts | 154 +++ .../src/features/deliverables/routes.ts | 119 ++ .../deliverables/spreadsheet-extract.ts | 139 ++ .../src/features/previews/content-preview.ts | 28 + packages/daemon/src/server/router.ts | 11 + .../daemon/test/deliverables-api.test.mjs | 83 ++ packages/daemon/test/deliverables.test.mjs | 156 +++ pnpm-lock.yaml | 1138 ++++++++++++++++- 20 files changed, 2434 insertions(+), 27 deletions(-) create mode 100644 apps/web/e2e/spreadsheet-viewer.spec.ts create mode 100644 apps/web/src/shared/conversation/preview/SpreadsheetGrid.tsx create mode 100644 packages/contracts/src/deliverables.ts create mode 100644 packages/daemon/src/features/deliverables/builders.ts create mode 100644 packages/daemon/src/features/deliverables/routes.ts create mode 100644 packages/daemon/src/features/deliverables/spreadsheet-extract.ts create mode 100644 packages/daemon/test/deliverables-api.test.mjs create mode 100644 packages/daemon/test/deliverables.test.mjs diff --git a/apps/web/e2e/spreadsheet-viewer.spec.ts b/apps/web/e2e/spreadsheet-viewer.spec.ts new file mode 100644 index 000000000..07ff59b2d --- /dev/null +++ b/apps/web/e2e/spreadsheet-viewer.spec.ts @@ -0,0 +1,145 @@ +import { expect, test, type Page } from "@playwright/test"; +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { mockDaemon } from "./support/mock-daemon"; +import { createBaseState } from "./support/mock-state"; +import { openEngineeringChannel } from "./support/test-helpers"; + +const evidence = resolve("../../output/playwright/spreadsheet-viewer"); +const workbookName = "quarterly-metrics.xlsx"; + +function spreadsheetState(): ReturnType { + const state = createBaseState({ approvalStatus: "expired" }); + state.runs = []; + state.tasks = []; + state.approvals = []; + state.channelMessages = [{ + id: "message_spreadsheet_preview", + workspaceId: "workspace_default", + channelId: "channel_engineering", + kind: "message", + intent: "discuss", + authorType: "local-user", + authorId: "tian", + body: "Quarterly metrics attached.", + mentions: [], + attachmentIds: ["attachment_spreadsheet_preview"], + linkPreviewIds: [], + fileRefs: [], + status: "ready", + createdAt: "2026-10-27T08:00:00.000Z", + updatedAt: "2026-10-27T08:00:00.000Z", + }]; + state.channelAttachments = [{ + id: "attachment_spreadsheet_preview", + workspaceId: "workspace_default", + channelId: "channel_engineering", + messageId: "message_spreadsheet_preview", + kind: "text", + fileName: workbookName, + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + sizeBytes: 8192, + path: `.ling-stack/attachments/${workbookName}`, + materializationStatus: "available", + previewStatus: "available", + createdAt: "2026-10-27T08:00:00.000Z", + }]; + return state; +} + +async function routeSpreadsheetPreview(page: Page): Promise { + await page.route(/\/attachments\/attachment_spreadsheet_preview\/preview(?:\?.*)?$/u, async (route) => { + await route.fulfill({ + json: { + ref: { + id: "attachment_spreadsheet_preview", + source: "workspace_file", + scope: "workspace", + label: workbookName, + displayPath: `reports/${workbookName}`, + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + sizeBytes: 8192, + previewable: true, + }, + kind: "spreadsheet", + spreadsheet: { + schemaVersion: 1, + sheets: [ + { index: 0, name: "Q3", rowCount: 0, colCount: 0 }, + { index: 1, name: "Q4", rowCount: 0, colCount: 0 }, + ], + }, + truncated: false, + sizeBytes: 8192, + redactionNotes: ["Preview is read-only and not included in AI context."], + }, + }); + }); + await page.route(/\/previews\/spreadsheet\?.*$/u, async (route) => { + const url = new URL(route.request().url()); + const sheet = Number.parseInt(url.searchParams.get("sheet") ?? "0", 10); + const rowStart = Number.parseInt(url.searchParams.get("rowStart") ?? "0", 10); + const rows = sheet === 0 + ? [["region", "revenue", "growth"], ["north", 1200, "0.18"], ["south", 980, "=B3*2"]] + : [["region", "revenue"], ["north", 1500]]; + await route.fulfill({ + json: { + schemaVersion: 1, + sheetIndex: sheet, + sheetName: sheet === 0 ? "Q3" : "Q4", + rowStart, + rowEnd: rowStart + rows.length, + colStart: 0, + colEnd: rows[0].length, + rowCount: 3 + rowStart, + colCount: rows[0].length, + cells: rows.map((row) => row.map((value) => ({ v: value, f: String(value).startsWith("=") ? String(value).slice(1) : undefined }))), + }, + }); + }); +} + +test("spreadsheet viewer opens workbooks with sheet tabs, selection and formula bar", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 960 }); + await page.addInitScript(() => { + localStorage.setItem("ling-stack.locale", "en-US"); + }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await mockDaemon(page, spreadsheetState()); + await routeSpreadsheetPreview(page); + await page.goto("/"); + await openEngineeringChannel(page); + + const feed = page.getByLabel("Channel message feed"); + await feed.getByRole("button", { name: `Preview: ${workbookName}` }).click(); + + const preview = page.getByLabel("File preview"); + const grid = preview.locator("[data-ui2-spreadsheet-grid]"); + await expect(grid).toBeVisible(); + + // Q3 sheet content + await expect(grid.locator(".spreadsheet-grid-cell").filter({ hasText: "north" })).toBeVisible(); + await expect(grid.locator(".spreadsheet-grid-cell").filter({ hasText: "1200" })).toBeVisible(); + + // Cell selection shows the coordinate in the formula bar + await grid.locator(".spreadsheet-grid-cell").filter({ hasText: "0.18" }).click(); + await expect(grid.locator(".spreadsheet-grid-formula code")).toHaveText(/^[A-Z]+\d+$/u); + await expect(grid.locator(".spreadsheet-grid-formula span")).toContainText("0.18"); + + // Formula cells display their formula in the formula bar + await grid.locator(".spreadsheet-grid-cell").filter({ hasText: "=B3*2" }).click(); + await expect(grid.locator(".spreadsheet-grid-formula span")).toContainText("B3*2"); + + await page.screenshot({ path: `${evidence}/spreadsheet-q3.png`, animations: "disabled" }); + + // Switch to the second sheet + const secondSheetRoute = page.waitForRequest((request) => request.url().includes("sheet=1")); + await preview.locator(".spreadsheet-grid-toolbar, .artifact-preview-panel").getByText("Q4", { exact: true }).first().click(); + await secondSheetRoute; + await expect(grid.locator(".spreadsheet-grid-cell").filter({ hasText: "1500" })).toBeVisible(); + await page.screenshot({ path: `${evidence}/spreadsheet-q4.png`, animations: "disabled" }); + + const unexpected = errors.filter((error) => !error.includes("lacks the 'allow-same-origin' flag")); + expect(unexpected).toEqual([]); +}); diff --git a/apps/web/src/features/workspace/right-dock/DockFileItemDetail.tsx b/apps/web/src/features/workspace/right-dock/DockFileItemDetail.tsx index 6f0fccbd8..e43337194 100644 --- a/apps/web/src/features/workspace/right-dock/DockFileItemDetail.tsx +++ b/apps/web/src/features/workspace/right-dock/DockFileItemDetail.tsx @@ -114,7 +114,7 @@ function DockWorkspaceResourcePreview({ cwd, target }: { readonly cwd: string; r data-workspace-capability-target={capabilityTarget} data-workspace-viewer-kind="artifact" > - + ); } @@ -130,7 +130,7 @@ function DockWorkspaceResourcePreview({ cwd, target }: { readonly cwd: string; r data-workspace-capability-target={capabilityTarget} data-workspace-viewer-kind="file" > - + ); } diff --git a/apps/web/src/i18n/locales/en-US.ts b/apps/web/src/i18n/locales/en-US.ts index 20942a06b..46490c834 100644 --- a/apps/web/src/i18n/locales/en-US.ts +++ b/apps/web/src/i18n/locales/en-US.ts @@ -4611,4 +4611,9 @@ export const enUS = { "Automation run finished": "Automation run finished", "Automation run failed": "Automation run failed", "Approval required": "Approval required", + "Spreadsheet preview": "Spreadsheet preview", + "Loading spreadsheet...": "Loading spreadsheet...", + "Load more rows": "Load more rows", + "Row": "Row", + "Formulas are shown for reference and never evaluated.": "Formulas are shown for reference and never evaluated.", } as const satisfies Readonly>; diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 47cafbf80..f18533a2f 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -6483,4 +6483,9 @@ export const zhCN = { "End": "结束", "Automation run finished": "自动化运行完成", "Automation run failed": "自动化运行失败", + "Spreadsheet preview": "表格预览", + "Loading spreadsheet...": "正在加载表格…", + "Load more rows": "加载更多行", + "Row": "行", + "Formulas are shown for reference and never evaluated.": "公式仅作展示,永不被求值。", } as const satisfies Readonly>; diff --git a/apps/web/src/shared/conversation/preview/ArtifactPreview.tsx b/apps/web/src/shared/conversation/preview/ArtifactPreview.tsx index 5a28ac5ac..7162eca87 100644 --- a/apps/web/src/shared/conversation/preview/ArtifactPreview.tsx +++ b/apps/web/src/shared/conversation/preview/ArtifactPreview.tsx @@ -2,20 +2,40 @@ import { CodeBlock } from "../blocks/CodeBlock.js"; import { CopyActionButton } from "../feedback/CopyActionButton.js"; import { ImageCompareViewer } from "./ImageCompareViewer.js"; import { MessageBodyRenderer } from "../blocks/MessageBodyRenderer.js"; +import { SpreadsheetGrid } from "./SpreadsheetGrid.js"; import { useI18n } from "../../../i18n.js"; import type { PreviewContentKind, PreviewContentResponse } from "../../../types.js"; export interface ArtifactPreviewProps { readonly preview: PreviewContentResponse; + readonly cwd?: string; } -export function ArtifactPreview({ preview }: ArtifactPreviewProps): React.JSX.Element { +export function ArtifactPreview({ preview, cwd }: ArtifactPreviewProps): React.JSX.Element { const { t } = useI18n(); if (preview.kind === "image" && preview.imageUrl) { return ; } + if (preview.kind === "spreadsheet") { + const sourcePath = preview.ref.displayPath ?? preview.ref.label; + if (cwd && preview.ref.source === "workspace_file") { + return ; + } + // Spreadsheet metadata without a workspace-relative source falls back to + // a text listing until the harness wires artifact-scoped windows. + if (preview.spreadsheet) { + return ( +
+ {(preview.spreadsheet.sheets ?? []).map((sheet) => ( +
{sheet.name}
+ ))} +
+ ); + } + } + if (preview.kind === "html_interactive" && preview.htmlInteractive) { return (