@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.
- package/CHANGELOG.md +12 -0
- package/README.md +43 -0
- package/dist/chat.d.ts +18 -8
- package/dist/chat.js +9 -7
- package/dist/chunk-4CLN43XT.js +45 -0
- package/dist/{chunk-AZWDI2JG.js → chunk-565EATZZ.js} +1 -1
- package/dist/{chunk-XROQW5J7.js → chunk-BWOUSEHM.js} +1 -1
- package/dist/{chunk-J2G7KCYH.js → chunk-KNYSKNZX.js} +113 -69
- package/dist/chunk-L57WRIND.js +59 -0
- package/dist/{chunk-ULDNFLIM.js → chunk-ND3ZFXQK.js} +11 -58
- package/dist/{chunk-AAUNOHVL.js → chunk-NWECTSOH.js} +1 -1
- package/dist/{chunk-R5GVQDL3.js → chunk-OXZRCXJK.js} +9 -7
- package/dist/{chunk-XPYPZMYT.js → chunk-Q7ZR6LSD.js} +1 -1
- package/dist/{chunk-RKC3GXHE.js → chunk-X53RZSX6.js} +99 -104
- package/dist/{chunk-PLWRAS5Z.js → chunk-XGOWUDDW.js} +270 -106
- package/dist/{chunk-ZRVH3WCA.js → chunk-XU3HB5QL.js} +21 -1
- package/dist/chunk-YTO5V6QS.js +19 -0
- package/dist/files.d.ts +41 -12
- package/dist/files.js +13 -5
- package/dist/hooks.js +9 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +37 -24
- package/dist/primitives.d.ts +15 -1
- package/dist/primitives.js +5 -1
- package/dist/run.d.ts +22 -17
- package/dist/run.js +11 -8
- package/dist/sdk-hooks.js +9 -7
- package/dist/tool-previews.js +2 -2
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +6 -4
- package/package.json +1 -1
- package/src/chat/agent-timeline.stories.tsx +163 -10
- package/src/chat/agent-timeline.test.tsx +114 -7
- package/src/chat/agent-timeline.tsx +120 -158
- package/src/chat/chat-container.test.tsx +59 -0
- package/src/chat/user-message.test.tsx +33 -0
- package/src/chat/user-message.tsx +13 -8
- package/src/files/file-artifact-pane.stories.tsx +149 -0
- package/src/files/file-artifact-pane.test.tsx +192 -0
- package/src/files/file-artifact-pane.tsx +6 -1
- package/src/files/file-format.ts +42 -4
- package/src/files/file-preview-kind.test.ts +109 -0
- package/src/files/file-preview.tsx +315 -132
- package/src/files/index.ts +8 -1
- package/src/primitives/index.ts +3 -0
- package/src/primitives/text-shimmer.test.tsx +34 -0
- package/src/primitives/text-shimmer.tsx +82 -0
- package/src/run/inline-thinking-item.stories.tsx +2 -2
- package/src/run/inline-thinking-item.tsx +2 -2
- package/src/run/inline-tool-item.test.tsx +76 -0
- package/src/run/inline-tool-item.tsx +9 -7
- package/src/run/run-group.tsx +4 -3
- package/src/run/run-row-shell.test.tsx +74 -4
- package/src/run/run-row-shell.tsx +81 -53
- package/src/run/tool-call-step.tsx +9 -7
- package/src/utils/tool-display.test.ts +53 -0
- package/src/utils/tool-display.ts +16 -15
- package/dist/chunk-LQS34IGP.js +0 -0
- /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>
|
package/src/files/file-format.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
123
|
-
|
|
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(
|
|
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
|
+
});
|