@tangle-network/ui 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,389 +0,0 @@
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
- }