@codrstudio/openclaude-chat 0.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.
Files changed (171) hide show
  1. package/dist/components/Chat.d.ts +23 -0
  2. package/dist/components/Chat.js +12 -0
  3. package/dist/components/ErrorNote.d.ts +6 -0
  4. package/dist/components/ErrorNote.js +6 -0
  5. package/dist/components/LazyRender.d.ts +8 -0
  6. package/dist/components/LazyRender.js +22 -0
  7. package/dist/components/Markdown.d.ts +5 -0
  8. package/dist/components/Markdown.js +65 -0
  9. package/dist/components/MessageBubble.d.ts +9 -0
  10. package/dist/components/MessageBubble.js +45 -0
  11. package/dist/components/MessageInput.d.ts +19 -0
  12. package/dist/components/MessageInput.js +214 -0
  13. package/dist/components/MessageList.d.ts +13 -0
  14. package/dist/components/MessageList.js +72 -0
  15. package/dist/components/StreamingIndicator.d.ts +1 -0
  16. package/dist/components/StreamingIndicator.js +9 -0
  17. package/dist/display/AlertRenderer.d.ts +2 -0
  18. package/dist/display/AlertRenderer.js +13 -0
  19. package/dist/display/CarouselRenderer.d.ts +2 -0
  20. package/dist/display/CarouselRenderer.js +41 -0
  21. package/dist/display/ChartRenderer.d.ts +2 -0
  22. package/dist/display/ChartRenderer.js +76 -0
  23. package/dist/display/ChoiceButtonsRenderer.d.ts +6 -0
  24. package/dist/display/ChoiceButtonsRenderer.js +23 -0
  25. package/dist/display/CodeBlockRenderer.d.ts +2 -0
  26. package/dist/display/CodeBlockRenderer.js +17 -0
  27. package/dist/display/ComparisonTableRenderer.d.ts +2 -0
  28. package/dist/display/ComparisonTableRenderer.js +26 -0
  29. package/dist/display/DataTableRenderer.d.ts +2 -0
  30. package/dist/display/DataTableRenderer.js +74 -0
  31. package/dist/display/DisplayReactRenderer.d.ts +26 -0
  32. package/dist/display/DisplayReactRenderer.js +192 -0
  33. package/dist/display/FileCardRenderer.d.ts +2 -0
  34. package/dist/display/FileCardRenderer.js +31 -0
  35. package/dist/display/GalleryRenderer.d.ts +2 -0
  36. package/dist/display/GalleryRenderer.js +11 -0
  37. package/dist/display/ImageViewerRenderer.d.ts +2 -0
  38. package/dist/display/ImageViewerRenderer.js +15 -0
  39. package/dist/display/LinkPreviewRenderer.d.ts +2 -0
  40. package/dist/display/LinkPreviewRenderer.js +20 -0
  41. package/dist/display/MapViewRenderer.d.ts +2 -0
  42. package/dist/display/MapViewRenderer.js +20 -0
  43. package/dist/display/MetricCardRenderer.d.ts +2 -0
  44. package/dist/display/MetricCardRenderer.js +12 -0
  45. package/dist/display/PriceHighlightRenderer.d.ts +2 -0
  46. package/dist/display/PriceHighlightRenderer.js +30 -0
  47. package/dist/display/ProductCardRenderer.d.ts +2 -0
  48. package/dist/display/ProductCardRenderer.js +23 -0
  49. package/dist/display/ProgressStepsRenderer.d.ts +2 -0
  50. package/dist/display/ProgressStepsRenderer.js +14 -0
  51. package/dist/display/SourcesListRenderer.d.ts +2 -0
  52. package/dist/display/SourcesListRenderer.js +5 -0
  53. package/dist/display/SpreadsheetRenderer.d.ts +2 -0
  54. package/dist/display/SpreadsheetRenderer.js +32 -0
  55. package/dist/display/StepTimelineRenderer.d.ts +2 -0
  56. package/dist/display/StepTimelineRenderer.js +21 -0
  57. package/dist/display/index.d.ts +21 -0
  58. package/dist/display/index.js +20 -0
  59. package/dist/display/react-sandbox/bootstrap.d.ts +1 -0
  60. package/dist/display/react-sandbox/bootstrap.js +154 -0
  61. package/dist/display/registry.d.ts +5 -0
  62. package/dist/display/registry.js +52 -0
  63. package/dist/display/sdk-types.d.ts +187 -0
  64. package/dist/display/sdk-types.js +4 -0
  65. package/dist/hooks/ChatProvider.d.ts +9 -0
  66. package/dist/hooks/ChatProvider.js +14 -0
  67. package/dist/hooks/useIsMobile.d.ts +1 -0
  68. package/dist/hooks/useIsMobile.js +12 -0
  69. package/dist/hooks/useOpenClaudeChat.d.ts +36 -0
  70. package/dist/hooks/useOpenClaudeChat.js +361 -0
  71. package/dist/index.d.ts +47 -0
  72. package/dist/index.js +42 -0
  73. package/dist/lib/utils.d.ts +2 -0
  74. package/dist/lib/utils.js +5 -0
  75. package/dist/parts/PartErrorBoundary.d.ts +21 -0
  76. package/dist/parts/PartErrorBoundary.js +27 -0
  77. package/dist/parts/PartRenderer.d.ts +8 -0
  78. package/dist/parts/PartRenderer.js +99 -0
  79. package/dist/parts/ReasoningBlock.d.ts +6 -0
  80. package/dist/parts/ReasoningBlock.js +18 -0
  81. package/dist/parts/ToolActivity.d.ts +11 -0
  82. package/dist/parts/ToolActivity.js +52 -0
  83. package/dist/parts/ToolResult.d.ts +7 -0
  84. package/dist/parts/ToolResult.js +38 -0
  85. package/dist/styles.css +2 -0
  86. package/dist/types.d.ts +40 -0
  87. package/dist/types.js +4 -0
  88. package/dist/ui/alert.d.ts +12 -0
  89. package/dist/ui/alert.js +28 -0
  90. package/dist/ui/badge.d.ts +9 -0
  91. package/dist/ui/badge.js +20 -0
  92. package/dist/ui/button.d.ts +11 -0
  93. package/dist/ui/button.js +31 -0
  94. package/dist/ui/card.d.ts +8 -0
  95. package/dist/ui/card.js +21 -0
  96. package/dist/ui/collapsible.d.ts +1 -0
  97. package/dist/ui/collapsible.js +2 -0
  98. package/dist/ui/dialog.d.ts +19 -0
  99. package/dist/ui/dialog.js +23 -0
  100. package/dist/ui/dropdown-menu.d.ts +11 -0
  101. package/dist/ui/dropdown-menu.js +15 -0
  102. package/dist/ui/input.d.ts +3 -0
  103. package/dist/ui/input.js +6 -0
  104. package/dist/ui/progress.d.ts +7 -0
  105. package/dist/ui/progress.js +9 -0
  106. package/dist/ui/scroll-area.d.ts +5 -0
  107. package/dist/ui/scroll-area.js +12 -0
  108. package/dist/ui/separator.d.ts +4 -0
  109. package/dist/ui/separator.js +8 -0
  110. package/dist/ui/skeleton.d.ts +3 -0
  111. package/dist/ui/skeleton.js +6 -0
  112. package/dist/ui/table.d.ts +10 -0
  113. package/dist/ui/table.js +27 -0
  114. package/package.json +61 -0
  115. package/src/components/Chat.tsx +107 -0
  116. package/src/components/ErrorNote.tsx +35 -0
  117. package/src/components/LazyRender.tsx +42 -0
  118. package/src/components/Markdown.tsx +114 -0
  119. package/src/components/MessageBubble.tsx +107 -0
  120. package/src/components/MessageInput.tsx +421 -0
  121. package/src/components/MessageList.tsx +153 -0
  122. package/src/components/StreamingIndicator.tsx +19 -0
  123. package/src/display/AlertRenderer.tsx +23 -0
  124. package/src/display/CarouselRenderer.tsx +141 -0
  125. package/src/display/ChartRenderer.tsx +195 -0
  126. package/src/display/ChoiceButtonsRenderer.tsx +114 -0
  127. package/src/display/CodeBlockRenderer.tsx +49 -0
  128. package/src/display/ComparisonTableRenderer.tsx +132 -0
  129. package/src/display/DataTableRenderer.tsx +144 -0
  130. package/src/display/DisplayReactRenderer.tsx +269 -0
  131. package/src/display/FileCardRenderer.tsx +55 -0
  132. package/src/display/GalleryRenderer.tsx +65 -0
  133. package/src/display/ImageViewerRenderer.tsx +114 -0
  134. package/src/display/LinkPreviewRenderer.tsx +74 -0
  135. package/src/display/MapViewRenderer.tsx +75 -0
  136. package/src/display/MetricCardRenderer.tsx +29 -0
  137. package/src/display/PriceHighlightRenderer.tsx +62 -0
  138. package/src/display/ProductCardRenderer.tsx +112 -0
  139. package/src/display/ProgressStepsRenderer.tsx +59 -0
  140. package/src/display/SourcesListRenderer.tsx +47 -0
  141. package/src/display/SpreadsheetRenderer.tsx +86 -0
  142. package/src/display/StepTimelineRenderer.tsx +75 -0
  143. package/src/display/index.ts +21 -0
  144. package/src/display/react-sandbox/bootstrap.ts +155 -0
  145. package/src/display/registry.ts +84 -0
  146. package/src/display/sdk-types.ts +217 -0
  147. package/src/hooks/ChatProvider.tsx +21 -0
  148. package/src/hooks/useIsMobile.ts +15 -0
  149. package/src/hooks/useOpenClaudeChat.ts +476 -0
  150. package/src/index.ts +76 -0
  151. package/src/lib/utils.ts +6 -0
  152. package/src/parts/PartErrorBoundary.tsx +51 -0
  153. package/src/parts/PartRenderer.tsx +145 -0
  154. package/src/parts/ReasoningBlock.tsx +41 -0
  155. package/src/parts/ToolActivity.tsx +78 -0
  156. package/src/parts/ToolResult.tsx +79 -0
  157. package/src/styles.css +2 -0
  158. package/src/types.ts +41 -0
  159. package/src/ui/alert.tsx +77 -0
  160. package/src/ui/badge.tsx +36 -0
  161. package/src/ui/button.tsx +54 -0
  162. package/src/ui/card.tsx +68 -0
  163. package/src/ui/collapsible.tsx +7 -0
  164. package/src/ui/dialog.tsx +122 -0
  165. package/src/ui/dropdown-menu.tsx +76 -0
  166. package/src/ui/input.tsx +24 -0
  167. package/src/ui/progress.tsx +36 -0
  168. package/src/ui/scroll-area.tsx +48 -0
  169. package/src/ui/separator.tsx +31 -0
  170. package/src/ui/skeleton.tsx +9 -0
  171. package/src/ui/table.tsx +114 -0
@@ -0,0 +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
+ }
@@ -0,0 +1,153 @@
1
+ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
2
+ import { useRef, useEffect, useMemo, useCallback, type ReactNode } from "react";
3
+ import { useVirtualizer } from "@tanstack/react-virtual";
4
+ import { Sparkles } from "lucide-react";
5
+ import type { Message } from "../types.js";
6
+ import { MessageBubble } from "./MessageBubble.js";
7
+ import { StreamingIndicator } from "./StreamingIndicator.js";
8
+ import { ErrorNote } from "./ErrorNote.js";
9
+ import type { DisplayRendererMap } from "../display/registry.js";
10
+ import { ScrollBar } from "../ui/scroll-area.js";
11
+ import { cn } from "../lib/utils.js";
12
+
13
+ export interface MessageListProps {
14
+ messages: Message[];
15
+ isLoading?: boolean;
16
+ displayRenderers?: DisplayRendererMap;
17
+ className?: string;
18
+ error?: Error;
19
+ onRetry?: () => void;
20
+ emptyState?: ReactNode;
21
+ }
22
+
23
+ function DefaultWelcome() {
24
+ return (
25
+ <div className="flex h-full flex-col items-center justify-center gap-4 px-6 py-16 text-center">
26
+ <div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary ring-1 ring-primary/20">
27
+ <Sparkles className="size-7" />
28
+ </div>
29
+ <div className="space-y-1.5">
30
+ <h2 className="text-xl font-semibold tracking-tight">Como posso ajudar?</h2>
31
+ <p className="max-w-sm text-sm text-muted-foreground">
32
+ Envie uma mensagem para comecar a conversa. Voce pode pedir respostas, acionar ferramentas ou colar conteudo para analise.
33
+ </p>
34
+ </div>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ export function MessageList({ messages, isLoading, displayRenderers,className, error, onRetry, emptyState }: MessageListProps) {
40
+ const viewportRef = useRef<HTMLDivElement>(null);
41
+ const isFollowingRef = useRef(true);
42
+
43
+ const lastAssistantIndex = useMemo(() =>
44
+ messages.reduceRight((found, msg, i) => {
45
+ if (found !== -1) return found;
46
+ return msg.role === "assistant" ? i : -1;
47
+ }, -1),
48
+ [messages]
49
+ );
50
+
51
+ const virtualizer = useVirtualizer({
52
+ count: messages.length,
53
+ getScrollElement: () => viewportRef.current,
54
+ estimateSize: () => 80,
55
+ overscan: 5,
56
+ paddingStart: 16,
57
+ });
58
+
59
+ // Track scroll position to detect if user is following
60
+ const handleScroll = useCallback(() => {
61
+ const viewport = viewportRef.current;
62
+ if (!viewport) return;
63
+ const distanceFromBottom =
64
+ viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
65
+ isFollowingRef.current = distanceFromBottom <= 100;
66
+ }, []);
67
+
68
+ useEffect(() => {
69
+ const viewport = viewportRef.current;
70
+ if (!viewport) return;
71
+ viewport.addEventListener("scroll", handleScroll, { passive: true });
72
+ return () => viewport.removeEventListener("scroll", handleScroll);
73
+ }, [handleScroll]);
74
+
75
+ // Auto-scroll to bottom when following
76
+ useEffect(() => {
77
+ if (messages.length > 0 && isFollowingRef.current) {
78
+ virtualizer.scrollToIndex(messages.length - 1, { align: "end" });
79
+ }
80
+ }, [messages, virtualizer]);
81
+
82
+ // Auto-scroll when error appears
83
+ useEffect(() => {
84
+ if (error && isFollowingRef.current) {
85
+ const viewport = viewportRef.current;
86
+ if (viewport) viewport.scrollTop = viewport.scrollHeight;
87
+ }
88
+ }, [error]);
89
+
90
+ const virtualItems = virtualizer.getVirtualItems();
91
+
92
+ if (messages.length === 0) {
93
+ return (
94
+ <ScrollAreaPrimitive.Root className={cn("flex-1 relative overflow-hidden", className)}>
95
+ <ScrollAreaPrimitive.Viewport ref={viewportRef} className="h-full w-full rounded-[inherit]">
96
+ {emptyState ?? <DefaultWelcome />}
97
+ </ScrollAreaPrimitive.Viewport>
98
+ <ScrollBar />
99
+ <ScrollAreaPrimitive.Corner />
100
+ </ScrollAreaPrimitive.Root>
101
+ );
102
+ }
103
+
104
+ return (
105
+ <ScrollAreaPrimitive.Root className={cn("flex-1 relative overflow-hidden", className)}>
106
+ <ScrollAreaPrimitive.Viewport ref={viewportRef} className="h-full w-full rounded-[inherit]">
107
+ <div
108
+ className="relative w-full"
109
+ style={{ height: virtualizer.getTotalSize() + 16 }}
110
+ >
111
+ <div>
112
+ {virtualItems.map((virtualRow) => {
113
+ const message = messages[virtualRow.index];
114
+ return (
115
+ <div
116
+ key={message.id ?? virtualRow.index}
117
+ data-index={virtualRow.index}
118
+ ref={virtualizer.measureElement}
119
+ className="pb-3 px-4"
120
+ style={{
121
+ position: "absolute",
122
+ top: 0,
123
+ left: 0,
124
+ right: 0,
125
+ transform: `translateY(${virtualRow.start}px)`,
126
+ }}
127
+ >
128
+ <MessageBubble
129
+ message={message}
130
+ isStreaming={virtualRow.index === lastAssistantIndex && isLoading && messages[messages.length - 1]?.role === "assistant"}
131
+ displayRenderers={displayRenderers}
132
+ />
133
+ </div>
134
+ );
135
+ })}
136
+ </div>
137
+ </div>
138
+ {isLoading && messages[messages.length - 1]?.role !== "assistant" && (
139
+ <div className="px-4 pb-3">
140
+ <StreamingIndicator />
141
+ </div>
142
+ )}
143
+ {!isLoading && error && (
144
+ <div className="px-4 pb-3">
145
+ <ErrorNote message={error.message} onRetry={onRetry} />
146
+ </div>
147
+ )}
148
+ </ScrollAreaPrimitive.Viewport>
149
+ <ScrollBar />
150
+ <ScrollAreaPrimitive.Corner />
151
+ </ScrollAreaPrimitive.Root>
152
+ );
153
+ }
@@ -0,0 +1,19 @@
1
+ export function StreamingIndicator() {
2
+ return (
3
+ <span
4
+ className="inline-flex items-end gap-1.5 py-1 mt-4"
5
+ aria-label="Gerando resposta..."
6
+ role="status"
7
+ >
8
+ <span className="size-1 rounded-full bg-primary animate-[streaming-bounce_1.2s_ease-in-out_infinite_0ms]" />
9
+ <span className="size-1 rounded-full bg-primary animate-[streaming-bounce_1.2s_ease-in-out_infinite_150ms]" />
10
+ <span className="size-1 rounded-full bg-primary animate-[streaming-bounce_1.2s_ease-in-out_infinite_300ms]" />
11
+ <style>{`
12
+ @keyframes streaming-bounce {
13
+ 0%, 60%, 100% { transform: translateY(0); }
14
+ 30% { transform: translateY(-4px); }
15
+ }
16
+ `}</style>
17
+ </span>
18
+ );
19
+ }
@@ -0,0 +1,23 @@
1
+ import type { DisplayAlert } from "./sdk-types.js";
2
+ import { AlertCircle, AlertTriangle, CheckCircle, Info } from "lucide-react";
3
+ import { Alert, AlertDescription, AlertTitle } from "../ui/alert.js";
4
+
5
+ type AlertVariant = "info" | "warning" | "error" | "success";
6
+
7
+ const VARIANT_ICON: Record<AlertVariant, typeof Info> = {
8
+ info: Info,
9
+ warning: AlertTriangle,
10
+ error: AlertCircle,
11
+ success: CheckCircle,
12
+ };
13
+
14
+ export function AlertRenderer({ variant = "info", title, message }: DisplayAlert) {
15
+ const Icon = VARIANT_ICON[variant as AlertVariant] ?? Info;
16
+ return (
17
+ <Alert variant={variant === "error" ? "destructive" : "default"}>
18
+ <Icon />
19
+ {title && <AlertTitle>{title}</AlertTitle>}
20
+ <AlertDescription>{message}</AlertDescription>
21
+ </Alert>
22
+ );
23
+ }