@tangle-network/ui 11.6.0 → 11.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +43 -0
  3. package/dist/chat.d.ts +18 -8
  4. package/dist/chat.js +9 -7
  5. package/dist/chunk-4CLN43XT.js +45 -0
  6. package/dist/{chunk-AZWDI2JG.js → chunk-565EATZZ.js} +1 -1
  7. package/dist/{chunk-XROQW5J7.js → chunk-BWOUSEHM.js} +1 -1
  8. package/dist/{chunk-J2G7KCYH.js → chunk-KNYSKNZX.js} +113 -69
  9. package/dist/chunk-L57WRIND.js +59 -0
  10. package/dist/{chunk-ULDNFLIM.js → chunk-ND3ZFXQK.js} +11 -58
  11. package/dist/{chunk-AAUNOHVL.js → chunk-NWECTSOH.js} +1 -1
  12. package/dist/{chunk-R5GVQDL3.js → chunk-OXZRCXJK.js} +9 -7
  13. package/dist/{chunk-XPYPZMYT.js → chunk-Q7ZR6LSD.js} +1 -1
  14. package/dist/{chunk-RKC3GXHE.js → chunk-X53RZSX6.js} +99 -104
  15. package/dist/{chunk-PLWRAS5Z.js → chunk-XGOWUDDW.js} +270 -106
  16. package/dist/{chunk-ZRVH3WCA.js → chunk-XU3HB5QL.js} +21 -1
  17. package/dist/chunk-YTO5V6QS.js +19 -0
  18. package/dist/files.d.ts +41 -12
  19. package/dist/files.js +13 -5
  20. package/dist/hooks.js +9 -7
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +37 -24
  23. package/dist/primitives.d.ts +15 -1
  24. package/dist/primitives.js +5 -1
  25. package/dist/run.d.ts +22 -17
  26. package/dist/run.js +11 -8
  27. package/dist/sdk-hooks.js +9 -7
  28. package/dist/tool-previews.js +2 -2
  29. package/dist/utils.d.ts +6 -0
  30. package/dist/utils.js +6 -4
  31. package/package.json +1 -1
  32. package/src/chat/agent-timeline.stories.tsx +163 -10
  33. package/src/chat/agent-timeline.test.tsx +114 -7
  34. package/src/chat/agent-timeline.tsx +120 -158
  35. package/src/chat/chat-container.test.tsx +59 -0
  36. package/src/chat/user-message.test.tsx +33 -0
  37. package/src/chat/user-message.tsx +13 -8
  38. package/src/files/file-artifact-pane.stories.tsx +149 -0
  39. package/src/files/file-artifact-pane.test.tsx +192 -0
  40. package/src/files/file-artifact-pane.tsx +6 -1
  41. package/src/files/file-format.ts +42 -4
  42. package/src/files/file-preview-kind.test.ts +109 -0
  43. package/src/files/file-preview.tsx +315 -132
  44. package/src/files/index.ts +8 -1
  45. package/src/primitives/index.ts +3 -0
  46. package/src/primitives/text-shimmer.test.tsx +34 -0
  47. package/src/primitives/text-shimmer.tsx +82 -0
  48. package/src/run/inline-thinking-item.stories.tsx +2 -2
  49. package/src/run/inline-thinking-item.tsx +2 -2
  50. package/src/run/inline-tool-item.test.tsx +76 -0
  51. package/src/run/inline-tool-item.tsx +9 -7
  52. package/src/run/run-group.tsx +4 -3
  53. package/src/run/run-row-shell.test.tsx +74 -4
  54. package/src/run/run-row-shell.tsx +81 -53
  55. package/src/run/tool-call-step.tsx +9 -7
  56. package/src/utils/tool-display.test.ts +53 -0
  57. package/src/utils/tool-display.ts +16 -15
  58. package/dist/chunk-LQS34IGP.js +0 -0
  59. /package/dist/{chunk-EJ5NBW7V.js → chunk-LDLOJKPC.js} +0 -0
@@ -0,0 +1,149 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { FileArtifactPane } from "./file-artifact-pane";
3
+
4
+ const TS_SOURCE = `interface SandboxConfig {
5
+ model: string
6
+ timeout: number
7
+ env: Record<string, string>
8
+ }
9
+
10
+ // Create a new sandbox and stream its output.
11
+ export async function run(config: SandboxConfig) {
12
+ const sandbox = await Sandbox.create(config)
13
+ for await (const chunk of sandbox.stream()) {
14
+ process.stdout.write(chunk)
15
+ }
16
+ }`;
17
+
18
+ const MARKDOWN_SOURCE = `# Run report
19
+
20
+ The agent finished **3 of 3** tasks.
21
+
22
+ | Task | Result |
23
+ | --- | --- |
24
+ | Build | pass |
25
+ | Test | 315 / 315 |
26
+ | Deploy | staging |
27
+
28
+ \`\`\`ts
29
+ export const ready = true
30
+ \`\`\`
31
+ `;
32
+
33
+ // A transparent SVG, larger than the pane, so the checker ground shows through
34
+ // and the fit-to-pane / natural-size toggle has a visible effect.
35
+ const IMAGE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 640 360">
36
+ <defs>
37
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
38
+ <stop offset="0" stop-color="#8263FF"/>
39
+ <stop offset="1" stop-color="#10b981"/>
40
+ </linearGradient>
41
+ </defs>
42
+ <circle cx="220" cy="180" r="120" fill="url(#g)"/>
43
+ <rect x="360" y="80" width="200" height="200" rx="28" fill="#FFB347" opacity="0.9"/>
44
+ <text x="320" y="330" text-anchor="middle" font-family="sans-serif" font-size="22" fill="#E8E6F6">chart.svg · 1600 × 900</text>
45
+ </svg>`;
46
+ const IMAGE_URL = `data:image/svg+xml;utf8,${encodeURIComponent(IMAGE_SVG)}`;
47
+
48
+ // One-page PDF (Helvetica text and a rule) so the viewer has real content.
49
+ const PDF_URL =
50
+ "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvQ29udGVudHMgNCAwIFIgL1Jlc291cmNlcyA8PCAvRm9udCA8PCAvRjEgNSAwIFIgPj4gPj4gPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCAyODkgPj4Kc3RyZWFtCkJUIC9GMSAyMiBUZiA1NiA3MDAgVGQgKEZpbGVBcnRpZmFjdFBhbmUpIFRqIEVUCkJUIC9GMSAxMiBUZiA1NiA2NzIgVGQgKFBERiBwcmV2aWV3IHJlbmRlcnMgdGhlIGJsb2JVcmwgYXQgZnVsbCBwYW5lIGhlaWdodC4pIFRqIEVUCkJUIC9GMSAxMiBUZiA1NiA2NTIgVGQgKEV2ZXJ5IGNvbnN1bWVyIG9mIEB0YW5nbGUtbmV0d29yay91aS9maWxlcyBnZXRzIHRoaXMgZm9yIGZyZWUuKSBUaiBFVAo1NiA2MjAgbSA1NTYgNjIwIGwgUwpCVCAvRjEgMTAgVGYgNTYgNTk2IFRkIChQYWdlIDEgb2YgMSkgVGogRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDA1ODEgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo2NTEKJSVFT0YK";
51
+
52
+ const CSV_SOURCE = [
53
+ "id,name,role,note,active",
54
+ ...Array.from({ length: 520 }, (_, i) =>
55
+ [
56
+ i + 1,
57
+ `"Lovelace, Ada ${i + 1}"`,
58
+ ["engineer", "maintainer", "architect"][i % 3],
59
+ `"Handles ""quoted"" values, and commas"`,
60
+ i % 4 === 0 ? "false" : "true",
61
+ ].join(","),
62
+ ),
63
+ ].join("\n");
64
+
65
+ // CC0 sample media hosted by MDN; the story only needs a playable src.
66
+ const VIDEO_URL = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4";
67
+ const AUDIO_URL = "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3";
68
+
69
+ const meta: Meta<typeof FileArtifactPane> = {
70
+ title: "Files/FileArtifactPane",
71
+ component: FileArtifactPane,
72
+ parameters: { layout: "fullscreen" },
73
+ argTypes: {
74
+ onDownload: { action: "download" },
75
+ onClose: { action: "close" },
76
+ },
77
+ decorators: [
78
+ (Story) => (
79
+ <div className="h-[560px] w-[760px] overflow-hidden rounded-xl border border-border bg-card text-foreground">
80
+ <Story />
81
+ </div>
82
+ ),
83
+ ],
84
+ };
85
+
86
+ export default meta;
87
+ type Story = StoryObj<typeof FileArtifactPane>;
88
+
89
+ export const Code: Story = {
90
+ args: { filename: "run.ts", path: "src/run.ts", content: TS_SOURCE },
91
+ };
92
+
93
+ export const Image: Story = {
94
+ args: {
95
+ filename: "chart.svg",
96
+ path: "out/chart.svg",
97
+ blobUrl: IMAGE_URL,
98
+ mimeType: "image/svg+xml",
99
+ size: 1_204,
100
+ },
101
+ };
102
+
103
+ export const Pdf: Story = {
104
+ name: "PDF",
105
+ args: {
106
+ filename: "report.pdf",
107
+ path: "out/report.pdf",
108
+ blobUrl: PDF_URL,
109
+ mimeType: "application/pdf",
110
+ size: 834,
111
+ },
112
+ };
113
+
114
+ export const Csv: Story = {
115
+ name: "CSV (520 rows, capped at 500)",
116
+ args: { filename: "people.csv", path: "data/people.csv", content: CSV_SOURCE },
117
+ };
118
+
119
+ export const Video: Story = {
120
+ args: {
121
+ filename: "flower.mp4",
122
+ path: "media/flower.mp4",
123
+ blobUrl: VIDEO_URL,
124
+ mimeType: "video/mp4",
125
+ },
126
+ };
127
+
128
+ export const Audio: Story = {
129
+ args: {
130
+ filename: "t-rex-roar.mp3",
131
+ path: "media/t-rex-roar.mp3",
132
+ blobUrl: AUDIO_URL,
133
+ mimeType: "audio/mpeg",
134
+ },
135
+ };
136
+
137
+ export const Spreadsheet: Story = {
138
+ args: { filename: "ledger.xlsx", path: "finance/ledger.xlsx", size: 48_213 },
139
+ };
140
+
141
+ export const MarkdownProse: Story = {
142
+ name: "Markdown",
143
+ args: { filename: "REPORT.md", path: "out/REPORT.md", content: MARKDOWN_SOURCE },
144
+ };
145
+
146
+ export const BinaryWithoutLink: Story = {
147
+ name: "Binary without a download link",
148
+ args: { filename: "weights.bin", path: "model/weights.bin", size: 402_653_184 },
149
+ };
@@ -0,0 +1,192 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import { FileArtifactPane } from "./file-artifact-pane";
4
+ import { CSV_PREVIEW_ROW_LIMIT, parseCsv } from "./file-preview";
5
+
6
+ const BLOB_URL = "blob:https://app.tangle.tools/1f2e3d4c";
7
+
8
+ function csvWithRows(rowCount: number): string {
9
+ const rows = Array.from({ length: rowCount }, (_, i) => `"Ada, Countess ${i}",x,${i}`);
10
+ return ["name,note,n", ...rows].join("\n");
11
+ }
12
+
13
+ describe("FileArtifactPane previews", () => {
14
+ it("renders an image from blobUrl and toggles natural size on click", () => {
15
+ const { container } = render(
16
+ <FileArtifactPane filename="chart.png" blobUrl={BLOB_URL} mimeType="image/png" />,
17
+ );
18
+
19
+ const img = container.querySelector("img");
20
+ expect(img).not.toBeNull();
21
+ expect(img).toHaveAttribute("src", BLOB_URL);
22
+ expect(img).toHaveAttribute("alt", "chart.png");
23
+ expect(img).toHaveClass("object-contain");
24
+
25
+ const toggle = screen.getByRole("button", { name: "Show image at natural size" });
26
+ expect(toggle).toHaveAttribute("aria-pressed", "false");
27
+ fireEvent.click(toggle);
28
+ expect(screen.getByRole("button", { name: "Fit image to pane" })).toHaveAttribute(
29
+ "aria-pressed",
30
+ "true",
31
+ );
32
+ expect(container.querySelector("img")).toHaveClass("max-w-none");
33
+ });
34
+
35
+ it("renders a PDF as an <object> of the blobUrl", () => {
36
+ const { container } = render(
37
+ <FileArtifactPane filename="report.pdf" blobUrl={BLOB_URL} mimeType="application/pdf" />,
38
+ );
39
+
40
+ const viewer = container.querySelector("object");
41
+ expect(viewer).not.toBeNull();
42
+ expect(viewer).toHaveAttribute("data", BLOB_URL);
43
+ expect(viewer).toHaveAttribute("type", "application/pdf");
44
+ expect(viewer).toHaveAttribute("title", "report.pdf");
45
+ });
46
+
47
+ it("renders video and audio with native controls", () => {
48
+ const video = render(<FileArtifactPane filename="demo.mp4" blobUrl={BLOB_URL} />);
49
+ const videoElement = video.container.querySelector("video");
50
+ expect(videoElement).not.toBeNull();
51
+ expect(videoElement).toHaveAttribute("src", BLOB_URL);
52
+ expect(videoElement).toHaveAttribute("controls");
53
+ video.unmount();
54
+
55
+ const audio = render(<FileArtifactPane filename="voice.m4a" blobUrl={BLOB_URL} />);
56
+ const audioElement = audio.container.querySelector("audio");
57
+ expect(audioElement).not.toBeNull();
58
+ expect(audioElement).toHaveAttribute("src", BLOB_URL);
59
+ expect(audioElement).toHaveAttribute("controls");
60
+ expect(audio.getAllByText("voice.m4a").length).toBeGreaterThan(1); // header + player label
61
+ });
62
+
63
+ it("parses quoted commas in CSV and caps the table at the row limit", () => {
64
+ const total = CSV_PREVIEW_ROW_LIMIT + 1;
65
+ const { container } = render(
66
+ <FileArtifactPane filename="people.csv" content={csvWithRows(total)} />,
67
+ );
68
+
69
+ const headers = Array.from(container.querySelectorAll("thead th")).map((th) => th.textContent);
70
+ expect(headers).toEqual(["name", "note", "n"]);
71
+ expect(container.querySelectorAll("thead th")[0]).toHaveClass("sticky");
72
+
73
+ const bodyRows = container.querySelectorAll("tbody tr");
74
+ expect(bodyRows).toHaveLength(CSV_PREVIEW_ROW_LIMIT);
75
+ expect(bodyRows[0].querySelectorAll("td")[0]).toHaveTextContent("Ada, Countess 0");
76
+ expect(bodyRows[0].querySelectorAll("td")).toHaveLength(3);
77
+
78
+ expect(
79
+ screen.getByText(`Showing ${CSV_PREVIEW_ROW_LIMIT} of ${total} rows · 3 columns`),
80
+ ).toBeInTheDocument();
81
+ });
82
+
83
+ it("reports the row count when the CSV is under the cap", () => {
84
+ render(<FileArtifactPane filename="people.csv" content={csvWithRows(3)} />);
85
+ expect(screen.getByText("3 rows · 3 columns")).toBeInTheDocument();
86
+ });
87
+
88
+ it("renders a spreadsheet as a download card and calls onDownload", () => {
89
+ const onDownload = vi.fn();
90
+ render(
91
+ <FileArtifactPane filename="book.xlsx" size={1536} onDownload={onDownload} />,
92
+ );
93
+
94
+ expect(screen.getByText("1.5 KB")).toBeInTheDocument();
95
+ expect(
96
+ screen.getByText("Download to open this workbook in a spreadsheet app."),
97
+ ).toBeInTheDocument();
98
+
99
+ fireEvent.click(screen.getByRole("button", { name: "Download" }));
100
+ expect(onDownload).toHaveBeenCalledTimes(1);
101
+
102
+ // The header keeps its own download control.
103
+ fireEvent.click(screen.getByRole("button", { name: "Download book.xlsx" }));
104
+ expect(onDownload).toHaveBeenCalledTimes(2);
105
+ });
106
+
107
+ it("renders markdown through the package Markdown renderer", () => {
108
+ const { container } = render(
109
+ <FileArtifactPane filename="README.md" content={"# Title\n\nBody with **bold**."} />,
110
+ );
111
+ expect(container.querySelector(".tangle-prose")).not.toBeNull();
112
+ expect(container.querySelector("h1")).toHaveTextContent("Title");
113
+ expect(container.querySelector("strong")).toHaveTextContent("bold");
114
+ });
115
+
116
+ it("renders a binary file with no download link as one muted sentence", () => {
117
+ const onDownload = vi.fn();
118
+ const withDownload = render(
119
+ <FileArtifactPane filename="weights.bin" onDownload={onDownload} />,
120
+ );
121
+ expect(
122
+ withDownload.getByText("This file needs a download link to preview."),
123
+ ).toBeInTheDocument();
124
+ fireEvent.click(withDownload.getByRole("button", { name: "Download" }));
125
+ expect(onDownload).toHaveBeenCalledTimes(1);
126
+ withDownload.unmount();
127
+
128
+ render(<FileArtifactPane filename="weights.bin" />);
129
+ expect(screen.getByText("This file needs a download link to preview.")).toBeInTheDocument();
130
+ expect(screen.queryByRole("button", { name: "Download" })).toBeNull();
131
+ });
132
+
133
+ it("renders the same sentence for an image, PDF, or video without a blobUrl", () => {
134
+ for (const filename of ["chart.png", "report.pdf", "demo.mp4"]) {
135
+ const { getByText, unmount } = render(<FileArtifactPane filename={filename} />);
136
+ expect(getByText("This file needs a download link to preview.")).toBeInTheDocument();
137
+ unmount();
138
+ }
139
+ });
140
+
141
+ it("renders an unknown file's text content as text", () => {
142
+ const { container } = render(
143
+ <FileArtifactPane filename="Makefile.custom" content={"build:\n\tgo build"} />,
144
+ );
145
+ expect(container.querySelector("pre")).toHaveTextContent("go build");
146
+ expect(container.querySelector("code")).toBeNull();
147
+ });
148
+
149
+ it("keeps code on the syntax highlighter and text on a plain pre", () => {
150
+ const code = render(
151
+ <FileArtifactPane filename="server.ts" content={"const x = 1;\nexport { x };"} />,
152
+ );
153
+ expect(code.container.querySelector("code")).not.toBeNull();
154
+ expect(code.container.querySelector("table")).toBeNull();
155
+ expect(code.getByText(/ts · 2 lines/)).toBeInTheDocument();
156
+ code.unmount();
157
+
158
+ const text = render(<FileArtifactPane filename="notes.txt" content="just words" />);
159
+ expect(text.container.querySelector("pre")).toHaveTextContent("just words");
160
+ expect(text.container.querySelector("code")).toBeNull();
161
+ });
162
+
163
+ it("renders SVG source as code when the host read it as text", () => {
164
+ const { container } = render(
165
+ <FileArtifactPane filename="icon.svg" content={'<svg xmlns="http://www.w3.org/2000/svg"/>'} />,
166
+ );
167
+ expect(container.querySelector("code")).not.toBeNull();
168
+ expect(container.querySelector("img")).toBeNull();
169
+ });
170
+ });
171
+
172
+ describe("parseCsv", () => {
173
+ it("keeps commas, line breaks, and doubled quotes inside quoted cells", () => {
174
+ expect(parseCsv('a,b\n"x, y","line 1\nline 2"\n"say ""hi""",z')).toEqual([
175
+ ["a", "b"],
176
+ ["x, y", "line 1\nline 2"],
177
+ ['say "hi"', "z"],
178
+ ]);
179
+ });
180
+
181
+ it("trims unquoted cells and accepts CRLF, blank lines, and a trailing line break", () => {
182
+ expect(parseCsv("a, b\r\n\r\n1 ,2\r\n")).toEqual([
183
+ ["a", "b"],
184
+ ["1", "2"],
185
+ ]);
186
+ });
187
+
188
+ it("returns no rows for empty input", () => {
189
+ expect(parseCsv("")).toEqual([]);
190
+ expect(parseCsv("\n\n")).toEqual([]);
191
+ });
192
+ });
@@ -51,13 +51,16 @@ export interface FileArtifactPaneProps extends Omit<FilePreviewProps, "className
51
51
 
52
52
  /**
53
53
  * FileArtifactPane — opinionated artifact frame for file previews with tabs and
54
- * header actions.
54
+ * header actions. The body is `FilePreview`, so every kind it renders (image,
55
+ * PDF, video, audio, CSV, spreadsheet, markdown, code, text) renders here too;
56
+ * `onDownload` drives both the header button and the body's download cards.
55
57
  */
56
58
  export function FileArtifactPane({
57
59
  filename,
58
60
  content,
59
61
  blobUrl,
60
62
  mimeType,
63
+ size,
61
64
  onClose,
62
65
  onDownload,
63
66
  path,
@@ -188,6 +191,8 @@ export function FileArtifactPane({
188
191
  content={content}
189
192
  blobUrl={blobUrl}
190
193
  mimeType={mimeType}
194
+ size={size}
195
+ onDownload={onDownload}
191
196
  hideHeader={true}
192
197
  />
193
198
  </ArtifactPane>
@@ -8,6 +8,8 @@
8
8
  export type FileFormat =
9
9
  | "pdf"
10
10
  | "image"
11
+ | "video"
12
+ | "audio"
11
13
  | "csv"
12
14
  | "spreadsheet"
13
15
  | "code"
@@ -17,7 +19,20 @@ export type FileFormat =
17
19
  | "text"
18
20
  | "unknown";
19
21
 
20
- const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
22
+ /**
23
+ * How a file surface renders a file. Every format maps to its own renderer;
24
+ * `unknown` becomes `binary`, which renders string content as text and offers a
25
+ * download for anything else.
26
+ */
27
+ export type FilePreviewKind = Exclude<FileFormat, "unknown"> | "binary";
28
+
29
+ const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "svg", "webp", "avif"];
30
+ const VIDEO_EXTENSIONS = ["mp4", "webm", "mov"];
31
+ const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "m4a"];
32
+ const SPREADSHEET_MIME_TYPES = [
33
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
34
+ "application/vnd.ms-excel",
35
+ ];
21
36
 
22
37
  const EXTENSION_TO_SYNTAX_LANGUAGE: Record<string, string> = {
23
38
  ts: "typescript",
@@ -97,14 +112,19 @@ export function detectFileFormat(filename: string, mimeType?: string): FileForma
97
112
  // 1. Specific MIME types are authoritative — they outrank the extension.
98
113
  if (mime === "application/pdf") return "pdf";
99
114
  if (mime.startsWith("image/")) return "image";
115
+ if (mime.startsWith("video/")) return "video";
116
+ if (mime.startsWith("audio/")) return "audio";
100
117
  if (mime === "text/markdown") return "markdown";
101
118
  if (mime === "application/json") return "json";
102
119
  if (mime === "text/csv" || mime === "application/csv") return "csv";
120
+ if (SPREADSHEET_MIME_TYPES.includes(mime)) return "spreadsheet";
103
121
  if (mime === "application/yaml" || mime === "application/x-yaml" || mime === "text/yaml") return "yaml";
104
122
 
105
123
  // 2. Fall back to the file extension.
106
124
  if (ext === "pdf") return "pdf";
107
125
  if (IMAGE_EXTENSIONS.includes(ext)) return "image";
126
+ if (VIDEO_EXTENSIONS.includes(ext)) return "video";
127
+ if (AUDIO_EXTENSIONS.includes(ext)) return "audio";
108
128
  if (ext === "csv") return "csv";
109
129
  if (ext === "xlsx" || ext === "xls") return "spreadsheet";
110
130
  if (CODE_EXTENSIONS.has(ext)) return "code";
@@ -119,13 +139,28 @@ export function detectFileFormat(filename: string, mimeType?: string): FileForma
119
139
  return "unknown";
120
140
  }
121
141
 
122
- /** Human-facing label for a detected format. */
123
- export function getFormatLabel(format: FileFormat): string {
142
+ /**
143
+ * The preview renderer for a file, from its name and optional MIME type only.
144
+ * Same precedence as `detectFileFormat` (MIME outranks extension); a file that
145
+ * matches nothing is `binary`. Content is not an input: the preview renders a
146
+ * `binary` file's string content as text and offers a download otherwise.
147
+ */
148
+ export function resolveFilePreviewKind(filename: string, mimeType?: string): FilePreviewKind {
149
+ const format = detectFileFormat(filename, mimeType);
150
+ return format === "unknown" ? "binary" : format;
151
+ }
152
+
153
+ /** Human-facing label for a detected format or preview kind. */
154
+ export function getFormatLabel(format: FileFormat | FilePreviewKind): string {
124
155
  switch (format) {
125
156
  case "pdf":
126
157
  return "PDF";
127
158
  case "image":
128
159
  return "Image";
160
+ case "video":
161
+ return "Video";
162
+ case "audio":
163
+ return "Audio";
129
164
  case "csv":
130
165
  return "CSV";
131
166
  case "spreadsheet":
@@ -161,7 +196,10 @@ export function getSyntaxLanguage(filename: string): string | undefined {
161
196
  * a MIME type on an extensionless file (where the extension can't reveal it);
162
197
  * any other code format keys off the extension.
163
198
  */
164
- export function getCodeLanguage(filename: string, format: FileFormat): string | undefined {
199
+ export function getCodeLanguage(
200
+ filename: string,
201
+ format: FileFormat | FilePreviewKind,
202
+ ): string | undefined {
165
203
  if (format === "json" || format === "yaml") return format;
166
204
  return getSyntaxLanguage(filename);
167
205
  }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ detectFileFormat,
4
+ getFormatLabel,
5
+ resolveFilePreviewKind,
6
+ type FilePreviewKind,
7
+ } from "./file-format";
8
+
9
+ describe("resolveFilePreviewKind", () => {
10
+ it.each<[string, string | undefined, FilePreviewKind]>([
11
+ // Images
12
+ ["photo.png", undefined, "image"],
13
+ ["photo.jpg", undefined, "image"],
14
+ ["photo.jpeg", undefined, "image"],
15
+ ["anim.gif", undefined, "image"],
16
+ ["photo.webp", undefined, "image"],
17
+ ["icon.svg", undefined, "image"],
18
+ ["photo.avif", undefined, "image"],
19
+ // Documents
20
+ ["report.pdf", undefined, "pdf"],
21
+ // Video
22
+ ["clip.mp4", undefined, "video"],
23
+ ["clip.webm", undefined, "video"],
24
+ ["clip.mov", undefined, "video"],
25
+ // Audio
26
+ ["track.mp3", undefined, "audio"],
27
+ ["track.wav", undefined, "audio"],
28
+ ["track.ogg", undefined, "audio"],
29
+ ["track.m4a", undefined, "audio"],
30
+ // Tabular
31
+ ["data.csv", undefined, "csv"],
32
+ ["book.xlsx", undefined, "spreadsheet"],
33
+ ["legacy.xls", undefined, "spreadsheet"],
34
+ // Prose and structured text
35
+ ["README.md", undefined, "markdown"],
36
+ ["notes.markdown", undefined, "markdown"],
37
+ ["config.json", undefined, "json"],
38
+ ["compose.yaml", undefined, "yaml"],
39
+ ["compose.yml", undefined, "yaml"],
40
+ ["server.ts", undefined, "code"],
41
+ ["main.py", undefined, "code"],
42
+ ["output.log", undefined, "text"],
43
+ ["notes.txt", undefined, "text"],
44
+ ])("%s (%s) → %s", (filename, mimeType, kind) => {
45
+ expect(resolveFilePreviewKind(filename, mimeType)).toBe(kind);
46
+ });
47
+
48
+ it("lets an authoritative MIME type outrank the extension", () => {
49
+ expect(resolveFilePreviewKind("photo.txt", "image/png")).toBe("image");
50
+ expect(resolveFilePreviewKind("scan.png", "application/pdf")).toBe("pdf");
51
+ expect(resolveFilePreviewKind("clip.bin", "video/mp4")).toBe("video");
52
+ expect(resolveFilePreviewKind("track.webm", "audio/webm")).toBe("audio");
53
+ expect(
54
+ resolveFilePreviewKind(
55
+ "export.csv",
56
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
57
+ ),
58
+ ).toBe("spreadsheet");
59
+ expect(resolveFilePreviewKind("book.bin", "application/vnd.ms-excel")).toBe("spreadsheet");
60
+ expect(resolveFilePreviewKind("notes.json", "text/markdown")).toBe("markdown");
61
+ });
62
+
63
+ it("resolves MIME-only files with no extension", () => {
64
+ expect(resolveFilePreviewKind("blob", "image/webp")).toBe("image");
65
+ expect(resolveFilePreviewKind("blob", "video/webm")).toBe("video");
66
+ expect(resolveFilePreviewKind("blob", "audio/mpeg")).toBe("audio");
67
+ expect(resolveFilePreviewKind("blob", "application/json; charset=utf-8")).toBe("json");
68
+ expect(resolveFilePreviewKind("blob", "text/plain")).toBe("text");
69
+ });
70
+
71
+ it("ignores a generic MIME type and keeps the extension", () => {
72
+ expect(resolveFilePreviewKind("photo.png", "application/octet-stream")).toBe("image");
73
+ expect(resolveFilePreviewKind("main.py", "text/plain")).toBe("code");
74
+ });
75
+
76
+ it("resolves everything unmatched to binary", () => {
77
+ expect(resolveFilePreviewKind("mystery.bin")).toBe("binary");
78
+ expect(resolveFilePreviewKind("noextension")).toBe("binary");
79
+ expect(resolveFilePreviewKind("weights", "application/octet-stream")).toBe("binary");
80
+ expect(resolveFilePreviewKind("pdf")).toBe("binary");
81
+ });
82
+
83
+ it("agrees with detectFileFormat for every format that has a renderer", () => {
84
+ for (const filename of [
85
+ "photo.png",
86
+ "report.pdf",
87
+ "clip.mp4",
88
+ "track.mp3",
89
+ "data.csv",
90
+ "book.xlsx",
91
+ "README.md",
92
+ "config.json",
93
+ "compose.yml",
94
+ "server.ts",
95
+ "notes.txt",
96
+ ]) {
97
+ expect(resolveFilePreviewKind(filename)).toBe(detectFileFormat(filename));
98
+ }
99
+ expect(detectFileFormat("mystery.bin")).toBe("unknown");
100
+ expect(resolveFilePreviewKind("mystery.bin")).toBe("binary");
101
+ });
102
+
103
+ it("labels every preview kind", () => {
104
+ expect(getFormatLabel("video")).toBe("Video");
105
+ expect(getFormatLabel("audio")).toBe("Audio");
106
+ expect(getFormatLabel("image")).toBe("Image");
107
+ expect(getFormatLabel("binary")).toBe("File");
108
+ });
109
+ });