@brainpilot/web 0.0.3 → 0.0.5

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 (97) hide show
  1. package/dist/assets/index-C-8G4D4j.js +448 -0
  2. package/dist/assets/index-C501m5OS.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +9 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/api.test.ts +103 -0
  8. package/src/__tests__/messageGroups.test.ts +80 -0
  9. package/src/__tests__/newUiComponents.test.tsx +101 -0
  10. package/src/__tests__/newUiEvents.test.ts +236 -0
  11. package/src/components/chat/AskUserCard.tsx +123 -0
  12. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  13. package/src/components/chat/ComposerInput.tsx +73 -0
  14. package/src/components/chat/ComposerSendButton.tsx +26 -0
  15. package/src/components/chat/MarkdownMessage.tsx +24 -0
  16. package/src/components/chat/MessageStream.tsx +464 -0
  17. package/src/components/chat/PromptComposer.tsx +398 -0
  18. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  19. package/src/components/demo/DemoFileTree.tsx +146 -0
  20. package/src/components/demo/DemoView.tsx +668 -0
  21. package/src/components/demo/TraceNodeModal.tsx +76 -0
  22. package/src/components/demo/demoBundle.ts +218 -0
  23. package/src/components/demo/demoCache.ts +42 -0
  24. package/src/components/files/FilePreviewView.tsx +153 -0
  25. package/src/components/files/FileSidebar.tsx +664 -0
  26. package/src/components/files/filePreview.ts +113 -0
  27. package/src/components/primitives/CustomSelect.tsx +200 -0
  28. package/src/components/primitives/IconButton.tsx +27 -0
  29. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  30. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  31. package/src/components/quota/QuotaFileManager.tsx +197 -0
  32. package/src/components/search/SearchDialog.tsx +101 -0
  33. package/src/components/session/AgentNetwork.tsx +1240 -0
  34. package/src/components/session/AgentTraceViews.tsx +381 -0
  35. package/src/components/session/AnalyticsTab.tsx +386 -0
  36. package/src/components/session/GlobalOverview.tsx +108 -0
  37. package/src/components/session/NodeTooltip.tsx +127 -0
  38. package/src/components/session/TimelineTab.tsx +320 -0
  39. package/src/components/session/TraceGraphView.tsx +301 -0
  40. package/src/components/session/TraceNodeDetail.tsx +142 -0
  41. package/src/components/session/agentAnalytics.ts +397 -0
  42. package/src/components/session/agentNetworkShared.ts +329 -0
  43. package/src/components/session/traceLayout.ts +150 -0
  44. package/src/components/settings/SettingsDialog.tsx +719 -0
  45. package/src/components/shell/DesktopShell.tsx +236 -0
  46. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  47. package/src/components/shell/SandboxStatus.tsx +287 -0
  48. package/src/components/shell/TerminalDrawer.tsx +387 -0
  49. package/src/components/sidebar/Sidebar.tsx +187 -0
  50. package/src/config.ts +10 -0
  51. package/src/contexts/AppProviders.tsx +20 -0
  52. package/src/contexts/AuthContext.tsx +61 -0
  53. package/src/contexts/PreferencesContext.tsx +125 -0
  54. package/src/contexts/SSEContext.tsx +175 -0
  55. package/src/contexts/SandboxContext.tsx +310 -0
  56. package/src/contexts/SessionContext.tsx +608 -0
  57. package/src/contexts/draftStore.ts +103 -0
  58. package/src/contexts/messageFilters.ts +29 -0
  59. package/src/contexts/messageGroups.ts +77 -0
  60. package/src/contexts/messageReducer.ts +401 -0
  61. package/src/contexts/newUiEvents.ts +190 -0
  62. package/src/contracts/backend.ts +846 -0
  63. package/src/contracts/demoBundle.ts +83 -0
  64. package/src/i18n/messages/analytics.ts +96 -0
  65. package/src/i18n/messages/chat.ts +108 -0
  66. package/src/i18n/messages/contexts.ts +40 -0
  67. package/src/i18n/messages/demo.ts +80 -0
  68. package/src/i18n/messages/files.ts +82 -0
  69. package/src/i18n/messages/network.ts +186 -0
  70. package/src/i18n/messages/profile.ts +40 -0
  71. package/src/i18n/messages/quota.ts +36 -0
  72. package/src/i18n/messages/sandbox.ts +116 -0
  73. package/src/i18n/messages/search.ts +16 -0
  74. package/src/i18n/messages/settings.ts +184 -0
  75. package/src/i18n/messages/shell.ts +38 -0
  76. package/src/i18n/messages/sidebar.ts +52 -0
  77. package/src/i18n/messages/terminal.ts +22 -0
  78. package/src/i18n/messages/trace.ts +84 -0
  79. package/src/i18n/messages.ts +32 -0
  80. package/src/i18n/translate.ts +46 -0
  81. package/src/i18n/types.ts +15 -0
  82. package/src/i18n/useT.ts +15 -0
  83. package/src/main.tsx +13 -0
  84. package/src/mocks/backend.ts +722 -0
  85. package/src/styles/global.css +7429 -0
  86. package/src/styles/tokens.css +161 -0
  87. package/src/utils/api.ts +627 -0
  88. package/src/utils/download.ts +18 -0
  89. package/src/utils/format.ts +7 -0
  90. package/src/utils/zip.ts +119 -0
  91. package/src/vite-env.d.ts +1 -0
  92. package/tsconfig.app.json +22 -0
  93. package/tsconfig.json +7 -0
  94. package/tsconfig.node.json +13 -0
  95. package/vite.config.ts +13 -0
  96. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  97. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,76 @@
1
+ import { useEffect } from "react";
2
+ import { GitBranch, X } from "lucide-react";
3
+ import type { TraceNode } from "../../contracts/backend";
4
+ import type { TranslateVars } from "../../i18n/translate";
5
+ import { IconButton } from "../primitives/IconButton";
6
+ import { TraceNodeDetail } from "../session/TraceNodeDetail";
7
+
8
+ interface TraceNodeModalProps {
9
+ node: TraceNode | null;
10
+ onClose: () => void;
11
+ onSelectNode: (id: string) => void;
12
+ /** Focus a produced file in the preview (closes the modal). */
13
+ onSelectArtifact: (path: string) => void;
14
+ activeArtifactPath: string | null;
15
+ closeLabel: string;
16
+ t: (key: string, vars?: TranslateVars) => string;
17
+ }
18
+
19
+ /**
20
+ * Popup showing a single reasoning-trace node's full info (what the agent did,
21
+ * dependencies, tool calls, produced files). Reuses the app's modal pattern
22
+ * (fixed backdrop + centered panel, click-outside / Escape to close) and the
23
+ * shared TraceNodeDetail body.
24
+ */
25
+ export function TraceNodeModal({ node, onClose, onSelectNode, onSelectArtifact, activeArtifactPath, closeLabel, t }: TraceNodeModalProps) {
26
+ useEffect(() => {
27
+ if (!node) {
28
+ return;
29
+ }
30
+ const handleKeyDown = (event: KeyboardEvent) => {
31
+ if (event.key === "Escape") {
32
+ onClose();
33
+ }
34
+ };
35
+ document.addEventListener("keydown", handleKeyDown);
36
+ return () => document.removeEventListener("keydown", handleKeyDown);
37
+ }, [node, onClose]);
38
+
39
+ if (!node) {
40
+ return null;
41
+ }
42
+
43
+ return (
44
+ <div className="trace-node-modal" onMouseDown={onClose} role="presentation">
45
+ <section
46
+ className="trace-node-modal__panel"
47
+ onMouseDown={(e) => e.stopPropagation()}
48
+ role="dialog"
49
+ aria-modal="true"
50
+ aria-label={node.title}
51
+ >
52
+ <div className="trace-node-modal__head">
53
+ <span className="trace-node-modal__eyebrow">
54
+ <GitBranch size={13} style={{ marginRight: 5, verticalAlign: "-2px" }} />
55
+ {node.id}
56
+ </span>
57
+ <IconButton label={closeLabel} onClick={onClose}>
58
+ <X size={16} />
59
+ </IconButton>
60
+ </div>
61
+ <div className="trace-node-modal__body trace-detail">
62
+ <TraceNodeDetail
63
+ node={node}
64
+ onSelectNode={onSelectNode}
65
+ onSelectArtifact={(path) => {
66
+ onSelectArtifact(path);
67
+ onClose();
68
+ }}
69
+ activeArtifactPath={activeArtifactPath}
70
+ t={t}
71
+ />
72
+ </div>
73
+ </section>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,218 @@
1
+ import type { AgentStatus, ChatMessage } from "../../contracts/backend";
2
+ import {
3
+ DEMO_BUNDLE_FORMAT,
4
+ DEMO_BUNDLE_VERSION,
5
+ DemoBundle,
6
+ DemoFile,
7
+ MAX_FILE_BYTES,
8
+ MAX_TOTAL_BYTES,
9
+ isDemoBundle,
10
+ } from "../../contracts/demoBundle";
11
+ import { api } from "../../utils/api";
12
+ import { getPreviewKind, mimeFromName } from "../files/filePreview";
13
+
14
+ export interface BuildDemoOptions {
15
+ session: { id: string; title: string; createdAt?: string; updatedAt?: string };
16
+ /**
17
+ * The running sandbox to read produced files from. Optional: when absent (no
18
+ * running sandbox), the conversation / trace / events are still packed from
19
+ * host-persisted storage and every produced file is recorded as unreadable.
20
+ */
21
+ sandboxId?: string;
22
+ /** In-memory folded messages, used only when no timestamped events exist. */
23
+ fallbackMessages?: ChatMessage[];
24
+ /** Detail shown on files that could not be read because no sandbox was available. */
25
+ filesUnavailableDetail?: string;
26
+ /** Progress notices for the UI (e.g. "packing 3 files…"). */
27
+ onProgress?: (message: string) => void;
28
+ }
29
+
30
+ /** Normalize a trace artifact path to a sandbox `/workspace/...` path. */
31
+ function toWorkspacePath(path: string): string {
32
+ if (path.startsWith("/workspace")) {
33
+ return path;
34
+ }
35
+ // Absolute paths outside /workspace (e.g. "/data/out.csv", "/tmp/x") are
36
+ // remapped under /workspace by their basename-bearing tail, since the sandbox
37
+ // file API only serves /workspace. A bare relative path is joined directly.
38
+ const rel = path.replace(/^\/+/, "").replace(/^\.\//, "");
39
+ return `/workspace/${rel}`;
40
+ }
41
+
42
+ /** Chunked base64 encode of binary data (avoids call-stack overflow). */
43
+ async function blobToBase64(blob: Blob): Promise<string> {
44
+ const bytes = new Uint8Array(await blob.arrayBuffer());
45
+ let binary = "";
46
+ const chunk = 0x8000;
47
+ for (let i = 0; i < bytes.length; i += chunk) {
48
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
49
+ }
50
+ return btoa(binary);
51
+ }
52
+
53
+ function utf8ByteLength(text: string): number {
54
+ return new TextEncoder().encode(text).length;
55
+ }
56
+
57
+ /**
58
+ * Collect the produced-file set referenced by the trace, fetch each, and embed
59
+ * it (utf8 for text, base64 for binary) honoring per-file and total size caps.
60
+ */
61
+ async function collectFiles(
62
+ sandboxId: string | undefined,
63
+ paths: string[],
64
+ onProgress?: (message: string) => void,
65
+ unavailableDetail?: string,
66
+ ): Promise<DemoFile[]> {
67
+ const files: DemoFile[] = [];
68
+ let totalEncoded = 0;
69
+ let index = 0;
70
+ for (const rawPath of paths) {
71
+ index += 1;
72
+ onProgress?.(`packing ${index}/${paths.length}: ${rawPath.split("/").pop() ?? rawPath}`);
73
+ const path = toWorkspacePath(rawPath);
74
+ const name = path.split("/").pop() ?? path;
75
+ const mime = mimeFromName(name);
76
+ const isText = getPreviewKind(name) === "text";
77
+ // No running sandbox to read from: the file bytes live in a sandbox
78
+ // workspace we can't reach right now. Record each as unreadable (with an
79
+ // honest reason) instead of failing the whole export — the conversation,
80
+ // trace and events still pack fine from host-persisted storage.
81
+ if (!sandboxId) {
82
+ files.push({
83
+ path: rawPath,
84
+ mime,
85
+ encoding: isText ? "utf8" : "base64",
86
+ size: 0,
87
+ truncated: true,
88
+ reason: "unreadable",
89
+ detail: unavailableDetail,
90
+ });
91
+ continue;
92
+ }
93
+ try {
94
+ if (isText) {
95
+ const content = await api.sandbox.readFile(sandboxId, path);
96
+ const size = content.size ?? utf8ByteLength(content.content);
97
+ const encodedLen = utf8ByteLength(content.content);
98
+ if (size > MAX_FILE_BYTES || totalEncoded + encodedLen > MAX_TOTAL_BYTES) {
99
+ files.push({ path: rawPath, mime, encoding: "utf8", size, truncated: true, reason: "tooLarge" });
100
+ continue;
101
+ }
102
+ totalEncoded += encodedLen;
103
+ files.push({ path: rawPath, mime, encoding: "utf8", size, truncated: false, data: content.content });
104
+ } else {
105
+ const blob = await api.sandbox.readRawFile(sandboxId, path);
106
+ const size = blob.size;
107
+ if (size > MAX_FILE_BYTES) {
108
+ files.push({ path: rawPath, mime, encoding: "base64", size, truncated: true, reason: "tooLarge" });
109
+ continue;
110
+ }
111
+ const data = await blobToBase64(blob);
112
+ if (totalEncoded + data.length > MAX_TOTAL_BYTES) {
113
+ files.push({ path: rawPath, mime, encoding: "base64", size, truncated: true, reason: "tooLarge" });
114
+ continue;
115
+ }
116
+ totalEncoded += data.length;
117
+ files.push({ path: rawPath, mime, encoding: "base64", size, truncated: false, data });
118
+ }
119
+ } catch (err) {
120
+ // Read failed (missing, path rejected, outside workspace, wrong sandbox).
121
+ // Record it as unreadable — NOT as "too large" — so the player can show
122
+ // an honest reason instead of a misleading size notice.
123
+ files.push({
124
+ path: rawPath,
125
+ mime,
126
+ encoding: isText ? "utf8" : "base64",
127
+ size: 0,
128
+ truncated: true,
129
+ reason: "unreadable",
130
+ detail: err instanceof Error ? err.message : String(err),
131
+ });
132
+ }
133
+ }
134
+ return files;
135
+ }
136
+
137
+ /** Build a portable demo bundle for an arbitrary session. */
138
+ export async function buildDemoBundle(opts: BuildDemoOptions): Promise<DemoBundle> {
139
+ const { session, sandboxId, fallbackMessages, filesUnavailableDetail, onProgress } = opts;
140
+
141
+ onProgress?.("reading reasoning trace…");
142
+ const trace = await api.sessions.getTrace(session.id);
143
+
144
+ let appVersion: string | undefined;
145
+ try {
146
+ appVersion = (await api.getVersion()).version;
147
+ } catch {
148
+ appVersion = undefined;
149
+ }
150
+
151
+ let agents: AgentStatus[] = [];
152
+ try {
153
+ agents = (await api.sessions.state(session.id)).agents;
154
+ } catch {
155
+ agents = [];
156
+ }
157
+
158
+ onProgress?.("reading conversation timeline…");
159
+ let timeline: DemoBundle["timeline"] = "timestamped";
160
+ let events = await api.sessions.getEvents(session.id);
161
+ let messages: ChatMessage[] | undefined;
162
+ if (!events || events.length === 0) {
163
+ timeline = "ordered";
164
+ events = undefined as never;
165
+ messages = fallbackMessages ?? [];
166
+ }
167
+
168
+ // Collect produced-file paths from trace artifacts (dedupe, skip dirs).
169
+ const seen = new Set<string>();
170
+ const paths: string[] = [];
171
+ for (const node of trace.nodes) {
172
+ for (const artifact of node.artifacts ?? []) {
173
+ if (!artifact.path || artifact.type === "dir") {
174
+ continue;
175
+ }
176
+ if (!seen.has(artifact.path)) {
177
+ seen.add(artifact.path);
178
+ paths.push(artifact.path);
179
+ }
180
+ }
181
+ }
182
+
183
+ const files = await collectFiles(sandboxId, paths, onProgress, filesUnavailableDetail);
184
+
185
+ onProgress?.("assembling bundle…");
186
+ return {
187
+ format: DEMO_BUNDLE_FORMAT,
188
+ version: DEMO_BUNDLE_VERSION,
189
+ exportedAt: new Date().toISOString(),
190
+ appVersion,
191
+ timeline,
192
+ session: {
193
+ id: session.id,
194
+ title: session.title,
195
+ createdAt: session.createdAt,
196
+ updatedAt: session.updatedAt,
197
+ },
198
+ events: timeline === "timestamped" ? events : undefined,
199
+ messages: timeline === "ordered" ? messages : undefined,
200
+ trace,
201
+ agents,
202
+ files,
203
+ };
204
+ }
205
+
206
+ /** Parse + validate an imported bundle file. Throws on invalid input. */
207
+ export function parseDemoBundle(text: string): DemoBundle {
208
+ let parsed: unknown;
209
+ try {
210
+ parsed = JSON.parse(text);
211
+ } catch {
212
+ throw new Error("Invalid JSON file.");
213
+ }
214
+ if (!isDemoBundle(parsed)) {
215
+ throw new Error("Not a valid live-demo bundle.");
216
+ }
217
+ return parsed;
218
+ }
@@ -0,0 +1,42 @@
1
+ import { DemoBundle } from "../../contracts/demoBundle";
2
+
3
+ /**
4
+ * Module-level (page-lifetime) cache of built demo bundles, keyed by
5
+ * sessionId + updatedAt. Survives DemoView unmount/remount so re-opening the
6
+ * same session's demo is instant and re-issues no requests. The updatedAt in
7
+ * the key auto-invalidates a stale bundle when the conversation has advanced.
8
+ *
9
+ * Bundles embed file bytes (base64), so an LRU cap bounds memory.
10
+ */
11
+
12
+ const MAX_CACHED = 6;
13
+ const cache = new Map<string, DemoBundle>();
14
+
15
+ function keyFor(sessionId: string, updatedAt?: string): string {
16
+ return updatedAt ? `${sessionId}::${updatedAt}` : sessionId;
17
+ }
18
+
19
+ export function getCachedBundle(sessionId: string, updatedAt?: string): DemoBundle | null {
20
+ const key = keyFor(sessionId, updatedAt);
21
+ const hit = cache.get(key);
22
+ if (!hit) {
23
+ return null;
24
+ }
25
+ // Mark as most-recently-used (Map preserves insertion order).
26
+ cache.delete(key);
27
+ cache.set(key, hit);
28
+ return hit;
29
+ }
30
+
31
+ export function setCachedBundle(sessionId: string, updatedAt: string | undefined, bundle: DemoBundle): void {
32
+ const key = keyFor(sessionId, updatedAt);
33
+ cache.delete(key);
34
+ cache.set(key, bundle);
35
+ while (cache.size > MAX_CACHED) {
36
+ const oldest = cache.keys().next().value;
37
+ if (oldest === undefined) {
38
+ break;
39
+ }
40
+ cache.delete(oldest);
41
+ }
42
+ }
@@ -0,0 +1,153 @@
1
+ import { MarkdownMessage } from "../chat/MarkdownMessage";
2
+ import { TranslateVars } from "../../i18n/translate";
3
+ import { codeLanguage, isMarkdown } from "./filePreview";
4
+
5
+ /** Skip highlighting very large files to avoid blocking the main thread. */
6
+ const MAX_HIGHLIGHT_BYTES = 200_000;
7
+
8
+ /** A fence longer than any backtick run in the text, so content can't break out. */
9
+ function fenceFor(text: string): string {
10
+ let max = 0;
11
+ const runs = text.match(/`+/g);
12
+ if (runs) {
13
+ for (const r of runs) {
14
+ max = Math.max(max, r.length);
15
+ }
16
+ }
17
+ return "`".repeat(Math.max(3, max + 1));
18
+ }
19
+
20
+ export type PreviewSource =
21
+ | { kind: "text"; text: string; truncated?: boolean }
22
+ | { kind: "image"; blobUrl?: string }
23
+ | { kind: "pdf"; blobUrl?: string }
24
+ | { kind: "download"; blobUrl?: string }
25
+ | { kind: "tooLarge" }
26
+ | { kind: "unreadable"; detail?: string };
27
+
28
+ interface FilePreviewViewProps {
29
+ name: string;
30
+ source: PreviewSource;
31
+ /** Render markdown (.md) text as formatted HTML instead of raw <pre>. */
32
+ renderMarkdown?: boolean;
33
+ /** Error loading the bytes (image/pdf), shown instead of the media. */
34
+ error?: string | null;
35
+ t: (key: string, vars?: TranslateVars) => string;
36
+ /** When provided, "too large" / "not previewable" notices offer a download. */
37
+ onDownload?: () => void;
38
+ /** Disables the download button while a download is in flight. */
39
+ isDownloading?: boolean;
40
+ }
41
+
42
+ /** Notice text + an optional download fallback for un-previewable files. */
43
+ function NoticeWithDownload({
44
+ text,
45
+ onDownload,
46
+ isDownloading,
47
+ t,
48
+ }: {
49
+ text: string;
50
+ onDownload?: () => void;
51
+ isDownloading?: boolean;
52
+ t: (key: string, vars?: TranslateVars) => string;
53
+ }) {
54
+ return (
55
+ <div className="file-preview__notice">
56
+ <p>{text}</p>
57
+ {onDownload ? (
58
+ <button
59
+ className="file-preview__download"
60
+ disabled={isDownloading}
61
+ onClick={() => onDownload()}
62
+ type="button"
63
+ >
64
+ {isDownloading ? t("files.preview.downloading") : t("files.preview.download")}
65
+ </button>
66
+ ) : null}
67
+ </div>
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Presentational file-content body — image / pdf / text / markdown / notices.
73
+ * Extracted from FileSidebar's FilePreviewPanel so the live preview and the
74
+ * demo player (which feeds embedded bytes) render identically. Callers own
75
+ * blob-URL lifecycle and byte loading; this component only renders.
76
+ */
77
+ export function FilePreviewView({
78
+ name,
79
+ source,
80
+ renderMarkdown,
81
+ error,
82
+ t,
83
+ onDownload,
84
+ isDownloading,
85
+ }: FilePreviewViewProps) {
86
+ if (source.kind === "tooLarge") {
87
+ return (
88
+ <NoticeWithDownload
89
+ text={t("files.preview.tooLarge")}
90
+ onDownload={onDownload}
91
+ isDownloading={isDownloading}
92
+ t={t}
93
+ />
94
+ );
95
+ }
96
+ if (source.kind === "unreadable") {
97
+ return (
98
+ <p className="file-preview__notice">
99
+ {t("files.preview.unreadable")}
100
+ {source.detail ? <small className="file-preview__notice-detail"> · {source.detail}</small> : null}
101
+ </p>
102
+ );
103
+ }
104
+ if (source.kind === "image") {
105
+ return (
106
+ <div className="file-preview__media">
107
+ {error ? <p>{error}</p> : source.blobUrl ? <img alt={name} src={source.blobUrl} /> : <p>{t("files.preview.loadingImage")}</p>}
108
+ </div>
109
+ );
110
+ }
111
+ if (source.kind === "pdf") {
112
+ return (
113
+ <div className="file-preview__media">
114
+ {error ? <p>{error}</p> : source.blobUrl ? <iframe src={source.blobUrl} title={name} /> : <p>{t("files.preview.loadingPdf")}</p>}
115
+ </div>
116
+ );
117
+ }
118
+ if (source.kind === "download") {
119
+ return (
120
+ <NoticeWithDownload
121
+ text={t("files.preview.notPreviewable")}
122
+ onDownload={onDownload}
123
+ isDownloading={isDownloading}
124
+ t={t}
125
+ />
126
+ );
127
+ }
128
+ // text
129
+ if (renderMarkdown && isMarkdown(name)) {
130
+ return (
131
+ <div className="file-preview__markdown">
132
+ <MarkdownMessage content={source.text} />
133
+ </div>
134
+ );
135
+ }
136
+ // Syntax-highlight code/text by rendering it as a fenced code block through
137
+ // the markdown pipeline (reuses rehype-highlight + the chat code theme; no
138
+ // extra bundle weight). Falls back to plain text for unknown or huge files.
139
+ const lang = codeLanguage(name);
140
+ if (lang !== "plaintext" && source.text.length <= MAX_HIGHLIGHT_BYTES) {
141
+ const fence = fenceFor(source.text);
142
+ return (
143
+ <div className="file-preview__markdown file-preview__code">
144
+ <MarkdownMessage content={`${fence}${lang}\n${source.text}\n${fence}`} />
145
+ </div>
146
+ );
147
+ }
148
+ return (
149
+ <pre className="file-preview__content">
150
+ <code>{source.text}</code>
151
+ </pre>
152
+ );
153
+ }