Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions apps/web/e2e/spreadsheet-viewer.spec.ts
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([]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ function DockWorkspaceResourcePreview({ cwd, target }: { readonly cwd: string; r
data-workspace-capability-target={capabilityTarget}
data-workspace-viewer-kind="artifact"
>
<ArtifactPreview preview={preview} />
<ArtifactPreview preview={preview} cwd={cwd} />
</article>
);
}
Expand All @@ -130,7 +130,7 @@ function DockWorkspaceResourcePreview({ cwd, target }: { readonly cwd: string; r
data-workspace-capability-target={capabilityTarget}
data-workspace-viewer-kind="file"
>
<ArtifactPreview preview={preview} />
<ArtifactPreview preview={preview} cwd={cwd} />
</article>
);
}
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>;
5 changes: 5 additions & 0 deletions apps/web/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>;
24 changes: 23 additions & 1 deletion apps/web/src/shared/conversation/preview/ArtifactPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,42 @@ 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 <ImageCompareViewer current={{ label: preview.ref.label, imageUrl: preview.imageUrl }} compare={preview.compare} />;
}

if (preview.kind === "spreadsheet") {
// The windowed route resolves the ref path against the workspace root and
// enforces containment server-side, so any source with a known cwd works.
const sourcePath = preview.ref.displayPath ?? preview.ref.label;
if (cwd) {
return <SpreadsheetGrid cwd={cwd} path={sourcePath} sheets={preview.spreadsheet?.sheets} />;
Comment thread
gansx marked this conversation as resolved.
Resolved
}
// Spreadsheet metadata without a workspace-relative source falls back to
// a text listing until the harness wires artifact-scoped windows.
if (preview.spreadsheet) {
return (
<div className="spreadsheet-grid spreadsheet-grid-note" data-ui2-spreadsheet-grid="true">
{(preview.spreadsheet.sheets ?? []).map((sheet) => (
<div key={sheet.index}>{sheet.name}</div>
))}
</div>
);
}
}

if (preview.kind === "html_interactive" && preview.htmlInteractive) {
return (
<iframe
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export function ArtifactPreviewDrawer({
{actionError}
</InlineFeedback>
) : null}
{preview && !previewUnavailable ? <ArtifactPreview preview={preview} /> : null}
{preview && !previewUnavailable ? <ArtifactPreview preview={preview} cwd={request?.cwd} /> : null}
</div>

{preview?.redactionNotes.length ? (
Expand Down Expand Up @@ -325,6 +325,9 @@ function artifactPreviewCanRender(preview: PreviewContentResponse): boolean {
if (preview.kind === "html_interactive") {
return Boolean(preview.htmlInteractive);
}
if (preview.kind === "spreadsheet") {
return Boolean(preview.spreadsheet);
}
return preview.text !== undefined;
}

Expand Down
151 changes: 151 additions & 0 deletions apps/web/src/shared/conversation/preview/SpreadsheetGrid.tsx
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 });
Comment thread
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>
);
}
Loading
Loading