@tangle-network/ui 1.0.1 → 4.1.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 (48) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/chat.js +8 -7
  3. package/dist/{chunk-TMFOPHHN.js → chunk-52Y3FMFI.js} +2 -2
  4. package/dist/{chunk-7UO2ZMRQ.js → chunk-5VPTNXX7.js} +2 -2
  5. package/dist/{chunk-XIHMJ7ZQ.js → chunk-AAUNOHVL.js} +5 -30
  6. package/dist/{chunk-YJ2G3XO5.js → chunk-CMX2I43A.js} +1 -1
  7. package/dist/{chunk-2VH6PUXD.js → chunk-DGW77LD7.js} +1 -1
  8. package/dist/{chunk-CD53GZOM.js → chunk-FJBTCTZM.js} +1 -1
  9. package/dist/{chunk-YNN4O57I.js → chunk-JBPWIYTQ.js} +4 -4
  10. package/dist/{chunk-2NFQRQOD.js → chunk-KT5RNO7N.js} +4 -4
  11. package/dist/{chunk-HJKCSXCH.js → chunk-LELGOLFV.js} +44 -78
  12. package/dist/{chunk-EEE55AVS.js → chunk-SZ44QDA6.js} +1 -1
  13. package/dist/{chunk-66BNMOVT.js → chunk-WUQDUBJG.js} +5 -4
  14. package/dist/chunk-ZRVH3WCA.js +107 -0
  15. package/dist/{code-block-DjXf8eOG.d.ts → code-block-0kSpWMnf.d.ts} +7 -1
  16. package/dist/{document-editor-pane-A5LT5H4N.js → document-editor-pane-WCTA3ZOE.js} +3 -3
  17. package/dist/editor.js +3 -4
  18. package/dist/files.d.ts +39 -2
  19. package/dist/files.js +16 -4
  20. package/dist/hooks.js +5 -5
  21. package/dist/index.d.ts +46 -7
  22. package/dist/index.js +120 -37
  23. package/dist/markdown.d.ts +1 -1
  24. package/dist/markdown.js +2 -2
  25. package/dist/nav.d.ts +25 -0
  26. package/dist/nav.js +16 -0
  27. package/dist/openui.js +3 -3
  28. package/dist/primitives.d.ts +1 -1
  29. package/dist/primitives.js +1 -1
  30. package/dist/run.js +8 -7
  31. package/dist/sdk-hooks.js +5 -5
  32. package/dist/tool-previews.js +3 -2
  33. package/package.json +13 -3
  34. package/src/files/file-artifact-pane.tsx +3 -3
  35. package/src/files/file-format.test.ts +176 -0
  36. package/src/files/file-format.ts +167 -0
  37. package/src/files/file-preview.stories.tsx +87 -0
  38. package/src/files/file-preview.test.tsx +52 -0
  39. package/src/files/file-preview.tsx +48 -94
  40. package/src/files/index.ts +8 -0
  41. package/src/index.ts +1 -2
  42. package/src/markdown/code-block.test.tsx +62 -0
  43. package/src/markdown/code-block.tsx +11 -4
  44. package/src/nav/index.tsx +34 -0
  45. package/src/redaction/index.ts +7 -0
  46. package/src/redaction/redacted-document.tsx +150 -0
  47. package/src/tool-previews/write-file-preview.tsx +2 -30
  48. package/dist/chunk-Q7EIIWTC.js +0 -0
@@ -4,7 +4,7 @@
4
4
  * Renders any file type beautifully:
5
5
  * - PDF: embedded viewer
6
6
  * - CSV/XLSX: tabular preview
7
- * - Code (py/json/yaml/ts/js): line-numbered viewer
7
+ * - Code (py/json/yaml/ts/js): syntax-highlighted, line-numbered viewer
8
8
  * - Markdown: rendered prose
9
9
  * - Images: inline display
10
10
  * - Text: monospace preview
@@ -17,6 +17,14 @@ import {
17
17
  } from "lucide-react";
18
18
  import { cn } from "../lib/utils";
19
19
  import { Markdown } from "../markdown/markdown";
20
+ import { CodeBlock, CopyButton } from "../markdown/code-block";
21
+ import {
22
+ detectFileFormat,
23
+ fileExtension,
24
+ getCodeLanguage,
25
+ getFormatLabel,
26
+ type FileFormat,
27
+ } from "./file-format";
20
28
 
21
29
  export interface FilePreviewProps {
22
30
  filename: string;
@@ -29,87 +37,33 @@ export interface FilePreviewProps {
29
37
  className?: string;
30
38
  }
31
39
 
32
- function getPreviewType(filename: string, mimeType?: string): string {
33
- const ext = filename.split(".").pop()?.toLowerCase() || "";
34
- if (mimeType?.startsWith("application/pdf") || ext === "pdf") return "pdf";
35
- if (mimeType?.startsWith("image/") || ["png", "jpg", "jpeg", "gif", "svg", "webp"].includes(ext)) return "image";
36
- if (["csv"].includes(ext)) return "csv";
37
- if (["xlsx", "xls"].includes(ext)) return "spreadsheet";
38
- if (["py", "ts", "js", "tsx", "jsx", "sh", "bash"].includes(ext) || ["profile", "bashrc", "bash_logout", "env", "gitignore"].includes(ext)) return "code";
39
- if (["json"].includes(ext)) return "json";
40
- if (["yaml", "yml"].includes(ext)) return "yaml";
41
- if (["md", "markdown"].includes(ext)) return "markdown";
42
- if (["txt", "log", "text"].includes(ext)) return "text";
43
-
44
- // If we have no known extension but we do have a text plain content payload, fallback to text rather than "unknown"
45
- if (mimeType?.startsWith("text/plain")) return "text";
46
-
47
- return "unknown";
48
- }
49
-
50
- function getPreviewLabel(previewType: string) {
51
- switch (previewType) {
52
- case "pdf":
53
- return "PDF";
54
- case "image":
55
- return "Image";
56
- case "csv":
57
- return "CSV";
58
- case "spreadsheet":
59
- return "Spreadsheet";
60
- case "code":
61
- return "Code";
62
- case "json":
63
- return "JSON";
64
- case "yaml":
65
- return "YAML";
66
- case "markdown":
67
- return "Markdown";
68
- case "text":
69
- return "Text";
70
- default:
71
- return "File";
72
- }
73
- }
74
-
75
- function CodePreview({ content, filename }: { content: string; filename: string }) {
76
- const lines = content.split("\n");
77
- const language = filename.split(".").pop()?.toUpperCase() || "TXT";
40
+ function CodePreview({
41
+ content,
42
+ filename,
43
+ format,
44
+ }: {
45
+ content: string;
46
+ filename: string;
47
+ format: FileFormat;
48
+ }) {
49
+ const lineCount = content.split("\n").length;
50
+ const language = getCodeLanguage(filename, format);
51
+ // Prefer the extension; for an extensionless file (e.g. one detected from its
52
+ // MIME type) fall back to the highlight language so the label stays meaningful.
53
+ const labelToken = fileExtension(filename) || language || "txt";
78
54
 
55
+ // Same theme-aware highlighter the chat markdown renderer uses, so code looks
56
+ // identical in an artifact pane and inline in a message.
79
57
  return (
80
- <div className="relative bg-background rounded-[var(--radius-md)] border border-border overflow-hidden">
81
- <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
82
- <div className="flex gap-1.5">
83
- <div className="w-3 h-3 rounded-full bg-[#FF5F57]" />
84
- <div className="w-3 h-3 rounded-full bg-[#FEBC2E]" />
85
- <div className="w-3 h-3 rounded-full bg-[#8E59FF]" />
86
- </div>
87
- <div className="ml-2 min-w-0 flex-1 truncate text-xs font-mono text-muted-foreground">
88
- {filename}
89
- </div>
90
- <div className="inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-border bg-card px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
91
- <span>{language}</span>
92
- <span className="h-1 w-1 rounded-full bg-[var(--border-hover)]" />
93
- <span>{lines.length} lines</span>
94
- </div>
95
- </div>
96
- <div className="overflow-auto max-h-[70vh]">
97
- <table className="w-full">
98
- <tbody>
99
- {lines.map((line, i) => (
100
- <tr key={i} className="hover:bg-accent">
101
- <td className="text-right pr-4 pl-4 py-0 select-none text-muted-foreground text-xs font-mono w-10 align-top leading-[1.55]">
102
- {i + 1}
103
- </td>
104
- <td className="pr-4 py-0 font-mono text-[13px] text-foreground leading-[1.55] whitespace-pre">
105
- {line || " "}
106
- </td>
107
- </tr>
108
- ))}
109
- </tbody>
110
- </table>
111
- </div>
112
- </div>
58
+ <CodeBlock
59
+ code={content}
60
+ language={language}
61
+ label={`${labelToken} · ${lineCount} lines`}
62
+ showLineNumbers
63
+ className="max-h-[70vh] overflow-auto"
64
+ >
65
+ <CopyButton text={content} />
66
+ </CodeBlock>
113
67
  );
114
68
  }
115
69
 
@@ -270,13 +224,13 @@ export function FilePreview({
270
224
  hideHeader = false,
271
225
  className,
272
226
  }: FilePreviewProps) {
273
- const previewType = getPreviewType(filename, mimeType);
274
- const previewLabel = getPreviewLabel(previewType);
227
+ const format: FileFormat = detectFileFormat(filename, mimeType);
228
+ const previewLabel = getFormatLabel(format);
275
229
  const hasRenderableSource =
276
230
  Boolean(content) ||
277
231
  Boolean(blobUrl) ||
278
- previewType === "unknown" ||
279
- previewType === "spreadsheet";
232
+ format === "unknown" ||
233
+ format === "spreadsheet";
280
234
 
281
235
  return (
282
236
  <div className={cn("flex flex-col h-full", className)}>
@@ -312,23 +266,23 @@ export function FilePreview({
312
266
  )}
313
267
 
314
268
  <div className="flex-1 overflow-auto p-3">
315
- {previewType === "pdf" && blobUrl && <PdfPreview blobUrl={blobUrl} filename={filename} />}
316
- {previewType === "image" && blobUrl && <ImagePreview src={blobUrl} filename={filename} />}
317
- {previewType === "csv" && typeof content === "string" && <CsvPreview content={content} />}
318
- {(previewType === "code" || previewType === "json" || previewType === "yaml") && typeof content === "string" && (
319
- <CodePreview content={content} filename={filename} />
269
+ {format === "pdf" && blobUrl && <PdfPreview blobUrl={blobUrl} filename={filename} />}
270
+ {format === "image" && blobUrl && <ImagePreview src={blobUrl} filename={filename} />}
271
+ {format === "csv" && typeof content === "string" && <CsvPreview content={content} />}
272
+ {(format === "code" || format === "json" || format === "yaml") && typeof content === "string" && (
273
+ <CodePreview content={content} filename={filename} format={format} />
320
274
  )}
321
- {previewType === "text" && typeof content === "string" && <TextPreview content={content} />}
322
- {previewType === "markdown" && typeof content === "string" && <MarkdownPreview content={content} />}
323
- {previewType === "spreadsheet" && (
275
+ {format === "text" && typeof content === "string" && <TextPreview content={content} />}
276
+ {format === "markdown" && typeof content === "string" && <MarkdownPreview content={content} />}
277
+ {format === "spreadsheet" && (
324
278
  <UnsupportedPreview
325
279
  filename={filename}
326
280
  title="Spreadsheet preview is not available in this surface"
327
281
  description="Download the workbook or convert it to CSV when you need an inline preview."
328
282
  />
329
283
  )}
330
- {previewType === "unknown" && typeof content !== "string" && <EmptyPreview filename={filename} />}
331
- {previewType === "unknown" && typeof content === "string" && <TextPreview content={content} />}
284
+ {format === "unknown" && typeof content !== "string" && <EmptyPreview filename={filename} />}
285
+ {format === "unknown" && typeof content === "string" && <TextPreview content={content} />}
332
286
  {!hasRenderableSource && typeof content !== "string" && (
333
287
  <UnsupportedPreview
334
288
  filename={filename}
@@ -15,3 +15,11 @@ export {
15
15
  export { FilePreview, type FilePreviewProps } from "./file-preview";
16
16
  export { FileTabs, type FileTabsProps, type FileTabData } from "./file-tabs";
17
17
  export { FileArtifactPane, type FileArtifactPaneProps } from "./file-artifact-pane";
18
+ export {
19
+ detectFileFormat,
20
+ fileExtension,
21
+ getCodeLanguage,
22
+ getFormatLabel,
23
+ getSyntaxLanguage,
24
+ type FileFormat,
25
+ } from "./file-format";
package/src/index.ts CHANGED
@@ -3,8 +3,6 @@ export * from "./chat";
3
3
  export * from "./run";
4
4
  export * from "./openui";
5
5
  export * from "./files";
6
- export { type ConnectionState } from "./editor";
7
- export * from "./editor";
8
6
  export * from "./markdown";
9
7
  export * from "./auth";
10
8
  export * from "./hooks";
@@ -12,3 +10,4 @@ export * from "./stores";
12
10
  export * from "./types";
13
11
  export * from "./utils";
14
12
  export * from "./tool-previews";
13
+ export * from "./redaction";
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { CodeBlock, CopyButton } from "./code-block";
4
+
5
+ describe("CodeBlock", () => {
6
+ it("renders the code through the syntax highlighter", () => {
7
+ const { container } = render(<CodeBlock code={"const x = 1;"} language="typescript" />);
8
+ expect(container.querySelector("code")).not.toBeNull();
9
+ });
10
+
11
+ it("uses `label` as the header text, overriding `language`", () => {
12
+ const { getByText, queryByText } = render(
13
+ <CodeBlock code={"x"} language="typescript" label="config.ts" />,
14
+ );
15
+ expect(getByText("config.ts")).toBeInTheDocument();
16
+ expect(queryByText("typescript")).toBeNull();
17
+ });
18
+
19
+ it("falls back to `language` for the header when no `label` is given", () => {
20
+ const { getByText } = render(<CodeBlock code={"x"} language="python" />);
21
+ expect(getByText("python")).toBeInTheDocument();
22
+ });
23
+
24
+ it("renders no header when neither label nor language is set", () => {
25
+ const { container } = render(<CodeBlock code={"x"} />);
26
+ // The header is the only element carrying a bottom border.
27
+ expect(container.querySelector(".border-b")).toBeNull();
28
+ });
29
+
30
+ it("treats an explicit empty label as 'no header', even with a language", () => {
31
+ const { container, queryByText } = render(
32
+ <CodeBlock code={"x"} language="python" label="" />,
33
+ );
34
+ expect(container.querySelector(".border-b")).toBeNull();
35
+ expect(queryByText("python")).toBeNull();
36
+ });
37
+
38
+ it("places children in the header when a header is shown", () => {
39
+ const { container } = render(
40
+ <CodeBlock code={"x"} label="run.ts">
41
+ <CopyButton text="x" />
42
+ </CodeBlock>,
43
+ );
44
+ const copyButton = container.querySelector('button[title="Copy to clipboard"]');
45
+ expect(copyButton).not.toBeNull();
46
+ // Inside the bordered header, not the absolute hover overlay.
47
+ expect(copyButton?.closest(".border-b")).not.toBeNull();
48
+ expect(copyButton?.closest(".absolute")).toBeNull();
49
+ });
50
+
51
+ it("moves children to a hover overlay when no header is shown", () => {
52
+ const { container } = render(
53
+ <CodeBlock code={"x"}>
54
+ <CopyButton text="x" />
55
+ </CodeBlock>,
56
+ );
57
+ const copyButton = container.querySelector('button[title="Copy to clipboard"]');
58
+ expect(copyButton).not.toBeNull();
59
+ expect(copyButton?.closest(".absolute")).not.toBeNull();
60
+ expect(copyButton?.closest(".border-b")).toBeNull();
61
+ });
62
+ });
@@ -74,6 +74,12 @@ function getSyntaxTheme(): { [key: string]: React.CSSProperties } {
74
74
  export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
75
75
  code: string;
76
76
  language?: string;
77
+ /**
78
+ * Header text. Defaults to `language`. Set this when the display name differs
79
+ * from the highlight.js language id — e.g. a file extension ("BASHRC") whose
80
+ * content highlights as a known language, or none.
81
+ */
82
+ label?: string;
77
83
  showLineNumbers?: boolean;
78
84
  /** Force light theme; defaults to dark */
79
85
  light?: boolean;
@@ -107,28 +113,29 @@ function useIsLightTheme(): boolean {
107
113
  }
108
114
 
109
115
  export const CodeBlock = memo(
110
- ({ code, language, showLineNumbers = false, light: lightProp, className, children, ...props }: CodeBlockProps) => {
116
+ ({ code, language, label, showLineNumbers = false, light: lightProp, className, children, ...props }: CodeBlockProps) => {
111
117
  const isLight = useIsLightTheme();
112
118
  const light = lightProp ?? isLight;
113
119
  const theme = getSyntaxTheme();
114
120
  const bg = "bg-card border-border";
115
121
  const headerBg = light ? "bg-muted/50 border-border" : "bg-background border-border";
116
122
  const langColor = "text-muted-foreground";
123
+ const headerLabel = label ?? language;
117
124
 
118
125
  return (
119
126
  <div
120
127
  className={cn("group relative overflow-hidden rounded-lg border font-mono", bg, className)}
121
128
  {...props}
122
129
  >
123
- {language && (
130
+ {headerLabel && (
124
131
  <div className={cn("flex items-center justify-between border-b px-3 py-1", headerBg)}>
125
132
  <span className={cn("text-[calc(var(--font-size-xs)-1px)] font-mono font-medium uppercase tracking-widest", langColor)}>
126
- {language}
133
+ {headerLabel}
127
134
  </span>
128
135
  {children}
129
136
  </div>
130
137
  )}
131
- {!language && children && (
138
+ {!headerLabel && children && (
132
139
  <div className="absolute right-2 top-2 z-10 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
133
140
  {children}
134
141
  </div>
@@ -0,0 +1,34 @@
1
+ import {
2
+ Link as RRLink,
3
+ NavLink as RRNavLink,
4
+ type LinkProps,
5
+ type NavLinkProps,
6
+ } from "react-router";
7
+
8
+ /**
9
+ * Navigation primitives that make agent-app products feel snappy by default.
10
+ *
11
+ * The dominant source of the "1–2s when I click through pages" latency in the
12
+ * fleet is NOT slow queries (route loaders' D1 indexes already cover their
13
+ * filters) — it is that every click is a COLD loader round-trip the user waits
14
+ * on, because bare `<Link>`s do no prefetching. React Router can fire the
15
+ * target route's loader on hover/focus (`prefetch="intent"`), overlapping the
16
+ * round-trip with the user's mouse travel so the transition feels instant.
17
+ *
18
+ * These wrappers default `prefetch="intent"` so a product gets that behaviour
19
+ * by importing the shared `<Link>` instead of remembering the flag on every
20
+ * nav element. The default is overridable — a caller that passes `prefetch`
21
+ * wins (the spread is applied after the default).
22
+ */
23
+
24
+ /** `react-router` `<Link>` with `prefetch="intent"` on by default. */
25
+ export function Link({ prefetch = "intent", ...props }: LinkProps) {
26
+ return <RRLink prefetch={prefetch} {...props} />;
27
+ }
28
+
29
+ /** `react-router` `<NavLink>` with `prefetch="intent"` on by default. */
30
+ export function NavLink({ prefetch = "intent", ...props }: NavLinkProps) {
31
+ return <RRNavLink prefetch={prefetch} {...props} />;
32
+ }
33
+
34
+ export type { LinkProps, NavLinkProps } from "react-router";
@@ -0,0 +1,7 @@
1
+ export {
2
+ RedactedDocument,
3
+ type RedactedDocumentProps,
4
+ type RedactedDocumentData,
5
+ type RedactedDocSegment,
6
+ type RevealResult,
7
+ } from "./redacted-document";
@@ -0,0 +1,150 @@
1
+ import { useCallback, useState, type ReactNode } from "react";
2
+ import { Eye, EyeOff, Loader2, ShieldAlert } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+
5
+ /**
6
+ * Viewer for a server-produced redacted document. Renders text inline and each
7
+ * redacted span as a masked chip; clicking a chip asks the server to reveal that
8
+ * one span. The original plaintext is NEVER in the document the client holds —
9
+ * the chip carries only an id + kind; `onReveal` round-trips to the server, where
10
+ * `@tangle-network/agent-app/redact`'s `revealSpan` runs the authorization check
11
+ * and writes the audit trail. So authz + audit are server-truth; this is display.
12
+ *
13
+ * Structural types (no `@tangle-network/agent-app` dependency) — the viewer needs
14
+ * only `{ id, kind }` per span; the cipher stays server-side.
15
+ */
16
+
17
+ export type RedactedDocSegment =
18
+ | { type: "text"; text: string }
19
+ | { type: "redacted"; id: string; kind: string };
20
+
21
+ export interface RedactedDocumentData {
22
+ segments: RedactedDocSegment[];
23
+ }
24
+
25
+ export interface RevealResult {
26
+ ok: boolean;
27
+ value?: string;
28
+ /** e.g. `forbidden` | `not_found` when `ok` is false. */
29
+ reason?: string;
30
+ }
31
+
32
+ export interface RedactedDocumentProps {
33
+ document: RedactedDocumentData;
34
+ /** Reveal one span by id. Wire to a server route that calls agent-app's
35
+ * `revealSpan` (authz + audit happen there). Resolves with the original. */
36
+ onReveal: (spanId: string) => Promise<RevealResult>;
37
+ /** Display label for a redaction kind (default: the kind, upper-cased). */
38
+ labelForKind?: (kind: string) => string;
39
+ className?: string;
40
+ }
41
+
42
+ type ChipState =
43
+ | { status: "masked" }
44
+ | { status: "loading" }
45
+ | { status: "revealed"; value: string }
46
+ | { status: "denied"; reason?: string };
47
+
48
+ const defaultLabel = (kind: string) => kind.replace(/[-_]/g, " ").toUpperCase();
49
+
50
+ function RedactedChip({
51
+ kind,
52
+ label,
53
+ onReveal,
54
+ }: {
55
+ kind: string;
56
+ label: string;
57
+ onReveal: () => Promise<RevealResult>;
58
+ }) {
59
+ const [state, setState] = useState<ChipState>({ status: "masked" });
60
+
61
+ const reveal = useCallback(async () => {
62
+ setState({ status: "loading" });
63
+ try {
64
+ const r = await onReveal();
65
+ setState(
66
+ r.ok && r.value !== undefined
67
+ ? { status: "revealed", value: r.value }
68
+ : { status: "denied", reason: r.reason },
69
+ );
70
+ } catch {
71
+ setState({ status: "denied", reason: "error" });
72
+ }
73
+ }, [onReveal]);
74
+
75
+ if (state.status === "revealed") {
76
+ return (
77
+ <button
78
+ type="button"
79
+ onClick={() => setState({ status: "masked" })}
80
+ title="Revealed — click to hide"
81
+ aria-label={`${label}: revealed, click to hide`}
82
+ className={cn(
83
+ "inline-flex items-center gap-1 rounded-[var(--radius-sm)] px-1 font-medium",
84
+ "bg-[color-mix(in_oklch,var(--color-warning,orange)_18%,transparent)] text-foreground ring-1 ring-warning/40",
85
+ )}
86
+ >
87
+ {state.value}
88
+ <EyeOff className="size-3 opacity-60" />
89
+ </button>
90
+ );
91
+ }
92
+
93
+ if (state.status === "denied") {
94
+ return (
95
+ <span
96
+ title={`Restricted${state.reason ? ` (${state.reason})` : ""}`}
97
+ aria-label={`${label}: restricted`}
98
+ className="inline-flex items-center gap-1 rounded-[var(--radius-sm)] bg-muted px-1 text-muted-foreground"
99
+ >
100
+ <ShieldAlert className="size-3" /> {label}
101
+ </span>
102
+ );
103
+ }
104
+
105
+ return (
106
+ <button
107
+ type="button"
108
+ disabled={state.status === "loading"}
109
+ onClick={reveal}
110
+ title={`${label} — click to reveal`}
111
+ aria-label={`${label} redacted, click to reveal`}
112
+ className={cn(
113
+ "inline-flex items-center gap-1 rounded-[var(--radius-sm)] px-1 font-medium tracking-wide",
114
+ "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors",
115
+ "cursor-pointer select-none",
116
+ )}
117
+ >
118
+ {state.status === "loading" ? (
119
+ <Loader2 className="size-3 animate-spin" />
120
+ ) : (
121
+ <Eye className="size-3 opacity-60" />
122
+ )}
123
+ <span aria-hidden>{"███"}</span> {label}
124
+ </button>
125
+ );
126
+ }
127
+
128
+ export function RedactedDocument({
129
+ document,
130
+ onReveal,
131
+ labelForKind = defaultLabel,
132
+ className,
133
+ }: RedactedDocumentProps): ReactNode {
134
+ return (
135
+ <div className={cn("whitespace-pre-wrap break-words leading-relaxed", className)}>
136
+ {document.segments.map((seg, i) =>
137
+ seg.type === "text" ? (
138
+ <span key={i}>{seg.text}</span>
139
+ ) : (
140
+ <RedactedChip
141
+ key={seg.id}
142
+ kind={seg.kind}
143
+ label={labelForKind(seg.kind)}
144
+ onReveal={() => onReveal(seg.id)}
145
+ />
146
+ ),
147
+ )}
148
+ </div>
149
+ );
150
+ }
@@ -1,6 +1,7 @@
1
1
  import { memo } from "react";
2
2
  import { FileEdit } from "lucide-react";
3
3
  import type { ToolPart } from "../types/parts";
4
+ import { getSyntaxLanguage } from "../files/file-format";
4
5
  import { CodeBlock, CopyButton } from "../markdown/code-block";
5
6
  import { PreviewCard, PreviewError, PreviewLoading } from "./preview-primitives";
6
7
 
@@ -18,35 +19,6 @@ function extractWriteContent(
18
19
  return { path, content };
19
20
  }
20
21
 
21
- function getLanguageFromPath(path: string): string | undefined {
22
- const ext = path.split(".").pop()?.toLowerCase();
23
- const map: Record<string, string> = {
24
- ts: "typescript",
25
- tsx: "tsx",
26
- js: "javascript",
27
- jsx: "jsx",
28
- rs: "rust",
29
- py: "python",
30
- go: "go",
31
- rb: "ruby",
32
- json: "json",
33
- yaml: "yaml",
34
- yml: "yaml",
35
- toml: "toml",
36
- md: "markdown",
37
- css: "css",
38
- scss: "scss",
39
- html: "html",
40
- sh: "bash",
41
- bash: "bash",
42
- zsh: "bash",
43
- sql: "sql",
44
- sol: "solidity",
45
- proto: "protobuf",
46
- };
47
- return ext ? map[ext] : undefined;
48
- }
49
-
50
22
  /**
51
23
  * Preview for file write/create operations.
52
24
  * Shows file path, line count, and the written content.
@@ -56,7 +28,7 @@ export const WriteFilePreview = memo(({ part }: WriteFilePreviewProps) => {
56
28
  if (!write) return null;
57
29
 
58
30
  const lineCount = write.content.split("\n").length;
59
- const language = getLanguageFromPath(write.path);
31
+ const language = getSyntaxLanguage(write.path);
60
32
 
61
33
  return (
62
34
  <PreviewCard
File without changes