sistema-de-chamados/apps/desktop/src/chat/ChatWidget.tsx
esdrasrenan 1986bf286a fix(desktop): corrige inicializacao do estado minimizado do chat
- Inicializa isMinimized baseado na altura real da janela
- Usa h-full em vez de h-screen para layout correto
- Evita inconsistencia entre estado React e tamanho da janela

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 23:48:12 -03:00

826 lines
28 KiB
TypeScript

/**
* ChatWidget - Componente de chat em tempo real usando Convex subscriptions
*
* Arquitetura:
* - Usa useQuery do Convex React para subscriptions reativas (tempo real verdadeiro)
* - Usa useMutation do Convex React para enviar mensagens
* - Mantém Tauri apenas para: upload de arquivos, gerenciamento de janela
* - Sem polling - todas as atualizacoes sao push-based via WebSocket
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { open as openDialog } from "@tauri-apps/plugin-dialog"
import { openUrl as openExternal } from "@tauri-apps/plugin-opener"
import { invoke } from "@tauri-apps/api/core"
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react"
import type { Id } from "@convex/_generated/dataModel"
import { useMachineMessages, usePostMachineMessage, useMarkMachineMessagesRead, type MachineMessage } from "./useConvexMachineQueries"
import { useConvexMachine } from "./ConvexMachineProvider"
const MAX_MESSAGES_IN_MEMORY = 200
const MARK_READ_BATCH_SIZE = 50
const SCROLL_BOTTOM_THRESHOLD_PX = 120
const ALLOWED_EXTENSIONS = [
"jpg", "jpeg", "png", "gif", "webp",
"pdf", "txt", "doc", "docx", "xls", "xlsx",
]
interface UploadedAttachment {
storageId: string
name: string
size?: number
type?: string
}
interface ChatAttachment {
storageId: string
name: string
size?: number
type?: string
}
function getFileIcon(fileName: string) {
const ext = fileName.toLowerCase().split(".").pop() ?? ""
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
return <ImageIcon className="size-4" />
}
if (["pdf", "doc", "docx", "txt"].includes(ext)) {
return <FileText className="size-4" />
}
return <File className="size-4" />
}
function isImageAttachment(attachment: ChatAttachment) {
if (attachment.type?.startsWith("image/")) return true
const ext = attachment.name.toLowerCase().split(".").pop() ?? ""
return ["jpg", "jpeg", "png", "gif", "webp"].includes(ext)
}
function formatAttachmentSize(size?: number) {
if (!size) return null
if (size < 1024) return `${size}B`
const kb = size / 1024
if (kb < 1024) return `${Math.round(kb)}KB`
return `${(kb / 1024).toFixed(1)}MB`
}
function getUnreadAgentMessageIds(messages: MachineMessage[], unreadCount: number): string[] {
if (unreadCount <= 0 || messages.length === 0) return []
const ids: string[] = []
for (let i = messages.length - 1; i >= 0 && ids.length < unreadCount; i--) {
const msg = messages[i]
if (!msg.isFromMachine) {
ids.push(msg.id)
}
}
return ids.reverse()
}
function chunkArray<T>(items: T[], size: number): T[][] {
if (size <= 0) return [items]
const result: T[][] = []
for (let i = 0; i < items.length; i += size) {
result.push(items.slice(i, i + size))
}
return result
}
function MessageAttachment({
attachment,
isAgent,
loadUrl,
}: {
attachment: ChatAttachment
isAgent: boolean
loadUrl: (storageId: string) => Promise<string>
}) {
const [url, setUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [downloading, setDownloading] = useState(false)
const [downloaded, setDownloaded] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
loadUrl(attachment.storageId)
.then((resolved) => {
if (!cancelled) setUrl(resolved)
})
.catch((err) => {
console.error("Falha ao carregar URL do anexo:", err)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [attachment.storageId, loadUrl])
const handleView = async () => {
if (!url) return
try {
await openExternal(url)
} catch (err) {
console.error("Falha ao abrir anexo:", err)
}
}
const handleDownload = async () => {
if (!url || downloading) return
setDownloading(true)
try {
const response = await fetch(url)
const blob = await response.blob()
const downloadUrl = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = downloadUrl
a.download = attachment.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(downloadUrl)
setDownloaded(true)
setTimeout(() => setDownloaded(false), 2000)
} catch (err) {
console.error("Falha ao baixar anexo:", err)
await handleView()
} finally {
setDownloading(false)
}
}
const sizeLabel = formatAttachmentSize(attachment.size)
const isImage = isImageAttachment(attachment)
if (loading) {
return (
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
<Loader2 className="size-4 animate-spin" />
<span className="truncate">Carregando anexo...</span>
</div>
)
}
if (isImage && url) {
return (
<div className={`group relative overflow-hidden rounded-lg border ${isAgent ? "border-white/10" : "border-slate-200"}`}>
{/* eslint-disable-next-line @next/next/no-img-element -- Tauri desktop app, not Next.js */}
<img
src={url}
alt={attachment.name}
className="size-24 cursor-pointer object-cover"
onClick={handleView}
/>
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={handleView}
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
title="Visualizar"
>
<Eye className="size-4 text-white" />
</button>
<button
onClick={handleDownload}
disabled={downloading}
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30 disabled:opacity-60"
title="Baixar"
>
{downloading ? (
<Loader2 className="size-4 animate-spin text-white" />
) : downloaded ? (
<Check className="size-4 text-emerald-300" />
) : (
<Download className="size-4 text-white" />
)}
</button>
</div>
</div>
)
}
return (
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
{getFileIcon(attachment.name)}
<button onClick={handleView} className="flex-1 truncate text-left hover:underline" title="Visualizar">
{attachment.name}
</button>
{sizeLabel && <span className="text-xs opacity-60">({sizeLabel})</span>}
<div className="ml-1 flex items-center gap-1">
<button
onClick={handleView}
className={`flex size-7 items-center justify-center rounded-md ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
title="Visualizar"
>
<Eye className="size-4" />
</button>
<button
onClick={handleDownload}
disabled={downloading}
className={`flex size-7 items-center justify-center rounded-md disabled:opacity-60 ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
title="Baixar"
>
{downloading ? (
<Loader2 className="size-4 animate-spin" />
) : downloaded ? (
<Check className="size-4 text-emerald-500" />
) : (
<Download className="size-4" />
)}
</button>
</div>
</div>
)
}
interface ChatWidgetProps {
ticketId: string
ticketRef?: number
}
export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
const [inputValue, setInputValue] = useState("")
const [isSending, setIsSending] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [pendingAttachments, setPendingAttachments] = useState<UploadedAttachment[]>([])
// Inicializa baseado na altura real da janela (< 100px = minimizado)
const [isMinimized, setIsMinimized] = useState(() => window.innerHeight < 100)
// Convex hooks
const { apiBaseUrl, machineToken } = useConvexMachine()
const { messages: convexMessages, hasSession, unreadCount, isLoading } = useMachineMessages(
ticketId as Id<"tickets">,
{ limit: MAX_MESSAGES_IN_MEMORY }
)
const postMessage = usePostMachineMessage()
const markMessagesRead = useMarkMachineMessagesRead()
// Limitar mensagens em memoria
const messages = useMemo(() => convexMessages.slice(-MAX_MESSAGES_IN_MEMORY), [convexMessages])
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const messageElementsRef = useRef<Map<string, HTMLDivElement>>(new Map())
const prevHasSessionRef = useRef<boolean>(false)
const [isAtBottom, setIsAtBottom] = useState(true)
const isAtBottomRef = useRef(true)
const pendingScrollActionRef = useRef<
| { type: "bottom"; behavior: ScrollBehavior; markRead: boolean }
| { type: "message"; messageId: string; behavior: ScrollBehavior; markRead: boolean }
| null
>(null)
const unreadAgentMessageIds = useMemo(() => getUnreadAgentMessageIds(messages, unreadCount), [messages, unreadCount])
const firstUnreadAgentMessageId = unreadAgentMessageIds[0] ?? null
const updateIsAtBottom = useCallback(() => {
const el = messagesContainerRef.current
if (!el) return
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
const atBottom = distance <= SCROLL_BOTTOM_THRESHOLD_PX
if (isAtBottomRef.current !== atBottom) {
isAtBottomRef.current = atBottom
setIsAtBottom(atBottom)
}
}, [])
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
messagesEndRef.current?.scrollIntoView({ behavior })
requestAnimationFrame(() => updateIsAtBottom())
}, [updateIsAtBottom])
const scrollToMessage = useCallback((messageId: string, behavior: ScrollBehavior) => {
const el = messageElementsRef.current.get(messageId)
if (!el) return false
el.scrollIntoView({ behavior, block: "center" })
requestAnimationFrame(() => updateIsAtBottom())
return true
}, [updateIsAtBottom])
// Fechar janela quando sessao termina
useEffect(() => {
const prevHasSession = prevHasSessionRef.current
if (prevHasSession && !hasSession) {
invoke("close_chat_window", { ticket_id: ticketId }).catch((err) => {
console.error("Erro ao fechar janela ao encerrar sessao:", err)
})
}
prevHasSessionRef.current = hasSession
}, [hasSession, ticketId])
// Ref para acessar isMinimized dentro de callbacks
const isMinimizedRef = useRef(isMinimized)
useEffect(() => {
isMinimizedRef.current = isMinimized
}, [isMinimized])
// Cache de URLs de anexos
const attachmentUrlCacheRef = useRef<Map<string, string>>(new Map())
const loadAttachmentUrl = useCallback(async (storageId: string) => {
const cached = attachmentUrlCacheRef.current.get(storageId)
if (cached) return cached
if (!apiBaseUrl || !machineToken) {
throw new Error("Configuracao nao disponivel")
}
const response = await fetch(`${apiBaseUrl}/api/machines/chat/attachments/url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineToken,
ticketId,
storageId,
}),
})
if (!response.ok) {
const text = await response.text().catch(() => "")
throw new Error(text || `Falha ao obter URL do anexo (${response.status})`)
}
const data = (await response.json()) as { url?: string }
if (!data.url) {
throw new Error("Resposta invalida ao obter URL do anexo")
}
attachmentUrlCacheRef.current.set(storageId, data.url)
return data.url
}, [apiBaseUrl, machineToken, ticketId])
const markUnreadMessagesRead = useCallback(async () => {
if (unreadCount <= 0) return
const ids = getUnreadAgentMessageIds(messages, unreadCount)
if (ids.length === 0) return
const chunks = chunkArray(ids, MARK_READ_BATCH_SIZE)
for (const chunk of chunks) {
await markMessagesRead({
ticketId: ticketId as Id<"tickets">,
messageIds: chunk as Id<"ticketChatMessages">[],
})
}
}, [messages, ticketId, unreadCount, markMessagesRead])
// Auto-scroll quando novas mensagens chegam (se ja estava no bottom)
const prevMessagesLengthRef = useRef(messages.length)
useEffect(() => {
if (messages.length > prevMessagesLengthRef.current && isAtBottomRef.current && !isMinimizedRef.current) {
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true }
}
prevMessagesLengthRef.current = messages.length
}, [messages.length])
// Executar scroll pendente
useEffect(() => {
if (isMinimized) return
const action = pendingScrollActionRef.current
if (!action) return
if (action.type === "bottom") {
if (!messagesEndRef.current) return
pendingScrollActionRef.current = null
scrollToBottom(action.behavior)
if (action.markRead) {
markUnreadMessagesRead().catch((err) => console.error("Falha ao marcar mensagens como lidas:", err))
}
return
}
const ok = scrollToMessage(action.messageId, action.behavior)
if (!ok) {
if (!messagesEndRef.current) return
pendingScrollActionRef.current = null
scrollToBottom(action.behavior)
if (action.markRead) {
markUnreadMessagesRead().catch((err) => console.error("Falha ao marcar mensagens como lidas:", err))
}
return
}
pendingScrollActionRef.current = null
if (action.markRead) {
markUnreadMessagesRead().catch((err) => console.error("Falha ao marcar mensagens como lidas:", err))
}
}, [isMinimized, messages, markUnreadMessagesRead, scrollToBottom, scrollToMessage])
// Sincronizar estado minimizado com tamanho da janela
useEffect(() => {
const mountTime = Date.now()
const STABILIZATION_DELAY = 500
const handler = () => {
if (Date.now() - mountTime < STABILIZATION_DELAY) {
return
}
const h = window.innerHeight
setIsMinimized(h < 100)
}
window.addEventListener("resize", handler)
return () => window.removeEventListener("resize", handler)
}, [])
// Selecionar arquivo para anexar
const handleAttach = async () => {
if (isUploading || isSending) return
try {
const selected = await openDialog({
multiple: false,
filters: [{
name: "Arquivos permitidos",
extensions: ALLOWED_EXTENSIONS,
}],
})
if (!selected) return
const filePath = typeof selected === "string" ? selected : (selected as { path: string }).path
setIsUploading(true)
if (!apiBaseUrl || !machineToken) {
throw new Error("Configuracao nao disponivel")
}
const attachment = await invoke<UploadedAttachment>("upload_chat_file", {
baseUrl: apiBaseUrl,
token: machineToken,
filePath,
})
setPendingAttachments(prev => [...prev, attachment])
} catch (err) {
console.error("Erro ao anexar arquivo:", err)
alert(typeof err === "string" ? err : "Erro ao anexar arquivo")
} finally {
setIsUploading(false)
}
}
// Remover anexo pendente
const handleRemoveAttachment = (storageId: string) => {
setPendingAttachments(prev => prev.filter(a => a.storageId !== storageId))
}
// Enviar mensagem
const handleSend = async () => {
if ((!inputValue.trim() && pendingAttachments.length === 0) || isSending) return
const messageText = inputValue.trim()
const attachmentsToSend = [...pendingAttachments]
setInputValue("")
setPendingAttachments([])
setIsSending(true)
try {
await postMessage({
ticketId: ticketId as Id<"tickets">,
body: messageText,
attachments: attachmentsToSend.length > 0 ? attachmentsToSend.map(a => ({
storageId: a.storageId as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
})) : undefined,
})
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: false }
} catch (err) {
console.error("Erro ao enviar mensagem:", err)
setInputValue(messageText)
setPendingAttachments(attachmentsToSend)
} finally {
setIsSending(false)
}
}
const handleMinimize = async () => {
setIsMinimized(true)
try {
await invoke("set_chat_minimized", { ticket_id: ticketId, minimized: true })
} catch (err) {
console.error("Erro ao minimizar janela:", err)
}
}
const handleExpand = async () => {
if (firstUnreadAgentMessageId) {
pendingScrollActionRef.current = { type: "message", messageId: firstUnreadAgentMessageId, behavior: "auto", markRead: unreadCount > 0 }
} else {
pendingScrollActionRef.current = { type: "bottom", behavior: "auto", markRead: false }
}
setIsMinimized(false)
try {
await invoke("set_chat_minimized", { ticket_id: ticketId, minimized: false })
} catch (err) {
console.error("Erro ao expandir janela:", err)
}
}
const handleClose = () => {
invoke("close_chat_window", { ticket_id: ticketId }).catch((err) => {
console.error("Erro ao fechar janela de chat:", err)
})
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
// Loading
if (isLoading) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm font-medium">Carregando...</span>
</div>
</div>
)
}
// Sem sessao ativa
if (!hasSession) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent">
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
<MessageCircle className="size-4" />
<span className="text-sm font-medium">
{ticketRef ? `Ticket #${ticketRef}` : "Chat"}
</span>
<span className="size-2 rounded-full bg-slate-400" />
<span className="text-xs text-slate-500">Offline</span>
<button
onClick={handleClose}
className="ml-1 rounded-full p-1 text-slate-600 hover:bg-slate-300/60"
title="Fechar"
>
<X className="size-4" />
</button>
</div>
</div>
)
}
// Minimizado
if (isMinimized) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
<button
onClick={handleExpand}
className="pointer-events-auto relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
>
<MessageCircle className="size-4" />
<span className="text-sm font-medium">
Ticket #{ticketRef}
</span>
<span className="size-2 rounded-full bg-emerald-400" />
<ChevronUp className="size-4" />
{unreadCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
</div>
)
}
// Expandido
return (
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
{/* Header */}
<div
data-tauri-drag-region
className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl"
>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-900">Chat</p>
<span className="flex items-center gap-1.5 text-xs text-emerald-600">
<span className="size-2 rounded-full bg-emerald-500 animate-pulse" />
Online
</span>
</div>
<p className="text-xs text-slate-500">
Ticket #{ticketRef} - Suporte
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleMinimize}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Minimizar"
>
<Minimize2 className="size-4" />
</button>
<button
onClick={handleClose}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Mensagens */}
<div
ref={messagesContainerRef}
onScroll={updateIsAtBottom}
className="flex-1 overflow-y-auto p-4"
>
{messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<p className="text-sm text-slate-400">
Nenhuma mensagem ainda
</p>
<p className="mt-1 text-xs text-slate-400">
O agente iniciara a conversa em breve
</p>
</div>
) : (
<div className="space-y-4">
{messages.map((msg) => {
const isAgent = !msg.isFromMachine
const bodyText = msg.body.trim()
const shouldShowBody =
bodyText.length > 0 && !(bodyText === "[Anexo]" && (msg.attachments?.length ?? 0) > 0)
return (
<div key={msg.id} className="space-y-2">
{firstUnreadAgentMessageId === msg.id && unreadCount > 0 && !isAtBottom && (
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-slate-200" />
<span className="text-xs font-medium text-slate-500">Novas mensagens</span>
<div className="h-px flex-1 bg-slate-200" />
</div>
)}
<div
ref={(el) => {
if (el) {
messageElementsRef.current.set(msg.id, el)
} else {
messageElementsRef.current.delete(msg.id)
}
}}
className={`flex gap-2 ${isAgent ? "flex-row-reverse" : "flex-row"}`}
>
{/* Avatar */}
<div
className={`flex size-7 shrink-0 items-center justify-center rounded-full ${
isAgent ? "bg-black text-white" : "bg-slate-200 text-slate-600"
}`}
>
{isAgent ? <MessageCircle className="size-3.5" /> : <User className="size-3.5" />}
</div>
{/* Bubble */}
<div
className={`max-w-[75%] rounded-2xl px-4 py-2 ${
isAgent
? "rounded-br-md bg-black text-white"
: "rounded-bl-md border border-slate-100 bg-white text-slate-900 shadow-sm"
}`}
>
{!isAgent && (
<p className="mb-1 text-xs font-medium text-slate-500">
{msg.authorName}
</p>
)}
{shouldShowBody && <p className="whitespace-pre-wrap text-sm">{msg.body}</p>}
{/* Anexos */}
{msg.attachments && msg.attachments.length > 0 && (
<div className="mt-2 space-y-2">
{msg.attachments.map((att) => (
<MessageAttachment
key={att.storageId}
attachment={{
storageId: att.storageId as string,
name: att.name,
size: att.size,
type: att.type,
}}
isAgent={isAgent}
loadUrl={loadAttachmentUrl}
/>
))}
</div>
)}
<p
className={`mt-1 text-right text-xs ${
isAgent ? "text-white/60" : "text-slate-400"
}`}
>
{formatTime(msg.createdAt)}
</p>
</div>
</div>
</div>
)
})}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div className="border-t border-slate-200 p-3">
{unreadCount > 0 && !isAtBottom && (
<div className="mb-2 flex justify-center">
<button
type="button"
onClick={() => {
const target = firstUnreadAgentMessageId
if (target) {
scrollToMessage(target, "smooth")
} else {
scrollToBottom("smooth")
}
markUnreadMessagesRead().catch((err) => console.error("Falha ao marcar mensagens como lidas:", err))
}}
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 hover:bg-slate-200"
>
Ver novas mensagens ({unreadCount > 9 ? "9+" : unreadCount})
</button>
</div>
)}
{/* Anexos pendentes */}
{pendingAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{pendingAttachments.map((att) => (
<div
key={att.storageId}
className="flex items-center gap-1 rounded-lg bg-slate-100 px-2 py-1 text-xs"
>
{getFileIcon(att.name)}
<span className="max-w-[100px] truncate">{att.name}</span>
<button
onClick={() => handleRemoveAttachment(att.storageId)}
className="ml-1 rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<X className="size-3" />
</button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="max-h-24 min-h-[36px] flex-1 resize-none rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400"
rows={1}
/>
<button
onClick={handleAttach}
disabled={isUploading || isSending}
className="flex size-9 items-center justify-center rounded-lg text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 disabled:opacity-50"
title="Anexar arquivo"
>
{isUploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Paperclip className="size-4" />
)}
</button>
<button
onClick={handleSend}
disabled={(!inputValue.trim() && pendingAttachments.length === 0) || isSending}
className="flex size-9 items-center justify-center rounded-lg bg-black text-white transition hover:bg-black/90 disabled:opacity-50"
>
{isSending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</button>
</div>
</div>
</div>
)
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp)
return date.toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
})
}