@codrstudio/openclaude-chat 0.1.0 → 0.1.9

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 (62) hide show
  1. package/dist/components/StreamingIndicator.js +5 -5
  2. package/dist/display/DisplayReactRenderer.js +12 -12
  3. package/dist/display/react-sandbox/bootstrap.js +150 -150
  4. package/dist/styles.css +1 -2
  5. package/package.json +64 -61
  6. package/src/components/Chat.tsx +107 -107
  7. package/src/components/ErrorNote.tsx +35 -35
  8. package/src/components/LazyRender.tsx +42 -42
  9. package/src/components/Markdown.tsx +114 -114
  10. package/src/components/MessageBubble.tsx +107 -107
  11. package/src/components/MessageInput.tsx +421 -421
  12. package/src/components/MessageList.tsx +153 -153
  13. package/src/components/StreamingIndicator.tsx +19 -19
  14. package/src/display/AlertRenderer.tsx +23 -23
  15. package/src/display/CarouselRenderer.tsx +141 -141
  16. package/src/display/ChartRenderer.tsx +195 -195
  17. package/src/display/ChoiceButtonsRenderer.tsx +114 -114
  18. package/src/display/CodeBlockRenderer.tsx +49 -49
  19. package/src/display/ComparisonTableRenderer.tsx +132 -132
  20. package/src/display/DataTableRenderer.tsx +144 -144
  21. package/src/display/DisplayReactRenderer.tsx +269 -269
  22. package/src/display/FileCardRenderer.tsx +55 -55
  23. package/src/display/GalleryRenderer.tsx +65 -65
  24. package/src/display/ImageViewerRenderer.tsx +114 -114
  25. package/src/display/LinkPreviewRenderer.tsx +74 -74
  26. package/src/display/MapViewRenderer.tsx +75 -75
  27. package/src/display/MetricCardRenderer.tsx +29 -29
  28. package/src/display/PriceHighlightRenderer.tsx +62 -62
  29. package/src/display/ProductCardRenderer.tsx +112 -112
  30. package/src/display/ProgressStepsRenderer.tsx +59 -59
  31. package/src/display/SourcesListRenderer.tsx +47 -47
  32. package/src/display/SpreadsheetRenderer.tsx +86 -86
  33. package/src/display/StepTimelineRenderer.tsx +75 -75
  34. package/src/display/index.ts +21 -21
  35. package/src/display/react-sandbox/bootstrap.ts +155 -155
  36. package/src/display/registry.ts +84 -84
  37. package/src/display/sdk-types.ts +217 -217
  38. package/src/hooks/ChatProvider.tsx +21 -21
  39. package/src/hooks/useIsMobile.ts +15 -15
  40. package/src/hooks/useOpenClaudeChat.ts +476 -476
  41. package/src/index.ts +76 -76
  42. package/src/lib/utils.ts +6 -6
  43. package/src/parts/PartErrorBoundary.tsx +51 -51
  44. package/src/parts/PartRenderer.tsx +145 -145
  45. package/src/parts/ReasoningBlock.tsx +41 -41
  46. package/src/parts/ToolActivity.tsx +78 -78
  47. package/src/parts/ToolResult.tsx +79 -79
  48. package/src/styles.css +2 -2
  49. package/src/types.ts +41 -41
  50. package/src/ui/alert.tsx +77 -77
  51. package/src/ui/badge.tsx +36 -36
  52. package/src/ui/button.tsx +54 -54
  53. package/src/ui/card.tsx +68 -68
  54. package/src/ui/collapsible.tsx +7 -7
  55. package/src/ui/dialog.tsx +122 -122
  56. package/src/ui/dropdown-menu.tsx +76 -76
  57. package/src/ui/input.tsx +24 -24
  58. package/src/ui/progress.tsx +36 -36
  59. package/src/ui/scroll-area.tsx +48 -48
  60. package/src/ui/separator.tsx +31 -31
  61. package/src/ui/skeleton.tsx +9 -9
  62. package/src/ui/table.tsx +114 -114
@@ -1,421 +1,421 @@
1
- import { useRef, useEffect, useState, useCallback } from "react";
2
- import { Send, Square, Plus, Mic, X, Camera, Paperclip, Image as ImageIcon, CircleStop, Loader2 } from "lucide-react";
3
- import { Button } from "../ui/button.js";
4
- import { cn } from "../lib/utils.js";
5
-
6
- // ── Types ──
7
-
8
- export interface Attachment {
9
- id: string;
10
- file: File;
11
- preview?: string;
12
- type: "image" | "file" | "audio";
13
- }
14
-
15
- export interface MessageInputProps {
16
- input: string;
17
- setInput: (value: string) => void;
18
- handleSubmit: (e: React.FormEvent, attachments?: Attachment[]) => void;
19
- isLoading?: boolean;
20
- isUploading?: boolean;
21
- stop?: () => void;
22
- placeholder?: string;
23
- className?: string;
24
- enableAttachments?: boolean;
25
- enableVoice?: boolean;
26
- }
27
-
28
- // ── Constants ──
29
-
30
- const LINE_HEIGHT_PX = 24;
31
- const MAX_ROWS = 10;
32
- const MULTILINE_THRESHOLD_PX = LINE_HEIGHT_PX * 1.5; // 36px — above this = multiline
33
-
34
- // ── Recording Hook ──
35
-
36
- function useAudioRecording(onComplete: (file: File) => void) {
37
- const [isRecording, setIsRecording] = useState(false);
38
- const [elapsed, setElapsed] = useState(0);
39
- const mediaRecorderRef = useRef<MediaRecorder | null>(null);
40
- const chunksRef = useRef<Blob[]>([]);
41
- const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
42
-
43
- const start = useCallback(async () => {
44
- try {
45
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
46
- const recorder = new MediaRecorder(stream);
47
- mediaRecorderRef.current = recorder;
48
- chunksRef.current = [];
49
- recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
50
- recorder.onstop = () => {
51
- const blob = new Blob(chunksRef.current, { type: "audio/webm" });
52
- const file = new File([blob], `audio-${Date.now()}.webm`, { type: "audio/webm" });
53
- stream.getTracks().forEach((t) => t.stop());
54
- onComplete(file);
55
- };
56
- recorder.start();
57
- setIsRecording(true);
58
- setElapsed(0);
59
- timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
60
- } catch { /* permission denied */ }
61
- }, [onComplete]);
62
-
63
- const stop = useCallback(() => {
64
- mediaRecorderRef.current?.stop();
65
- setIsRecording(false);
66
- clearInterval(timerRef.current);
67
- }, []);
68
-
69
- const cancel = useCallback(() => {
70
- if (mediaRecorderRef.current) {
71
- mediaRecorderRef.current.onstop = null;
72
- mediaRecorderRef.current.stop();
73
- mediaRecorderRef.current.stream.getTracks().forEach((t) => t.stop());
74
- }
75
- setIsRecording(false);
76
- setElapsed(0);
77
- clearInterval(timerRef.current);
78
- }, []);
79
-
80
- useEffect(() => () => clearInterval(timerRef.current), []);
81
-
82
- return { isRecording, elapsed, start, stop, cancel };
83
- }
84
-
85
- function formatTime(seconds: number): string {
86
- const m = Math.floor(seconds / 60);
87
- const s = seconds % 60;
88
- return `${m}:${s.toString().padStart(2, "0")}`;
89
- }
90
-
91
- // ── Attachment Preview ──
92
-
93
- function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; onRemove: () => void }) {
94
- return (
95
- <div className="relative group shrink-0">
96
- {attachment.type === "image" && attachment.preview ? (
97
- <div className="relative h-16 w-16 rounded-lg overflow-hidden border border-border/50">
98
- <img src={attachment.preview} alt={attachment.file.name} className="h-full w-full object-cover" />
99
- </div>
100
- ) : (
101
- <div className="flex items-center gap-2 rounded-lg border border-border/50 bg-background/50 px-3 py-2">
102
- <Paperclip className="h-4 w-4 text-muted-foreground shrink-0" />
103
- <span className="text-xs text-muted-foreground truncate max-w-[120px]">{attachment.file.name}</span>
104
- </div>
105
- )}
106
- <button
107
- type="button"
108
- onClick={onRemove}
109
- className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-foreground text-background flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
110
- aria-label="Remover"
111
- >
112
- <X className="h-3 w-3" />
113
- </button>
114
- </div>
115
- );
116
- }
117
-
118
- // ── Plus Menu ──
119
-
120
- function PlusMenu({ onFile, onCamera, onGallery, onClose }: {
121
- onFile: () => void; onCamera: () => void; onGallery: () => void; onClose: () => void;
122
- }) {
123
- const menuRef = useRef<HTMLDivElement>(null);
124
-
125
- useEffect(() => {
126
- function handleClick(e: MouseEvent) {
127
- if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
128
- }
129
- document.addEventListener("mousedown", handleClick);
130
- return () => document.removeEventListener("mousedown", handleClick);
131
- }, [onClose]);
132
-
133
- return (
134
- <div ref={menuRef} className="absolute bottom-full left-0 mb-2 rounded-xl border border-border bg-popover text-popover-foreground shadow-lg py-1 min-w-[160px] z-10">
135
- {[
136
- { icon: Paperclip, label: "Arquivo", onClick: onFile },
137
- { icon: Camera, label: "Camera", onClick: onCamera },
138
- { icon: ImageIcon, label: "Galeria", onClick: onGallery },
139
- ].map((item) => (
140
- <button
141
- key={item.label}
142
- type="button"
143
- onClick={() => { item.onClick(); onClose(); }}
144
- className="flex w-full items-center gap-3 px-4 py-2.5 text-sm hover:bg-muted/50 transition-colors"
145
- >
146
- <item.icon className="h-4 w-4 text-muted-foreground" />
147
- {item.label}
148
- </button>
149
- ))}
150
- </div>
151
- );
152
- }
153
-
154
- // ── Main Component ──
155
-
156
- export function MessageInput({
157
- input,
158
- setInput,
159
- handleSubmit,
160
- isLoading,
161
- isUploading = false,
162
- stop,
163
- placeholder = "Caixa de mensagem...",
164
- className,
165
- enableAttachments = true,
166
- enableVoice = true,
167
- }: MessageInputProps) {
168
- const textareaRef = useRef<HTMLTextAreaElement>(null);
169
- const fileInputRef = useRef<HTMLInputElement>(null);
170
- const imageInputRef = useRef<HTMLInputElement>(null);
171
- const containerRef = useRef<HTMLDivElement>(null);
172
-
173
- const [attachments, setAttachments] = useState<Attachment[]>([]);
174
- const [showMenu, setShowMenu] = useState(false);
175
- const [isDragging, setIsDragging] = useState(false);
176
- const [isMultiline, setIsMultiline] = useState(false);
177
- const historyRef = useRef<string[]>([]);
178
- const historyPosRef = useRef(-1);
179
- const savedInputRef = useRef("");
180
-
181
- const onRecordingComplete = useCallback((file: File) => {
182
- const att: Attachment = { id: crypto.randomUUID(), file, type: "audio" };
183
- setAttachments((prev) => [...prev, att]);
184
- }, []);
185
-
186
- const { isRecording, elapsed, start: startRecording, stop: stopRecording, cancel: cancelRecording } = useAudioRecording(onRecordingComplete);
187
-
188
- const hasContent = input.trim().length > 0 || attachments.length > 0;
189
-
190
- // Auto-focus
191
- useEffect(() => { textareaRef.current?.focus(); }, []);
192
-
193
- // Auto-expand — stable measurement with height:auto
194
- useEffect(() => {
195
- const el = textareaRef.current;
196
- if (!el) return;
197
- el.style.height = "auto";
198
- const scrollH = el.scrollHeight;
199
- const maxH = MAX_ROWS * LINE_HEIGHT_PX;
200
- const clampedH = Math.min(scrollH, maxH);
201
- el.style.height = `${clampedH}px`;
202
- setIsMultiline(scrollH > MULTILINE_THRESHOLD_PX);
203
- }, [input]);
204
-
205
- // ── Attachments ──
206
-
207
- const addFiles = useCallback((files: FileList | File[]) => {
208
- const newAttachments: Attachment[] = Array.from(files).map((file) => {
209
- const isImage = file.type.startsWith("image/");
210
- const att: Attachment = { id: crypto.randomUUID(), file, type: isImage ? "image" : "file" };
211
- if (isImage) {
212
- const reader = new FileReader();
213
- reader.onload = (e) => {
214
- setAttachments((prev) => prev.map((a) => (a.id === att.id ? { ...a, preview: e.target?.result as string } : a)));
215
- };
216
- reader.readAsDataURL(file);
217
- }
218
- return att;
219
- });
220
- setAttachments((prev) => [...prev, ...newAttachments]);
221
- }, []);
222
-
223
- const removeAttachment = useCallback((id: string) => {
224
- setAttachments((prev) => prev.filter((a) => a.id !== id));
225
- }, []);
226
-
227
- // ── Drag & Drop ──
228
-
229
- const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }, []);
230
-
231
- const handleDragLeave = useCallback((e: React.DragEvent) => {
232
- if (containerRef.current && !containerRef.current.contains(e.relatedTarget as Node)) setIsDragging(false);
233
- }, []);
234
-
235
- const handleDrop = useCallback((e: React.DragEvent) => {
236
- e.preventDefault(); setIsDragging(false);
237
- if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
238
- }, [addFiles]);
239
-
240
- // ── Clipboard Paste ──
241
-
242
- const handlePaste = useCallback((e: React.ClipboardEvent) => {
243
- const files: File[] = [];
244
- for (let i = 0; i < e.clipboardData.items.length; i++) {
245
- if (e.clipboardData.items[i].kind === "file") {
246
- const file = e.clipboardData.items[i].getAsFile();
247
- if (file) files.push(file);
248
- }
249
- }
250
- if (files.length) { e.preventDefault(); addFiles(files); }
251
- }, [addFiles]);
252
-
253
- // ── Submit ──
254
-
255
- function onSubmit(e: React.FormEvent) {
256
- e.preventDefault();
257
- if (!hasContent || isLoading) return;
258
- if (input.trim()) {
259
- historyRef.current.unshift(input);
260
- if (historyRef.current.length > 50) historyRef.current.length = 50;
261
- }
262
- historyPosRef.current = -1;
263
- savedInputRef.current = "";
264
- handleSubmit(e, attachments.length > 0 ? attachments : undefined);
265
- setAttachments([]);
266
- }
267
-
268
- function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
269
- // History navigation — only when cursor is at start/end and not multiline content
270
- const el = textareaRef.current;
271
- if (e.key === "ArrowUp" && historyRef.current.length > 0 && el) {
272
- const atTop = el.selectionStart === 0 && el.selectionEnd === 0;
273
- const isEmpty = input === "";
274
- if (atTop || isEmpty) {
275
- e.preventDefault();
276
- if (historyPosRef.current === -1) savedInputRef.current = input;
277
- const nextPos = Math.min(historyPosRef.current + 1, historyRef.current.length - 1);
278
- if (nextPos !== historyPosRef.current) {
279
- historyPosRef.current = nextPos;
280
- setInput(historyRef.current[nextPos]);
281
- }
282
- return;
283
- }
284
- }
285
-
286
- if (e.key === "ArrowDown" && historyPosRef.current >= 0 && el) {
287
- const atBottom = el.selectionStart === input.length;
288
- if (atBottom) {
289
- e.preventDefault();
290
- const nextPos = historyPosRef.current - 1;
291
- historyPosRef.current = nextPos;
292
- setInput(nextPos < 0 ? savedInputRef.current : historyRef.current[nextPos]);
293
- return;
294
- }
295
- }
296
-
297
- if (e.key === "Enter" && !e.shiftKey) {
298
- e.preventDefault();
299
- if (hasContent && !isLoading) onSubmit(e as unknown as React.FormEvent);
300
- }
301
- }
302
-
303
- // ── Render ──
304
-
305
- return (
306
- <div
307
- ref={containerRef}
308
- onDragOver={enableAttachments ? handleDragOver : undefined}
309
- onDragLeave={enableAttachments ? handleDragLeave : undefined}
310
- onDrop={enableAttachments ? handleDrop : undefined}
311
- className={cn(
312
- "relative border border-border/50 bg-muted transition-[border-radius] duration-200",
313
- isMultiline || attachments.length > 0 ? "rounded-2xl" : "rounded-full",
314
- isDragging && "ring-2 ring-primary/50",
315
- className,
316
- )}
317
- >
318
- {/* ── Drag overlay ── */}
319
- {isDragging && (
320
- <div className="absolute inset-0 z-10 flex items-center justify-center rounded-[inherit] bg-primary/5 border-2 border-dashed border-primary/30">
321
- <span className="text-sm text-primary font-medium">Solte aqui</span>
322
- </div>
323
- )}
324
-
325
- {/* ── Recording overlay ── */}
326
- {isRecording && (
327
- <div className="flex items-center gap-3 px-3 py-2">
328
- <Button type="button" variant="ghost" size="icon" onClick={cancelRecording} className="h-8 w-8 rounded-full shrink-0 text-muted-foreground" aria-label="Cancelar">
329
- <X className="h-4 w-4" />
330
- </Button>
331
- <div className="flex items-center gap-2 flex-1">
332
- <span className="h-2 w-2 rounded-full bg-destructive animate-pulse" />
333
- <span className="text-sm font-medium tabular-nums">{formatTime(elapsed)}</span>
334
- <div className="flex-1 flex items-center gap-0.5 px-2">
335
- {Array.from({ length: 20 }, (_, i) => (
336
- <span key={i} className="w-1 bg-foreground/30 rounded-full" style={{ height: `${4 + Math.random() * 12}px` }} />
337
- ))}
338
- </div>
339
- </div>
340
- <Button type="button" size="icon" onClick={stopRecording} className="h-8 w-8 rounded-full shrink-0" aria-label="Parar">
341
- <CircleStop className="h-4 w-4" />
342
- </Button>
343
- </div>
344
- )}
345
-
346
- {/* ── Main content (hidden when recording) ── */}
347
- <div className={cn(isRecording && "hidden")}>
348
- {/* Attachment previews */}
349
- {(attachments.length > 0 || isUploading) && (
350
- <div className="flex flex-wrap gap-2 px-4 pt-3 pb-1">
351
- {attachments.map((att) => (
352
- <AttachmentPreview key={att.id} attachment={att} onRemove={() => removeAttachment(att.id)} />
353
- ))}
354
- {isUploading && (
355
- <div className="flex items-center gap-2 rounded-lg border border-border/50 bg-background/50 px-3 py-2 text-xs text-muted-foreground">
356
- <Loader2 className="h-4 w-4 animate-spin shrink-0" />
357
- <span>Enviando arquivos...</span>
358
- </div>
359
- )}
360
- </div>
361
- )}
362
-
363
- {/* ── Single unified input row ── */}
364
- <div className={cn("flex gap-1 p-1.5", isMultiline ? "items-end" : "items-center")}>
365
- {/* Left: Plus button */}
366
- {enableAttachments && (
367
- <div className="relative shrink-0">
368
- <Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => setShowMenu(!showMenu)} aria-label="Adicionar">
369
- <Plus className="h-4 w-4" />
370
- </Button>
371
- {showMenu && (
372
- <PlusMenu
373
- onFile={() => fileInputRef.current?.click()}
374
- onCamera={() => imageInputRef.current?.click()}
375
- onGallery={() => imageInputRef.current?.click()}
376
- onClose={() => setShowMenu(false)}
377
- />
378
- )}
379
- </div>
380
- )}
381
-
382
- {/* Center: Textarea */}
383
- <textarea
384
- ref={textareaRef}
385
- value={input}
386
- onChange={(e) => setInput(e.target.value)}
387
- onKeyDown={handleKeyDown}
388
- onPaste={enableAttachments ? handlePaste : undefined}
389
- placeholder={placeholder}
390
- rows={1}
391
- disabled={isLoading}
392
- aria-label="Mensagem"
393
- className="flex-1 min-w-0 bg-transparent text-foreground text-sm resize-none outline-none placeholder:text-muted-foreground leading-6 py-1 px-2"
394
- />
395
-
396
- {/* Right: Action buttons */}
397
- <div className="flex items-center gap-0.5 shrink-0">
398
- {enableVoice && !hasContent && (
399
- <Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-full text-muted-foreground" onClick={startRecording} aria-label="Gravar audio">
400
- <Mic className="h-4 w-4" />
401
- </Button>
402
- )}
403
- {isLoading && stop ? (
404
- <Button type="button" variant="ghost" size="icon" onClick={stop} className="h-8 w-8 rounded-full" aria-label="Parar geração">
405
- <Square className="h-4 w-4" />
406
- </Button>
407
- ) : (
408
- <Button type="button" size="icon" onClick={onSubmit} disabled={!hasContent || !!isLoading} className="h-8 w-8 rounded-full" aria-label="Enviar mensagem">
409
- <Send className="h-4 w-4" />
410
- </Button>
411
- )}
412
- </div>
413
- </div>
414
- </div>
415
-
416
- {/* Hidden file inputs */}
417
- <input ref={fileInputRef} type="file" multiple className="hidden" onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ""; }} />
418
- <input ref={imageInputRef} type="file" accept="image/*" multiple className="hidden" onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ""; }} />
419
- </div>
420
- );
421
- }
1
+ import { useRef, useEffect, useState, useCallback } from "react";
2
+ import { Send, Square, Plus, Mic, X, Camera, Paperclip, Image as ImageIcon, CircleStop, Loader2 } from "lucide-react";
3
+ import { Button } from "../ui/button.js";
4
+ import { cn } from "../lib/utils.js";
5
+
6
+ // ── Types ──
7
+
8
+ export interface Attachment {
9
+ id: string;
10
+ file: File;
11
+ preview?: string;
12
+ type: "image" | "file" | "audio";
13
+ }
14
+
15
+ export interface MessageInputProps {
16
+ input: string;
17
+ setInput: (value: string) => void;
18
+ handleSubmit: (e: React.FormEvent, attachments?: Attachment[]) => void;
19
+ isLoading?: boolean;
20
+ isUploading?: boolean;
21
+ stop?: () => void;
22
+ placeholder?: string;
23
+ className?: string;
24
+ enableAttachments?: boolean;
25
+ enableVoice?: boolean;
26
+ }
27
+
28
+ // ── Constants ──
29
+
30
+ const LINE_HEIGHT_PX = 24;
31
+ const MAX_ROWS = 10;
32
+ const MULTILINE_THRESHOLD_PX = LINE_HEIGHT_PX * 1.5; // 36px — above this = multiline
33
+
34
+ // ── Recording Hook ──
35
+
36
+ function useAudioRecording(onComplete: (file: File) => void) {
37
+ const [isRecording, setIsRecording] = useState(false);
38
+ const [elapsed, setElapsed] = useState(0);
39
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
40
+ const chunksRef = useRef<Blob[]>([]);
41
+ const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
42
+
43
+ const start = useCallback(async () => {
44
+ try {
45
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
46
+ const recorder = new MediaRecorder(stream);
47
+ mediaRecorderRef.current = recorder;
48
+ chunksRef.current = [];
49
+ recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
50
+ recorder.onstop = () => {
51
+ const blob = new Blob(chunksRef.current, { type: "audio/webm" });
52
+ const file = new File([blob], `audio-${Date.now()}.webm`, { type: "audio/webm" });
53
+ stream.getTracks().forEach((t) => t.stop());
54
+ onComplete(file);
55
+ };
56
+ recorder.start();
57
+ setIsRecording(true);
58
+ setElapsed(0);
59
+ timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
60
+ } catch { /* permission denied */ }
61
+ }, [onComplete]);
62
+
63
+ const stop = useCallback(() => {
64
+ mediaRecorderRef.current?.stop();
65
+ setIsRecording(false);
66
+ clearInterval(timerRef.current);
67
+ }, []);
68
+
69
+ const cancel = useCallback(() => {
70
+ if (mediaRecorderRef.current) {
71
+ mediaRecorderRef.current.onstop = null;
72
+ mediaRecorderRef.current.stop();
73
+ mediaRecorderRef.current.stream.getTracks().forEach((t) => t.stop());
74
+ }
75
+ setIsRecording(false);
76
+ setElapsed(0);
77
+ clearInterval(timerRef.current);
78
+ }, []);
79
+
80
+ useEffect(() => () => clearInterval(timerRef.current), []);
81
+
82
+ return { isRecording, elapsed, start, stop, cancel };
83
+ }
84
+
85
+ function formatTime(seconds: number): string {
86
+ const m = Math.floor(seconds / 60);
87
+ const s = seconds % 60;
88
+ return `${m}:${s.toString().padStart(2, "0")}`;
89
+ }
90
+
91
+ // ── Attachment Preview ──
92
+
93
+ function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; onRemove: () => void }) {
94
+ return (
95
+ <div className="relative group shrink-0">
96
+ {attachment.type === "image" && attachment.preview ? (
97
+ <div className="relative h-16 w-16 rounded-lg overflow-hidden border border-border/50">
98
+ <img src={attachment.preview} alt={attachment.file.name} className="h-full w-full object-cover" />
99
+ </div>
100
+ ) : (
101
+ <div className="flex items-center gap-2 rounded-lg border border-border/50 bg-background/50 px-3 py-2">
102
+ <Paperclip className="h-4 w-4 text-muted-foreground shrink-0" />
103
+ <span className="text-xs text-muted-foreground truncate max-w-[120px]">{attachment.file.name}</span>
104
+ </div>
105
+ )}
106
+ <button
107
+ type="button"
108
+ onClick={onRemove}
109
+ className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-foreground text-background flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
110
+ aria-label="Remover"
111
+ >
112
+ <X className="h-3 w-3" />
113
+ </button>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ // ── Plus Menu ──
119
+
120
+ function PlusMenu({ onFile, onCamera, onGallery, onClose }: {
121
+ onFile: () => void; onCamera: () => void; onGallery: () => void; onClose: () => void;
122
+ }) {
123
+ const menuRef = useRef<HTMLDivElement>(null);
124
+
125
+ useEffect(() => {
126
+ function handleClick(e: MouseEvent) {
127
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
128
+ }
129
+ document.addEventListener("mousedown", handleClick);
130
+ return () => document.removeEventListener("mousedown", handleClick);
131
+ }, [onClose]);
132
+
133
+ return (
134
+ <div ref={menuRef} className="absolute bottom-full left-0 mb-2 rounded-xl border border-border bg-popover text-popover-foreground shadow-lg py-1 min-w-[160px] z-10">
135
+ {[
136
+ { icon: Paperclip, label: "Arquivo", onClick: onFile },
137
+ { icon: Camera, label: "Camera", onClick: onCamera },
138
+ { icon: ImageIcon, label: "Galeria", onClick: onGallery },
139
+ ].map((item) => (
140
+ <button
141
+ key={item.label}
142
+ type="button"
143
+ onClick={() => { item.onClick(); onClose(); }}
144
+ className="flex w-full items-center gap-3 px-4 py-2.5 text-sm hover:bg-muted/50 transition-colors"
145
+ >
146
+ <item.icon className="h-4 w-4 text-muted-foreground" />
147
+ {item.label}
148
+ </button>
149
+ ))}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ // ── Main Component ──
155
+
156
+ export function MessageInput({
157
+ input,
158
+ setInput,
159
+ handleSubmit,
160
+ isLoading,
161
+ isUploading = false,
162
+ stop,
163
+ placeholder = "Caixa de mensagem...",
164
+ className,
165
+ enableAttachments = true,
166
+ enableVoice = true,
167
+ }: MessageInputProps) {
168
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
169
+ const fileInputRef = useRef<HTMLInputElement>(null);
170
+ const imageInputRef = useRef<HTMLInputElement>(null);
171
+ const containerRef = useRef<HTMLDivElement>(null);
172
+
173
+ const [attachments, setAttachments] = useState<Attachment[]>([]);
174
+ const [showMenu, setShowMenu] = useState(false);
175
+ const [isDragging, setIsDragging] = useState(false);
176
+ const [isMultiline, setIsMultiline] = useState(false);
177
+ const historyRef = useRef<string[]>([]);
178
+ const historyPosRef = useRef(-1);
179
+ const savedInputRef = useRef("");
180
+
181
+ const onRecordingComplete = useCallback((file: File) => {
182
+ const att: Attachment = { id: crypto.randomUUID(), file, type: "audio" };
183
+ setAttachments((prev) => [...prev, att]);
184
+ }, []);
185
+
186
+ const { isRecording, elapsed, start: startRecording, stop: stopRecording, cancel: cancelRecording } = useAudioRecording(onRecordingComplete);
187
+
188
+ const hasContent = input.trim().length > 0 || attachments.length > 0;
189
+
190
+ // Auto-focus
191
+ useEffect(() => { textareaRef.current?.focus(); }, []);
192
+
193
+ // Auto-expand — stable measurement with height:auto
194
+ useEffect(() => {
195
+ const el = textareaRef.current;
196
+ if (!el) return;
197
+ el.style.height = "auto";
198
+ const scrollH = el.scrollHeight;
199
+ const maxH = MAX_ROWS * LINE_HEIGHT_PX;
200
+ const clampedH = Math.min(scrollH, maxH);
201
+ el.style.height = `${clampedH}px`;
202
+ setIsMultiline(scrollH > MULTILINE_THRESHOLD_PX);
203
+ }, [input]);
204
+
205
+ // ── Attachments ──
206
+
207
+ const addFiles = useCallback((files: FileList | File[]) => {
208
+ const newAttachments: Attachment[] = Array.from(files).map((file) => {
209
+ const isImage = file.type.startsWith("image/");
210
+ const att: Attachment = { id: crypto.randomUUID(), file, type: isImage ? "image" : "file" };
211
+ if (isImage) {
212
+ const reader = new FileReader();
213
+ reader.onload = (e) => {
214
+ setAttachments((prev) => prev.map((a) => (a.id === att.id ? { ...a, preview: e.target?.result as string } : a)));
215
+ };
216
+ reader.readAsDataURL(file);
217
+ }
218
+ return att;
219
+ });
220
+ setAttachments((prev) => [...prev, ...newAttachments]);
221
+ }, []);
222
+
223
+ const removeAttachment = useCallback((id: string) => {
224
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
225
+ }, []);
226
+
227
+ // ── Drag & Drop ──
228
+
229
+ const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }, []);
230
+
231
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
232
+ if (containerRef.current && !containerRef.current.contains(e.relatedTarget as Node)) setIsDragging(false);
233
+ }, []);
234
+
235
+ const handleDrop = useCallback((e: React.DragEvent) => {
236
+ e.preventDefault(); setIsDragging(false);
237
+ if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
238
+ }, [addFiles]);
239
+
240
+ // ── Clipboard Paste ──
241
+
242
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
243
+ const files: File[] = [];
244
+ for (let i = 0; i < e.clipboardData.items.length; i++) {
245
+ if (e.clipboardData.items[i].kind === "file") {
246
+ const file = e.clipboardData.items[i].getAsFile();
247
+ if (file) files.push(file);
248
+ }
249
+ }
250
+ if (files.length) { e.preventDefault(); addFiles(files); }
251
+ }, [addFiles]);
252
+
253
+ // ── Submit ──
254
+
255
+ function onSubmit(e: React.FormEvent) {
256
+ e.preventDefault();
257
+ if (!hasContent || isLoading) return;
258
+ if (input.trim()) {
259
+ historyRef.current.unshift(input);
260
+ if (historyRef.current.length > 50) historyRef.current.length = 50;
261
+ }
262
+ historyPosRef.current = -1;
263
+ savedInputRef.current = "";
264
+ handleSubmit(e, attachments.length > 0 ? attachments : undefined);
265
+ setAttachments([]);
266
+ }
267
+
268
+ function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
269
+ // History navigation — only when cursor is at start/end and not multiline content
270
+ const el = textareaRef.current;
271
+ if (e.key === "ArrowUp" && historyRef.current.length > 0 && el) {
272
+ const atTop = el.selectionStart === 0 && el.selectionEnd === 0;
273
+ const isEmpty = input === "";
274
+ if (atTop || isEmpty) {
275
+ e.preventDefault();
276
+ if (historyPosRef.current === -1) savedInputRef.current = input;
277
+ const nextPos = Math.min(historyPosRef.current + 1, historyRef.current.length - 1);
278
+ if (nextPos !== historyPosRef.current) {
279
+ historyPosRef.current = nextPos;
280
+ setInput(historyRef.current[nextPos]);
281
+ }
282
+ return;
283
+ }
284
+ }
285
+
286
+ if (e.key === "ArrowDown" && historyPosRef.current >= 0 && el) {
287
+ const atBottom = el.selectionStart === input.length;
288
+ if (atBottom) {
289
+ e.preventDefault();
290
+ const nextPos = historyPosRef.current - 1;
291
+ historyPosRef.current = nextPos;
292
+ setInput(nextPos < 0 ? savedInputRef.current : historyRef.current[nextPos]);
293
+ return;
294
+ }
295
+ }
296
+
297
+ if (e.key === "Enter" && !e.shiftKey) {
298
+ e.preventDefault();
299
+ if (hasContent && !isLoading) onSubmit(e as unknown as React.FormEvent);
300
+ }
301
+ }
302
+
303
+ // ── Render ──
304
+
305
+ return (
306
+ <div
307
+ ref={containerRef}
308
+ onDragOver={enableAttachments ? handleDragOver : undefined}
309
+ onDragLeave={enableAttachments ? handleDragLeave : undefined}
310
+ onDrop={enableAttachments ? handleDrop : undefined}
311
+ className={cn(
312
+ "relative border border-border/50 bg-muted transition-[border-radius] duration-200",
313
+ isMultiline || attachments.length > 0 ? "rounded-2xl" : "rounded-full",
314
+ isDragging && "ring-2 ring-primary/50",
315
+ className,
316
+ )}
317
+ >
318
+ {/* ── Drag overlay ── */}
319
+ {isDragging && (
320
+ <div className="absolute inset-0 z-10 flex items-center justify-center rounded-[inherit] bg-primary/5 border-2 border-dashed border-primary/30">
321
+ <span className="text-sm text-primary font-medium">Solte aqui</span>
322
+ </div>
323
+ )}
324
+
325
+ {/* ── Recording overlay ── */}
326
+ {isRecording && (
327
+ <div className="flex items-center gap-3 px-3 py-2">
328
+ <Button type="button" variant="ghost" size="icon" onClick={cancelRecording} className="h-8 w-8 rounded-full shrink-0 text-muted-foreground" aria-label="Cancelar">
329
+ <X className="h-4 w-4" />
330
+ </Button>
331
+ <div className="flex items-center gap-2 flex-1">
332
+ <span className="h-2 w-2 rounded-full bg-destructive animate-pulse" />
333
+ <span className="text-sm font-medium tabular-nums">{formatTime(elapsed)}</span>
334
+ <div className="flex-1 flex items-center gap-0.5 px-2">
335
+ {Array.from({ length: 20 }, (_, i) => (
336
+ <span key={i} className="w-1 bg-foreground/30 rounded-full" style={{ height: `${4 + Math.random() * 12}px` }} />
337
+ ))}
338
+ </div>
339
+ </div>
340
+ <Button type="button" size="icon" onClick={stopRecording} className="h-8 w-8 rounded-full shrink-0" aria-label="Parar">
341
+ <CircleStop className="h-4 w-4" />
342
+ </Button>
343
+ </div>
344
+ )}
345
+
346
+ {/* ── Main content (hidden when recording) ── */}
347
+ <div className={cn(isRecording && "hidden")}>
348
+ {/* Attachment previews */}
349
+ {(attachments.length > 0 || isUploading) && (
350
+ <div className="flex flex-wrap gap-2 px-4 pt-3 pb-1">
351
+ {attachments.map((att) => (
352
+ <AttachmentPreview key={att.id} attachment={att} onRemove={() => removeAttachment(att.id)} />
353
+ ))}
354
+ {isUploading && (
355
+ <div className="flex items-center gap-2 rounded-lg border border-border/50 bg-background/50 px-3 py-2 text-xs text-muted-foreground">
356
+ <Loader2 className="h-4 w-4 animate-spin shrink-0" />
357
+ <span>Enviando arquivos...</span>
358
+ </div>
359
+ )}
360
+ </div>
361
+ )}
362
+
363
+ {/* ── Single unified input row ── */}
364
+ <div className={cn("flex gap-1 p-1.5", isMultiline ? "items-end" : "items-center")}>
365
+ {/* Left: Plus button */}
366
+ {enableAttachments && (
367
+ <div className="relative shrink-0">
368
+ <Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => setShowMenu(!showMenu)} aria-label="Adicionar">
369
+ <Plus className="h-4 w-4" />
370
+ </Button>
371
+ {showMenu && (
372
+ <PlusMenu
373
+ onFile={() => fileInputRef.current?.click()}
374
+ onCamera={() => imageInputRef.current?.click()}
375
+ onGallery={() => imageInputRef.current?.click()}
376
+ onClose={() => setShowMenu(false)}
377
+ />
378
+ )}
379
+ </div>
380
+ )}
381
+
382
+ {/* Center: Textarea */}
383
+ <textarea
384
+ ref={textareaRef}
385
+ value={input}
386
+ onChange={(e) => setInput(e.target.value)}
387
+ onKeyDown={handleKeyDown}
388
+ onPaste={enableAttachments ? handlePaste : undefined}
389
+ placeholder={placeholder}
390
+ rows={1}
391
+ disabled={isLoading}
392
+ aria-label="Mensagem"
393
+ className="flex-1 min-w-0 bg-transparent text-foreground text-sm resize-none outline-none placeholder:text-muted-foreground leading-6 py-1 px-2"
394
+ />
395
+
396
+ {/* Right: Action buttons */}
397
+ <div className="flex items-center gap-0.5 shrink-0">
398
+ {enableVoice && !hasContent && (
399
+ <Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-full text-muted-foreground" onClick={startRecording} aria-label="Gravar audio">
400
+ <Mic className="h-4 w-4" />
401
+ </Button>
402
+ )}
403
+ {isLoading && stop ? (
404
+ <Button type="button" variant="ghost" size="icon" onClick={stop} className="h-8 w-8 rounded-full" aria-label="Parar geração">
405
+ <Square className="h-4 w-4" />
406
+ </Button>
407
+ ) : (
408
+ <Button type="button" size="icon" onClick={onSubmit} disabled={!hasContent || !!isLoading} className="h-8 w-8 rounded-full" aria-label="Enviar mensagem">
409
+ <Send className="h-4 w-4" />
410
+ </Button>
411
+ )}
412
+ </div>
413
+ </div>
414
+ </div>
415
+
416
+ {/* Hidden file inputs */}
417
+ <input ref={fileInputRef} type="file" multiple className="hidden" onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ""; }} />
418
+ <input ref={imageInputRef} type="file" accept="image/*" multiple className="hidden" onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ""; }} />
419
+ </div>
420
+ );
421
+ }