@gugacoder/agentic-chat 0.2.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 (187) hide show
  1. package/dist/components/Chat.d.ts +21 -0
  2. package/dist/components/Chat.js +13 -0
  3. package/dist/components/ErrorNote.d.ts +5 -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 +10 -0
  10. package/dist/components/MessageBubble.js +39 -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 +12 -0
  14. package/dist/components/MessageList.js +68 -0
  15. package/dist/components/StreamingIndicator.d.ts +1 -0
  16. package/dist/components/StreamingIndicator.js +9 -0
  17. package/dist/conversations/CollapsibleGroup.d.ts +11 -0
  18. package/dist/conversations/CollapsibleGroup.js +9 -0
  19. package/dist/conversations/ConversationBar.d.ts +27 -0
  20. package/dist/conversations/ConversationBar.js +53 -0
  21. package/dist/conversations/ConversationList.d.ts +33 -0
  22. package/dist/conversations/ConversationList.js +48 -0
  23. package/dist/conversations/ConversationListItem.d.ts +20 -0
  24. package/dist/conversations/ConversationListItem.js +22 -0
  25. package/dist/conversations/DeleteDialog.d.ts +13 -0
  26. package/dist/conversations/DeleteDialog.js +8 -0
  27. package/dist/conversations/RenameDialog.d.ts +15 -0
  28. package/dist/conversations/RenameDialog.js +15 -0
  29. package/dist/conversations/index.d.ts +9 -0
  30. package/dist/conversations/index.js +5 -0
  31. package/dist/conversations/types.d.ts +21 -0
  32. package/dist/conversations/types.js +1 -0
  33. package/dist/conversations/useConversations.d.ts +19 -0
  34. package/dist/conversations/useConversations.js +102 -0
  35. package/dist/conversations/utils.d.ts +8 -0
  36. package/dist/conversations/utils.js +134 -0
  37. package/dist/display/AlertRenderer.d.ts +2 -0
  38. package/dist/display/AlertRenderer.js +13 -0
  39. package/dist/display/CarouselRenderer.d.ts +2 -0
  40. package/dist/display/CarouselRenderer.js +41 -0
  41. package/dist/display/ChartRenderer.d.ts +2 -0
  42. package/dist/display/ChartRenderer.js +76 -0
  43. package/dist/display/ChoiceButtonsRenderer.d.ts +6 -0
  44. package/dist/display/ChoiceButtonsRenderer.js +23 -0
  45. package/dist/display/CodeBlockRenderer.d.ts +2 -0
  46. package/dist/display/CodeBlockRenderer.js +17 -0
  47. package/dist/display/ComparisonTableRenderer.d.ts +2 -0
  48. package/dist/display/ComparisonTableRenderer.js +26 -0
  49. package/dist/display/DataTableRenderer.d.ts +2 -0
  50. package/dist/display/DataTableRenderer.js +74 -0
  51. package/dist/display/FileCardRenderer.d.ts +2 -0
  52. package/dist/display/FileCardRenderer.js +31 -0
  53. package/dist/display/GalleryRenderer.d.ts +2 -0
  54. package/dist/display/GalleryRenderer.js +11 -0
  55. package/dist/display/ImageViewerRenderer.d.ts +2 -0
  56. package/dist/display/ImageViewerRenderer.js +15 -0
  57. package/dist/display/LinkPreviewRenderer.d.ts +2 -0
  58. package/dist/display/LinkPreviewRenderer.js +20 -0
  59. package/dist/display/MapViewRenderer.d.ts +2 -0
  60. package/dist/display/MapViewRenderer.js +20 -0
  61. package/dist/display/MetricCardRenderer.d.ts +2 -0
  62. package/dist/display/MetricCardRenderer.js +12 -0
  63. package/dist/display/PriceHighlightRenderer.d.ts +2 -0
  64. package/dist/display/PriceHighlightRenderer.js +13 -0
  65. package/dist/display/ProductCardRenderer.d.ts +2 -0
  66. package/dist/display/ProductCardRenderer.js +23 -0
  67. package/dist/display/ProgressStepsRenderer.d.ts +2 -0
  68. package/dist/display/ProgressStepsRenderer.js +14 -0
  69. package/dist/display/SourcesListRenderer.d.ts +2 -0
  70. package/dist/display/SourcesListRenderer.js +5 -0
  71. package/dist/display/SpreadsheetRenderer.d.ts +2 -0
  72. package/dist/display/SpreadsheetRenderer.js +32 -0
  73. package/dist/display/StepTimelineRenderer.d.ts +2 -0
  74. package/dist/display/StepTimelineRenderer.js +21 -0
  75. package/dist/display/index.d.ts +21 -0
  76. package/dist/display/index.js +20 -0
  77. package/dist/display/registry.d.ts +5 -0
  78. package/dist/display/registry.js +50 -0
  79. package/dist/hooks/ChatProvider.d.ts +10 -0
  80. package/dist/hooks/ChatProvider.js +14 -0
  81. package/dist/hooks/useBackboneChat.d.ts +37 -0
  82. package/dist/hooks/useBackboneChat.js +121 -0
  83. package/dist/hooks/useIsMobile.d.ts +1 -0
  84. package/dist/hooks/useIsMobile.js +12 -0
  85. package/dist/index.d.ts +47 -0
  86. package/dist/index.js +40 -0
  87. package/dist/lib/utils.d.ts +2 -0
  88. package/dist/lib/utils.js +5 -0
  89. package/dist/parts/PartRenderer.d.ts +40 -0
  90. package/dist/parts/PartRenderer.js +97 -0
  91. package/dist/parts/ReasoningBlock.d.ts +6 -0
  92. package/dist/parts/ReasoningBlock.js +18 -0
  93. package/dist/parts/ToolActivity.d.ts +11 -0
  94. package/dist/parts/ToolActivity.js +52 -0
  95. package/dist/parts/ToolResult.d.ts +7 -0
  96. package/dist/parts/ToolResult.js +38 -0
  97. package/dist/styles.css +2 -0
  98. package/dist/ui/alert.d.ts +12 -0
  99. package/dist/ui/alert.js +28 -0
  100. package/dist/ui/badge.d.ts +9 -0
  101. package/dist/ui/badge.js +20 -0
  102. package/dist/ui/button.d.ts +11 -0
  103. package/dist/ui/button.js +31 -0
  104. package/dist/ui/card.d.ts +8 -0
  105. package/dist/ui/card.js +21 -0
  106. package/dist/ui/collapsible.d.ts +1 -0
  107. package/dist/ui/collapsible.js +2 -0
  108. package/dist/ui/dialog.d.ts +19 -0
  109. package/dist/ui/dialog.js +23 -0
  110. package/dist/ui/dropdown-menu.d.ts +11 -0
  111. package/dist/ui/dropdown-menu.js +15 -0
  112. package/dist/ui/input.d.ts +3 -0
  113. package/dist/ui/input.js +6 -0
  114. package/dist/ui/progress.d.ts +7 -0
  115. package/dist/ui/progress.js +9 -0
  116. package/dist/ui/scroll-area.d.ts +5 -0
  117. package/dist/ui/scroll-area.js +12 -0
  118. package/dist/ui/separator.d.ts +4 -0
  119. package/dist/ui/separator.js +8 -0
  120. package/dist/ui/skeleton.d.ts +3 -0
  121. package/dist/ui/skeleton.js +6 -0
  122. package/dist/ui/table.d.ts +10 -0
  123. package/dist/ui/table.js +27 -0
  124. package/package.json +53 -0
  125. package/src/components/Chat.tsx +80 -0
  126. package/src/components/ErrorNote.tsx +32 -0
  127. package/src/components/LazyRender.tsx +42 -0
  128. package/src/components/Markdown.tsx +114 -0
  129. package/src/components/MessageBubble.tsx +102 -0
  130. package/src/components/MessageInput.tsx +421 -0
  131. package/src/components/MessageList.tsx +139 -0
  132. package/src/components/StreamingIndicator.tsx +19 -0
  133. package/src/conversations/CollapsibleGroup.tsx +41 -0
  134. package/src/conversations/ConversationBar.tsx +200 -0
  135. package/src/conversations/ConversationList.tsx +234 -0
  136. package/src/conversations/ConversationListItem.tsx +123 -0
  137. package/src/conversations/DeleteDialog.tsx +55 -0
  138. package/src/conversations/RenameDialog.tsx +74 -0
  139. package/src/conversations/index.ts +14 -0
  140. package/src/conversations/types.ts +17 -0
  141. package/src/conversations/useConversations.ts +148 -0
  142. package/src/conversations/utils.ts +159 -0
  143. package/src/display/AlertRenderer.tsx +27 -0
  144. package/src/display/CarouselRenderer.tsx +141 -0
  145. package/src/display/ChartRenderer.tsx +195 -0
  146. package/src/display/ChoiceButtonsRenderer.tsx +114 -0
  147. package/src/display/CodeBlockRenderer.tsx +49 -0
  148. package/src/display/ComparisonTableRenderer.tsx +132 -0
  149. package/src/display/DataTableRenderer.tsx +144 -0
  150. package/src/display/FileCardRenderer.tsx +55 -0
  151. package/src/display/GalleryRenderer.tsx +65 -0
  152. package/src/display/ImageViewerRenderer.tsx +114 -0
  153. package/src/display/LinkPreviewRenderer.tsx +74 -0
  154. package/src/display/MapViewRenderer.tsx +75 -0
  155. package/src/display/MetricCardRenderer.tsx +29 -0
  156. package/src/display/PriceHighlightRenderer.tsx +44 -0
  157. package/src/display/ProductCardRenderer.tsx +112 -0
  158. package/src/display/ProgressStepsRenderer.tsx +59 -0
  159. package/src/display/SourcesListRenderer.tsx +47 -0
  160. package/src/display/SpreadsheetRenderer.tsx +86 -0
  161. package/src/display/StepTimelineRenderer.tsx +75 -0
  162. package/src/display/index.ts +21 -0
  163. package/src/display/registry.ts +81 -0
  164. package/src/hooks/ChatProvider.tsx +22 -0
  165. package/src/hooks/useBackboneChat.ts +148 -0
  166. package/src/hooks/useIsMobile.ts +15 -0
  167. package/src/index.ts +80 -0
  168. package/src/lib/utils.ts +6 -0
  169. package/src/parts/PartRenderer.tsx +198 -0
  170. package/src/parts/ReasoningBlock.tsx +41 -0
  171. package/src/parts/ToolActivity.tsx +79 -0
  172. package/src/parts/ToolResult.tsx +79 -0
  173. package/src/styles.css +2 -0
  174. package/src/ui/alert.tsx +77 -0
  175. package/src/ui/badge.tsx +36 -0
  176. package/src/ui/button.tsx +54 -0
  177. package/src/ui/card.tsx +68 -0
  178. package/src/ui/collapsible.tsx +7 -0
  179. package/src/ui/dialog.tsx +122 -0
  180. package/src/ui/dropdown-menu.tsx +76 -0
  181. package/src/ui/input.tsx +24 -0
  182. package/src/ui/progress.tsx +36 -0
  183. package/src/ui/scroll-area.tsx +48 -0
  184. package/src/ui/separator.tsx +31 -0
  185. package/src/ui/skeleton.tsx +9 -0
  186. package/src/ui/table.tsx +114 -0
  187. package/tsconfig.json +17 -0
@@ -0,0 +1,42 @@
1
+ import { useRef, useState, useEffect, type ReactNode } from "react";
2
+
3
+ interface LazyRenderProps {
4
+ children: ReactNode;
5
+ minHeight?: number;
6
+ rootMargin?: string;
7
+ }
8
+
9
+ export function LazyRender({ children, minHeight = 120, rootMargin = "200px" }: LazyRenderProps) {
10
+ const ref = useRef<HTMLDivElement>(null);
11
+ const [visible, setVisible] = useState(false);
12
+
13
+ useEffect(() => {
14
+ const el = ref.current;
15
+ if (!el) return;
16
+
17
+ const observer = new IntersectionObserver(
18
+ ([entry]) => {
19
+ if (entry.isIntersecting) {
20
+ setVisible(true);
21
+ observer.disconnect();
22
+ }
23
+ },
24
+ { rootMargin }
25
+ );
26
+
27
+ observer.observe(el);
28
+ return () => observer.disconnect();
29
+ }, [rootMargin]);
30
+
31
+ if (visible) return <>{children}</>;
32
+
33
+ return (
34
+ <div
35
+ ref={ref}
36
+ className="flex items-center justify-center text-muted-foreground text-xs rounded-md bg-muted/20 animate-pulse"
37
+ style={{ minHeight }}
38
+ >
39
+ Carregando...
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,114 @@
1
+ import { memo } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import rehypeHighlight from "rehype-highlight";
5
+ import type { Components } from "react-markdown";
6
+ import { cn } from "../lib/utils.js";
7
+
8
+ const REMARK_PLUGINS = [remarkGfm];
9
+ const REHYPE_PLUGINS = [rehypeHighlight];
10
+
11
+ const components: Components = {
12
+ h1({ children }) {
13
+ return <h1 className="text-xl font-semibold" style={{ marginTop: "20px", marginBottom: "8px" }}>{children}</h1>;
14
+ },
15
+ h2({ children }) {
16
+ return <h2 className="text-lg font-semibold" style={{ marginTop: "20px", marginBottom: "8px" }}>{children}</h2>;
17
+ },
18
+ h3({ children }) {
19
+ return <h3 className="text-base font-semibold" style={{ marginTop: "20px", marginBottom: "8px" }}>{children}</h3>;
20
+ },
21
+ h4({ children }) {
22
+ return <h4 className="font-semibold" style={{ marginTop: "20px", marginBottom: "8px" }}>{children}</h4>;
23
+ },
24
+ p({ children }) {
25
+ return <p className="mb-4 last:mb-0">{children}</p>;
26
+ },
27
+ ul({ children }) {
28
+ return <ul style={{ paddingLeft: "24px", marginTop: "8px", marginBottom: "8px", listStyleType: "disc" }}>{children}</ul>;
29
+ },
30
+ ol({ children }) {
31
+ return <ol style={{ paddingLeft: "24px", marginTop: "8px", marginBottom: "8px", listStyleType: "decimal" }}>{children}</ol>;
32
+ },
33
+ li({ children }) {
34
+ return <li style={{ marginTop: "4px", marginBottom: "4px" }}>{children}</li>;
35
+ },
36
+ hr() {
37
+ return <hr className="border-border" style={{ marginTop: "16px", marginBottom: "16px" }} />;
38
+ },
39
+ pre({ children }) {
40
+ return (
41
+ <pre className="bg-muted border border-border rounded-md overflow-hidden" style={{ marginTop: "12px", marginBottom: "12px" }}>
42
+ {children}
43
+ </pre>
44
+ );
45
+ },
46
+ code({ className, children }) {
47
+ const isBlock = className?.startsWith("language-");
48
+ if (isBlock) {
49
+ return (
50
+ <code className={cn("block p-4 overflow-x-auto font-mono text-sm", className)}>
51
+ {children}
52
+ </code>
53
+ );
54
+ }
55
+ return (
56
+ <code className="bg-muted border border-border rounded-sm px-1.5 py-0.5 font-mono text-sm">
57
+ {children}
58
+ </code>
59
+ );
60
+ },
61
+ a({ href, children }) {
62
+ return (
63
+ <a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline underline-offset-2 hover:opacity-80">
64
+ {children}
65
+ </a>
66
+ );
67
+ },
68
+ blockquote({ children }) {
69
+ return (
70
+ <blockquote className="border-l-[3px] border-border py-1 px-3 text-muted-foreground" style={{ marginTop: "12px", marginBottom: "12px" }}>
71
+ {children}
72
+ </blockquote>
73
+ );
74
+ },
75
+ table({ children }) {
76
+ return (
77
+ <div className="overflow-x-auto">
78
+ <table className="w-full border-collapse text-sm">{children}</table>
79
+ </div>
80
+ );
81
+ },
82
+ th({ children }) {
83
+ return (
84
+ <th className="border border-border px-3 py-1.5 text-left font-semibold bg-muted">
85
+ {children}
86
+ </th>
87
+ );
88
+ },
89
+ td({ children }) {
90
+ return (
91
+ <td className="border border-border px-3 py-1.5 text-left">
92
+ {children}
93
+ </td>
94
+ );
95
+ },
96
+ };
97
+
98
+ interface MarkdownProps {
99
+ children: string;
100
+ }
101
+
102
+ export const Markdown = memo(function Markdown({ children }: MarkdownProps) {
103
+ return (
104
+ <div className="text-foreground text-sm" style={{ lineHeight: "1.625em" }}>
105
+ <ReactMarkdown
106
+ remarkPlugins={REMARK_PLUGINS}
107
+ rehypePlugins={REHYPE_PLUGINS}
108
+ components={components}
109
+ >
110
+ {children}
111
+ </ReactMarkdown>
112
+ </div>
113
+ );
114
+ });
@@ -0,0 +1,102 @@
1
+ import { memo, useState, useCallback } from "react";
2
+ import type { Message } from "@ai-sdk/react";
3
+ import { cn } from "../lib/utils.js";
4
+ import { Markdown } from "./Markdown.js";
5
+ import { StreamingIndicator } from "./StreamingIndicator.js";
6
+ import { PartRenderer } from "../parts/PartRenderer.js";
7
+ import type { DisplayRendererMap } from "../display/registry.js";
8
+ import { Copy, Check } from "lucide-react";
9
+
10
+ export interface MessageBubbleProps {
11
+ message: Message;
12
+ isStreaming?: boolean;
13
+ displayRenderers?: DisplayRendererMap;
14
+ attachmentUrl?: (ref: string) => string;
15
+ className?: string;
16
+ }
17
+
18
+ function extractText(message: Message): string {
19
+ if (message.content) return message.content;
20
+ if (!Array.isArray(message.parts)) return "";
21
+ return message.parts
22
+ .filter((p): p is { type: "text"; text: string } => (p as { type: string }).type === "text")
23
+ .map((p) => p.text)
24
+ .join("\n");
25
+ }
26
+
27
+ function CopyButton({ text }: { text: string }) {
28
+ const [copied, setCopied] = useState(false);
29
+
30
+ const handleCopy = useCallback(async () => {
31
+ await navigator.clipboard.writeText(text);
32
+ setCopied(true);
33
+ setTimeout(() => setCopied(false), 2000);
34
+ }, [text]);
35
+
36
+ return (
37
+ <button
38
+ type="button"
39
+ onClick={handleCopy}
40
+ className={cn(
41
+ "h-7 w-7 flex items-center justify-center rounded-lg transition-all",
42
+ "text-muted-foreground/50 opacity-0 group-hover/bubble:opacity-100",
43
+ "hover:bg-muted/50 hover:text-muted-foreground",
44
+ )}
45
+ aria-label={copied ? "Copiado" : "Copiar mensagem"}
46
+ >
47
+ {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
48
+ </button>
49
+ );
50
+ }
51
+
52
+ export const MessageBubble = memo(function MessageBubble({ message, isStreaming, displayRenderers, attachmentUrl, className }: MessageBubbleProps) {
53
+ const isUser = message.role === "user";
54
+ const hasParts = Array.isArray(message.parts) && message.parts.length > 0;
55
+
56
+ return (
57
+ <div className={cn("group/bubble", isUser ? "flex flex-col items-end" : "flex flex-col items-start")}>
58
+ {/* Bubble */}
59
+ <div
60
+ className={cn(
61
+ "min-w-0 overflow-hidden",
62
+ isUser
63
+ ? "max-w-[80%] rounded-lg rounded-br-sm bg-muted text-foreground px-4 py-2.5"
64
+ : "w-full text-foreground py-1",
65
+ className
66
+ )}
67
+ >
68
+ {hasParts
69
+ ? <div className="flex flex-col gap-3">
70
+ {(message.parts as { type: string }[]).map((part, i) => (
71
+ <PartRenderer
72
+ key={i}
73
+ part={part as Parameters<typeof PartRenderer>[0]["part"]}
74
+ isStreaming={isStreaming}
75
+ displayRenderers={displayRenderers}
76
+ attachmentUrl={attachmentUrl}
77
+ />
78
+ ))}
79
+ </div>
80
+ : <Markdown>{message.content}</Markdown>
81
+ }
82
+ {isStreaming && !isUser && <StreamingIndicator />}
83
+ </div>
84
+
85
+ {/* Action bar — outside bubble, below */}
86
+ {!isStreaming && (
87
+ <div className={cn(
88
+ "flex items-center gap-0.5 mt-0.5",
89
+ isUser ? "justify-end" : "justify-start"
90
+ )}>
91
+ <CopyButton text={extractText(message)} />
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }, (prev, next) =>
97
+ prev.message === next.message
98
+ && prev.isStreaming === next.isStreaming
99
+ && prev.displayRenderers === next.displayRenderers
100
+ && prev.attachmentUrl === next.attachmentUrl
101
+ && prev.className === next.className
102
+ );
@@ -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>>();
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-red-500 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
+ }