feat(artifacts): preview CSV and TSV files as bounded tables (#5284)

* feat(artifacts): preview CSV and TSV files as bounded tables

* chore: keep preview screenshots out of the PR file diff

* fix(artifacts): detect record newlines outside quoted fields

* test(auth): include project permissions in me contract expectations
This commit is contained in:
Ryker_Feng 2026-09-08 19:11:11 +08:00 committed by GitHub
parent dde131a808
commit 9fda432ba1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1562 additions and 42 deletions

View File

@ -1311,6 +1311,8 @@ After each run, DeerFlow records a workspace change summary for the run-owned `w
Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes.
CSV and TSV artifacts open as tables in the artifact panel and in a separate window. The preview preserves text values (including leading zeros), supports an optional header row, and pages through up to 200 rows and 50 columns from the initial sample. Long or multiline cells can be opened and copied in full. Switch to source to inspect or edit the file; downloads and separate windows use the saved version.
Text artifacts are streamed with HTTP byte-range support. The Web UI initially
loads at most 1 MiB, shows the preview size when a file is larger, and waits for
an explicit **Load full file** action before fetching the remainder or mounting

View File

@ -30,6 +30,9 @@ _ALL_PERMISSIONS = [
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]

View File

@ -50,7 +50,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
### Source Layout (`src/`)
- **`app/`** — Next.js App Router. Routes include `/` (landing), `/showcase/[thread_id]` (allowlisted public read-only demos), `/workspace/chats/[thread_id]` (authenticated chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/artifacts/view` (chrome-free window that renders one markdown artifact with the panel's own renderer), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`).
- **`app/`** — Next.js App Router. Routes include `/` (landing), `/showcase/[thread_id]` (allowlisted public read-only demos), `/workspace/chats/[thread_id]` (authenticated chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/artifacts/view` (chrome-free window that renders Markdown or CSV/TSV artifacts with the panel's own renderer), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`).
- **`components/`** — React components:
- `ui/` — Shadcn UI primitives (auto-generated, ESLint-ignored)
- `ai-elements/` — Vercel AI SDK elements (auto-generated, ESLint-ignored)
@ -125,3 +125,7 @@ Pin/archive responses must not merge unrelated metadata flags: out-of-order
organization requests can otherwise roll back each other's confirmed state.
Run-created optimistic snapshots have no archive flag: refresh archive-filtered
lists from the server instead of inserting those snapshots into either view.
### Delimited artifact preview
CSV/TSV previews share `artifact-table-preview.tsx` between the panel and standalone viewer. Papa Parse runs only inside `delimited-preview.worker.ts`; `use-delimited-preview.ts` bounds input before transfer, cancels stale work, and enforces a five-second timeout. The parser detects the first record separator outside quoted fields and passes it explicitly to Papa Parse, so embedded newlines in an incomplete quoted field cannot corrupt newline detection. It retains at most 202 logical records and 50 columns, discarding an incomplete final record from truncated input. UI pagination displays at most 200 data rows in pages of 50. Keep the table mounted but inactive when switching to source so header/pagination state survives; changing file identity resets it. Pending `write_file` content stays in source mode until success.

View File

@ -81,6 +81,7 @@
"nextra-theme-docs": "^4.6.1",
"nuxt-og-image": "^5.1.13",
"ogl": "^1.0.11",
"papaparse": "5.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-resizable-panels": "^4.4.1",
@ -110,6 +111,7 @@
"@testing-library/react": "^16.3.2",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/papaparse": "5.5.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.23.0",

View File

@ -149,12 +149,12 @@ importers:
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.4)
gsap:
specifier: ^3.13.0
version: 3.14.2
github-slugger:
specifier: ^2.0.0
version: 2.0.0
gsap:
specifier: ^3.13.0
version: 3.14.2
h3:
specifier: 1.15.9
version: 1.15.9
@ -191,6 +191,9 @@ importers:
ogl:
specifier: ^1.0.11
version: 1.0.11
papaparse:
specifier: 5.7.0
version: 5.7.0
react:
specifier: ^19.0.0
version: 19.2.4
@ -203,12 +206,12 @@ importers:
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
rehype-sanitize:
specifier: ^6.0.0
version: 6.0.0
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
rehype-sanitize:
specifier: ^6.0.0
version: 6.0.0
rehype-slug:
specifier: ^6.0.0
version: 6.0.0
@ -273,6 +276,9 @@ importers:
'@types/node':
specifier: ^20.14.10
version: 20.19.33
'@types/papaparse':
specifier: 5.5.2
version: 5.5.2
'@types/react':
specifier: ^19.0.0
version: 19.2.13
@ -2480,6 +2486,9 @@ packages:
'@types/node@20.19.33':
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
'@types/papaparse@5.5.2':
resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@ -4822,6 +4831,9 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
papaparse@5.7.0:
resolution: {integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@ -8149,6 +8161,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/papaparse@5.5.2':
dependencies:
'@types/node': 20.19.33
'@types/react-dom@19.2.3(@types/react@19.2.13)':
dependencies:
'@types/react': 19.2.13
@ -11221,6 +11237,8 @@ snapshots:
pako@0.2.9: {}
papaparse@5.7.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0

View File

@ -44,7 +44,10 @@ import {
reconcileArtifactDraft,
} from "@/core/artifacts/editing";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { getArtifactViewState } from "@/core/artifacts/preview";
import {
getArtifactViewState,
getTabularDelimiter,
} from "@/core/artifacts/preview";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
resolveArtifactOpenURL,
@ -169,9 +172,9 @@ export function ArtifactFileDetail({
const canPreviewInBrowser = useMemo(() => {
return canBrowserPreviewFile(filepath);
}, [filepath]);
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
const isTabular = getTabularDelimiter(language) !== null;
const isSupportPreview =
language === "html" || language === "markdown" || isTabular;
const toolResult = (() => {
if (!isWriteFile) {
return undefined;
@ -185,7 +188,9 @@ export function ArtifactFileDetail({
})();
const artifactViewState = getArtifactViewState({
filepath: filepathFromProps,
isSupportPreview,
isSupportPreview:
isSupportPreview &&
(!isTabular || !isWriteFile || toolResult?.trim() === "OK"),
toolResult,
});
const {
@ -254,7 +259,7 @@ export function ArtifactFileDetail({
truncated && language === "html" ? "code" : viewMode;
useEffect(() => {
setViewMode(artifactViewState.initialViewMode);
}, [artifactViewState.initialViewMode]);
}, [artifactViewState.initialViewMode, filepathFromProps]);
const confirmDiscard = useCallback(() => {
return !isDirty || window.confirm(t.artifactEditing.discardChanges);
@ -420,7 +425,7 @@ export function ArtifactFileDetail({
</ArtifactTitle>
</div>
<div className="flex min-w-0 grow items-center justify-center gap-2">
{artifactViewState.canPreview && !truncated && (
{artifactViewState.canPreview && (!truncated || isTabular) && (
<ToggleGroup
className="mx-auto"
type="single"
@ -433,10 +438,18 @@ export function ArtifactFileDetail({
}
}}
>
<ToggleGroupItem value="code">
<ToggleGroupItem
value="code"
aria-label={t.artifactPreview.viewSource}
>
<Code2Icon />
</ToggleGroupItem>
<ToggleGroupItem value="preview">
<ToggleGroupItem
value="preview"
aria-label={
isTabular ? t.artifactTable.title : t.common.preview
}
>
<EyeIcon />
</ToggleGroupItem>
</ToggleGroup>
@ -535,7 +548,11 @@ export function ArtifactFileDetail({
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
tooltip={
isTabular && isDirty
? t.artifactTable.savedVersion
: t.common.openInNewWindow
}
onClick={() => {
const w = window.open(
resolveArtifactOpenURL({ filepath, threadId, isMock }),
@ -573,7 +590,11 @@ export function ArtifactFileDetail({
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
tooltip={
isTabular && isDirty
? t.artifactTable.savedVersion
: t.common.download
}
onClick={() => {
const w = window.open(
urlOfArtifact({
@ -608,7 +629,7 @@ export function ArtifactFileDetail({
</div>
</ArtifactHeader>
<ArtifactContent className="flex flex-col p-0">
{truncated && (
{truncated && !(isTabular && effectiveViewMode === "preview") && (
<div className="border-border bg-muted/40 flex shrink-0 items-center justify-between gap-3 border-b px-4 py-2 text-sm">
<span className="text-muted-foreground">
{t.artifactPreview.limited(
@ -639,15 +660,17 @@ export function ArtifactFileDetail({
)}
{artifactViewState.canPreview &&
!error &&
effectiveViewMode === "preview" &&
!isLoading &&
(!truncated || language === "markdown") &&
(language === "markdown" || language === "html") && (
(isTabular || effectiveViewMode === "preview") &&
(!isLoading || isTabular) &&
(!truncated || language === "markdown" || isTabular) &&
(language === "markdown" || language === "html" || isTabular) && (
<ArtifactFilePreview
content={editorContent}
language={language}
scrollKey={filepathFromProps}
language={language ?? "text"}
scrollKey={`${threadId}:${filepathFromProps}`}
url={url}
truncated={truncated}
active={effectiveViewMode === "preview" && !isLoading}
/>
)}
{isCodeFile &&

View File

@ -1,10 +1,12 @@
"use client";
import { DownloadIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
getTabularDelimiter,
appendHtmlPreviewBaseHref,
appendHtmlPreviewScrollRestoration,
createHtmlPreviewScrollKey,
@ -116,16 +118,26 @@ export function ArtifactDownloadFallback({
);
}
const ArtifactTablePreview = dynamic(() =>
import("./artifact-table-preview").then(
(module) => module.ArtifactTablePreview,
),
);
export function ArtifactFilePreview({
content,
language,
scrollKey,
url,
truncated = false,
active = true,
}: {
content: string;
language: string;
scrollKey: string;
url?: string;
truncated?: boolean;
active?: boolean;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const scrollPositionRef = useRef({ x: 0, y: 0 });
@ -204,6 +216,18 @@ export function ArtifactFilePreview({
};
}, [content, language, scrollKey, url]);
const delimiter = getTabularDelimiter(language);
if (delimiter !== null) {
return (
<ArtifactTablePreview
content={content}
delimiter={delimiter}
truncated={truncated}
identity={scrollKey}
active={active}
/>
);
}
if (language === "markdown") {
return (
<div className="size-full overflow-auto px-4 py-3">

View File

@ -0,0 +1,307 @@
"use client";
import {
ChevronLeftIcon,
ChevronRightIcon,
LoaderIcon,
Table2Icon,
} from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useDelimitedPreview } from "@/core/artifacts/use-delimited-preview";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
const PAGE_SIZE = 50;
const MAX_DATA_ROWS = 200;
interface TablePreviewProps {
content: string;
delimiter: "," | "\t";
truncated: boolean;
identity: string;
active?: boolean;
}
export function ArtifactTablePreview(props: TablePreviewProps) {
return <TablePreview key={props.identity} {...props} />;
}
function TablePreview({
content,
delimiter,
truncated,
identity,
active = true,
}: TablePreviewProps) {
const { t } = useI18n();
const labels = t.artifactTable;
const { result, status, retry } = useDelimitedPreview({
content,
delimiter,
truncated,
identity,
active,
});
const [hasHeader, setHasHeader] = useState(true);
const [pagination, setPagination] = useState({ content, page: 0 });
const [cell, setCell] = useState<{
value: string;
label: string;
content: string;
} | null>(null);
const [copyStatus, setCopyStatus] = useState("");
const page = pagination.content === content ? pagination.page : 0;
const rows =
result?.rows.slice(
hasHeader ? 1 : 0,
(hasHeader ? 1 : 0) + MAX_DATA_ROWS,
) ?? [];
const limited = Boolean(
result?.limited === true ||
(result && result.rows.length > MAX_DATA_ROWS + Number(hasHeader)),
);
const columnCount = Math.min(result?.columnCount ?? 0, 50);
const start = Math.min(page * PAGE_SIZE, Math.max(0, rows.length - 1));
const end = Math.min(start + PAGE_SIZE, rows.length);
const columns = Array.from({ length: columnCount }, (_, index) => index);
return (
<div
hidden={!active}
className="flex h-full min-h-0 flex-col"
data-testid="artifact-table-preview"
>
{status === "error" ? (
<div
role="status"
className="text-muted-foreground flex flex-col items-center gap-3 p-8 text-center text-sm"
>
<p>{labels.failed}</p>
<Button variant="outline" onClick={retry}>
{labels.retry}
</Button>
</div>
) : status === "loading" || !result ? (
<div
role="status"
className="text-muted-foreground flex items-center gap-2 p-6 text-sm"
>
<LoaderIcon className="size-4 animate-spin" />
{t.common.loading}
</div>
) : result.rows.length === 0 ? (
<p role="status" className="text-muted-foreground p-6 text-sm">
{limited ? labels.incomplete : labels.empty}
</p>
) : (
<>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3 text-xs">
<span className="flex items-center gap-2 font-medium">
<Table2Icon className="text-muted-foreground size-4" />
{limited ? labels.sample(rows.length) : labels.total(rows.length)}
</span>
<label className="text-muted-foreground flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={hasHeader}
onChange={(event) => {
setHasHeader(event.target.checked);
setPagination({ content, page: 0 });
}}
className="accent-primary size-3.5"
/>
{labels.header}
</label>
</div>
{(result.columnCount > 50 || result.unevenRows) && (
<p
role="status"
className="bg-muted/30 text-muted-foreground border-b px-4 py-2 text-xs"
>
{result.columnCount > 50 && labels.columnsLimited}{" "}
{result.unevenRows && labels.uneven}
</p>
)}
<div
className="min-h-0 flex-1 overflow-auto"
tabIndex={0}
role="region"
aria-label={labels.title}
>
<table
aria-label={labels.title}
className="w-full table-fixed border-separate border-spacing-0 text-sm"
style={{ minWidth: Math.max(320, columnCount * 180 + 48) }}
>
<colgroup>
<col style={{ width: 48 }} />
{columns.map((index) => (
<col key={index} />
))}
</colgroup>
<thead className="bg-muted sticky top-0 z-10">
<tr>
<th
scope="col"
className="text-muted-foreground border-b px-3 py-3 text-xs font-normal"
>
#
</th>
{columns.map((index) => (
<th
key={index}
scope="col"
className="truncate border-b border-l px-3 py-3 text-left text-xs font-medium"
title={hasHeader ? result.rows[0]?.[index] : undefined}
>
{hasHeader
? (result.rows[0]?.[index] ?? "")
: labels.column(index + 1)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.slice(start, end).map((row, rowIndex) => (
<tr
key={start + rowIndex}
className="even:bg-muted/20 hover:bg-muted/40"
>
<th
scope="row"
className="text-muted-foreground border-b px-3 py-2.5 text-right text-xs font-normal tabular-nums"
>
{start + rowIndex + 1}
</th>
{columns.map((column) => {
const value = row[column];
const expandable =
value !== undefined &&
(value.length > 120 || /[\r\n]/.test(value));
return (
<td
key={column}
className="border-b border-l px-3 py-2.5 align-top"
>
<div className="truncate">
{value === undefined ? (
<span className="text-muted-foreground text-xs italic">
{labels.missing}
</span>
) : expandable ? (
<button
type="button"
className="max-w-full cursor-pointer truncate text-left underline underline-offset-4"
aria-label={labels.cell(
start + rowIndex + 1,
column + 1,
)}
onClick={() => {
setCell({
content,
value,
label: labels.cell(
start + rowIndex + 1,
column + 1,
),
});
setCopyStatus("");
}}
>
{value.slice(0, 120)}
</button>
) : (
<span className="whitespace-pre" title={value}>
{value}
</span>
)}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="text-muted-foreground flex shrink-0 items-center justify-between border-t px-4 py-2 text-xs">
<span aria-live="polite">
{labels.range(rows.length ? start + 1 : 0, end, limited)}
</span>
<div className="flex gap-1">
<Button
size="icon-sm"
variant="ghost"
aria-label={labels.previous}
disabled={page === 0}
onClick={() => setPagination({ content, page: page - 1 })}
>
<ChevronLeftIcon className="size-4" />
</Button>
<Button
size="icon-sm"
variant="ghost"
aria-label={labels.next}
disabled={end >= rows.length}
onClick={() => setPagination({ content, page: page + 1 })}
>
<ChevronRightIcon className="size-4" />
</Button>
</div>
</div>
</>
)}
<Dialog
open={cell !== null && cell.content === content && active}
onOpenChange={(open) => {
if (!open) setCell(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{labels.cellValue}</DialogTitle>
<DialogDescription>{cell?.label}</DialogDescription>
</DialogHeader>
<textarea
aria-label={labels.cellValue}
readOnly
value={cell?.value ?? ""}
className="h-64 w-full resize-none rounded-md border p-3 font-mono text-sm"
/>
<div className="flex items-center justify-end gap-3">
<span role="status" className="text-muted-foreground text-xs">
{copyStatus}
</span>
<Button
variant="outline"
onClick={() => {
void writeTextToClipboard(cell?.value ?? "")
.then((ok) =>
setCopyStatus(
ok
? t.clipboard.copiedToClipboard
: t.clipboard.failedToCopyToClipboard,
),
)
.catch(() =>
setCopyStatus(t.clipboard.failedToCopyToClipboard),
);
}}
>
{t.clipboard.copyToClipboard}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -4,7 +4,9 @@ import { DownloadIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useStandaloneArtifactContent } from "@/core/artifacts/hooks";
import { getTabularDelimiter } from "@/core/artifacts/preview";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { resolveStoredArtifactLanguage } from "@/core/artifacts/viewer";
import { useI18n } from "@/core/i18n/hooks";
import { getFileIcon, getFileName } from "@/core/utils/files";
@ -13,15 +15,7 @@ import {
ArtifactFilePreview,
} from "./artifact-file-preview";
/**
* Standalone markdown artifact window.
*
* The artifacts panel's "open in new window" action used to hand the browser
* the raw Gateway response, which shows markdown as its own source. This
* renders it with the same components the panel uses, so the new window is a
* reader rather than a text dump. Markdown only HTML and SVG artifacts stay
* on the Gateway's download path so active content never runs in this origin.
*/
/** Shared standalone reader for Markdown and bounded CSV/TSV previews. */
export function ArtifactViewer({
filepath,
threadId,
@ -33,6 +27,8 @@ export function ArtifactViewer({
}) {
const { t } = useI18n();
const filename = getFileName(filepath);
const language = resolveStoredArtifactLanguage(filepath) ?? "text";
const isTabular = getTabularDelimiter(language) !== null;
const {
content,
url,
@ -86,7 +82,7 @@ export function ArtifactViewer({
</Button>
</header>
{truncated && (
{truncated && !isTabular && (
<div className="border-border bg-muted/40 flex shrink-0 items-center justify-between gap-3 border-b px-4 py-2 text-sm">
<span className="text-muted-foreground">
{t.artifactPreview.limited(
@ -106,7 +102,9 @@ export function ArtifactViewer({
</div>
)}
<main className="mx-auto min-h-0 w-full max-w-4xl flex-1 overflow-hidden">
<main
className={`mx-auto min-h-0 w-full flex-1 overflow-hidden ${isTabular ? "" : "max-w-4xl"}`}
>
{error ? (
<p className="text-muted-foreground p-6 text-sm">
{t.artifactPreview.previewFailed}
@ -119,8 +117,9 @@ export function ArtifactViewer({
) : (
<ArtifactFilePreview
content={content}
language="markdown"
scrollKey={filepath}
language={language}
scrollKey={`${threadId}:${filepath}`}
truncated={truncated}
url={url}
/>
)}

View File

@ -0,0 +1,21 @@
export const DELIMITED_INPUT_LIMIT = 1_048_576;
export const DELIMITED_RECORD_LIMIT = 202;
export const DELIMITED_COLUMN_LIMIT = 50;
export const DELIMITED_TIMEOUT_MS = 5_000;
export interface DelimitedPreviewInput {
content: string;
delimiter: "," | "\t";
truncated: boolean;
}
export interface DelimitedPreviewResult {
rows: string[][];
columnCount: number;
limited: boolean;
unevenRows: boolean;
}
export type DelimitedPreviewResponse =
| { result: DelimitedPreviewResult }
| { error: string };

View File

@ -0,0 +1,91 @@
import Papa from "papaparse";
import {
DELIMITED_COLUMN_LIMIT,
DELIMITED_RECORD_LIMIT,
type DelimitedPreviewInput,
type DelimitedPreviewResult,
} from "./delimited-preview-types";
export type { DelimitedPreviewResult } from "./delimited-preview-types";
/** Find the first record separator outside quoted fields, even in a prefix. */
function detectRecordNewline(content: string, delimiter: string) {
let quoted = false;
let fieldStart = true;
for (let index = 0; index < content.length; index++) {
const char = content[index];
if (quoted) {
if (char === '"') {
if (content[index + 1] === '"') index++;
else quoted = false;
}
continue;
}
if (char === '"' && fieldStart) quoted = true;
else if (char === "\r") return content[index + 1] === "\n" ? "\r\n" : "\r";
else if (char === "\n") return "\n";
fieldStart = char === delimiter;
}
// No complete record separator: Papa still validates quotes and the caller
// drops an unfinished terminal record when the input is truncated.
return "\n";
}
/** Parse only the bounded, already-loaded sample. Runs exclusively in a Worker. */
export function parseDelimitedPreview({
content,
delimiter,
truncated,
}: DelimitedPreviewInput): DelimitedPreviewResult {
// Papa strips BOM too; strip here so cursor and length use the same coordinates.
const input = content.startsWith("\uFEFF") ? content.slice(1) : content;
const result: DelimitedPreviewResult = {
rows: [],
columnCount: 0,
limited: truncated,
unevenRows: false,
};
let cursor = 0;
let firstWidth: number | undefined;
Papa.parse<string[]>(input, {
delimiter,
// Papa's auto-detection can count CRs inside an unfinished quoted field.
newline: detectRecordNewline(input, delimiter),
header: false,
dynamicTyping: false,
skipEmptyLines: false,
worker: false,
download: false,
step(record, parser) {
const nextCursor = record.meta.cursor;
const terminal = nextCursor === input.length;
const incompleteQuotes =
record.errors.length > 0 &&
record.errors.every((error) => error.code === "MissingQuotes");
if (record.errors.length > 0) {
if (truncated && terminal && incompleteQuotes) {
parser.abort();
return;
}
throw new Error("Invalid delimited file syntax");
}
// Papa emits an EOF empty row after a trailing record separator. Its cursor
// does not advance; actual blank records consume their separator.
if (nextCursor === cursor) return;
cursor = nextCursor;
if (truncated && terminal && !input.endsWith(record.meta.linebreak))
return;
const width = record.data.length;
firstWidth ??= width;
result.unevenRows ||= width !== firstWidth;
result.columnCount = Math.max(result.columnCount, width);
result.rows.push(record.data.slice(0, DELIMITED_COLUMN_LIMIT));
if (result.rows.length === DELIMITED_RECORD_LIMIT) {
result.limited ||= cursor < input.length;
parser.abort();
}
},
});
return result;
}

View File

@ -0,0 +1,15 @@
import { parseDelimitedPreview } from "./delimited-preview";
import {
type DelimitedPreviewInput,
type DelimitedPreviewResponse,
} from "./delimited-preview-types";
self.onmessage = (event: MessageEvent<DelimitedPreviewInput>) => {
let response: DelimitedPreviewResponse;
try {
response = { result: parseDelimitedPreview(event.data) };
} catch {
response = { error: "Unable to reliably preview this delimited file" };
}
self.postMessage(response);
};

View File

@ -1,3 +1,9 @@
export function getTabularDelimiter(
language: string | null,
): "," | "\t" | null {
return language === "csv" ? "," : language === "tsv" ? "\t" : null;
}
export type ArtifactViewMode = "code" | "preview";
type ArtifactPreviewMessage = {

View File

@ -0,0 +1,106 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
DELIMITED_INPUT_LIMIT,
DELIMITED_TIMEOUT_MS,
type DelimitedPreviewInput,
type DelimitedPreviewResponse,
type DelimitedPreviewResult,
} from "./delimited-preview-types";
interface PreviewKey extends DelimitedPreviewInput {
identity: string;
}
interface PreviewState {
key: PreviewKey;
attempt: number;
status: "loading" | "ready" | "error";
result?: DelimitedPreviewResult;
}
export function useDelimitedPreview({
content,
delimiter,
truncated,
active,
identity,
}: DelimitedPreviewInput & { active: boolean; identity: string }) {
const prefix = useMemo(() => {
let end = Math.min(content.length, DELIMITED_INPUT_LIMIT);
// Don't send half a Unicode character when the character budget cuts a pair.
if (end < content.length) {
const last = content.charCodeAt(end - 1);
if (last >= 0xd800 && last <= 0xdbff) end--;
}
return content.slice(0, end);
}, [content]);
const isPrefix = truncated || prefix.length < content.length;
const key = useMemo<PreviewKey>(
() => ({ content: prefix, delimiter, truncated: isPrefix, identity }),
[prefix, delimiter, isPrefix, identity],
);
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<PreviewState>();
const settled = useRef<PreviewState | undefined>(undefined);
const retry = useCallback(() => setAttempt((value) => value + 1), []);
useEffect(() => {
if (!active) return;
if (settled.current?.key === key && settled.current.attempt === attempt)
return;
let cancelled = false;
let worker: Worker | undefined;
const dispose = () => {
clearTimeout(timer);
worker?.terminate();
worker = undefined;
};
const finish = (response: DelimitedPreviewResponse) => {
if (cancelled) return;
cancelled = true;
dispose();
const next: PreviewState =
"result" in response
? { key, attempt, status: "ready", result: response.result }
: { key, attempt, status: "error" };
settled.current = next;
setState(next);
};
setState({ key, attempt, status: "loading" });
const timer = setTimeout(
() => finish({ error: "timeout" }),
DELIMITED_TIMEOUT_MS,
);
try {
worker = new Worker(
new URL("./delimited-preview.worker.ts", import.meta.url),
{
type: "module",
},
);
worker.onmessage = (event: MessageEvent<DelimitedPreviewResponse>) =>
finish(event.data);
worker.onerror = () => finish({ error: "worker unavailable" });
worker.onmessageerror = () => finish({ error: "invalid worker message" });
worker.postMessage({ content: prefix, delimiter, truncated: isPrefix });
} catch {
finish({ error: "worker unavailable" });
}
return () => {
cancelled = true;
dispose();
};
}, [active, key, attempt, prefix, delimiter, isPrefix]);
// Guard during render, before effects run, so another file never flashes here.
const current =
state?.key === key && state.attempt === attempt ? state : undefined;
return {
result: current?.result,
status: current?.status ?? "loading",
retry,
};
}

View File

@ -1,6 +1,7 @@
import { resolveStaticDemoArtifact } from "@/core/threads/static-demo";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { getTabularDelimiter } from "./preview";
import { urlOfArtifact } from "./utils";
/** Standalone route that renders a stored artifact with the app's own renderer. */
@ -30,7 +31,7 @@ export function resolveStoredArtifactLanguage(filepath: string) {
/**
* Target for the artifacts panel's "open in new window" action.
*
* Markdown goes to the in-app viewer route, which renders it with the same
* Markdown and tabular files go to the in-app viewer route, which renders it with the same
* components as the panel instead of handing the browser a `text/markdown`
* response it can only show as raw source. Everything else keeps the raw
* Gateway URL notably HTML/SVG, which the Gateway deliberately serves as a
@ -45,7 +46,8 @@ export function resolveArtifactOpenURL({
threadId: string;
isMock?: boolean;
}) {
if (resolveStoredArtifactLanguage(filepath) !== "markdown") {
const language = resolveStoredArtifactLanguage(filepath);
if (language !== "markdown" && getTabularDelimiter(language) === null) {
return urlOfArtifact({ filepath, threadId, isMock });
}
return buildArtifactViewerURL({ filepath, threadId, isMock });

View File

@ -128,6 +128,32 @@ export const enUS: Translations = {
missingTarget: "This link does not say which artifact to display.",
},
artifactTable: {
title: "Table preview",
header: "First row as header",
column: (index) => `Column ${index}`,
total: (count) => `${count} rows`,
sample: (count) => `Preview of first ${count} rows`,
range: (start, end, limited) =>
`${start}${end}${limited ? " of preview" : ""}`,
columnsLimited: "Showing the first 50 columns of the preview.",
uneven:
"Some rows have different numbers of fields. Missing fields are marked.",
empty: "This file is empty.",
incomplete:
"No complete records fit in this preview. View the source or download the file.",
failed:
"Unable to preview this table reliably. View the source or download the file.",
retry: "Retry preview",
previous: "Previous page",
next: "Next page",
cell: (row, column) => `View cell: row ${row}, column ${column}`,
cellValue: "Cell value",
missing: "Missing",
savedVersion:
"Opens or downloads the saved file. Your draft has not been saved.",
},
artifactArchive: {
downloadCurrent: (count) =>
`Download current versions (${count} ${count === 1 ? "file" : "files"})`,

View File

@ -106,6 +106,27 @@ export interface Translations {
missingTarget: string;
};
artifactTable: {
title: string;
header: string;
column: (index: number) => string;
total: (count: number) => string;
sample: (count: number) => string;
range: (start: number, end: number, limited: boolean) => string;
columnsLimited: string;
uneven: string;
empty: string;
incomplete: string;
failed: string;
retry: string;
previous: string;
next: string;
cell: (row: number, column: number) => string;
cellValue: string;
missing: string;
savedVersion: string;
};
artifactArchive: {
downloadCurrent: (count: number) => string;
currentVersionNotice: string;

View File

@ -125,6 +125,28 @@ export const zhCN: Translations = {
missingTarget: "该链接没有指明要展示哪个文件。",
},
artifactTable: {
title: "表格预览",
header: "首行作为表头",
column: (index) => `${index}`,
total: (count) => `${count}`,
sample: (count) => `预览前 ${count}`,
range: (start, end, limited) =>
`${limited ? "预览 " : ""}${start}${end}`,
columnsLimited: "仅展示预览样本的前 50 列。",
uneven: "部分行的字段数量不同,缺失字段已标记。",
empty: "文件为空。",
incomplete: "当前预览范围内没有完整记录,请查看源码或下载文件。",
failed: "无法可靠预览此表格,请查看源码或下载文件。",
retry: "重新预览",
previous: "上一页",
next: "下一页",
cell: (row, column) => `查看第 ${row} 行、第 ${column}`,
cellValue: "单元格内容",
missing: "缺失",
savedVersion: "打开或下载的是已保存文件,当前草稿尚未保存。",
},
artifactArchive: {
downloadCurrent: (count) => `下载当前版本(${count} 个文件)`,
currentVersionNotice:

View File

@ -12,6 +12,7 @@ const extensionMap: Record<string, string> = {
// Text
txt: "text",
csv: "csv",
tsv: "tsv",
log: "text",
conf: "text",
config: "text",

View File

@ -16,6 +16,9 @@ const AUTH_DISABLED_PERMISSIONS = [
"runs:create",
"runs:read",
"runs:cancel",
"projects:read",
"projects:write",
"projects:delete",
];
test.describe("auth-disabled contract (real backend)", () => {

View File

@ -0,0 +1,202 @@
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const threadId = "00000000-0000-0000-0000-000000003141";
const filepath = "/mnt/user-data/outputs/performance.csv";
const normal =
"ID,Note\n" +
Array.from({ length: 12_000 }, (_, i) => `${i},${"x".repeat(80)}\n`).join("");
const wide = '"",'.repeat(349_000) + '""\n';
const viewerURL = (name = filepath) =>
`/artifacts/view?path=${encodeURIComponent(name)}&thread_id=${threadId}`;
type Metrics = {
started: number;
firstTable: number | null;
beats: number;
maxHeartbeatGap: number;
longTasks: number[];
};
type MeasuredWindow = Window & { __csvMetrics: Metrics };
async function setup(page: Page, body: string) {
mockLangGraphAPI(page);
await page
.context()
.route(`**/api/threads/${threadId}/artifacts/**`, async (route) => {
// Start after the viewer has requested its content. Include cold Worker and
// table chunk loading in the reported latency, excluding page navigation.
await page.evaluate(() => {
const measured = window as unknown as MeasuredWindow;
const metrics: Metrics = {
started: performance.now(),
firstTable: null,
beats: 0,
maxHeartbeatGap: 0,
longTasks: [],
};
measured.__csvMetrics = metrics;
let previous = metrics.started;
const timer = setInterval(() => {
const now = performance.now();
if (metrics.firstTable !== null) return;
metrics.beats++;
metrics.maxHeartbeatGap = Math.max(
metrics.maxHeartbeatGap,
now - previous,
);
previous = now;
}, 16);
const observer = new MutationObserver(() => {
if (
document.querySelector(
'[data-testid="artifact-table-preview"] table',
)
) {
metrics.firstTable ??= performance.now();
observer.disconnect();
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
const longTasks = new PerformanceObserver((entries) => {
for (const entry of entries.getEntries()) {
if (
entry.startTime >= metrics.started &&
(metrics.firstTable === null ||
entry.startTime <= metrics.firstTable)
)
metrics.longTasks.push(entry.duration);
}
});
longTasks.observe({ type: "longtask", buffered: false });
window.addEventListener(
"pagehide",
() => {
clearInterval(timer);
observer.disconnect();
longTasks.disconnect();
},
{ once: true },
);
});
await route.fulfill({
status: 200,
contentType: "text/csv",
body,
});
});
}
async function report(page: Page, name: string, bytes: number) {
const metrics = await page.evaluate(
() => (window as unknown as MeasuredWindow).__csvMetrics,
);
const report = {
sample: name,
bytes,
firstTableMs:
metrics.firstTable === null ? null : metrics.firstTable - metrics.started,
heartbeatCount: metrics.beats,
maxHeartbeatGapMs: metrics.maxHeartbeatGap,
longTaskCount: metrics.longTasks.length,
longTaskDurationsMs: metrics.longTasks,
};
console.log("CSV browser performance", JSON.stringify(report));
await test.info().attach(`${name}-performance`, {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
return report;
}
test("measures a cold real Worker with a normal approximately 1 MiB CSV", async ({
page,
}) => {
test.setTimeout(60_000);
await setup(page, normal);
await page.goto(viewerURL());
await expect(page.getByRole("table")).toBeVisible({ timeout: 30_000 });
await expect(
page.getByText("Preview of first 200 rows", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("cell", { name: "0", exact: true }),
).toBeVisible();
await expect(page.getByRole("row")).toHaveCount(51);
const metrics = await report(page, "normal-1mib", Buffer.byteLength(normal));
expect(metrics.firstTableMs).not.toBeNull();
// Report actual latency/long tasks, without asserting hardware-specific targets.
await page.getByRole("button", { name: "Next page" }).click();
await expect(page.getByText(/^51100/)).toBeVisible();
});
test("keeps the main thread responsive during an extremely wide quoted record", async ({
page,
}) => {
test.setTimeout(60_000);
await setup(page, wide);
const workerStarted = page.waitForEvent("worker");
await page.goto(viewerURL());
await workerStarted;
// An actual DOM interaction must finish while the real parser runs in its
// Worker. This probe is deliberately independent of the chat composer.
await page.evaluate(() => {
const button = document.createElement("button");
button.textContent = "Responsiveness probe";
button.onclick = () => {
button.textContent = "Probe acknowledged";
};
document.body.prepend(button);
});
await page.getByRole("button", { name: "Responsiveness probe" }).click();
await expect(
page.getByRole("button", { name: "Probe acknowledged" }),
).toBeVisible();
await expect
.poll(async () =>
page.evaluate(
() => (window as unknown as MeasuredWindow).__csvMetrics.beats,
),
)
.toBeGreaterThan(2);
await expect(
page
.getByRole("table")
.or(page.getByText(/Unable to preview this table reliably/)),
).toBeVisible({ timeout: 15_000 });
await report(page, "wide-quoted-1mib", Buffer.byteLength(wide));
});
test("can leave a pathological parse and open another real Worker result", async ({
page,
}) => {
test.setTimeout(60_000);
await setup(page, wide);
const workerStarted = page.waitForEvent("worker");
await page.goto(viewerURL());
const worker = await workerStarted;
let closed = false;
worker.on("close", () => {
closed = true;
});
await page
.context()
.route(`**/api/threads/${threadId}/artifacts/**/replacement.csv`, (route) =>
route.fulfill({
status: 200,
contentType: "text/csv",
body: "ID,Note\nreplacement,ready\n",
}),
);
await page.goto(viewerURL("/mnt/user-data/outputs/replacement.csv"));
await expect(
page.getByRole("cell", { name: "replacement", exact: true }),
).toBeVisible({ timeout: 20_000 });
await expect.poll(() => closed).toBe(true);
await expect(page.getByRole("row")).toHaveCount(2);
});

View File

@ -0,0 +1,248 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const threadId = "00000000-0000-0000-0000-000000003140";
const csvPath = "/mnt/user-data/outputs/monthly-revenue.csv";
const tsvPath = "/mnt/user-data/outputs/regional-summary.tsv";
const headers = ["Order ID", "Region", "Revenue (CNY)", "Growth", "Notes"];
const records = Array.from({ length: 65 }, (_, index) => [
String(index + 1).padStart(5, "0"),
["华东 · Shanghai", "华南 · Shenzhen", "华北 · Beijing"][index % 3]!,
String(128600 + index * 1350),
`${8 + (index % 9)}.2%`,
index === 0 ? "Includes online orders, excludes refunds" : "Reviewed",
]);
const csv = [headers, ...records]
.map((row) => row.map((cell) => `"${cell.replaceAll('"', '""')}"`).join(","))
.join("\r\n");
async function setup(page: Page, body = csv, truncated = false) {
mockLangGraphAPI(page, {
threads: [
{
thread_id: threadId,
title: "Monthly revenue review",
artifacts: [csvPath, tsvPath],
messages: [
{
type: "human",
id: "request",
content:
"Analyze monthly revenue by region and export the detailed results as CSV.",
},
{
type: "ai",
id: "answer",
content:
"The regional revenue analysis is ready. The detailed export preserves order IDs and includes revenue, growth, and review notes. Open the CSV to inspect the results before the next analysis.",
tool_calls: [
{
id: "present",
name: "present_files",
args: { filepaths: [csvPath, tsvPath] },
},
],
},
],
},
],
});
for (const suffix of [
"token-usage",
"mcp-tasks**",
"runs/*/artifacts/archive",
])
await page.route(`**/api/threads/${threadId}/${suffix}`, (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: "{}",
}),
);
await page
.context()
.route(`**/api/threads/${threadId}/artifacts/**`, (route) => {
const isTsv = route.request().url().endsWith(".tsv");
const content = isTsv
? "Region\tRevenue\n华东\t128600\n华南\t132500"
: body;
return route.fulfill({
status: truncated ? 206 : 200,
contentType: "text/plain",
headers: {
ETag: `"${"a".repeat(64)}"`,
...(truncated
? {
"Content-Range": `bytes 0-${Buffer.byteLength(content) - 1}/2000000`,
}
: {}),
},
body: content,
});
});
await page.goto(`/workspace/chats/${threadId}`);
await page.getByText("monthly-revenue.csv").first().click();
return page.locator("#artifacts");
}
test("previews real Worker results, paginates, toggles source, and opens a wide viewer", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 980 });
const panel = await setup(page);
await expect(panel.getByRole("table")).toBeVisible({ timeout: 20000 });
await expect(
panel.getByRole("cell", { name: "00001", exact: true }),
).toBeVisible();
await expect(panel.getByText("65 rows", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "Next page" }).click();
await expect(panel.getByText("5165", { exact: true })).toBeVisible();
await panel.getByRole("checkbox", { name: "First row as header" }).uncheck();
await panel.getByRole("radio", { name: "View source" }).click();
await expect(panel.locator(".cm-editor")).toBeVisible();
await panel.getByRole("radio", { name: "Table preview" }).click();
await expect(
panel.getByRole("checkbox", { name: "First row as header" }),
).not.toBeChecked();
await panel.getByRole("checkbox", { name: "First row as header" }).check();
const viewerPromise = page.context().waitForEvent("page");
await panel.getByRole("button", { name: "Open in new window" }).click();
const viewer = await viewerPromise;
await expect(viewer.getByRole("table")).toBeVisible();
await expect(
viewer.getByRole("cell", {
name: "Includes online orders, excludes refunds",
}),
).toBeVisible();
if (process.env.DEERFLOW_SCREENSHOT_DIR) {
await mkdir(process.env.DEERFLOW_SCREENSHOT_DIR, { recursive: true });
await page.bringToFront();
await expect(panel.getByRole("table")).toBeVisible();
await page.screenshot({
path: path.join(
process.env.DEERFLOW_SCREENSHOT_DIR,
"csv-preview-chat.png",
),
});
await viewer.screenshot({
path: path.join(
process.env.DEERFLOW_SCREENSHOT_DIR,
"csv-preview-window.png",
),
});
}
await viewer.setViewportSize({ width: 390, height: 844 });
await expect(viewer.getByRole("table")).toBeVisible();
expect(
await viewer.evaluate(() => document.documentElement.scrollWidth),
).toBeLessThanOrEqual(390);
if (process.env.DEERFLOW_SCREENSHOT_DIR) {
await viewer.screenshot({
path: path.join(
process.env.DEERFLOW_SCREENSHOT_DIR,
"csv-preview-mobile.png",
),
});
}
await viewer.close();
await panel.getByRole("combobox").click();
await page.getByRole("option", { name: "regional-summary.tsv" }).click();
await expect(
panel.getByRole("cell", { name: "华东", exact: true }),
).toBeVisible();
await expect(
panel.getByRole("cell", { name: "00001", exact: true }),
).toHaveCount(0);
});
test("discards a truncated final record and does not fetch the full file in table mode", async ({
page,
}) => {
const panel = await setup(
page,
'ID,Note\n00123,complete\n002,"unfinished\nfield',
true,
);
await expect(panel.getByRole("table")).toBeVisible({ timeout: 20000 });
await expect(
panel.getByText("Preview of first 1 rows", { exact: true }),
).toBeVisible();
await expect(panel.getByText("00123", { exact: true })).toBeVisible();
await expect(panel.getByText("002", { exact: true })).toHaveCount(0);
await expect(
panel.getByRole("button", { name: "Load full file" }),
).toHaveCount(0);
});
test("rejects malformed quotes and keeps source available", async ({
page,
}) => {
const panel = await setup(page, 'ID,Note\n00123,"bad"quote');
await expect(
panel.getByText(/Unable to preview this table reliably/),
).toBeVisible({ timeout: 20000 });
await panel.getByRole("radio", { name: "View source" }).click();
await expect(panel.locator(".cm-editor")).toBeVisible();
});
test("previews an unsaved draft and preserves it after a save conflict", async ({
page,
}) => {
const panel = await setup(page);
await expect(panel.getByRole("table")).toBeVisible();
await panel.getByRole("button", { name: "Edit", exact: true }).click();
const editor = panel.locator('.cm-content[contenteditable="true"]');
await editor.fill("ID,Note\n00999,draft value");
await panel.getByRole("radio", { name: "Table preview" }).click();
await expect(
panel.getByRole("cell", { name: "00999", exact: true }),
).toBeVisible();
await page.route(`**/api/threads/${threadId}/artifacts/**`, (route) => {
if (route.request().method() === "PUT")
return route.fulfill({
status: 412,
contentType: "application/json",
body: JSON.stringify({ detail: "The file changed on the server" }),
});
return route.fallback();
});
await panel.getByRole("button", { name: "Save", exact: true }).click();
await expect(
panel.getByText("Changed remotely", { exact: true }),
).toBeVisible();
await expect(
panel.getByRole("button", { name: "Save", exact: true }),
).toBeDisabled();
await expect(
panel.getByRole("cell", { name: "draft value", exact: true }),
).toBeVisible();
});
test("retains header preference after loading the full source", async ({
page,
}) => {
const panel = await setup(page, "ID,Note\n00123,complete\n002,partial", true);
await expect(panel.getByRole("table")).toBeVisible();
await panel.getByRole("checkbox", { name: "First row as header" }).uncheck();
await panel.getByRole("radio", { name: "View source" }).click();
await page.route(`**/api/threads/${threadId}/artifacts/**`, (route) =>
route.fulfill({
status: 200,
contentType: "text/plain",
body: "ID,Note\n00123,complete\n002,complete",
}),
);
await panel.getByRole("button", { name: "Load full file" }).click();
await expect(panel.locator(".cm-editor")).toBeVisible();
await panel.getByRole("radio", { name: "Table preview" }).click();
await expect(
panel.getByRole("checkbox", { name: "First row as header" }),
).not.toBeChecked();
await expect(
panel.getByRole("cell", { name: "002", exact: true }),
).toBeVisible();
});

View File

@ -96,7 +96,7 @@ test.describe("Artifact viewer window", () => {
await viewer.waitForLoadState("domcontentloaded");
await expect
.poll(() => new URL(viewer.url()).pathname)
.poll(() => new URL(viewer.url(), "http://localhost").pathname)
.toBe("/artifacts/view");
const params = new URL(viewer.url()).searchParams;
expect(params.get("path")).toBe(MARKDOWN_ARTIFACT_PATH);
@ -157,7 +157,7 @@ test.describe("Artifact viewer window", () => {
const opened = await openedPromise;
await expect
.poll(() => new URL(opened.url()).pathname)
.poll(() => new URL(opened.url(), "http://localhost").pathname)
.toBe(
`/api/threads/${HTML_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.html`,
);

View File

@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
const state = rs.hoisted(() => ({
result: {
rows: [] as string[][],
columnCount: 2,
limited: false,
unevenRows: false,
},
status: "ready",
retry: rs.fn(),
}));
rs.mock("@/core/artifacts/use-delimited-preview", () => ({
useDelimitedPreview: () => state,
}));
import { ArtifactTablePreview } from "@/components/workspace/artifacts/artifact-table-preview";
import { I18nProvider } from "@/core/i18n/context";
function mount() {
return render(
<I18nProvider initialLocale="en-US">
<ArtifactTablePreview
content="fixture"
delimiter=","
truncated={false}
identity="file-1"
/>
</I18nProvider>,
);
}
afterEach(cleanup);
beforeEach(() => {
state.status = "ready";
state.result = {
rows: [
["ID", "Note"],
["00123", "<script>alert(1)</script>"],
],
columnCount: 2,
limited: false,
unevenRows: false,
};
});
describe("ArtifactTablePreview", () => {
it("preserves literal data and lets the first row become data", () => {
const { container } = mount();
expect(screen.getByRole("columnheader", { name: "ID" })).toBeTruthy();
expect(screen.getByText("00123")).toBeTruthy();
expect(container.querySelector("script")).toBeNull();
fireEvent.click(
screen.getByRole("checkbox", { name: "First row as header" }),
);
expect(screen.getByRole("columnheader", { name: "Column 1" })).toBeTruthy();
expect(screen.getByRole("cell", { name: "ID" })).toBeTruthy();
});
it("paginates locally and labels bounded samples honestly", () => {
state.result.rows = [
["ID", "Note"],
...Array.from({ length: 202 }, (_, i) => [String(i + 1), "note"]),
];
mount();
expect(screen.getByText("Preview of first 200 rows")).toBeTruthy();
expect(screen.getByText("150 of preview")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
expect(screen.getByText("51100 of preview")).toBeTruthy();
expect(screen.getByRole("cell", { name: "51" })).toBeTruthy();
expect(screen.queryByRole("cell", { name: "1" })).toBeNull();
});
it("keeps long fields available without enlarging every row", () => {
const longText = "first line\n" + "x".repeat(200);
state.result.rows = [
["ID", "Note"],
["00123", longText],
];
mount();
fireEvent.click(
screen.getByRole("button", { name: "View cell: row 1, column 2" }),
);
expect(
screen.getByRole<HTMLTextAreaElement>("textbox", {
name: "Cell value",
}).value,
).toBe(longText);
});
it("offers an explicit retry without displaying stale data on failure", () => {
state.status = "error";
mount();
expect(screen.queryByRole("table")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Retry preview" }));
expect(state.retry).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,115 @@
import { describe, expect, it } from "@rstest/core";
import { parseDelimitedPreview } from "@/core/artifacts/delimited-preview";
const parse = (
content: string,
truncated = false,
delimiter: "," | "\t" = ",",
) => parseDelimitedPreview({ content, truncated, delimiter });
describe("delimited preview parser", () => {
it("preserves strings, BOM, duplicate/empty headings and quoted fields", () => {
expect(
parse(
'\uFEFF名字,,名字\r\n001,"a,b","say ""hi"""\r\n12345678901234567890,"多\n行", true ',
).rows,
).toEqual([
["名字", "", "名字"],
["001", "a,b", 'say "hi"'],
["12345678901234567890", "多\n行", " true "],
]);
});
it.each(["\n", "\r\n", "\r"])(
"preserves real empty records with %j",
(newline) => {
expect(parse(`a${newline}${newline}b${newline}`).rows).toEqual([
["a"],
[""],
["b"],
]);
expect(parse(newline).rows).toEqual([[""]]);
},
);
it("distinguishes an empty file from empty fields and missing fields", () => {
expect(parse("").rows).toEqual([]);
expect(parse("a,b\n,\nx")).toEqual({
rows: [["a", "b"], ["", ""], ["x"]],
columnCount: 2,
unevenRows: true,
limited: false,
});
});
it("uses the explicit TSV delimiter", () => {
expect(parse('a\tb\n"x\ty"\t1,000', false, "\t").rows).toEqual([
["a", "b"],
["x\ty", "1,000"],
]);
});
it("drops only incomplete terminal prefix records", () => {
expect(parse('a,b\n"multi\nline",tail', true).rows).toEqual([["a", "b"]]);
expect(parse('a,b\n"multi\n', true).rows).toEqual([["a", "b"]]);
expect(parse('a,b\n"multi\nline",tail\n', true).rows).toEqual([
["a", "b"],
["multi\nline", "tail"],
]);
expect(parse("abc", true).rows).toEqual([]);
expect(parse("\uFEFFa\nb\n", true).rows).toEqual([["a"], ["b"]]);
expect(parse("a\n\n", true).rows).toEqual([["a"], [""]]);
});
it("fails malformed quote syntax even in a prefix", () => {
expect(() => parse('a\n"unfinished')).toThrow();
expect(() => parse('a\n"bad"x\n', true)).toThrow();
expect(() => parse('a\n"bad"x\n')).toThrow();
});
it.each(["," as const, "\t" as const])(
"ignores embedded CRs in an incomplete quoted field for delimiter %j",
(delimiter) => {
const prefix = `ID${delimiter}Note\r\n001${delimiter}good\r\n"hello\rworld\rthird`;
const content = prefix + "x".repeat(1_048_576 - prefix.length);
expect(parse(content, true, delimiter)).toEqual({
rows: [
["ID", "Note"],
["001", "good"],
],
columnCount: 2,
limited: true,
unevenRows: false,
});
},
);
it.each(["\r\n", "\n", "\r"])(
"finds %j record boundaries after a quoted multiline first field",
(newline) => {
expect(
parse(
`"hello\rworld\nwith ""quotes""",Note${newline}001,good${newline}`,
).rows,
).toEqual([
['hello\rworld\nwith "quotes"', "Note"],
["001", "good"],
]);
},
);
it("does not treat a literal quote inside an unquoted field as an opening quote", () => {
expect(
parse('inch",Note\r\n001,good\r\n"unfinished\ra\rb', true).rows,
).toEqual([
['inch"', "Note"],
["001", "good"],
]);
});
it("bounds records and fields while reporting actual sample width", () => {
const result = parse(
Array.from({ length: 300 }, () =>
Array.from({ length: 60 }, (_, i) => `${i}`).join(","),
).join("\n"),
);
expect(result.rows).toHaveLength(202);
expect(result.rows.every((row) => row.length === 50)).toBe(true);
expect(result.columnCount).toBe(60);
expect(result.limited).toBe(true);
expect(result.unevenRows).toBe(false);
});
it("does not validate beyond the bounded record sample", () => {
expect(parse(`${"a\n".repeat(202)}"broken`).rows).toHaveLength(202);
});
});

View File

@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { act, cleanup, renderHook } from "@testing-library/react";
import { useDelimitedPreview } from "@/core/artifacts/use-delimited-preview";
class FakeWorker {
static instances: FakeWorker[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
onmessageerror: (() => void) | null = null;
postMessage = rs.fn();
terminate = rs.fn();
constructor() {
FakeWorker.instances.push(this);
}
complete() {
this.onmessage?.({ data: { result: sample } } as MessageEvent);
}
}
const sample = {
rows: [["a"]],
columnCount: 1,
limited: false,
unevenRows: false,
};
const input = {
content: "a",
delimiter: "," as const,
truncated: false,
active: true,
identity: "file-a",
};
const lastWorker = () => FakeWorker.instances.at(-1)!;
describe("useDelimitedPreview", () => {
beforeEach(() => {
FakeWorker.instances = [];
rs.stubGlobal("Worker", FakeWorker);
rs.useFakeTimers();
});
afterEach(() => {
cleanup();
rs.useRealTimers();
rs.unstubAllGlobals();
});
it("starts only while active, reuses success, and ignores unrelated renders", () => {
const { result, rerender } = renderHook(useDelimitedPreview, {
initialProps: { ...input, active: false },
});
expect(FakeWorker.instances).toHaveLength(0);
rerender(input);
expect(result.current.status).toBe("loading");
act(() => lastWorker().complete());
expect(result.current.result).toEqual(sample);
expect(lastWorker().terminate).toHaveBeenCalledTimes(1);
rerender({ ...input });
rerender({ ...input, active: false });
rerender(input);
expect(FakeWorker.instances).toHaveLength(1);
expect(result.current.status).toBe("ready");
});
it("terminates on input changes and rejects late responses", () => {
const { result, rerender } = renderHook(useDelimitedPreview, {
initialProps: input,
});
const old = lastWorker();
const late = old.onmessage!;
rerender({ ...input, identity: "file-b" });
expect(old.terminate).toHaveBeenCalled();
expect(result.current.result).toBeUndefined();
act(() => late({ data: { result: sample } } as MessageEvent));
expect(result.current.result).toBeUndefined();
act(() => lastWorker().complete());
expect(result.current.result).toEqual(sample);
rerender({ ...input, identity: "file-c" });
expect(result.current.result).toBeUndefined();
});
it("cancels pending work when hidden and on unmount", () => {
const { rerender, unmount } = renderHook(useDelimitedPreview, {
initialProps: input,
});
const first = lastWorker();
rerender({ ...input, active: false });
expect(first.terminate).toHaveBeenCalled();
rerender(input);
const second = lastWorker();
expect(second).not.toBe(first);
unmount();
expect(second.terminate).toHaveBeenCalled();
});
it("times out after 5 seconds and retries only on explicit request", () => {
const { result, rerender } = renderHook(useDelimitedPreview, {
initialProps: input,
});
act(() => {
rs.advanceTimersByTime(4999);
});
expect(result.current.status).toBe("loading");
act(() => {
rs.advanceTimersByTime(1);
});
expect(result.current.status).toBe("error");
expect(lastWorker().terminate).toHaveBeenCalled();
rerender({ ...input, active: false });
rerender(input);
expect(FakeWorker.instances).toHaveLength(1);
act(() => result.current.retry());
expect(FakeWorker.instances).toHaveLength(2);
act(() => lastWorker().complete());
expect(result.current.status).toBe("ready");
});
it("surfaces unavailable workers and worker syntax/load/message errors", () => {
const { result } = renderHook(useDelimitedPreview, { initialProps: input });
act(() => lastWorker().onerror?.());
expect(result.current.status).toBe("error");
act(() => result.current.retry());
act(() =>
lastWorker().onmessage?.({ data: { error: "syntax" } } as MessageEvent),
);
expect(result.current.status).toBe("error");
act(() => result.current.retry());
act(() => lastWorker().onmessageerror?.());
expect(result.current.status).toBe("error");
rs.stubGlobal("Worker", undefined);
act(() => result.current.retry());
expect(result.current.status).toBe("error");
});
it("invalidates a result on content, delimiter, or truncation changes", () => {
const { result, rerender } = renderHook(useDelimitedPreview, {
initialProps: { ...input, delimiter: input.delimiter as "," | "\t" },
});
act(() => lastWorker().complete());
rerender({ ...input, content: "b" });
expect(result.current.result).toBeUndefined();
act(() => lastWorker().complete());
rerender({ ...input, content: "b", delimiter: "\t" });
expect(result.current.result).toBeUndefined();
act(() => lastWorker().complete());
rerender({ ...input, content: "b", delimiter: "\t", truncated: true });
expect(result.current.result).toBeUndefined();
expect(FakeWorker.instances).toHaveLength(4);
});
it("caps input before posting without splitting a surrogate pair", () => {
renderHook(useDelimitedPreview, {
initialProps: { ...input, content: "a".repeat(1_048_575) + "😀tail" },
});
expect(lastWorker().postMessage).toHaveBeenCalledWith({
content: "a".repeat(1_048_575),
delimiter: ",",
truncated: true,
});
});
});

View File

@ -250,3 +250,15 @@ describe("requiresAuthenticatedViewer", () => {
).toBe(true);
});
});
test.each(["csv", "tsv", "CSV"])(
"opens %s data in the shared viewer",
(extension) => {
expect(
resolveArtifactOpenURL({
filepath: `/mnt/user-data/outputs/data.${extension}`,
threadId,
}),
).toContain(ARTIFACT_VIEWER_ROUTE);
},
);