@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,258 @@
1
+ /**
2
+ * FileTree — directory browser with file type icons.
3
+ *
4
+ * Renders a hierarchical file system view. Clicking a file
5
+ * triggers onSelect with the full path.
6
+ */
7
+
8
+ import { useState, type ReactNode } from "react";
9
+ import {
10
+ File,
11
+ FileText,
12
+ FileCode,
13
+ FileSpreadsheet,
14
+ FileImage,
15
+ Folder,
16
+ FolderOpen,
17
+ ChevronRight,
18
+ FileJson,
19
+ } from "lucide-react";
20
+ import { cn } from "../lib/utils";
21
+
22
+ export interface FileNode {
23
+ name: string;
24
+ path: string;
25
+ type: "file" | "directory";
26
+ children?: FileNode[];
27
+ size?: number;
28
+ mimeType?: string;
29
+ }
30
+
31
+ export interface FileTreeProps {
32
+ root: FileNode;
33
+ selectedPath?: string;
34
+ onSelect?: (path: string, node: FileNode) => void;
35
+ className?: string;
36
+ defaultExpanded?: boolean;
37
+ visibility?: FileTreeVisibilityOptions;
38
+ }
39
+
40
+ export interface FileTreeVisibilityOptions {
41
+ hiddenPaths?: string[];
42
+ hiddenPathPrefixes?: string[];
43
+ isVisible?: (node: FileNode) => boolean;
44
+ }
45
+
46
+ function isNodeVisible(node: FileNode, visibility?: FileTreeVisibilityOptions) {
47
+ if (!visibility) return true;
48
+
49
+ if (visibility.hiddenPaths?.includes(node.path)) {
50
+ return false;
51
+ }
52
+
53
+ if (
54
+ visibility.hiddenPathPrefixes?.some(
55
+ (prefix) => node.path === prefix || node.path.startsWith(`${prefix}/`),
56
+ )
57
+ ) {
58
+ return false;
59
+ }
60
+
61
+ return visibility.isVisible ? visibility.isVisible(node) : true;
62
+ }
63
+
64
+ export function filterFileTree(root: FileNode, visibility?: FileTreeVisibilityOptions): FileNode | null {
65
+ if (!isNodeVisible(root, visibility)) {
66
+ return null;
67
+ }
68
+
69
+ if (root.type === "file") {
70
+ return root;
71
+ }
72
+
73
+ const children = (root.children ?? [])
74
+ .map((child) => filterFileTree(child, visibility))
75
+ .filter((child): child is FileNode => child !== null);
76
+
77
+ if (root.children && root.children.length > 0 && children.length === 0) {
78
+ return null;
79
+ }
80
+
81
+ return {
82
+ ...root,
83
+ children,
84
+ };
85
+ }
86
+
87
+ const FILE_ICONS: Record<string, typeof File> = {
88
+ pdf: FileText,
89
+ csv: FileSpreadsheet,
90
+ xlsx: FileSpreadsheet,
91
+ xls: FileSpreadsheet,
92
+ py: FileCode,
93
+ ts: FileCode,
94
+ js: FileCode,
95
+ json: FileJson,
96
+ yaml: FileCode,
97
+ yml: FileCode,
98
+ md: FileText,
99
+ txt: FileText,
100
+ png: FileImage,
101
+ jpg: FileImage,
102
+ jpeg: FileImage,
103
+ gif: FileImage,
104
+ svg: FileImage,
105
+ };
106
+
107
+ function getFileIcon(name: string): typeof File {
108
+ const ext = name.split(".").pop()?.toLowerCase() || "";
109
+ return FILE_ICONS[ext] || File;
110
+ }
111
+
112
+ function getFileColor(name: string): string {
113
+ const ext = name.split(".").pop()?.toLowerCase() || "";
114
+ switch (ext) {
115
+ case "pdf": return "text-red-400";
116
+ case "py": return "text-yellow-400";
117
+ case "ts":
118
+ case "js": return "text-blue-400";
119
+ case "json": return "text-green-400";
120
+ case "yaml":
121
+ case "yml": return "text-[var(--accent-text)]";
122
+ case "csv":
123
+ case "xlsx": return "text-emerald-400";
124
+ case "md": return "text-foreground";
125
+ default: return "text-muted-foreground";
126
+ }
127
+ }
128
+
129
+ interface TreeNodeProps {
130
+ node: FileNode;
131
+ depth: number;
132
+ selectedPath?: string;
133
+ onSelect?: (path: string, node: FileNode) => void;
134
+ defaultExpanded: boolean;
135
+ }
136
+
137
+ function TreeNode({ node, depth, selectedPath, onSelect, defaultExpanded }: TreeNodeProps) {
138
+ const [expanded, setExpanded] = useState(defaultExpanded);
139
+ const isSelected = node.path === selectedPath;
140
+ const isDir = node.type === "directory";
141
+
142
+ const handleClick = () => {
143
+ if (isDir) {
144
+ setExpanded(!expanded);
145
+ }
146
+ onSelect?.(node.path, node);
147
+ };
148
+
149
+ const Icon = isDir
150
+ ? (expanded ? FolderOpen : Folder)
151
+ : getFileIcon(node.name);
152
+
153
+ const iconColor = isDir ? "text-primary" : getFileColor(node.name);
154
+
155
+ return (
156
+ <div>
157
+ <button
158
+ type="button"
159
+ className={cn(
160
+ "flex items-center gap-1.5 w-full text-left px-2 py-[2px] cursor-pointer text-[13px] transition-colors",
161
+ "appearance-none bg-transparent border-none",
162
+ "hover:bg-accent",
163
+ isSelected ? "bg-primary/15 text-foreground" : "text-foreground",
164
+ )}
165
+ style={{ paddingLeft: `${depth * 16 + 8}px` }}
166
+ onClick={handleClick}
167
+ aria-expanded={isDir ? expanded : undefined}
168
+ >
169
+ {isDir && (
170
+ <ChevronRight
171
+ className={cn(
172
+ "h-3 w-3 shrink-0 text-muted-foreground transition-transform",
173
+ expanded && "rotate-90",
174
+ )}
175
+ />
176
+ )}
177
+ {!isDir && <span className="w-3" />}
178
+ <Icon className={cn("h-4 w-4 shrink-0", iconColor)} />
179
+ <span className="truncate">{node.name}</span>
180
+ {node.size !== undefined && !isDir && (
181
+ <span className="text-muted-foreground text-xs ml-auto tabular-nums">
182
+ {formatSize(node.size)}
183
+ </span>
184
+ )}
185
+ </button>
186
+ {isDir && expanded && node.children && (
187
+ <div>
188
+ {node.children
189
+ .sort((a, b) => {
190
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
191
+ return a.name.localeCompare(b.name);
192
+ })
193
+ .map((child) => (
194
+ <TreeNode
195
+ key={child.path}
196
+ node={child}
197
+ depth={depth + 1}
198
+ selectedPath={selectedPath}
199
+ onSelect={onSelect}
200
+ defaultExpanded={defaultExpanded}
201
+ />
202
+ ))}
203
+ </div>
204
+ )}
205
+ </div>
206
+ );
207
+ }
208
+
209
+ function formatSize(bytes: number): string {
210
+ if (bytes < 1024) return `${bytes}B`;
211
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
212
+ return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
213
+ }
214
+
215
+ export function FileTree({
216
+ root,
217
+ selectedPath,
218
+ onSelect,
219
+ className,
220
+ defaultExpanded = true,
221
+ visibility,
222
+ }: FileTreeProps) {
223
+ const visibleRoot = filterFileTree(root, visibility);
224
+
225
+ if (!visibleRoot) {
226
+ return null;
227
+ }
228
+
229
+ return (
230
+ <div className={cn("text-sm font-sans", className)}>
231
+ {visibleRoot.children ? (
232
+ visibleRoot.children
233
+ .sort((a, b) => {
234
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
235
+ return a.name.localeCompare(b.name);
236
+ })
237
+ .map((child) => (
238
+ <TreeNode
239
+ key={child.path}
240
+ node={child}
241
+ depth={0}
242
+ selectedPath={selectedPath}
243
+ onSelect={onSelect}
244
+ defaultExpanded={defaultExpanded}
245
+ />
246
+ ))
247
+ ) : (
248
+ <TreeNode
249
+ node={visibleRoot}
250
+ depth={0}
251
+ selectedPath={selectedPath}
252
+ onSelect={onSelect}
253
+ defaultExpanded={defaultExpanded}
254
+ />
255
+ )}
256
+ </div>
257
+ );
258
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ FileTree,
3
+ filterFileTree,
4
+ type FileTreeProps,
5
+ type FileNode,
6
+ type FileTreeVisibilityOptions,
7
+ } from "./file-tree";
8
+ export {
9
+ RichFileTree,
10
+ type RichFileTreeProps,
11
+ type RichFileTreeGitEntry,
12
+ type RichFileTreeGitStatus,
13
+ type RichFileTreeThemeVars,
14
+ } from "./rich-file-tree";
15
+ export { FilePreview, type FilePreviewProps } from "./file-preview";
16
+ export { FileTabs, type FileTabsProps, type FileTabData } from "./file-tabs";
17
+ export { FileArtifactPane, type FileArtifactPaneProps } from "./file-artifact-pane";
@@ -0,0 +1,104 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { RichFileTree, type RichFileTreeGitEntry } from "./rich-file-tree";
3
+ import type { FileNode } from "./file-tree";
4
+
5
+ const meta: Meta<typeof RichFileTree> = {
6
+ title: "Files/RichFileTree",
7
+ component: RichFileTree,
8
+ parameters: { layout: "padded", backgrounds: { default: "dark" } },
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="w-[420px] h-[480px] rounded-xl border border-border bg-card overflow-hidden">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ };
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof RichFileTree>;
20
+
21
+ const sampleRoot: FileNode = {
22
+ name: "vault",
23
+ path: "",
24
+ type: "directory",
25
+ children: [
26
+ {
27
+ name: "personas",
28
+ path: "personas",
29
+ type: "directory",
30
+ children: [
31
+ { name: "domain-saas-founder.md", path: "personas/domain-saas-founder.md", type: "file" },
32
+ { name: "regression-paranoid-shipper.md", path: "personas/regression-paranoid-shipper.md", type: "file" },
33
+ { name: "skeptical-agent-builder.md", path: "personas/skeptical-agent-builder.md", type: "file" },
34
+ ],
35
+ },
36
+ {
37
+ name: "signals",
38
+ path: "signals",
39
+ type: "directory",
40
+ children: [
41
+ {
42
+ name: "2026-04-28",
43
+ path: "signals/2026-04-28",
44
+ type: "directory",
45
+ children: [
46
+ { name: "router-pricing.md", path: "signals/2026-04-28/router-pricing.md", type: "file" },
47
+ { name: "competitor-launch.md", path: "signals/2026-04-28/competitor-launch.md", type: "file" },
48
+ ],
49
+ },
50
+ ],
51
+ },
52
+ { name: "brief.md", path: "brief.md", type: "file" },
53
+ { name: "README.md", path: "README.md", type: "file" },
54
+ ],
55
+ };
56
+
57
+ const flatPaths = [
58
+ "README.md",
59
+ "brief.md",
60
+ "personas/domain-saas-founder.md",
61
+ "personas/regression-paranoid-shipper.md",
62
+ "personas/skeptical-agent-builder.md",
63
+ "signals/2026-04-28/router-pricing.md",
64
+ "signals/2026-04-28/competitor-launch.md",
65
+ ]
66
+
67
+ export const Default: Story = {
68
+ name: "Default (paths input)",
69
+ args: { paths: flatPaths, search: true },
70
+ };
71
+
72
+ export const FromTreeNode: Story = {
73
+ name: "From recursive FileNode",
74
+ args: { root: sampleRoot, search: true },
75
+ };
76
+
77
+ export const WithGitStatus: Story = {
78
+ name: "With git-status overlays",
79
+ args: {
80
+ paths: flatPaths,
81
+ gitStatus: [
82
+ { path: "brief.md", status: "modified" },
83
+ { path: "signals/2026-04-28/router-pricing.md", status: "added" },
84
+ { path: "personas/skeptical-agent-builder.md", status: "modified" },
85
+ ] as RichFileTreeGitEntry[],
86
+ search: true,
87
+ },
88
+ };
89
+
90
+ export const NoSearch: Story = {
91
+ name: "Without search input",
92
+ args: { paths: flatPaths, search: false },
93
+ };
94
+
95
+ export const HugeTree: Story = {
96
+ name: "Huge (5000 files — virtualization smoke test)",
97
+ args: {
98
+ paths: Array.from({ length: 5000 }, (_, i) => {
99
+ const dir = `dir-${Math.floor(i / 50)}`;
100
+ return `${dir}/file-${i}.md`;
101
+ }),
102
+ search: true,
103
+ },
104
+ };
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { render } from "@testing-library/react"
3
+ import { RichFileTree } from "./rich-file-tree"
4
+
5
+ describe("RichFileTree", () => {
6
+ it("renders without crashing given a flat path list", () => {
7
+ const { container } = render(
8
+ <RichFileTree paths={["README.md", "src/index.ts"]} />,
9
+ )
10
+ // Pierre renders into a custom element / shadow root; we can't peek
11
+ // inside shadow DOM from RTL, but we can confirm the host mounted.
12
+ expect(container.querySelectorAll("*").length).toBeGreaterThan(0)
13
+ })
14
+
15
+ it("flattens a recursive FileNode tree to paths", () => {
16
+ const { container } = render(
17
+ <RichFileTree
18
+ root={{
19
+ name: "root",
20
+ path: "",
21
+ type: "directory",
22
+ children: [
23
+ { name: "a.md", path: "a.md", type: "file" },
24
+ {
25
+ name: "b",
26
+ path: "b",
27
+ type: "directory",
28
+ children: [{ name: "c.md", path: "b/c.md", type: "file" }],
29
+ },
30
+ ],
31
+ }}
32
+ />,
33
+ )
34
+ expect(container.querySelectorAll("*").length).toBeGreaterThan(0)
35
+ })
36
+
37
+ it("throws when both root and paths are passed", () => {
38
+ expect(() =>
39
+ render(<RichFileTree root={{ name: "x", path: "x", type: "file" }} paths={["y"]} />),
40
+ ).toThrow(/root.*paths.*not both/i)
41
+ })
42
+ })
@@ -0,0 +1,232 @@
1
+ /**
2
+ * RichFileTree — feature-rich file browser built on @pierre/trees.
3
+ *
4
+ * Why this exists alongside the existing FileTree:
5
+ * - Built-in search (cmd-K-style fuzzy filter)
6
+ * - Virtualized rendering for vaults with thousands of files
7
+ * - Git-status overlays per row (modified / added / staged)
8
+ * - Right-click + button-lane context menus with custom item rendering
9
+ * - VS Code-style icons via @pierre/vscode-icons (optional)
10
+ *
11
+ * The existing FileTree stays as-is for simple cases. RichFileTree is the
12
+ * upgrade path when a consumer's vault grows past a few hundred files or
13
+ * needs richer affordances.
14
+ *
15
+ * Pierre renders inside a shadow DOM to keep its CSS isolated. We bridge
16
+ * the host theme via CSS variables on the host element below — the tree
17
+ * picks them up through `--trees-*-override` overrides.
18
+ */
19
+
20
+ import {
21
+ FileTree as PierreFileTree,
22
+ useFileTree as usePierreFileTree,
23
+ } from "@pierre/trees/react";
24
+ import type {
25
+ ContextMenuItem,
26
+ ContextMenuOpenContext,
27
+ GitStatus,
28
+ GitStatusEntry,
29
+ } from "@pierre/trees";
30
+ import { useEffect, useMemo, type CSSProperties, type ReactNode } from "react";
31
+ import type { FileNode } from "./file-tree";
32
+
33
+ /**
34
+ * Re-export Pierre's git-status union under a stable name so consumers
35
+ * don't import directly from the dep. If we ever swap the underlying
36
+ * implementation, only this file changes.
37
+ */
38
+ export type RichFileTreeGitStatus = GitStatus;
39
+ export type RichFileTreeGitEntry = GitStatusEntry;
40
+
41
+ export interface RichFileTreeProps {
42
+ /**
43
+ * Either a recursive `FileNode` tree (matches the existing FileTree
44
+ * input) or a flat list of canonical paths. Pass exactly one.
45
+ */
46
+ root?: FileNode;
47
+ paths?: ReadonlyArray<string>;
48
+
49
+ /** Currently-selected path. Pierre supports multi-select internally; the
50
+ * wrapper exposes a single string for parity with FileTree. */
51
+ selectedPath?: string;
52
+ /** Called whenever the selection changes (single-select fan-out). */
53
+ onSelect?: (path: string) => void;
54
+
55
+ /** Show the inline search input. Defaults to true. */
56
+ search?: boolean;
57
+ /** Open / closed initial expansion. Defaults to "open" — match FileTree. */
58
+ initialExpansion?: "open" | "closed";
59
+
60
+ /** Optional git-status decorations per path. */
61
+ gitStatus?: ReadonlyArray<RichFileTreeGitEntry>;
62
+
63
+ /**
64
+ * Right-click / button-lane menu content. Receives the row and the
65
+ * trigger context (which interaction opened the menu).
66
+ */
67
+ renderContextMenu?: (
68
+ item: ContextMenuItem,
69
+ context: ContextMenuOpenContext,
70
+ ) => ReactNode;
71
+
72
+ /** Optional header rendered above the tree rows. */
73
+ header?: ReactNode;
74
+
75
+ /**
76
+ * Theme override map for shadow-DOM CSS variables. Most consumers can
77
+ * leave this — defaults derive from the host element's computed
78
+ * tokens via `cssVarFromToken()`.
79
+ */
80
+ themeOverrides?: Partial<RichFileTreeThemeVars>;
81
+
82
+ className?: string;
83
+ style?: CSSProperties;
84
+ /** Inline height (or set via `style.height`). Defaults to 100% of parent. */
85
+ height?: number | string;
86
+ }
87
+
88
+ export interface RichFileTreeThemeVars {
89
+ /** Selected-row background. */
90
+ selectedBg: string;
91
+ /** Selected-row foreground. */
92
+ selectedFg: string;
93
+ /** Default row foreground. */
94
+ fg: string;
95
+ /** Hover background. */
96
+ hoverBg: string;
97
+ /** Border / divider color. */
98
+ border: string;
99
+ /** Muted (parent path, breadcrumb) foreground. */
100
+ mutedFg: string;
101
+ }
102
+
103
+ /**
104
+ * Map a sandbox-ui design token (e.g. `--accent-surface-soft`) to a CSS
105
+ * `var(--token)` reference. Pierre's CSS-variable surface accepts any
106
+ * valid CSS color expression, so passing `var(--token)` lets the host
107
+ * theme propagate through without us having to read computed styles.
108
+ */
109
+ function cssVarFromToken(name: string): string {
110
+ return `var(${name})`;
111
+ }
112
+
113
+ const DEFAULT_THEME: RichFileTreeThemeVars = {
114
+ selectedBg: cssVarFromToken("--accent-surface-soft"),
115
+ selectedFg: cssVarFromToken("--accent-text"),
116
+ fg: cssVarFromToken("--foreground"),
117
+ hoverBg: cssVarFromToken("--muted"),
118
+ border: cssVarFromToken("--border"),
119
+ mutedFg: cssVarFromToken("--muted-foreground"),
120
+ };
121
+
122
+ /**
123
+ * Walk a recursive FileNode and return a flat list of canonical paths.
124
+ *
125
+ * Pierre infers directory structure from path segments — passing both
126
+ * `dir` and `dir/file.md` collides because `dir` looks like a file.
127
+ * We emit only files; pure-directory leaves get a trailing slash so
128
+ * Pierre keeps them as folders even when they have no children.
129
+ */
130
+ function flattenFileNode(node: FileNode, out: string[] = []): string[] {
131
+ if (node.type === "file" && node.path) {
132
+ out.push(node.path);
133
+ return out;
134
+ }
135
+ if (node.type === "directory") {
136
+ if (node.children?.length) {
137
+ for (const child of node.children) {
138
+ flattenFileNode(child, out);
139
+ }
140
+ return out;
141
+ }
142
+ // Empty directory: trailing slash keeps Pierre treating it as a folder.
143
+ if (node.path) out.push(`${node.path}/`);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ export function RichFileTree({
149
+ root,
150
+ paths,
151
+ selectedPath,
152
+ onSelect,
153
+ search = true,
154
+ initialExpansion = "open",
155
+ gitStatus,
156
+ renderContextMenu,
157
+ header,
158
+ themeOverrides,
159
+ className,
160
+ style,
161
+ height,
162
+ }: RichFileTreeProps) {
163
+ if (root && paths) {
164
+ throw new Error("RichFileTree: pass `root` or `paths`, not both");
165
+ }
166
+
167
+ const flatPaths = useMemo<string[]>(() => {
168
+ if (paths) return Array.from(paths);
169
+ if (root) return flattenFileNode(root);
170
+ return [];
171
+ }, [paths, root]);
172
+
173
+ const { model } = usePierreFileTree({
174
+ paths: flatPaths,
175
+ search,
176
+ initialExpansion,
177
+ onSelectionChange: (selected) => {
178
+ const next = selected[0]
179
+ if (next && next !== selectedPath) onSelect?.(next)
180
+ },
181
+ });
182
+
183
+ // Push git status whenever it changes. The model's setter is the only
184
+ // way to update post-construction; useEffect keeps it in sync.
185
+ useEffect(() => {
186
+ if (!gitStatus) return;
187
+ model.setGitStatus(gitStatus);
188
+ }, [model, gitStatus]);
189
+
190
+ // Reset paths when the input changes after first render. resetPaths
191
+ // re-uses the model and preserves expansion / selection where it can.
192
+ useEffect(() => {
193
+ model.resetPaths(flatPaths);
194
+ }, [model, flatPaths]);
195
+
196
+ // Drive selection from props (controlled). Skip when already selected
197
+ // to avoid an emit loop with onSelectionChange.
198
+ useEffect(() => {
199
+ if (!selectedPath) return;
200
+ const current = model.getSelectedPaths();
201
+ if (current.length === 1 && current[0] === selectedPath) return;
202
+ // The public API exposes selection via `select` semantics through the
203
+ // controller — fall back to direct setter when present, no-op otherwise.
204
+ const m = model as unknown as { setSelectedPaths?: (paths: readonly string[]) => void };
205
+ m.setSelectedPaths?.([selectedPath]);
206
+ }, [model, selectedPath]);
207
+
208
+ const theme = { ...DEFAULT_THEME, ...themeOverrides };
209
+ const themeStyle = useMemo<CSSProperties>(
210
+ () => ({
211
+ ["--trees-selected-bg-override" as string]: theme.selectedBg,
212
+ ["--trees-selected-fg-override" as string]: theme.selectedFg,
213
+ ["--trees-fg-override" as string]: theme.fg,
214
+ ["--trees-hover-bg-override" as string]: theme.hoverBg,
215
+ ["--trees-border-color-override" as string]: theme.border,
216
+ ["--trees-muted-fg-override" as string]: theme.mutedFg,
217
+ height: height ?? "100%",
218
+ ...style,
219
+ }),
220
+ [theme, height, style],
221
+ );
222
+
223
+ return (
224
+ <PierreFileTree
225
+ model={model}
226
+ header={header}
227
+ renderContextMenu={renderContextMenu}
228
+ className={className}
229
+ style={themeStyle}
230
+ />
231
+ );
232
+ }
@@ -0,0 +1,10 @@
1
+ export * from "./use-auto-scroll";
2
+ export * from "./use-dropdown-menu";
3
+ export * from "./use-live-time";
4
+ export * from "./use-run-collapse-state";
5
+ export * from "./use-run-groups";
6
+ export * from "./use-tool-call-stream";
7
+ export * from "./use-auth";
8
+ export * from "./use-realtime-session";
9
+ export * from "./use-sdk-session";
10
+ export * from "./use-sse-stream";