Repository navigation
feat: document deliverables (PPT/Word/Excel build route) + interactive spreadsheet viewer #606
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
25f5cb4
feat(daemon,web): document deliverables build route + interactive spr…
f0c1fa8
fix(scripts): reviewed license exceptions for exceljs transitive deps
9817d6c
fix(scripts): normalize check-license-policy line endings to LF
cfb187f
fix(daemon): keep exceljs out of the keychain sandbox import graph
8910020
Merge branch 'main' into feat/document-deliverables
gansx 987ffb1
chore: translate catalogs for new source strings
ling-stack-model-catalog[bot] d125f0e
fix(daemon,web): address P1/P2 review findings on deliverables and sp…
399ba8a
chore: remove accidentally committed local debug logs
cd1b42a
chore: remove remaining local api debug snapshots
b9066ef
Merge branch 'main' into feat/document-deliverables
gansx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof createBaseState> { | ||
| 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<void> { | ||
| 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([]); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
apps/web/src/shared/conversation/preview/SpreadsheetGrid.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| import { useI18n } from "../../../i18n.js"; | ||
| import type { SpreadsheetSheetWindow } from "../../../types.js"; | ||
| import { daemonClient } from "../../../api/client.js"; | ||
|
|
||
| /** | ||
| * Interactive spreadsheet viewer (ux-gap-roadmap-12): sheet tabs, sticky | ||
| * header, cell selection with a formula bar, and windowed reads so large | ||
| * sheets never load whole. Formulas are displayed, never evaluated. | ||
| */ | ||
| export function SpreadsheetGrid({ cwd, path, sheets }: { | ||
| readonly cwd: string; | ||
| readonly path: string; | ||
| readonly sheets?: readonly { readonly index: number; readonly name: string }[]; | ||
| }): React.JSX.Element { | ||
| const { t } = useI18n(); | ||
| const [sheetIndex, setSheetIndex] = useState(0); | ||
| const [rowStart, setRowStart] = useState(0); | ||
| const [windowData, setWindowData] = useState<SpreadsheetSheetWindow | undefined>(undefined); | ||
| const [selected, setSelected] = useState<{ row: number; col: number } | undefined>(undefined); | ||
| const [error, setError] = useState(""); | ||
|
|
||
| useEffect(() => { | ||
| let cancelled = false; | ||
| setWindowData(undefined); | ||
| setError(""); | ||
| void daemonClient._get<SpreadsheetSheetWindow>("/previews/spreadsheet", { | ||
| cwd, | ||
| path, | ||
| sheet: sheetIndex, | ||
| rowStart, | ||
| }) | ||
| .then((body: SpreadsheetSheetWindow) => { | ||
| if (cancelled) return; | ||
| setWindowData(body); | ||
| setSelected({ row: 0, col: 0 }); | ||
|
gansx marked this conversation as resolved.
Resolved
|
||
| }) | ||
| .catch((cause: unknown) => { | ||
| if (cancelled) return; | ||
| setError(cause instanceof Error ? cause.message : String(cause)); | ||
| }); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [cwd, path, sheetIndex, rowStart]); | ||
|
|
||
| const loadNextPage = useCallback(() => { | ||
| if (!windowData) return; | ||
| setRowStart(Math.min(windowData.rowEnd, Math.max(0, windowData.rowCount - 1))); | ||
| }, [windowData]); | ||
|
|
||
| const selectedCell = useMemo(() => { | ||
| if (!windowData || !selected) return undefined; | ||
| const row = windowData.cells[selected.row - windowData.rowStart]; | ||
| return row?.[selected.col - windowData.colStart]; | ||
| }, [selected, windowData]); | ||
|
|
||
| if (error) { | ||
| return <div className="spreadsheet-grid spreadsheet-grid-error" role="alert">{error}</div>; | ||
| } | ||
| if (!windowData) { | ||
| return <div className="spreadsheet-grid spreadsheet-grid-loading">{t("Loading spreadsheet...")}</div>; | ||
| } | ||
|
|
||
| const colLabel = (index: number): string => { | ||
| let label = ""; | ||
| let n = index; | ||
| do { | ||
| label = String.fromCharCode(65 + (n % 26)) + label; | ||
| n = Math.floor(n / 26) - 1; | ||
| } while (n >= 0); | ||
| return label; | ||
| }; | ||
| const cellText = (value: unknown): string => value === null || value === undefined ? "" : String(value); | ||
|
|
||
| return ( | ||
| <div className="spreadsheet-grid" data-ui2-spreadsheet-grid="true" aria-label={t("Spreadsheet preview")}> | ||
| {sheets && sheets.length > 1 ? ( | ||
| <div className="spreadsheet-grid-tabs" role="tablist" aria-label={t("Sheets")}> | ||
| {sheets.map((sheet) => ( | ||
| <button | ||
| key={sheet.index} | ||
| type="button" | ||
| role="tab" | ||
| aria-selected={sheet.index === sheetIndex} | ||
| className={sheet.index === sheetIndex ? "spreadsheet-grid-tab active" : "spreadsheet-grid-tab"} | ||
| onClick={() => { | ||
| setSheetIndex(sheet.index); | ||
| setRowStart(0); | ||
| }} | ||
| > | ||
| {sheet.name} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| ) : null} | ||
| <div className="spreadsheet-grid-toolbar"> | ||
| {windowData.rowCount > windowData.rowEnd ? ( | ||
| <button type="button" className="spreadsheet-grid-page-button" onClick={loadNextPage}> | ||
| {t("Load more rows")} ({windowData.rowEnd}/{windowData.rowCount}) | ||
| </button> | ||
| ) : ( | ||
| <span className="spreadsheet-grid-page-info">{windowData.rowCount} × {windowData.colCount}</span> | ||
| )} | ||
| {selected ? ( | ||
| <span className="spreadsheet-grid-formula" data-has-formula={selectedCell?.f ? "true" : undefined}> | ||
| <code>{colLabel(selected.col)}{selected.row + 1}</code> | ||
| <span>{selectedCell?.f ? `= ${selectedCell.f}` : cellText(selectedCell?.v) || "—"}</span> | ||
| </span> | ||
| ) : null} | ||
| </div> | ||
| <div className="spreadsheet-grid-scroll"> | ||
| <table className="spreadsheet-grid-table"> | ||
| <thead> | ||
| <tr> | ||
| <th className="spreadsheet-grid-corner" aria-label={t("Row")} /> | ||
| {Array.from({ length: windowData.colEnd - windowData.colStart }, (_, col) => ( | ||
| <th key={col} scope="col">{colLabel(windowData.colStart + col)}</th> | ||
| ))} | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {windowData.cells.map((row, rowIndex) => { | ||
| const absoluteRow = windowData.rowStart + rowIndex; | ||
| return ( | ||
| <tr key={absoluteRow}> | ||
| <th className="spreadsheet-grid-row-number" scope="row">{absoluteRow + 1}</th> | ||
| {row.map((cell, colIndex) => { | ||
| const absoluteCol = windowData.colStart + colIndex; | ||
| const isSelected = selected?.row === absoluteRow && selected?.col === absoluteCol; | ||
| return ( | ||
| <td | ||
| key={absoluteCol} | ||
| className={isSelected ? "spreadsheet-grid-cell selected" : "spreadsheet-grid-cell"} | ||
| data-has-formula={cell.f ? "true" : undefined} | ||
| onClick={() => setSelected({ row: absoluteRow, col: absoluteCol })} | ||
| > | ||
| {cellText(cell.v)} | ||
| </td> | ||
| ); | ||
| })} | ||
| </tr> | ||
| ); | ||
| })} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| <p className="spreadsheet-grid-note">{t("Formulas are shown for reference and never evaluated.")}</p> | ||
| </div> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.