feat(desktop): implementa Convex React subscriptions para chat em tempo real

- Adiciona ConvexMachineProvider para autenticacao via machine token
- Cria hooks customizados (useMachineSessions, useMachineMessages, etc)
- Refatora ChatWidget e ChatHubWidget para usar useQuery/useMutation
- Remove polling e dependencia de Tauri events para mensagens
- Adiciona copia local dos arquivos _generated do Convex
- Remove componentes obsoletos (ChatSessionItem, ChatSessionList)

Beneficios:
- Tempo real verdadeiro via WebSocket (sem polling)
- Melhor escalabilidade e performance
- Codigo mais simples e maintivel
- Consistencia de estado entre multiplas janelas

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-15 23:40:34 -03:00
parent a6af4aa580
commit c51b08f127
16 changed files with 1181 additions and 650 deletions

View file

@ -21,6 +21,7 @@
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2",
"convex": "^1.31.0",
"lucide-react": "^0.544.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"

View file

@ -1,95 +1,26 @@
import { useCallback, useEffect, useRef, useState } from "react"
/**
* ChatHubWidget - Lista de sessoes de chat ativas usando Convex subscriptions
*
* Arquitetura:
* - Usa useQuery do Convex React para subscription reativa (tempo real verdadeiro)
* - Sem polling - todas as atualizacoes sao push-based via WebSocket
* - Tauri usado apenas para gerenciamento de janelas
*/
import { useEffect, useState } from "react"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Loader2, MessageCircle, ChevronUp } from "lucide-react"
import { ChatSessionList } from "./ChatSessionList"
import type { ChatSession, NewMessageEvent, SessionStartedEvent, SessionEndedEvent, UnreadUpdateEvent } from "./types"
import { getMachineStoreConfig } from "./machineStore"
import { Loader2, MessageCircle, ChevronUp, X, Minimize2 } from "lucide-react"
import { useMachineSessions, type MachineSession } from "./useConvexMachineQueries"
/**
* Hub Widget - Lista todas as sessoes de chat ativas
* Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket
*/
export function ChatHubWidget() {
const [sessions, setSessions] = useState<ChatSession[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isMinimized, setIsMinimized] = useState(true) // Inicia minimizado
const [isMinimized, setIsMinimized] = useState(true)
const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null)
const ensureConfig = useCallback(async () => {
const cfg = configRef.current ?? (await getMachineStoreConfig())
configRef.current = cfg
return cfg
}, [])
// Buscar sessoes do backend
const loadSessions = useCallback(async () => {
try {
const cfg = await ensureConfig()
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineToken: cfg.token }),
})
if (!response.ok) {
throw new Error(`Falha ao buscar sessoes: ${response.status}`)
}
const data = await response.json() as { sessions: ChatSession[] }
setSessions(data.sessions || [])
setError(null)
} catch (err) {
console.error("Erro ao carregar sessoes:", err)
setError(err instanceof Error ? err.message : "Erro desconhecido")
} finally {
setIsLoading(false)
}
}, [ensureConfig])
// Carregar sessoes na montagem
useEffect(() => {
loadSessions()
}, [loadSessions])
// Escutar eventos de atualizacao
useEffect(() => {
const unlisteners: (() => void)[] = []
// Quando nova sessao inicia
listen<SessionStartedEvent>("raven://chat/session-started", () => {
loadSessions()
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
console.error("Erro ao registrar listener session-started:", err)
})
// Quando sessao encerra
listen<SessionEndedEvent>("raven://chat/session-ended", () => {
loadSessions()
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
console.error("Erro ao registrar listener session-ended:", err)
})
// Quando contador de nao lidos muda
listen<UnreadUpdateEvent>("raven://chat/unread-update", (event) => {
setSessions(event.payload.sessions || [])
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
console.error("Erro ao registrar listener unread-update:", err)
})
// Quando nova mensagem chega
listen<NewMessageEvent>("raven://chat/new-message", (event) => {
setSessions(event.payload.sessions || [])
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
console.error("Erro ao registrar listener new-message:", err)
})
return () => {
unlisteners.forEach((unlisten) => unlisten())
}
}, [loadSessions])
// Convex subscription reativa
const { sessions = [], isLoading, hasToken } = useMachineSessions()
// Sincronizar estado minimizado com tamanho da janela
useEffect(() => {
@ -109,7 +40,6 @@ export function ChatHubWidget() {
const handleSelectSession = async (ticketId: string, ticketRef: number) => {
try {
// Tauri 2 espera snake_case nos parametros
await invoke("open_chat_window", { ticket_id: ticketId, ticket_ref: ticketRef })
} catch (err) {
console.error("Erro ao abrir janela de chat:", err)
@ -128,11 +58,10 @@ export function ChatHubWidget() {
const handleExpand = async () => {
try {
await invoke("set_hub_minimized", { minimized: false })
// Aguarda a janela redimensionar antes de atualizar o estado
setTimeout(() => setIsMinimized(false), 100)
} catch (err) {
console.error("Erro ao expandir hub:", err)
setIsMinimized(false) // Fallback
setIsMinimized(false)
}
}
@ -144,6 +73,17 @@ export function ChatHubWidget() {
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
// Sem token
if (!hasToken) {
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-red-100 px-4 py-2 text-red-600 shadow-lg">
<span className="text-sm font-medium">Token nao configurado</span>
</div>
</div>
)
}
// Loading
if (isLoading) {
return (
@ -156,26 +96,7 @@ export function ChatHubWidget() {
)
}
// Erro
if (error) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<button
type="button"
onClick={() => {
setIsLoading(true)
loadSessions()
}}
className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg transition hover:bg-red-200/60"
title="Tentar novamente"
>
<span className="text-sm font-medium">Erro - Tentar novamente</span>
</button>
</div>
)
}
// Sem sessoes ativas - mostrar chip cinza
// Sem sessoes ativas
if (sessions.length === 0) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
@ -187,7 +108,7 @@ export function ChatHubWidget() {
)
}
// Minimizado - mostrar chip com contador
// Minimizado
if (isMinimized) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
@ -211,15 +132,114 @@ export function ChatHubWidget() {
)
}
// Expandido - mostrar lista
// Expandido
return (
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
<ChatSessionList
sessions={sessions}
onSelectSession={handleSelectSession}
onClose={handleClose}
onMinimize={handleMinimize}
/>
{/* 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>
<p className="text-sm font-semibold text-slate-900">Chats Ativos</p>
<p className="text-xs text-slate-500">
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""}
</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>
{/* Lista de sessoes */}
<div className="flex-1 overflow-y-auto p-2">
<div className="space-y-2">
{sessions.map((session) => (
<SessionItem
key={session.sessionId}
session={session}
onClick={() => handleSelectSession(session.ticketId, session.ticketRef)}
/>
))}
</div>
</div>
</div>
)
}
function SessionItem({
session,
onClick,
}: {
session: MachineSession
onClick: () => void
}) {
return (
<button
onClick={onClick}
className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition hover:bg-slate-50"
>
{/* Avatar */}
<div className="relative flex size-10 shrink-0 items-center justify-center rounded-full bg-black text-white">
<MessageCircle className="size-5" />
{/* Indicador online */}
<span className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-white bg-emerald-500" />
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium text-slate-900">
Ticket #{session.ticketRef}
</p>
<span className="shrink-0 text-xs text-slate-400">
{formatRelativeTime(session.lastActivityAt)}
</span>
</div>
<p className="truncate text-xs text-slate-500">
{session.agentName}
</p>
</div>
{/* Badge nao lidas */}
{session.unreadCount > 0 && (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{session.unreadCount > 9 ? "9+" : session.unreadCount}
</span>
)}
</button>
)
}
function formatRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return "agora"
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
return `${days}d`
}

View file

@ -1,86 +0,0 @@
import { MessageCircle } from "lucide-react"
import type { ChatSession } from "./types"
type ChatSessionItemProps = {
session: ChatSession
isActive?: boolean
onClick: () => void
}
function formatTime(timestamp: number) {
const now = Date.now()
const diff = now - timestamp
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return "Agora"
if (minutes < 60) return `${minutes}min`
if (hours < 24) return `${hours}h`
if (days === 1) return "Ontem"
return new Date(timestamp).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
})
}
export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemProps) {
const hasUnread = session.unreadCount > 0
const handleClick = () => {
onClick()
}
return (
<button
type="button"
onClick={handleClick}
className={`flex w-full items-start gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors hover:bg-slate-50 ${
isActive ? "bg-slate-100" : ""
} ${hasUnread ? "bg-red-50/50 hover:bg-red-50" : ""}`}
>
{/* Avatar/Icone */}
<div
className={`flex size-10 shrink-0 items-center justify-center rounded-full ${
hasUnread ? "bg-red-100 text-red-600" : "bg-slate-100 text-slate-600"
}`}
>
<MessageCircle className="size-5" />
</div>
{/* Conteudo */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={`text-sm font-semibold ${hasUnread ? "text-red-700" : "text-slate-900"}`}>
#{session.ticketRef}
</span>
{/* Indicador online - sessao ativa significa online */}
<span className="size-2 rounded-full bg-emerald-500" title="Online" />
</div>
<span className="text-xs text-slate-400">
{formatTime(session.lastActivityAt)}
</span>
</div>
<p className="mt-0.5 truncate text-sm text-slate-600">
{session.ticketSubject}
</p>
<p className="mt-0.5 truncate text-xs text-slate-400">
{session.agentName}
</p>
</div>
{/* Badge de nao lidos */}
{hasUnread && (
<div className="flex shrink-0 items-center justify-center">
<span className="flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{session.unreadCount > 9 ? "9+" : session.unreadCount}
</span>
</div>
)}
</button>
)
}

View file

@ -1,99 +0,0 @@
import { useMemo } from "react"
import { MessageCircle, X } from "lucide-react"
import { ChatSessionItem } from "./ChatSessionItem"
import type { ChatSession } from "./types"
type ChatSessionListProps = {
sessions: ChatSession[]
onSelectSession: (ticketId: string, ticketRef: number) => void
onClose: () => void
onMinimize: () => void
}
export function ChatSessionList({
sessions,
onSelectSession,
onClose,
onMinimize,
}: ChatSessionListProps) {
// Ordenar: nao lidos primeiro, depois por ultima atividade (desc)
const sortedSessions = useMemo(() => {
return [...sessions].sort((a, b) => {
// Nao lidos primeiro
if (a.unreadCount > 0 && b.unreadCount === 0) return -1
if (a.unreadCount === 0 && b.unreadCount > 0) return 1
// Depois por ultima atividade (mais recente primeiro)
return b.lastActivityAt - a.lastActivityAt
})
}, [sessions])
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
return (
<div className="flex h-full flex-col">
{/* Header - arrastavel */}
<div
data-tauri-drag-region
className="flex items-center justify-between rounded-t-2xl border-b border-slate-200 bg-slate-50 px-4 py-3"
>
<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>
<p className="text-sm font-semibold text-slate-900">Chats</p>
<p className="text-xs text-slate-500">
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""}
{totalUnread > 0 && (
<span className="ml-1 text-red-500">
({totalUnread} não lida{totalUnread !== 1 ? "s" : ""})
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={onMinimize}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Minimizar"
>
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<button
onClick={onClose}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
title="Fechar"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Lista de sessoes */}
<div className="flex-1 overflow-y-auto bg-white">
{sortedSessions.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center p-4 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-slate-100">
<MessageCircle className="size-6 text-slate-400" />
</div>
<p className="mt-3 text-sm font-medium text-slate-600">Nenhum chat ativo</p>
<p className="mt-1 text-xs text-slate-400">
Os chats aparecerao aqui quando iniciados
</p>
</div>
) : (
sortedSessions.map((session) => (
<ChatSessionItem
key={session.ticketId}
session={session}
onClick={() => onSelectSession(session.ticketId, session.ticketRef)}
/>
))
)}
</div>
</div>
)
}

View file

@ -1,25 +1,26 @@
/**
* 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 { listen } from "@tauri-apps/api/event"
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react"
import type {
ChatAttachment,
ChatMessage,
ChatMessagesResponse,
NewMessageEvent,
SessionEndedEvent,
SessionStartedEvent,
UnreadUpdateEvent,
} from "./types"
import { getMachineStoreConfig } from "./machineStore"
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 // Limite de mensagens para evitar memory leak
const MAX_MESSAGES_IN_MEMORY = 200
const MARK_READ_BATCH_SIZE = 50
const SCROLL_BOTTOM_THRESHOLD_PX = 120
// Tipos de arquivo permitidos
const ALLOWED_EXTENSIONS = [
"jpg", "jpeg", "png", "gif", "webp",
"pdf", "txt", "doc", "docx", "xls", "xlsx",
@ -32,6 +33,13 @@ interface UploadedAttachment {
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)) {
@ -57,7 +65,7 @@ function formatAttachmentSize(size?: number) {
return `${(kb / 1024).toFixed(1)}MB`
}
function getUnreadAgentMessageIds(messages: ChatMessage[], unreadCount: number): string[] {
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--) {
@ -138,7 +146,6 @@ function MessageAttachment({
setTimeout(() => setDownloaded(false), 2000)
} catch (err) {
console.error("Falha ao baixar anexo:", err)
// Fallback: abrir no navegador/sistema
await handleView()
} finally {
setDownloading(false)
@ -160,7 +167,7 @@ function MessageAttachment({
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 */}
{/* eslint-disable-next-line @next/next/no-img-element -- Tauri desktop app, not Next.js */}
<img
src={url}
alt={attachment.name}
@ -234,24 +241,28 @@ interface ChatWidgetProps {
}
export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [isSending, setIsSending] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null)
const [hasSession, setHasSession] = useState(false)
const [pendingAttachments, setPendingAttachments] = useState<UploadedAttachment[]>([])
// Inicializa minimizado porque o Rust abre a janela e minimiza imediatamente
const [isMinimized, setIsMinimized] = useState(true)
const [unreadCount, setUnreadCount] = useState(0)
// 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 retryDelayMsRef = useRef<number>(1_000)
const [isAtBottom, setIsAtBottom] = useState(true)
const isAtBottomRef = useRef(true)
@ -288,43 +299,39 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
return true
}, [updateIsAtBottom])
// Quando a sessão termina (hasSession muda de true -> false), fechar a janela para não ficar "Offline" preso
// 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 sessão:", err)
console.error("Erro ao fechar janela ao encerrar sessao:", err)
})
}
prevHasSessionRef.current = hasSession
}, [hasSession, ticketId])
// Ref para acessar isMinimized dentro do callback sem causar resubscription
// Ref para acessar isMinimized dentro de callbacks
const isMinimizedRef = useRef(isMinimized)
useEffect(() => {
isMinimizedRef.current = isMinimized
}, [isMinimized])
const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null)
const ensureConfig = useCallback(async () => {
const cfg = configRef.current ?? (await getMachineStoreConfig())
configRef.current = cfg
return cfg
}, [])
// 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
const cfg = await ensureConfig()
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, {
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: cfg.token,
machineToken,
ticketId,
storageId,
}),
@ -342,148 +349,32 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
attachmentUrlCacheRef.current.set(storageId, data.url)
return data.url
}, [ensureConfig, ticketId])
const loadMessages = useCallback(async () => {
try {
const cfg = await ensureConfig()
const result = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
baseUrl: cfg.apiBaseUrl,
token: cfg.token,
ticketId,
since: null,
})
setHasSession(result.hasSession)
setUnreadCount(result.unreadCount ?? 0)
setMessages(result.messages.slice(-MAX_MESSAGES_IN_MEMORY))
if (result.messages.length > 0) {
const first = result.messages[0]
setTicketInfo((prevInfo) =>
prevInfo ?? {
ref: ticketRef ?? 0,
subject: "",
agentName: first.authorName ?? "Suporte",
}
)
}
setError(null)
retryDelayMsRef.current = 1_000
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message || "Erro ao carregar mensagens.")
} finally {
setIsLoading(false)
}
}, [ensureConfig, ticketId, ticketRef])
// Auto-retry leve quando houver erro (evita ficar "morto" após falha transiente).
useEffect(() => {
if (!error) return
const delayMs = retryDelayMsRef.current
const timeout = window.setTimeout(() => {
loadMessages()
}, delayMs)
retryDelayMsRef.current = Math.min(retryDelayMsRef.current * 2, 30_000)
return () => {
window.clearTimeout(timeout)
}
}, [error, loadMessages])
}, [apiBaseUrl, machineToken, ticketId])
const markUnreadMessagesRead = useCallback(async () => {
if (unreadCount <= 0) return
const ids = getUnreadAgentMessageIds(messages, unreadCount)
if (ids.length === 0) return
const cfg = await ensureConfig()
const chunks = chunkArray(ids, MARK_READ_BATCH_SIZE)
for (const chunk of chunks) {
await invoke("mark_chat_messages_read", {
base_url: cfg.apiBaseUrl,
token: cfg.token,
ticket_id: ticketId,
message_ids: chunk,
await markMessagesRead({
ticketId: ticketId as Id<"tickets">,
messageIds: chunk as Id<"ticketChatMessages">[],
})
}
}, [messages, ticketId, unreadCount, markMessagesRead])
setUnreadCount(0)
}, [ensureConfig, messages, ticketId, unreadCount])
// Carregar mensagens na montagem / troca de ticket
// Auto-scroll quando novas mensagens chegam (se ja estava no bottom)
const prevMessagesLengthRef = useRef(messages.length)
useEffect(() => {
setIsLoading(true)
setMessages([])
setUnreadCount(0)
loadMessages()
}, [loadMessages])
// Recarregar quando o Rust sinalizar novas mensagens para este ticket
useEffect(() => {
let unlisten: (() => void) | null = null
listen<NewMessageEvent>("raven://chat/new-message", (event) => {
const sessions = event.payload?.sessions ?? []
if (sessions.some((s) => s.ticketId === ticketId)) {
const shouldAutoScroll = !isMinimizedRef.current && isAtBottomRef.current
if (shouldAutoScroll) {
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true }
}
loadMessages()
}
})
.then((u) => {
unlisten = u
})
.catch((err) => console.error("Falha ao registrar listener new-message:", err))
return () => {
unlisten?.()
if (messages.length > prevMessagesLengthRef.current && isAtBottomRef.current && !isMinimizedRef.current) {
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true }
}
}, [ticketId, loadMessages])
prevMessagesLengthRef.current = messages.length
}, [messages.length])
// Recarregar quando uma nova sessão iniciar (usuário pode estar com o chat aberto em "Offline")
useEffect(() => {
let unlisten: (() => void) | null = null
listen<SessionStartedEvent>("raven://chat/session-started", (event) => {
if (event.payload?.session?.ticketId === ticketId) {
loadMessages()
}
})
.then((u) => {
unlisten = u
})
.catch((err) => console.error("Falha ao registrar listener session-started:", err))
return () => {
unlisten?.()
}
}, [ticketId, loadMessages])
// Atualizar contador em tempo real (inclui decremento quando a máquina marca como lida)
useEffect(() => {
let unlisten: (() => void) | null = null
listen<UnreadUpdateEvent>("raven://chat/unread-update", (event) => {
const sessions = event.payload?.sessions ?? []
const session = sessions.find((s) => s.ticketId === ticketId)
setUnreadCount(session?.unreadCount ?? 0)
})
.then((u) => {
unlisten = u
})
.catch((err) => console.error("Falha ao registrar listener unread-update:", err))
return () => {
unlisten?.()
}
}, [ticketId])
// Executar scroll pendente (após expandir ou após novas mensagens)
// Executar scroll pendente
useEffect(() => {
if (isMinimized) return
@ -517,84 +408,16 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
}
}, [isMinimized, messages, markUnreadMessagesRead, scrollToBottom, scrollToMessage])
// Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente)
// Sincronizar estado minimizado com tamanho da janela
useEffect(() => {
let unlisten: (() => void) | null = null
listen<SessionEndedEvent>("raven://chat/session-ended", (event) => {
if (event.payload?.ticketId === ticketId) {
loadMessages()
}
})
.then((u) => {
unlisten = u
})
.catch((err) => console.error("Falha ao registrar listener session-ended:", err))
return () => {
unlisten?.()
}
}, [ticketId, loadMessages])
// Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions
/* useEffect(() => {
setIsLoading(true)
setMessages([])
messagesSubRef.current?.()
// reset contador ao trocar ticket
setUnreadCount(0)
subscribeMachineMessages(
ticketId,
(payload) => {
setIsLoading(false)
setHasSession(payload.hasSession)
hadSessionRef.current = hadSessionRef.current || payload.hasSession
// Usa o unreadCount do backend (baseado em unreadByMachine da sessao)
const backendUnreadCount = (payload as { unreadCount?: number }).unreadCount ?? 0
setUnreadCount(backendUnreadCount)
setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id))
const combined = [...prev, ...payload.messages.filter(m => !existingIds.has(m.id))]
return combined.slice(-MAX_MESSAGES_IN_MEMORY)
})
// Atualiza info basica do ticket
if (payload.messages.length > 0) {
const first = payload.messages[0]
setTicketInfo((prevInfo) => prevInfo ?? { ref: 0, subject: "", agentName: first.authorName ?? "Suporte" })
}
// NAO marca como lidas aqui - deixa o useEffect de expansao fazer isso
// Isso evita marcar como lidas antes do usuario expandir o chat
},
(err) => {
setIsLoading(false)
setError(err.message || "Erro ao carregar mensagens.")
}
).then((unsub) => {
messagesSubRef.current = unsub
})
return () => {
messagesSubRef.current?.()
messagesSubRef.current = null
}
}, [ticketId]) */ // Removido isMinimized - evita memory leak de resubscriptions
// Sincroniza estado de minimizado com o tamanho da janela (apenas em resizes reais, nao na montagem)
// O estado inicial isMinimized=true e definido no useState e nao deve ser sobrescrito na montagem
useEffect(() => {
// Ignorar todos os eventos de resize nos primeiros 500ms apos a montagem
// Isso da tempo ao Tauri de aplicar o tamanho correto da janela
// e evita que resizes transitórios durante a criação da janela alterem o estado
const mountTime = Date.now()
const STABILIZATION_DELAY = 500 // ms para a janela estabilizar
const STABILIZATION_DELAY = 500
const handler = () => {
// Ignorar eventos de resize durante o periodo de estabilizacao
if (Date.now() - mountTime < STABILIZATION_DELAY) {
return
}
const h = window.innerHeight
// thresholds alinhados com set_chat_minimized (52px minimizado, 520px expandido)
setIsMinimized(h < 100)
}
window.addEventListener("resize", handler)
@ -616,16 +439,17 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
if (!selected) return
// O retorno pode ser string (path único) ou objeto com path
const filePath = typeof selected === "string" ? selected : (selected as { path: string }).path
setIsUploading(true)
const config = await getMachineStoreConfig()
if (!apiBaseUrl || !machineToken) {
throw new Error("Configuracao nao disponivel")
}
const attachment = await invoke<UploadedAttachment>("upload_chat_file", {
baseUrl: config.apiBaseUrl,
token: config.token,
baseUrl: apiBaseUrl,
token: machineToken,
filePath,
})
@ -654,34 +478,19 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
setIsSending(true)
try {
const bodyToSend = messageText
const cfg = await ensureConfig()
await invoke("send_chat_message", {
base_url: cfg.apiBaseUrl,
token: cfg.token,
ticket_id: ticketId,
body: bodyToSend,
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
})
// Adicionar mensagem localmente
setMessages(prev => [...prev, {
id: crypto.randomUUID(),
body: bodyToSend,
authorName: "Você",
isFromMachine: true,
createdAt: Date.now(),
attachments: attachmentsToSend.map(a => ({
storageId: a.storageId,
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)
// Restaurar input e anexos em caso de erro
setInputValue(messageText)
setPendingAttachments(attachmentsToSend)
} finally {
@ -726,9 +535,8 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
}
}
// Loading
if (isLoading) {
// Mostrar chip compacto enquanto carrega (compativel com janela minimizada)
// pointer-events-none no container para que a area transparente nao seja clicavel
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">
@ -739,31 +547,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
)
}
if (error) {
// Mostrar chip compacto de erro (compativel com janela minimizada)
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
<button
type="button"
onClick={() => loadMessages()}
className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg transition hover:bg-red-200/60"
title="Tentar novamente"
>
<X className="size-4" />
<span className="text-sm font-medium">Erro no chat</span>
</button>
</div>
)
}
// Quando não há sessão, mostrar versão minimizada com indicador de offline
// 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}` : ticketInfo?.ref ? `Ticket #${ticketInfo.ref}` : "Chat"}
{ticketRef ? `Ticket #${ticketRef}` : "Chat"}
</span>
<span className="size-2 rounded-full bg-slate-400" />
<span className="text-xs text-slate-500">Offline</span>
@ -779,8 +570,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
)
}
// Versão minimizada (chip compacto igual web)
// pointer-events-none no container para que apenas o botao seja clicavel
// Minimizado
if (isMinimized) {
return (
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
@ -790,11 +580,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
>
<MessageCircle className="size-4" />
<span className="text-sm font-medium">
Ticket #{ticketRef ?? ticketInfo?.ref}
Ticket #{ticketRef}
</span>
<span className="size-2 rounded-full bg-emerald-400" />
<ChevronUp className="size-4" />
{/* Badge de mensagens não lidas */}
{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}
@ -805,9 +594,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
)
}
// Expandido
return (
<div className="flex h-screen flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
{/* Header - arrastavel */}
{/* 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"
@ -824,11 +614,9 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
Online
</span>
</div>
{(ticketRef || ticketInfo) && (
<p className="text-xs text-slate-500">
Ticket #{ticketRef ?? ticketInfo?.ref} - {ticketInfo?.agentName ?? "Suporte"}
</p>
)}
<p className="text-xs text-slate-500">
Ticket #{ticketRef} - Suporte
</p>
</div>
</div>
<div className="flex items-center gap-1">
@ -861,14 +649,12 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
Nenhuma mensagem ainda
</p>
<p className="mt-1 text-xs text-slate-400">
O agente iniciará a conversa em breve
O agente iniciara a conversa em breve
</p>
</div>
) : (
<div className="space-y-4">
{messages.map((msg) => {
// No desktop: isFromMachine=true significa mensagem do cliente (maquina)
// Layout igual à web: cliente à esquerda, agente à direita
const isAgent = !msg.isFromMachine
const bodyText = msg.body.trim()
const shouldShowBody =
@ -883,62 +669,67 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
</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"
}`}
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"}`}
>
{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={att}
isAgent={isAgent}
loadUrl={loadAttachmentUrl}
/>
))}
</div>
)}
<p
className={`mt-1 text-right text-xs ${
isAgent ? "text-white/60" : "text-slate-400"
{/* 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"
}`}
>
{formatTime(msg.createdAt)}
</p>
{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>
)
})}
<div ref={messagesEndRef} />

View file

@ -0,0 +1,146 @@
/**
* ConvexMachineProvider - Provider Convex para autenticacao via token de maquina
*
* Este provider inicializa o ConvexReactClient usando o token da maquina
* armazenado no Tauri Store, permitindo subscriptions reativas em tempo real.
*
* Arquitetura:
* - Carrega o token do Tauri Store na montagem
* - Inicializa o ConvexReactClient com a URL do Convex
* - Disponibiliza o cliente para componentes filhos via Context
* - Reconecta automaticamente quando o token muda
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
import { ConvexReactClient } from "convex/react"
import { getMachineStoreConfig } from "./machineStore"
// URL do Convex - em producao, usa o dominio personalizado
const CONVEX_URL = import.meta.env.MODE === "production"
? "https://convex.esdrasrenan.com.br"
: (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br")
type MachineAuthState = {
token: string | null
apiBaseUrl: string | null
isLoading: boolean
error: string | null
}
type ConvexMachineContextValue = {
client: ConvexReactClient | null
machineToken: string | null
apiBaseUrl: string | null
isReady: boolean
error: string | null
reload: () => Promise<void>
}
const ConvexMachineContext = createContext<ConvexMachineContextValue | null>(null)
export function useConvexMachine() {
const ctx = useContext(ConvexMachineContext)
if (!ctx) {
throw new Error("useConvexMachine must be used within ConvexMachineProvider")
}
return ctx
}
export function useMachineToken() {
const { machineToken } = useConvexMachine()
return machineToken
}
interface ConvexMachineProviderProps {
children: ReactNode
}
export function ConvexMachineProvider({ children }: ConvexMachineProviderProps) {
const [authState, setAuthState] = useState<MachineAuthState>({
token: null,
apiBaseUrl: null,
isLoading: true,
error: null,
})
const [client, setClient] = useState<ConvexReactClient | null>(null)
// Funcao para carregar configuracao do Tauri Store
const loadConfig = async () => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }))
try {
const config = await getMachineStoreConfig()
if (!config.token) {
setAuthState({
token: null,
apiBaseUrl: config.apiBaseUrl,
isLoading: false,
error: "Token da maquina nao encontrado",
})
return
}
setAuthState({
token: config.token,
apiBaseUrl: config.apiBaseUrl,
isLoading: false,
error: null,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setAuthState({
token: null,
apiBaseUrl: null,
isLoading: false,
error: message || "Erro ao carregar configuracao",
})
}
}
// Carregar configuracao na montagem
useEffect(() => {
loadConfig()
}, [])
// Inicializar/reinicializar cliente Convex quando token muda
useEffect(() => {
if (!authState.token) {
// Limpar cliente se nao tem token
if (client) {
client.close()
setClient(null)
}
return
}
// Criar novo cliente Convex
const newClient = new ConvexReactClient(CONVEX_URL, {
// Desabilitar retry agressivo para evitar loops infinitos
unsavedChangesWarning: false,
})
setClient(newClient)
// Cleanup ao desmontar ou trocar token
return () => {
newClient.close()
}
}, [authState.token]) // eslint-disable-line react-hooks/exhaustive-deps
const contextValue: ConvexMachineContextValue = {
client,
machineToken: authState.token,
apiBaseUrl: authState.apiBaseUrl,
isReady: !authState.isLoading && !!client && !!authState.token,
error: authState.error,
reload: loadConfig,
}
return (
<ConvexMachineContext.Provider value={contextValue}>
{children}
</ConvexMachineContext.Provider>
)
}

View file

@ -1,20 +1,63 @@
import { ConvexProvider } from "convex/react"
import { ChatWidget } from "./ChatWidget"
import { ChatHubWidget } from "./ChatHubWidget"
import { ConvexMachineProvider, useConvexMachine } from "./ConvexMachineProvider"
import { Loader2 } from "lucide-react"
function ChatAppContent() {
const { client, isReady, error } = useConvexMachine()
export function ChatApp() {
// Obter ticketId e ticketRef da URL
const params = new URLSearchParams(window.location.search)
const ticketId = params.get("ticketId")
const ticketRef = params.get("ticketRef")
const isHub = params.get("hub") === "true"
// Aguardar cliente Convex estar pronto
if (!isReady || !client) {
if (error) {
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-red-100 px-4 py-2 text-red-600 shadow-lg">
<span className="text-sm font-medium">Erro: {error}</span>
</div>
</div>
)
}
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">Conectando...</span>
</div>
</div>
)
}
// Modo hub - lista de todas as sessoes
if (isHub || !ticketId) {
return <ChatHubWidget />
return (
<ConvexProvider client={client}>
<ChatHubWidget />
</ConvexProvider>
)
}
// Modo chat - conversa de um ticket especifico
return <ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
return (
<ConvexProvider client={client}>
<ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
</ConvexProvider>
)
}
export function ChatApp() {
return (
<ConvexMachineProvider>
<ChatAppContent />
</ConvexMachineProvider>
)
}
export { ChatWidget }

View file

@ -0,0 +1,206 @@
/**
* Hooks customizados para queries/mutations do Convex com token de maquina
*
* Estes hooks encapsulam a logica de passar o machineToken automaticamente
* para as queries e mutations do Convex, proporcionando uma API simples
* e reativa para os componentes de chat.
*/
import { useQuery, useMutation, useAction } from "convex/react"
import { api } from "@convex/_generated/api"
import type { Id } from "@convex/_generated/dataModel"
import { useMachineToken } from "./ConvexMachineProvider"
// ============================================
// TIPOS
// ============================================
export type MachineSession = {
sessionId: Id<"liveChatSessions">
ticketId: Id<"tickets">
ticketRef: number
ticketSubject: string
agentName: string
agentEmail?: string
agentAvatarUrl?: string
unreadCount: number
lastActivityAt: number
startedAt: number
}
export type MachineMessage = {
id: Id<"ticketChatMessages">
body: string
authorName: string
authorAvatarUrl?: string
isFromMachine: boolean
createdAt: number
attachments: Array<{
storageId: Id<"_storage">
name: string
size?: number
type?: string
}>
}
export type MachineMessagesResult = {
messages: MachineMessage[]
hasSession: boolean
unreadCount: number
}
export type MachineUpdatesResult = {
hasActiveSessions: boolean
sessions: Array<{
ticketId: Id<"tickets">
ticketRef: number
unreadCount: number
lastActivityAt: number
}>
totalUnread: number
}
// ============================================
// HOOKS
// ============================================
/**
* Hook para listar sessoes ativas da maquina
* Subscription reativa - atualiza automaticamente quando ha mudancas
*/
export function useMachineSessions() {
const machineToken = useMachineToken()
const sessions = useQuery(
api.liveChat.listMachineSessions,
machineToken ? { machineToken } : "skip"
)
return {
sessions: sessions as MachineSession[] | undefined,
isLoading: sessions === undefined && !!machineToken,
hasToken: !!machineToken,
}
}
/**
* Hook para listar mensagens de um ticket especifico
* Subscription reativa - atualiza automaticamente quando ha novas mensagens
*/
export function useMachineMessages(ticketId: Id<"tickets"> | null, options?: { limit?: number }) {
const machineToken = useMachineToken()
const result = useQuery(
api.liveChat.listMachineMessages,
machineToken && ticketId
? { machineToken, ticketId, limit: options?.limit }
: "skip"
)
return {
messages: (result as MachineMessagesResult | undefined)?.messages ?? [],
hasSession: (result as MachineMessagesResult | undefined)?.hasSession ?? false,
unreadCount: (result as MachineMessagesResult | undefined)?.unreadCount ?? 0,
isLoading: result === undefined && !!machineToken && !!ticketId,
hasToken: !!machineToken,
}
}
/**
* Hook para verificar updates (polling leve)
* Usado como fallback ou para verificar status rapidamente
*/
export function useMachineUpdates() {
const machineToken = useMachineToken()
const result = useQuery(
api.liveChat.checkMachineUpdates,
machineToken ? { machineToken } : "skip"
)
return {
hasActiveSessions: (result as MachineUpdatesResult | undefined)?.hasActiveSessions ?? false,
sessions: (result as MachineUpdatesResult | undefined)?.sessions ?? [],
totalUnread: (result as MachineUpdatesResult | undefined)?.totalUnread ?? 0,
isLoading: result === undefined && !!machineToken,
hasToken: !!machineToken,
}
}
/**
* Hook para enviar mensagem
*/
export function usePostMachineMessage() {
const machineToken = useMachineToken()
const postMessage = useMutation(api.liveChat.postMachineMessage)
return async (args: {
ticketId: Id<"tickets">
body: string
attachments?: Array<{
storageId: Id<"_storage">
name: string
size?: number
type?: string
}>
}) => {
if (!machineToken) {
throw new Error("Token da maquina nao disponivel")
}
return postMessage({
machineToken,
ticketId: args.ticketId,
body: args.body,
attachments: args.attachments,
})
}
}
/**
* Hook para marcar mensagens como lidas
*/
export function useMarkMachineMessagesRead() {
const machineToken = useMachineToken()
const markRead = useMutation(api.liveChat.markMachineMessagesRead)
return async (args: {
ticketId: Id<"tickets">
messageIds: Id<"ticketChatMessages">[]
}) => {
if (!machineToken) {
throw new Error("Token da maquina nao disponivel")
}
return markRead({
machineToken,
ticketId: args.ticketId,
messageIds: args.messageIds,
})
}
}
/**
* Hook para gerar URL de upload
*/
export function useGenerateMachineUploadUrl() {
const machineToken = useMachineToken()
const generateUrl = useAction(api.liveChat.generateMachineUploadUrl)
return async (args: {
fileName: string
fileType: string
fileSize: number
}) => {
if (!machineToken) {
throw new Error("Token da maquina nao disponivel")
}
return generateUrl({
machineToken,
fileName: args.fileName,
fileType: args.fileType,
fileSize: args.fileSize,
})
}
}

View file

@ -0,0 +1,121 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as alerts from "../alerts.js";
import type * as automations from "../automations.js";
import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js";
import type * as categorySlas from "../categorySlas.js";
import type * as checklistTemplates from "../checklistTemplates.js";
import type * as commentTemplates from "../commentTemplates.js";
import type * as companies from "../companies.js";
import type * as crons from "../crons.js";
import type * as dashboards from "../dashboards.js";
import type * as deviceExportTemplates from "../deviceExportTemplates.js";
import type * as deviceFieldDefaults from "../deviceFieldDefaults.js";
import type * as deviceFields from "../deviceFields.js";
import type * as devices from "../devices.js";
import type * as emprestimos from "../emprestimos.js";
import type * as fields from "../fields.js";
import type * as files from "../files.js";
import type * as incidents from "../incidents.js";
import type * as invites from "../invites.js";
import type * as liveChat from "../liveChat.js";
import type * as machines from "../machines.js";
import type * as metrics from "../metrics.js";
import type * as migrations from "../migrations.js";
import type * as ops from "../ops.js";
import type * as queues from "../queues.js";
import type * as rbac from "../rbac.js";
import type * as reports from "../reports.js";
import type * as revision from "../revision.js";
import type * as seed from "../seed.js";
import type * as slas from "../slas.js";
import type * as teams from "../teams.js";
import type * as ticketFormSettings from "../ticketFormSettings.js";
import type * as ticketFormTemplates from "../ticketFormTemplates.js";
import type * as ticketNotifications from "../ticketNotifications.js";
import type * as tickets from "../tickets.js";
import type * as usbPolicy from "../usbPolicy.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
alerts: typeof alerts;
automations: typeof automations;
bootstrap: typeof bootstrap;
categories: typeof categories;
categorySlas: typeof categorySlas;
checklistTemplates: typeof checklistTemplates;
commentTemplates: typeof commentTemplates;
companies: typeof companies;
crons: typeof crons;
dashboards: typeof dashboards;
deviceExportTemplates: typeof deviceExportTemplates;
deviceFieldDefaults: typeof deviceFieldDefaults;
deviceFields: typeof deviceFields;
devices: typeof devices;
emprestimos: typeof emprestimos;
fields: typeof fields;
files: typeof files;
incidents: typeof incidents;
invites: typeof invites;
liveChat: typeof liveChat;
machines: typeof machines;
metrics: typeof metrics;
migrations: typeof migrations;
ops: typeof ops;
queues: typeof queues;
rbac: typeof rbac;
reports: typeof reports;
revision: typeof revision;
seed: typeof seed;
slas: typeof slas;
teams: typeof teams;
ticketFormSettings: typeof ticketFormSettings;
ticketFormTemplates: typeof ticketFormTemplates;
ticketNotifications: typeof ticketNotifications;
tickets: typeof tickets;
usbPolicy: typeof usbPolicy;
users: typeof users;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};

View file

@ -0,0 +1,23 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

View file

@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

View file

@ -0,0 +1,143 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View file

@ -0,0 +1,93 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;

View file

@ -19,7 +19,13 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"jsx": "react-jsx",
"types": ["vite/client"]
"types": ["vite/client"],
/* Paths */
"baseUrl": ".",
"paths": {
"@convex/_generated/*": ["./src/convex/_generated/*"]
}
},
"include": ["src"]
}

View file

@ -1,5 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
const host = process.env.TAURI_DEV_HOST;
@ -7,6 +8,13 @@ const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react()],
resolve: {
alias: {
// Usar arquivos _generated locais para evitar problemas de type-check
"@convex/_generated": resolve(__dirname, "./src/convex/_generated"),
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors

View file

@ -115,6 +115,7 @@
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2",
"convex": "^1.31.0",
"lucide-react": "^0.544.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -2335,6 +2336,8 @@
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"appsdesktop/convex": ["convex@1.31.0", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ht3dtpWQmxX62T8PT3p/5PDlRzSW5p2IDTP4exKjQ5dqmvhtn1wLFakJAX4CCeu1s0Ch0dKY5g2dk/wETTRAOw=="],
"appsdesktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
"appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
@ -2469,6 +2472,8 @@
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"appsdesktop/convex/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@ -2600,5 +2605,55 @@
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"appsdesktop/convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"appsdesktop/convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
"appsdesktop/convex/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
"appsdesktop/convex/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
"appsdesktop/convex/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
"appsdesktop/convex/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
"appsdesktop/convex/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
"appsdesktop/convex/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
"appsdesktop/convex/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
"appsdesktop/convex/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
"appsdesktop/convex/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
"appsdesktop/convex/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
"appsdesktop/convex/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
"appsdesktop/convex/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
"appsdesktop/convex/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
"appsdesktop/convex/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
"appsdesktop/convex/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
"appsdesktop/convex/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
"appsdesktop/convex/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
"appsdesktop/convex/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
"appsdesktop/convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
"appsdesktop/convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
}
}