@tangle-network/ui 1.0.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 (220) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +33 -0
  4. package/dist/active-sessions-store-CeOmXgv5.d.ts +85 -0
  5. package/dist/artifact-pane-DvJyPWV4.d.ts +24 -0
  6. package/dist/auth.d.ts +74 -0
  7. package/dist/auth.js +15 -0
  8. package/dist/button-CMQuQEW_.d.ts +17 -0
  9. package/dist/chat.d.ts +232 -0
  10. package/dist/chat.js +30 -0
  11. package/dist/chunk-2NFQRQOD.js +1009 -0
  12. package/dist/chunk-2VH6PUXD.js +186 -0
  13. package/dist/chunk-34A66VBG.js +214 -0
  14. package/dist/chunk-3OI2QKFD.js +0 -0
  15. package/dist/chunk-4CLN43XT.js +45 -0
  16. package/dist/chunk-54SQQMMM.js +156 -0
  17. package/dist/chunk-5Z5ZYMOJ.js +0 -0
  18. package/dist/chunk-66BNMOVT.js +167 -0
  19. package/dist/chunk-6BGQA4BQ.js +0 -0
  20. package/dist/chunk-7UO2ZMRQ.js +133 -0
  21. package/dist/chunk-BX6AQMUS.js +183 -0
  22. package/dist/chunk-CD53GZOM.js +59 -0
  23. package/dist/chunk-CSAIKY36.js +54 -0
  24. package/dist/chunk-EEE55AVS.js +1201 -0
  25. package/dist/chunk-GYPQXTJU.js +230 -0
  26. package/dist/chunk-HFL6R6IF.js +37 -0
  27. package/dist/chunk-HJKCSXCH.js +737 -0
  28. package/dist/chunk-LISXUB4D.js +1222 -0
  29. package/dist/chunk-LQS34IGP.js +0 -0
  30. package/dist/chunk-MKTSMWVD.js +109 -0
  31. package/dist/chunk-NKDZ7GZE.js +192 -0
  32. package/dist/chunk-OEX7NZE3.js +321 -0
  33. package/dist/chunk-Q56BYXQF.js +61 -0
  34. package/dist/chunk-Q7EIIWTC.js +0 -0
  35. package/dist/chunk-REJESC5U.js +117 -0
  36. package/dist/chunk-RQGKSCEZ.js +0 -0
  37. package/dist/chunk-RQHJBTEU.js +10 -0
  38. package/dist/chunk-TMFOPHHN.js +299 -0
  39. package/dist/chunk-XGKULLYE.js +40 -0
  40. package/dist/chunk-XIHMJ7ZQ.js +614 -0
  41. package/dist/chunk-YJ2G3XO5.js +1048 -0
  42. package/dist/chunk-YNN4O57I.js +754 -0
  43. package/dist/code-block-DjXf8eOG.d.ts +19 -0
  44. package/dist/document-editor-pane-A5LT5H4N.js +12 -0
  45. package/dist/document-editor-pane-DyDEX_Zm.d.ts +124 -0
  46. package/dist/editor.d.ts +120 -0
  47. package/dist/editor.js +34 -0
  48. package/dist/files.d.ts +175 -0
  49. package/dist/files.js +20 -0
  50. package/dist/hooks.d.ts +56 -0
  51. package/dist/hooks.js +41 -0
  52. package/dist/index.d.ts +43 -0
  53. package/dist/index.js +446 -0
  54. package/dist/markdown.d.ts +15 -0
  55. package/dist/markdown.js +14 -0
  56. package/dist/message-BHWbxBtT.d.ts +15 -0
  57. package/dist/openui.d.ts +115 -0
  58. package/dist/openui.js +12 -0
  59. package/dist/parts-dj7AcUg0.d.ts +36 -0
  60. package/dist/primitives.d.ts +332 -0
  61. package/dist/primitives.js +191 -0
  62. package/dist/run-PfLmDAox.d.ts +41 -0
  63. package/dist/run.d.ts +69 -0
  64. package/dist/run.js +36 -0
  65. package/dist/sdk-hooks.d.ts +285 -0
  66. package/dist/sdk-hooks.js +31 -0
  67. package/dist/stores.d.ts +17 -0
  68. package/dist/stores.js +76 -0
  69. package/dist/tool-call-feed-Bs3MyQMT.d.ts +68 -0
  70. package/dist/tool-display-z4JcDmMQ.d.ts +32 -0
  71. package/dist/tool-previews.d.ts +48 -0
  72. package/dist/tool-previews.js +21 -0
  73. package/dist/types.d.ts +19 -0
  74. package/dist/types.js +1 -0
  75. package/dist/utils.d.ts +45 -0
  76. package/dist/utils.js +32 -0
  77. package/package.json +193 -0
  78. package/src/auth/auth.tsx +228 -0
  79. package/src/auth/index.ts +13 -0
  80. package/src/auth/login-layout.tsx +46 -0
  81. package/src/chat/agent-timeline.stories.tsx +429 -0
  82. package/src/chat/agent-timeline.tsx +360 -0
  83. package/src/chat/chat-container.tsx +486 -0
  84. package/src/chat/chat-input.stories.tsx +142 -0
  85. package/src/chat/chat-input.tsx +389 -0
  86. package/src/chat/chat-message.stories.tsx +237 -0
  87. package/src/chat/chat-message.tsx +129 -0
  88. package/src/chat/index.ts +18 -0
  89. package/src/chat/message-list.stories.tsx +336 -0
  90. package/src/chat/message-list.tsx +79 -0
  91. package/src/chat/thinking-indicator.stories.tsx +56 -0
  92. package/src/chat/thinking-indicator.tsx +30 -0
  93. package/src/chat/user-message.stories.tsx +92 -0
  94. package/src/chat/user-message.tsx +43 -0
  95. package/src/editor/document-editor-pane.tsx +351 -0
  96. package/src/editor/editor-provider.tsx +428 -0
  97. package/src/editor/editor-toolbar.tsx +130 -0
  98. package/src/editor/index.ts +31 -0
  99. package/src/editor/markdown-conversion.ts +21 -0
  100. package/src/editor/markdown-document-editor.tsx +137 -0
  101. package/src/editor/tiptap-editor.tsx +331 -0
  102. package/src/editor/use-editor.ts +221 -0
  103. package/src/files/file-artifact-pane.tsx +183 -0
  104. package/src/files/file-preview.tsx +342 -0
  105. package/src/files/file-tabs.tsx +71 -0
  106. package/src/files/file-tree.tsx +258 -0
  107. package/src/files/index.ts +17 -0
  108. package/src/files/rich-file-tree.stories.tsx +104 -0
  109. package/src/files/rich-file-tree.test.tsx +42 -0
  110. package/src/files/rich-file-tree.tsx +232 -0
  111. package/src/hooks/index.ts +10 -0
  112. package/src/hooks/use-auth.ts +153 -0
  113. package/src/hooks/use-auto-scroll.ts +59 -0
  114. package/src/hooks/use-dropdown-menu.ts +40 -0
  115. package/src/hooks/use-live-time.test.tsx +40 -0
  116. package/src/hooks/use-live-time.ts +27 -0
  117. package/src/hooks/use-realtime-session.ts +319 -0
  118. package/src/hooks/use-run-collapse-state.ts +25 -0
  119. package/src/hooks/use-run-groups.ts +111 -0
  120. package/src/hooks/use-sdk-session.ts +575 -0
  121. package/src/hooks/use-sse-stream.ts +475 -0
  122. package/src/hooks/use-tool-call-stream.ts +96 -0
  123. package/src/index.ts +14 -0
  124. package/src/lib/utils.ts +6 -0
  125. package/src/markdown/code-block.tsx +198 -0
  126. package/src/markdown/index.ts +2 -0
  127. package/src/markdown/markdown.stories.tsx +190 -0
  128. package/src/markdown/markdown.tsx +62 -0
  129. package/src/openui/index.ts +20 -0
  130. package/src/openui/openui-artifact-renderer.tsx +542 -0
  131. package/src/primitives/artifact-pane.tsx +91 -0
  132. package/src/primitives/avatar.stories.tsx +95 -0
  133. package/src/primitives/avatar.tsx +47 -0
  134. package/src/primitives/badge.stories.tsx +57 -0
  135. package/src/primitives/badge.tsx +97 -0
  136. package/src/primitives/button.stories.tsx +48 -0
  137. package/src/primitives/button.tsx +115 -0
  138. package/src/primitives/card.stories.tsx +53 -0
  139. package/src/primitives/card.tsx +98 -0
  140. package/src/primitives/code-block.stories.tsx +115 -0
  141. package/src/primitives/code-block.tsx +22 -0
  142. package/src/primitives/design-tokens.stories.tsx +162 -0
  143. package/src/primitives/dialog.stories.tsx +176 -0
  144. package/src/primitives/dialog.tsx +137 -0
  145. package/src/primitives/drop-zone.stories.tsx +123 -0
  146. package/src/primitives/drop-zone.tsx +131 -0
  147. package/src/primitives/dropdown-menu.stories.tsx +122 -0
  148. package/src/primitives/dropdown-menu.tsx +214 -0
  149. package/src/primitives/empty-state.stories.tsx +81 -0
  150. package/src/primitives/empty-state.tsx +40 -0
  151. package/src/primitives/index.ts +118 -0
  152. package/src/primitives/input.stories.tsx +113 -0
  153. package/src/primitives/input.tsx +136 -0
  154. package/src/primitives/label.stories.tsx +84 -0
  155. package/src/primitives/label.tsx +24 -0
  156. package/src/primitives/progress.stories.tsx +93 -0
  157. package/src/primitives/progress.tsx +50 -0
  158. package/src/primitives/segmented-control.test.tsx +328 -0
  159. package/src/primitives/segmented-control.tsx +154 -0
  160. package/src/primitives/select.stories.tsx +164 -0
  161. package/src/primitives/select.tsx +158 -0
  162. package/src/primitives/sidebar-drop-zone.stories.tsx +100 -0
  163. package/src/primitives/sidebar-drop-zone.tsx +149 -0
  164. package/src/primitives/skeleton.stories.tsx +79 -0
  165. package/src/primitives/skeleton.tsx +55 -0
  166. package/src/primitives/stat-card.stories.tsx +137 -0
  167. package/src/primitives/stat-card.tsx +97 -0
  168. package/src/primitives/switch.stories.tsx +85 -0
  169. package/src/primitives/switch.tsx +28 -0
  170. package/src/primitives/table.stories.tsx +170 -0
  171. package/src/primitives/table.tsx +116 -0
  172. package/src/primitives/tabs.stories.tsx +180 -0
  173. package/src/primitives/tabs.tsx +71 -0
  174. package/src/primitives/terminal-display.stories.tsx +191 -0
  175. package/src/primitives/terminal-display.tsx +189 -0
  176. package/src/primitives/theme-toggle.stories.tsx +32 -0
  177. package/src/primitives/theme-toggle.tsx +96 -0
  178. package/src/primitives/toast.stories.tsx +155 -0
  179. package/src/primitives/toast.tsx +190 -0
  180. package/src/primitives/upload-progress.stories.tsx +120 -0
  181. package/src/primitives/upload-progress.tsx +110 -0
  182. package/src/run/expanded-tool-detail.stories.tsx +182 -0
  183. package/src/run/expanded-tool-detail.tsx +186 -0
  184. package/src/run/index.ts +13 -0
  185. package/src/run/inline-thinking-item.stories.tsx +136 -0
  186. package/src/run/inline-thinking-item.tsx +120 -0
  187. package/src/run/inline-tool-item.stories.tsx +222 -0
  188. package/src/run/inline-tool-item.tsx +190 -0
  189. package/src/run/run-group.stories.tsx +322 -0
  190. package/src/run/run-group.tsx +569 -0
  191. package/src/run/run-item-primitives.tsx +17 -0
  192. package/src/run/tool-call-feed.stories.tsx +294 -0
  193. package/src/run/tool-call-feed.tsx +192 -0
  194. package/src/run/tool-call-step.stories.tsx +198 -0
  195. package/src/run/tool-call-step.tsx +240 -0
  196. package/src/sdk-hooks.ts +38 -0
  197. package/src/stores/active-sessions-store.ts +455 -0
  198. package/src/stores/chat-store.ts +43 -0
  199. package/src/stores/index.ts +2 -0
  200. package/src/tool-previews/command-preview.tsx +116 -0
  201. package/src/tool-previews/diff-preview.tsx +85 -0
  202. package/src/tool-previews/glob-results-preview.tsx +98 -0
  203. package/src/tool-previews/grep-results-preview.tsx +157 -0
  204. package/src/tool-previews/index.ts +22 -0
  205. package/src/tool-previews/preview-primitives.tsx +84 -0
  206. package/src/tool-previews/question-preview.tsx +101 -0
  207. package/src/tool-previews/web-search-preview.tsx +117 -0
  208. package/src/tool-previews/write-file-preview.tsx +80 -0
  209. package/src/types/branding.ts +11 -0
  210. package/src/types/index.ts +5 -0
  211. package/src/types/message.ts +13 -0
  212. package/src/types/parts.ts +51 -0
  213. package/src/types/run.ts +56 -0
  214. package/src/types/tool-display.ts +41 -0
  215. package/src/utils/copy-text.ts +30 -0
  216. package/src/utils/format.test.ts +43 -0
  217. package/src/utils/format.ts +56 -0
  218. package/src/utils/index.ts +10 -0
  219. package/src/utils/time-ago.ts +9 -0
  220. package/src/utils/tool-display.ts +238 -0
@@ -0,0 +1,183 @@
1
+ import { lazy, Suspense } from "react";
2
+ import { Download, X } from "lucide-react";
3
+ import {
4
+ type DocumentEditorBackend,
5
+ type DocumentEditorMode,
6
+ type DocumentEditorPaneCollaborationConfig,
7
+ } from "../editor/document-editor-pane";
8
+ import { ArtifactPane, type ArtifactPaneProps } from "../primitives/artifact-pane";
9
+ import { FilePreview, type FilePreviewProps } from "./file-preview";
10
+ import { FileTabs, type FileTabData } from "./file-tabs";
11
+
12
+ const LazyDocumentEditorPane = lazy(async () => {
13
+ const module = await import("../editor/document-editor-pane");
14
+ return { default: module.DocumentEditorPane };
15
+ });
16
+
17
+ export interface FileArtifactPaneEditorOptions {
18
+ enabled?: boolean;
19
+ mode?: DocumentEditorMode;
20
+ defaultMode?: DocumentEditorMode;
21
+ onModeChange?: (mode: DocumentEditorMode) => void;
22
+ backend?: DocumentEditorBackend;
23
+ collaboration?: DocumentEditorPaneCollaborationConfig;
24
+ placeholder?: string;
25
+ autoFocus?: boolean;
26
+ readOnly?: boolean;
27
+ saving?: boolean;
28
+ saveLabel?: string;
29
+ onChange?: (markdown: string) => void;
30
+ onSave?: (markdown: string) => Promise<void> | void;
31
+ previewClassName?: string;
32
+ editorClassName?: string;
33
+ }
34
+
35
+ export interface FileArtifactPaneProps extends Omit<FilePreviewProps, "className"> {
36
+ path?: string;
37
+ tabs?: FileTabData[];
38
+ activeTabId?: string;
39
+ onTabSelect?: (id: string) => void;
40
+ onTabClose?: (id: string) => void;
41
+ eyebrow?: ArtifactPaneProps["eyebrow"];
42
+ meta?: ArtifactPaneProps["meta"];
43
+ toolbar?: ArtifactPaneProps["toolbar"];
44
+ footer?: ArtifactPaneProps["footer"];
45
+ className?: string;
46
+ editor?: FileArtifactPaneEditorOptions;
47
+ }
48
+
49
+ /**
50
+ * FileArtifactPane — opinionated artifact frame for file previews with tabs and
51
+ * header actions.
52
+ */
53
+ export function FileArtifactPane({
54
+ filename,
55
+ content,
56
+ blobUrl,
57
+ mimeType,
58
+ onClose,
59
+ onDownload,
60
+ path,
61
+ tabs = [],
62
+ activeTabId,
63
+ onTabSelect,
64
+ onTabClose,
65
+ eyebrow = "Artifact",
66
+ meta,
67
+ toolbar,
68
+ footer,
69
+ className,
70
+ editor,
71
+ }: FileArtifactPaneProps) {
72
+ const showTabs = tabs.length > 0 && onTabSelect && onTabClose;
73
+ const paneTabs = showTabs ? (
74
+ <FileTabs
75
+ tabs={tabs}
76
+ activeId={activeTabId}
77
+ onSelect={onTabSelect}
78
+ onClose={onTabClose}
79
+ />
80
+ ) : undefined;
81
+ const isMarkdown =
82
+ mimeType === "text/markdown" ||
83
+ filename.toLowerCase().endsWith(".md") ||
84
+ path?.toLowerCase().endsWith(".md");
85
+ const isEditableMarkdown = isMarkdown && editor?.enabled;
86
+ const headerActions = (
87
+ <>
88
+ {onDownload && (
89
+ <button
90
+ type="button"
91
+ aria-label={`Download ${filename}`}
92
+ onClick={onDownload}
93
+ className="rounded-[var(--radius-sm)] p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
94
+ >
95
+ <Download className="h-4 w-4" />
96
+ </button>
97
+ )}
98
+ {onClose && (
99
+ <button
100
+ type="button"
101
+ aria-label={`Close ${filename}`}
102
+ onClick={onClose}
103
+ className="rounded-[var(--radius-sm)] p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
104
+ >
105
+ <X className="h-4 w-4" />
106
+ </button>
107
+ )}
108
+ </>
109
+ );
110
+
111
+ if (isEditableMarkdown) {
112
+ return (
113
+ <Suspense
114
+ fallback={(
115
+ <ArtifactPane
116
+ eyebrow={eyebrow}
117
+ title={filename}
118
+ subtitle={path}
119
+ meta={meta}
120
+ toolbar={toolbar}
121
+ footer={footer}
122
+ className={className}
123
+ tabs={paneTabs}
124
+ headerActions={headerActions}
125
+ >
126
+ <div className="flex min-h-[12rem] items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-border bg-muted text-sm text-muted-foreground">
127
+ Loading editor…
128
+ </div>
129
+ </ArtifactPane>
130
+ )}
131
+ >
132
+ <LazyDocumentEditorPane
133
+ eyebrow={eyebrow}
134
+ title={filename}
135
+ subtitle={path}
136
+ meta={meta}
137
+ toolbar={toolbar}
138
+ footer={footer}
139
+ className={className}
140
+ tabs={paneTabs}
141
+ headerActions={headerActions}
142
+ markdown={content ?? ""}
143
+ mode={editor.mode}
144
+ defaultMode={editor.defaultMode}
145
+ onModeChange={editor.onModeChange}
146
+ backend={editor.backend}
147
+ collaboration={editor.collaboration}
148
+ placeholder={editor.placeholder}
149
+ autoFocus={editor.autoFocus}
150
+ readOnly={editor.readOnly}
151
+ saving={editor.saving}
152
+ saveLabel={editor.saveLabel}
153
+ onChange={editor.onChange}
154
+ onSave={editor.onSave}
155
+ previewClassName={editor.previewClassName}
156
+ editorClassName={editor.editorClassName}
157
+ />
158
+ </Suspense>
159
+ );
160
+ }
161
+
162
+ return (
163
+ <ArtifactPane
164
+ eyebrow={eyebrow}
165
+ title={filename}
166
+ subtitle={path}
167
+ meta={meta}
168
+ toolbar={toolbar}
169
+ footer={footer}
170
+ className={className}
171
+ tabs={paneTabs}
172
+ headerActions={headerActions}
173
+ >
174
+ <FilePreview
175
+ filename={filename}
176
+ content={content}
177
+ blobUrl={blobUrl}
178
+ mimeType={mimeType}
179
+ hideHeader={true}
180
+ />
181
+ </ArtifactPane>
182
+ );
183
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * FilePreview — universal file renderer.
3
+ *
4
+ * Renders any file type beautifully:
5
+ * - PDF: embedded viewer
6
+ * - CSV/XLSX: tabular preview
7
+ * - Code (py/json/yaml/ts/js): line-numbered viewer
8
+ * - Markdown: rendered prose
9
+ * - Images: inline display
10
+ * - Text: monospace preview
11
+ */
12
+
13
+ import {
14
+ Download,
15
+ X,
16
+ FileText,
17
+ } from "lucide-react";
18
+ import { cn } from "../lib/utils";
19
+ import { Markdown } from "../markdown/markdown";
20
+
21
+ export interface FilePreviewProps {
22
+ filename: string;
23
+ content?: string;
24
+ blobUrl?: string;
25
+ mimeType?: string;
26
+ onClose?: () => void;
27
+ onDownload?: () => void;
28
+ hideHeader?: boolean;
29
+ className?: string;
30
+ }
31
+
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";
78
+
79
+ 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>
113
+ );
114
+ }
115
+
116
+ function parseCsvRow(line: string) {
117
+ const cells: string[] = [];
118
+ let current = "";
119
+ let inQuotes = false;
120
+
121
+ for (let index = 0; index < line.length; index += 1) {
122
+ const char = line[index];
123
+ const next = line[index + 1];
124
+
125
+ if (char === "\"") {
126
+ if (inQuotes && next === "\"") {
127
+ current += "\"";
128
+ index += 1;
129
+ continue;
130
+ }
131
+
132
+ inQuotes = !inQuotes;
133
+ continue;
134
+ }
135
+
136
+ if (char === "," && !inQuotes) {
137
+ cells.push(current.trim());
138
+ current = "";
139
+ continue;
140
+ }
141
+
142
+ current += char;
143
+ }
144
+
145
+ cells.push(current.trim());
146
+ return cells;
147
+ }
148
+
149
+ function CsvPreview({ content }: { content: string }) {
150
+ const lines = content
151
+ .trim()
152
+ .split(/\r?\n/)
153
+ .filter(Boolean);
154
+
155
+ if (lines.length === 0) return null;
156
+
157
+ const headers = parseCsvRow(lines[0]).map((header) => header.replace(/^"|"$/g, ""));
158
+ const rows = lines.slice(1).map((line) =>
159
+ parseCsvRow(line).map((cell) => cell.replace(/^"|"$/g, "")),
160
+ );
161
+
162
+ return (
163
+ <div className="overflow-auto max-h-[70vh] rounded-[var(--radius-md)] border border-border">
164
+ <table className="w-full text-sm">
165
+ <thead>
166
+ <tr className="bg-muted/50 sticky top-0">
167
+ {headers.map((h, i) => (
168
+ <th
169
+ key={i}
170
+ className="px-3 py-2 text-left text-xs font-semibold text-foreground border-b border-border whitespace-nowrap"
171
+ >
172
+ {h}
173
+ </th>
174
+ ))}
175
+ </tr>
176
+ </thead>
177
+ <tbody>
178
+ {rows.map((row, i) => (
179
+ <tr key={i} className="border-b border-border hover:bg-accent">
180
+ {row.map((cell, j) => (
181
+ <td
182
+ key={j}
183
+ className="px-3 py-1.5 text-foreground font-mono text-xs whitespace-nowrap"
184
+ >
185
+ {cell}
186
+ </td>
187
+ ))}
188
+ </tr>
189
+ ))}
190
+ </tbody>
191
+ </table>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ function ImagePreview({ src, filename }: { src: string; filename: string }) {
197
+ return (
198
+ <div className="flex items-center justify-center p-4 bg-background rounded-[var(--radius-md)] border border-border">
199
+ <img src={src} alt={filename} className="max-w-full max-h-[70vh] object-contain rounded" />
200
+ </div>
201
+ );
202
+ }
203
+
204
+ function PdfPreview({ blobUrl, filename }: { blobUrl: string; filename: string }) {
205
+ // Simple iframe-based PDF viewer. For richer rendering, consumers can
206
+ // swap in react-pdf at the app level.
207
+ return (
208
+ <div className="rounded-[var(--radius-md)] border border-border overflow-hidden bg-background">
209
+ <iframe
210
+ src={blobUrl}
211
+ title={filename}
212
+ className="w-full h-[70vh] border-0"
213
+ />
214
+ </div>
215
+ );
216
+ }
217
+
218
+ function TextPreview({ content }: { content: string }) {
219
+ return (
220
+ <pre className="bg-background rounded-[var(--radius-md)] border border-border p-4 overflow-auto max-h-[70vh] text-sm text-foreground font-mono leading-[1.55]">
221
+ {content}
222
+ </pre>
223
+ );
224
+ }
225
+
226
+ function UnsupportedPreview({
227
+ filename,
228
+ title,
229
+ description,
230
+ }: {
231
+ filename: string;
232
+ title: string;
233
+ description: string;
234
+ }) {
235
+ return (
236
+ <div className="flex flex-col items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-background px-6 py-16 text-center text-muted-foreground">
237
+ <FileText className="mb-3 h-12 w-12 opacity-30" />
238
+ <p className="text-sm text-foreground">{title}</p>
239
+ <p className="mt-1 max-w-md text-xs">{description}</p>
240
+ <p className="mt-4 text-[11px] uppercase tracking-[0.12em]">{filename}</p>
241
+ </div>
242
+ );
243
+ }
244
+
245
+ function MarkdownPreview({ content }: { content: string }) {
246
+ return (
247
+ <div className="rounded-[var(--radius-md)] border border-border bg-background p-5">
248
+ <Markdown className="prose-sm max-w-none">{content}</Markdown>
249
+ </div>
250
+ );
251
+ }
252
+
253
+ function EmptyPreview({ filename }: { filename: string }) {
254
+ return (
255
+ <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
256
+ <FileText className="h-12 w-12 mb-3 opacity-30" />
257
+ <p className="text-sm">Cannot preview {filename}</p>
258
+ <p className="text-xs mt-1">Download to view this file</p>
259
+ </div>
260
+ );
261
+ }
262
+
263
+ export function FilePreview({
264
+ filename,
265
+ content,
266
+ blobUrl,
267
+ mimeType,
268
+ onClose,
269
+ onDownload,
270
+ hideHeader = false,
271
+ className,
272
+ }: FilePreviewProps) {
273
+ const previewType = getPreviewType(filename, mimeType);
274
+ const previewLabel = getPreviewLabel(previewType);
275
+ const hasRenderableSource =
276
+ Boolean(content) ||
277
+ Boolean(blobUrl) ||
278
+ previewType === "unknown" ||
279
+ previewType === "spreadsheet";
280
+
281
+ return (
282
+ <div className={cn("flex flex-col h-full", className)}>
283
+ {!hideHeader && (
284
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
285
+ <div className="min-w-0 flex-1">
286
+ <div className="truncate text-sm font-medium text-foreground">{filename}</div>
287
+ <div className="mt-0.5 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
288
+ {previewLabel}
289
+ </div>
290
+ </div>
291
+ {onDownload && (
292
+ <button
293
+ type="button"
294
+ onClick={onDownload}
295
+ aria-label={`Download ${filename}`}
296
+ className="p-1.5 rounded-[var(--radius-sm)] hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
297
+ >
298
+ <Download className="h-4 w-4" />
299
+ </button>
300
+ )}
301
+ {onClose && (
302
+ <button
303
+ type="button"
304
+ onClick={onClose}
305
+ aria-label={`Close ${filename}`}
306
+ className="p-1.5 rounded-[var(--radius-sm)] hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
307
+ >
308
+ <X className="h-4 w-4" />
309
+ </button>
310
+ )}
311
+ </div>
312
+ )}
313
+
314
+ <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} />
320
+ )}
321
+ {previewType === "text" && typeof content === "string" && <TextPreview content={content} />}
322
+ {previewType === "markdown" && typeof content === "string" && <MarkdownPreview content={content} />}
323
+ {previewType === "spreadsheet" && (
324
+ <UnsupportedPreview
325
+ filename={filename}
326
+ title="Spreadsheet preview is not available in this surface"
327
+ description="Download the workbook or convert it to CSV when you need an inline preview."
328
+ />
329
+ )}
330
+ {previewType === "unknown" && typeof content !== "string" && <EmptyPreview filename={filename} />}
331
+ {previewType === "unknown" && typeof content === "string" && <TextPreview content={content} />}
332
+ {!hasRenderableSource && typeof content !== "string" && (
333
+ <UnsupportedPreview
334
+ filename={filename}
335
+ title="Preview data is not available yet"
336
+ description="This artifact can be shown once the app provides inline content or a downloadable blob."
337
+ />
338
+ )}
339
+ </div>
340
+ </div>
341
+ );
342
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * FileTabs — tab bar for open files in the preview panel.
3
+ */
4
+
5
+ import { X, FileText, FileCode, FileSpreadsheet } from "lucide-react";
6
+ import { cn } from "../lib/utils";
7
+
8
+ export interface FileTabData {
9
+ id: string;
10
+ name: string;
11
+ path: string;
12
+ dirty?: boolean;
13
+ }
14
+
15
+ export interface FileTabsProps {
16
+ tabs: FileTabData[];
17
+ activeId?: string;
18
+ onSelect: (id: string) => void;
19
+ onClose: (id: string) => void;
20
+ className?: string;
21
+ }
22
+
23
+ function getTabIcon(name: string) {
24
+ const ext = name.split(".").pop()?.toLowerCase() || "";
25
+ if (["pdf"].includes(ext)) return FileText;
26
+ if (["csv", "xlsx"].includes(ext)) return FileSpreadsheet;
27
+ return FileCode;
28
+ }
29
+
30
+ export function FileTabs({ tabs, activeId, onSelect, onClose, className }: FileTabsProps) {
31
+ if (tabs.length === 0) return null;
32
+
33
+ return (
34
+ <div className={cn("flex items-center border-b border-border bg-background overflow-x-auto", className)}>
35
+ {tabs.map((tab) => {
36
+ const isActive = tab.id === activeId;
37
+ const Icon = getTabIcon(tab.name);
38
+
39
+ return (
40
+ <div
41
+ key={tab.id}
42
+ className={cn(
43
+ "group flex items-center border-r border-border shrink-0",
44
+ isActive
45
+ ? "bg-card text-foreground border-b-2 border-b-primary"
46
+ : "text-muted-foreground hover:bg-muted/50",
47
+ )}
48
+ >
49
+ <button
50
+ type="button"
51
+ onClick={() => onSelect(tab.id)}
52
+ className="flex min-w-0 items-center gap-1.5 px-3 py-1.5 text-xs transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/60"
53
+ >
54
+ <Icon className="h-3 w-3 shrink-0" />
55
+ <span className="max-w-[120px] truncate">{tab.name}</span>
56
+ {tab.dirty && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
57
+ </button>
58
+ <button
59
+ type="button"
60
+ aria-label={`Close ${tab.name}`}
61
+ onClick={() => onClose(tab.id)}
62
+ className="mr-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-accent focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 group-hover:opacity-100"
63
+ >
64
+ <X className="h-2.5 w-2.5" />
65
+ </button>
66
+ </div>
67
+ );
68
+ })}
69
+ </div>
70
+ );
71
+ }