@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,389 @@
1
+ /**
2
+ * ChatInput — message input bar with file attach, drag-and-drop, send/cancel.
3
+ *
4
+ * - Auto-resizing textarea (up to max height)
5
+ * - Enter to send, Shift+Enter for newline
6
+ * - Drag-and-drop files onto the input with styled overlay
7
+ * - File attachment button (files) + folder attachment button
8
+ * - Pending file/folder chips
9
+ * - Cancel button when streaming
10
+ * - Optional model selector pill
11
+ */
12
+
13
+ import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, type DragEvent } from "react";
14
+ import { Send, Square, Paperclip, FolderUp, X, Upload } from "lucide-react";
15
+ import { cn } from "../lib/utils";
16
+
17
+ export interface PendingFile {
18
+ id: string;
19
+ name: string;
20
+ size: number;
21
+ type: "file" | "folder";
22
+ /** Number of files inside (for folders) */
23
+ fileCount?: number;
24
+ status: "pending" | "uploading" | "ready" | "error";
25
+ }
26
+
27
+ export interface ChatInputProps {
28
+ onSend: (message: string, files?: File[]) => void;
29
+ onCancel?: () => void;
30
+ isStreaming?: boolean;
31
+ disabled?: boolean;
32
+ placeholder?: string;
33
+ /** Currently selected model label */
34
+ modelLabel?: string;
35
+ onModelClick?: () => void;
36
+ /** Pending uploaded files */
37
+ pendingFiles?: PendingFile[];
38
+ onRemoveFile?: (id: string) => void;
39
+ /** Called when files are attached (via button or drag-and-drop) */
40
+ onAttach?: (files: FileList) => void;
41
+ /** Called when a folder is selected via the folder button */
42
+ onAttachFolder?: (files: FileList) => void;
43
+ /** Accepted file types for the file input (e.g. ".pdf,.csv") */
44
+ accept?: string;
45
+ /** Drop zone overlay title */
46
+ dropTitle?: string;
47
+ /** Drop zone overlay description */
48
+ dropDescription?: string;
49
+ className?: string;
50
+ /** Label above the input. Set to null to hide. Default: "Agent Command Deck" */
51
+ inputLabel?: string | null;
52
+ /** Status text shown when idle. Set to null to hide. Default: "Ready for next instruction" */
53
+ idleStatus?: string | null;
54
+ /** Status text shown when streaming. Set to null to hide. Default: "Streaming response" */
55
+ streamingStatus?: string | null;
56
+ /** Hide the Cmd+L focus shortcut hint */
57
+ hideShortcutHint?: boolean;
58
+ }
59
+
60
+ export function ChatInput({
61
+ onSend,
62
+ onCancel,
63
+ isStreaming,
64
+ disabled,
65
+ placeholder = "Ask the agent to inspect files, run commands, or explain results…",
66
+ modelLabel,
67
+ onModelClick,
68
+ pendingFiles = [],
69
+ onRemoveFile,
70
+ onAttach,
71
+ onAttachFolder,
72
+ accept,
73
+ dropTitle = "Drop files to add context",
74
+ dropDescription = "Files will be attached to your next message.",
75
+ className,
76
+ inputLabel = "Agent Command Deck",
77
+ idleStatus = "Ready for next instruction",
78
+ streamingStatus = "Streaming response",
79
+ hideShortcutHint,
80
+ }: ChatInputProps) {
81
+ const [value, setValue] = useState("");
82
+ const [dragOver, setDragOver] = useState(false);
83
+ const dragCounter = useRef(0);
84
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
85
+ const fileInputRef = useRef<HTMLInputElement>(null);
86
+ const folderInputRef = useRef<HTMLInputElement>(null);
87
+
88
+ const handleSend = useCallback(() => {
89
+ const trimmed = value.trim();
90
+ if (!trimmed || isStreaming || disabled) return;
91
+ onSend(trimmed);
92
+ setValue("");
93
+ if (textareaRef.current) {
94
+ textareaRef.current.style.height = "auto";
95
+ }
96
+ }, [value, isStreaming, disabled, onSend]);
97
+
98
+ const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
99
+ if (e.key === "Enter" && !e.shiftKey) {
100
+ e.preventDefault();
101
+ handleSend();
102
+ }
103
+ };
104
+
105
+ const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
106
+ setValue(e.target.value);
107
+ const el = e.target;
108
+ el.style.height = "auto";
109
+ el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
110
+ };
111
+
112
+ const handleAttachClick = () => {
113
+ fileInputRef.current?.click();
114
+ };
115
+
116
+ const handleFolderClick = () => {
117
+ folderInputRef.current?.click();
118
+ };
119
+
120
+ const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
121
+ if (e.target.files?.length) {
122
+ onAttach?.(e.target.files);
123
+ e.target.value = "";
124
+ }
125
+ };
126
+
127
+ const handleFolderChange = (e: ChangeEvent<HTMLInputElement>) => {
128
+ if (e.target.files?.length) {
129
+ (onAttachFolder ?? onAttach)?.(e.target.files);
130
+ e.target.value = "";
131
+ }
132
+ };
133
+
134
+ // Drag-and-drop handlers
135
+ const handleDragEnter = useCallback((e: DragEvent) => {
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+ dragCounter.current++;
139
+ if (e.dataTransfer?.types.includes("Files")) {
140
+ setDragOver(true);
141
+ }
142
+ }, []);
143
+
144
+ const handleDragLeave = useCallback((e: DragEvent) => {
145
+ e.preventDefault();
146
+ e.stopPropagation();
147
+ dragCounter.current--;
148
+ if (dragCounter.current === 0) {
149
+ setDragOver(false);
150
+ }
151
+ }, []);
152
+
153
+ const handleDragOver = useCallback((e: DragEvent) => {
154
+ e.preventDefault();
155
+ e.stopPropagation();
156
+ e.dataTransfer.dropEffect = "copy";
157
+ }, []);
158
+
159
+ const handleDrop = useCallback((e: DragEvent) => {
160
+ e.preventDefault();
161
+ e.stopPropagation();
162
+ dragCounter.current = 0;
163
+ setDragOver(false);
164
+
165
+ const files = e.dataTransfer?.files;
166
+ if (files?.length && onAttach) {
167
+ onAttach(files);
168
+ }
169
+ }, [onAttach]);
170
+
171
+ const fileChips = pendingFiles.filter((f) => f.type === "file" || !f.type);
172
+ const folderChips = pendingFiles.filter((f) => f.type === "folder");
173
+
174
+ return (
175
+ <div
176
+ className={cn("relative", className)}
177
+ onDragEnter={onAttach ? handleDragEnter : undefined}
178
+ onDragLeave={onAttach ? handleDragLeave : undefined}
179
+ onDragOver={onAttach ? handleDragOver : undefined}
180
+ onDrop={onAttach ? handleDrop : undefined}
181
+ >
182
+ {/* Drop zone overlay */}
183
+ {dragOver && (
184
+ <div className="absolute inset-0 z-10 flex items-center justify-center rounded-[var(--radius-xl)] border-2 border-dashed border-border bg-card pointer-events-none">
185
+ <div className="text-center">
186
+ <div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--accent-surface-soft)]">
187
+ <Upload className="h-6 w-6 text-primary" />
188
+ </div>
189
+ <p className="text-sm font-semibold text-foreground">{dropTitle}</p>
190
+ <p className="mt-1 text-xs text-muted-foreground">{dropDescription}</p>
191
+ </div>
192
+ </div>
193
+ )}
194
+
195
+ {/* Pending file chips */}
196
+ {pendingFiles.length > 0 && (
197
+ <div className="mb-3 flex flex-wrap gap-2">
198
+ {folderChips.map((f) => (
199
+ <span
200
+ key={f.id}
201
+ className={cn(
202
+ "inline-flex items-center gap-1.5 rounded-[var(--radius-full)] border px-3 py-1.5 text-xs",
203
+ "border-border bg-muted/50",
204
+ f.status === "error" && "border-[var(--code-error)]/30 text-[var(--code-error)]",
205
+ f.status !== "error" && "text-foreground",
206
+ )}
207
+ >
208
+ <FolderUp className="h-3 w-3 shrink-0" />
209
+ <span className="truncate max-w-[150px]">{f.name}</span>
210
+ {f.fileCount !== undefined && (
211
+ <span className="text-muted-foreground">({f.fileCount})</span>
212
+ )}
213
+ {f.status === "uploading" && (
214
+ <span className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
215
+ )}
216
+ {onRemoveFile && (
217
+ <button
218
+ type="button"
219
+ aria-label={`Remove ${f.name}`}
220
+ onClick={() => onRemoveFile(f.id)}
221
+ className="rounded p-0.5 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
222
+ >
223
+ <X className="h-3 w-3" />
224
+ </button>
225
+ )}
226
+ </span>
227
+ ))}
228
+ {fileChips.map((f) => (
229
+ <span
230
+ key={f.id}
231
+ className={cn(
232
+ "inline-flex items-center gap-1.5 rounded-[var(--radius-full)] border px-3 py-1.5 text-xs",
233
+ "border-border bg-muted/50",
234
+ f.status === "error" && "border-[var(--code-error)]/30 text-[var(--code-error)]",
235
+ f.status !== "error" && "text-foreground",
236
+ )}
237
+ >
238
+ <Paperclip className="h-3 w-3 shrink-0" />
239
+ <span className="truncate max-w-[150px]">{f.name}</span>
240
+ {f.status === "uploading" && (
241
+ <span className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
242
+ )}
243
+ {onRemoveFile && (
244
+ <button
245
+ type="button"
246
+ aria-label={`Remove ${f.name}`}
247
+ onClick={() => onRemoveFile(f.id)}
248
+ className="rounded p-0.5 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
249
+ >
250
+ <X className="h-3 w-3" />
251
+ </button>
252
+ )}
253
+ </span>
254
+ ))}
255
+ </div>
256
+ )}
257
+
258
+ {/* Input row */}
259
+ <div className="rounded-[24px] border border-[var(--chat-input-border,var(--border-default))] [background:var(--chat-input-bg,var(--bg-card))] shadow-[var(--chat-input-shadow,0_1px_2px_rgba(15,23,42,0.05))] transition-all focus-within:border-[var(--chat-input-focus-border,var(--border-accent))] focus-within:shadow-[var(--chat-input-focus-shadow,0_10px_30px_rgba(15,23,42,0.08))]">
260
+ <div className="rounded-[24px] px-4 py-[var(--chat-input-py)]">
261
+ {(inputLabel !== null || idleStatus !== null || streamingStatus !== null) && (
262
+ <div className="mb-1.5 flex items-center justify-between gap-3 px-1">
263
+ {inputLabel !== null && (
264
+ <div className="text-[var(--chat-label-size,11px)] font-[var(--chat-label-weight,600)] uppercase tracking-[var(--chat-label-tracking,0.16em)] text-[var(--text-muted)]">
265
+ {inputLabel}
266
+ </div>
267
+ )}
268
+ {(idleStatus !== null || streamingStatus !== null) && (
269
+ <div className="text-[var(--chat-label-size,11px)] text-[var(--text-muted)]">
270
+ {isStreaming ? (streamingStatus ?? "") : (idleStatus ?? "")}
271
+ </div>
272
+ )}
273
+ </div>
274
+ )}
275
+ <div className="flex items-end gap-2.5">
276
+ {/* Attach buttons */}
277
+ {onAttach && (
278
+ <>
279
+ <button
280
+ type="button"
281
+ onClick={handleAttachClick}
282
+ disabled={isStreaming}
283
+ aria-label="Attach files"
284
+ title="Attach files"
285
+ className="mb-0.5 shrink-0 rounded-[var(--radius-md)] border border-transparent p-2 text-muted-foreground transition-colors hover:border-border hover:bg-accent hover:text-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
286
+ >
287
+ <Paperclip className="h-4 w-4" />
288
+ </button>
289
+ <input
290
+ ref={fileInputRef}
291
+ type="file"
292
+ multiple
293
+ className="hidden"
294
+ onChange={handleFileChange}
295
+ accept={accept ?? ".pdf,.csv,.xlsx,.xls,.jpg,.jpeg,.png,.gif,.txt,.json,.yaml,.yml"}
296
+ />
297
+ </>
298
+ )}
299
+ {(onAttachFolder ?? onAttach) && (
300
+ <>
301
+ <button
302
+ type="button"
303
+ onClick={handleFolderClick}
304
+ disabled={isStreaming}
305
+ aria-label="Attach folder"
306
+ title="Attach folder"
307
+ className="mb-0.5 shrink-0 rounded-[var(--radius-md)] border border-transparent p-2 text-muted-foreground transition-colors hover:border-border hover:bg-accent hover:text-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
308
+ >
309
+ <FolderUp className="h-4 w-4" />
310
+ </button>
311
+ <input
312
+ ref={folderInputRef}
313
+ type="file"
314
+ multiple
315
+ className="hidden"
316
+ onChange={handleFolderChange}
317
+ // @ts-ignore webkitdirectory is non-standard but widely supported
318
+ webkitdirectory=""
319
+ />
320
+ </>
321
+ )}
322
+
323
+ {/* Textarea */}
324
+ <textarea
325
+ ref={textareaRef}
326
+ value={value}
327
+ onChange={handleChange}
328
+ onKeyDown={handleKeyDown}
329
+ placeholder={placeholder}
330
+ disabled={isStreaming || disabled}
331
+ rows={1}
332
+ aria-label="Message input"
333
+ className="min-h-[42px] max-h-[160px] flex-1 resize-none bg-transparent py-2 text-[15px] leading-6 text-foreground placeholder:text-muted-foreground disabled:opacity-50 focus-visible:outline-none"
334
+ />
335
+
336
+ {/* Send / Cancel */}
337
+ {isStreaming ? (
338
+ <button
339
+ type="button"
340
+ onClick={onCancel}
341
+ aria-label="Stop response"
342
+ className="mb-0.5 shrink-0 rounded-[var(--radius-lg)] border border-[var(--code-error)]/20 bg-[var(--code-error)]/14 p-2.5 text-[var(--code-error)] transition-colors hover:bg-[var(--code-error)]/24 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--code-error)]/50"
343
+ >
344
+ <Square className="h-4 w-4" />
345
+ </button>
346
+ ) : (
347
+ <button
348
+ type="button"
349
+ onClick={handleSend}
350
+ disabled={!value.trim() || disabled}
351
+ aria-label="Send message"
352
+ className="mb-0.5 inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[var(--chat-send-border,var(--border-accent))] [background:var(--chat-send-bg,var(--brand-primary))] px-3.5 py-2.5 text-sm font-medium text-[var(--chat-send-color,white)] shadow-[var(--chat-send-shadow,0_6px_16px_rgba(15,23,42,0.12))] transition-all hover:translate-y-[-1px] hover:[background:var(--chat-send-hover-bg,var(--brand-strong))] disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chat-send-ring,var(--border-accent))]"
353
+ >
354
+ <Send className="h-4 w-4" />
355
+ <span>Send</span>
356
+ </button>
357
+ )}
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ {/* Footer: model selector + shortcuts */}
363
+ {(modelLabel || !hideShortcutHint) && (
364
+ <div className="mt-2 flex items-center justify-between px-1">
365
+ <div className="flex items-center gap-2">
366
+ {modelLabel && (
367
+ <button
368
+ type="button"
369
+ onClick={onModelClick}
370
+ aria-label={`Select model, current model ${modelLabel}`}
371
+ className="inline-flex items-center gap-1.5 rounded-[var(--radius-full)] border border-border bg-[linear-gradient(180deg,rgba(255,255,255,0.04),transparent)] px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/20 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
372
+ >
373
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--code-success)]" />
374
+ {modelLabel}
375
+ </button>
376
+ )}
377
+ </div>
378
+ {!hideShortcutHint && (
379
+ <span className="text-xs text-muted-foreground">
380
+ <kbd className="px-1 py-0.5 bg-background rounded border border-border text-[10px]">Cmd</kbd>
381
+ <kbd className="px-1 py-0.5 bg-background rounded border border-border text-[10px] ml-0.5">L</kbd>
382
+ <span className="ml-1">to focus</span>
383
+ </span>
384
+ )}
385
+ </div>
386
+ )}
387
+ </div>
388
+ );
389
+ }
@@ -0,0 +1,237 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { ChatMessage } from './chat-message'
3
+ import { ToolCallStep } from '../run/tool-call-step'
4
+
5
+ const meta: Meta<typeof ChatMessage> = {
6
+ title: 'Chat/ChatMessage',
7
+ component: ChatMessage,
8
+ parameters: {
9
+ layout: 'fullscreen',
10
+ backgrounds: { default: 'dark' },
11
+ },
12
+ decorators: [
13
+ (Story) => (
14
+ <div className="min-h-screen bg-[var(--bg-root)] p-8">
15
+ <div className="mx-auto max-w-3xl space-y-4">
16
+ <Story />
17
+ </div>
18
+ </div>
19
+ ),
20
+ ],
21
+ }
22
+
23
+ export default meta
24
+ type Story = StoryObj<typeof ChatMessage>
25
+
26
+ const ts = (offsetMinutes = 0) =>
27
+ new Date(Date.now() - offsetMinutes * 60 * 1000)
28
+
29
+ export const UserMessage: Story = {
30
+ args: {
31
+ role: 'user',
32
+ content: 'How do I implement a binary search tree in TypeScript?',
33
+ timestamp: ts(3),
34
+ },
35
+ }
36
+
37
+ export const AssistantSimple: Story = {
38
+ args: {
39
+ role: 'assistant',
40
+ content:
41
+ 'A binary search tree (BST) is a node-based data structure where each node has at most two children, and all left descendants are less than the node, all right descendants are greater.',
42
+ timestamp: ts(2),
43
+ },
44
+ }
45
+
46
+ export const AssistantWithCode: Story = {
47
+ args: {
48
+ role: 'assistant',
49
+ content: `Here's a clean BST implementation in TypeScript:
50
+
51
+ \`\`\`typescript
52
+ interface TreeNode<T> {
53
+ value: T
54
+ left: TreeNode<T> | null
55
+ right: TreeNode<T> | null
56
+ }
57
+
58
+ class BinarySearchTree<T> {
59
+ private root: TreeNode<T> | null = null
60
+
61
+ insert(value: T): void {
62
+ this.root = this.insertNode(this.root, value)
63
+ }
64
+
65
+ private insertNode(node: TreeNode<T> | null, value: T): TreeNode<T> {
66
+ if (!node) return { value, left: null, right: null }
67
+ if (value < node.value) node.left = this.insertNode(node.left, value)
68
+ else if (value > node.value) node.right = this.insertNode(node.right, value)
69
+ return node
70
+ }
71
+
72
+ contains(value: T): boolean {
73
+ let current = this.root
74
+ while (current) {
75
+ if (value === current.value) return true
76
+ current = value < current.value ? current.left : current.right
77
+ }
78
+ return false
79
+ }
80
+
81
+ inOrder(): T[] {
82
+ const result: T[] = []
83
+ const traverse = (node: TreeNode<T> | null) => {
84
+ if (!node) return
85
+ traverse(node.left)
86
+ result.push(node.value)
87
+ traverse(node.right)
88
+ }
89
+ traverse(this.root)
90
+ return result
91
+ }
92
+ }
93
+ \`\`\`
94
+
95
+ **Key properties:**
96
+ - \`insert\` — O(log n) average, O(n) worst case
97
+ - \`contains\` — O(log n) average, O(n) worst case
98
+ - \`inOrder\` — O(n) always, yields sorted output
99
+
100
+ For a balanced variant, consider AVL or Red-Black trees.`,
101
+ timestamp: ts(2),
102
+ },
103
+ }
104
+
105
+ export const AssistantStreaming: Story = {
106
+ args: {
107
+ role: 'assistant',
108
+ content: `I'll analyze the performance bottleneck in your \`fetchData\` hook. The issue is that \`useState(true)\` initializes loading as true before React even checks the cache`,
109
+ isStreaming: true,
110
+ timestamp: ts(0),
111
+ },
112
+ }
113
+
114
+ export const AssistantWithToolCalls: Story = {
115
+ args: {
116
+ role: 'assistant',
117
+ content: 'Let me read the current implementation first.',
118
+ toolCalls: (
119
+ <div className="mt-3 space-y-2">
120
+ <ToolCallStep
121
+ type="read"
122
+ label="Read src/hooks/useFetchData.ts"
123
+ status="success"
124
+ output={`export function useFetchData<T>(url: string) {\n const [data, setData] = useState<T | null>(null)\n const [loading, setLoading] = useState(true)\n // ...`}
125
+ duration={48}
126
+ />
127
+ <ToolCallStep
128
+ type="grep"
129
+ label="Search for cache references"
130
+ status="success"
131
+ output="No cache layer found. Data fetched on every mount."
132
+ duration={12}
133
+ />
134
+ </div>
135
+ ),
136
+ timestamp: ts(1),
137
+ },
138
+ }
139
+
140
+ export const AssistantMarkdownRich: Story = {
141
+ args: {
142
+ role: 'assistant',
143
+ content: `## Rate Limiter Design
144
+
145
+ The **sliding window** algorithm is the right choice here. Here's why:
146
+
147
+ | Algorithm | Burst Handling | Redis Ops | Accuracy |
148
+ |-----------|---------------|-----------|---------- |
149
+ | Fixed window | Poor | 1 | Low |
150
+ | Token bucket | Good | 2-3 | Medium |
151
+ | **Sliding window** | **Excellent** | **2** | **High** |
152
+
153
+ ### Implementation
154
+
155
+ \`\`\`typescript
156
+ import Redis from 'ioredis'
157
+
158
+ export async function rateLimitCheck(
159
+ redis: Redis,
160
+ userId: string,
161
+ limit = 100,
162
+ windowMs = 60_000,
163
+ ): Promise<{ allowed: boolean; remaining: number }> {
164
+ const now = Date.now()
165
+ const key = \`rl:\${userId}\`
166
+ const windowStart = now - windowMs
167
+
168
+ const pipeline = redis.pipeline()
169
+ pipeline.zremrangebyscore(key, '-inf', windowStart)
170
+ pipeline.zadd(key, now, \`\${now}-\${Math.random()}\`)
171
+ pipeline.zcard(key)
172
+ pipeline.pexpire(key, windowMs)
173
+
174
+ try {
175
+ const results = await pipeline.exec()
176
+ const count = (results?.[2]?.[1] as number) ?? 0
177
+ return { allowed: count <= limit, remaining: Math.max(0, limit - count) }
178
+ } catch {
179
+ // Fail open on Redis errors
180
+ return { allowed: true, remaining: limit }
181
+ }
182
+ }
183
+ \`\`\`
184
+
185
+ > **Note:** The \`zremrangebyscore\` + \`zadd\` pair is atomic per key, keeping overhead to ~0.3ms p99 on a local Redis instance.`,
186
+ timestamp: ts(1),
187
+ },
188
+ }
189
+
190
+ export const SystemMessage: Story = {
191
+ args: {
192
+ role: 'system',
193
+ content: 'Session started. Connected to agent runtime v2.4.1.',
194
+ timestamp: ts(5),
195
+ },
196
+ }
197
+
198
+ export const Conversation: Story = {
199
+ render: () => (
200
+ <div className="space-y-4">
201
+ <ChatMessage
202
+ role="user"
203
+ content="Can you help me debug why my React query cache isn't working?"
204
+ timestamp={ts(5)}
205
+ />
206
+ <ChatMessage
207
+ role="assistant"
208
+ content="Sure — let me look at your query setup. What version of TanStack Query are you on, and how are you configuring the `QueryClient`?"
209
+ timestamp={ts(4)}
210
+ />
211
+ <ChatMessage
212
+ role="user"
213
+ content="v5.28. I'm using the defaults — just `new QueryClient()` with no options."
214
+ timestamp={ts(3)}
215
+ />
216
+ <ChatMessage
217
+ role="assistant"
218
+ content={`The default \`staleTime\` in v5 is \`0\`, which means every component mount triggers a background refetch even if data is in cache. That explains the flicker.
219
+
220
+ Set a non-zero \`staleTime\`:
221
+
222
+ \`\`\`typescript
223
+ const queryClient = new QueryClient({
224
+ defaultOptions: {
225
+ queries: {
226
+ staleTime: 60 * 1000, // 1 minute
227
+ },
228
+ },
229
+ })
230
+ \`\`\`
231
+
232
+ With this, cached data is considered fresh for 60 seconds and no refetch occurs on mount.`}
233
+ timestamp={ts(2)}
234
+ />
235
+ </div>
236
+ ),
237
+ }