feat(chat): desktop usando Convex WS direto e fallback WS dedicado

This commit is contained in:
esdrasrenan 2025-12-09 01:01:54 -03:00
parent 8db7c3c810
commit a8f5ff9d51
14 changed files with 735 additions and 458 deletions

View file

@ -1,13 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Store } from "@tauri-apps/plugin-store"
import { appLocalDataDir, join } from "@tauri-apps/api/path"
import { open } from "@tauri-apps/plugin-dialog"
import { invoke } from "@tauri-apps/api/core"
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react"
import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types"
import type { ChatMessage } from "./types"
import {
subscribeMachineMessages,
sendMachineMessage,
markMachineMessagesRead,
getMachineStoreConfig,
} from "./convexMachineClient"
const STORE_FILENAME = "machine-agent.json"
const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak
// Tipos de arquivo permitidos
@ -52,8 +54,7 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
const [unreadCount, setUnreadCount] = useState(0)
const messagesEndRef = useRef<HTMLDivElement>(null)
const lastFetchRef = useRef<number>(0)
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const messagesSubRef = useRef<(() => void) | null>(null)
const hadSessionRef = useRef<boolean>(false)
// Scroll para o final quando novas mensagens chegam
@ -77,153 +78,48 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
hadSessionRef.current = hasSession
}, [hasSession, ticketId])
// Carregar configuracao do store
const loadConfig = useCallback(async () => {
try {
const appData = await appLocalDataDir()
const storePath = await join(appData, STORE_FILENAME)
const store = await Store.load(storePath)
const token = await store.get<string>("token")
const config = await store.get<{ apiBaseUrl: string }>("config")
if (!token || !config?.apiBaseUrl) {
setError("Máquina não registrada")
setIsLoading(false)
return null
}
return { token, baseUrl: config.apiBaseUrl }
} catch (err) {
setError("Erro ao carregar configuracao")
setIsLoading(false)
return null
}
}, [])
// Buscar mensagens
const fetchMessages = useCallback(async (baseUrl: string, token: string, since?: number) => {
try {
const response = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
baseUrl,
token,
ticketId,
since: since ?? null,
})
setHasSession(response.hasSession)
if (response.messages.length > 0) {
if (since) {
// Adicionar apenas novas mensagens (com limite para evitar memory leak)
setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id))
const newMsgs = response.messages.filter(m => !existingIds.has(m.id))
const combined = [...prev, ...newMsgs]
// Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens
return combined.slice(-MAX_MESSAGES_IN_MEMORY)
})
} else {
// Primeira carga (já limitada)
setMessages(response.messages.slice(-MAX_MESSAGES_IN_MEMORY))
}
lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt))
}
return response
} catch (err) {
console.error("Erro ao buscar mensagens:", err)
return null
}
}, [ticketId])
// Buscar info da sessao
const fetchSessionInfo = useCallback(async (baseUrl: string, token: string) => {
try {
const sessions = await invoke<Array<{
ticketId: string
ticketRef: number
ticketSubject: string
agentName: string
}>>("fetch_chat_sessions", { baseUrl, token })
const session = sessions.find(s => s.ticketId === ticketId)
if (session) {
setTicketInfo({
ref: session.ticketRef,
subject: session.ticketSubject,
agentName: session.agentName,
})
}
} catch (err) {
console.error("Erro ao buscar sessao:", err)
}
}, [ticketId])
// Inicializacao
// Inicializacao via Convex (WS)
useEffect(() => {
let mounted = true
setIsLoading(true)
setMessages([])
messagesSubRef.current?.()
const init = async () => {
const config = await loadConfig()
if (!config || !mounted) return
const { baseUrl, token } = config
// Buscar sessao e mensagens iniciais
await Promise.all([
fetchSessionInfo(baseUrl, token),
fetchMessages(baseUrl, token),
])
if (!mounted) return
setIsLoading(false)
// Iniciar polling (2 segundos para maior responsividade)
pollIntervalRef.current = setInterval(async () => {
await fetchMessages(baseUrl, token, lastFetchRef.current)
}, 2000)
}
init()
// Listener para eventos de nova mensagem do Tauri
const unlistenNewMessage = listen<{ ticketId: string; message: ChatMessage }>(
"raven://chat/new-message",
(event) => {
if (event.payload.ticketId === ticketId) {
setMessages(prev => {
if (prev.some(m => m.id === event.payload.message.id)) {
return prev
}
const combined = [...prev, event.payload.message]
// Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens
return combined.slice(-MAX_MESSAGES_IN_MEMORY)
})
subscribeMachineMessages(
ticketId,
(payload) => {
setIsLoading(false)
setHasSession(payload.hasSession)
hadSessionRef.current = hadSessionRef.current || payload.hasSession
const unread = payload.messages.filter(m => !m.isFromMachine).length
setUnreadCount(unread)
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" })
}
}
)
// Listener para atualização de mensagens não lidas
const unlistenUnread = listen<{ totalUnread: number; sessions: Array<{ ticketId: string; unreadCount: number }> }>(
"raven://chat/unread-update",
(event) => {
// Encontrar o unread count para este ticket
const session = event.payload.sessions?.find(s => s.ticketId === ticketId)
if (session) {
setUnreadCount(session.unreadCount ?? 0)
const unreadIds = payload.messages.filter(m => !m.isFromMachine).map(m => m.id as string)
if (unreadIds.length > 0) {
markMachineMessagesRead(ticketId, unreadIds).catch(err => console.error("mark read falhou", err))
}
},
(err) => {
setIsLoading(false)
setError(err.message || "Erro ao carregar mensagens.")
}
)
).then((unsub) => {
messagesSubRef.current = unsub
})
return () => {
mounted = false
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current)
}
unlistenNewMessage.then(unlisten => unlisten())
unlistenUnread.then(unlisten => unlisten())
messagesSubRef.current?.()
messagesSubRef.current = null
}
}, [ticketId, loadConfig, fetchMessages, fetchSessionInfo])
}, [ticketId])
// Selecionar arquivo para anexar
const handleAttach = async () => {
@ -245,14 +141,10 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
setIsUploading(true)
const config = await loadConfig()
if (!config) {
setIsUploading(false)
return
}
const config = await getMachineStoreConfig()
const attachment = await invoke<UploadedAttachment>("upload_chat_file", {
baseUrl: config.baseUrl,
baseUrl: config.apiBaseUrl,
token: config.token,
filePath,
})
@ -282,29 +174,20 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
setIsSending(true)
try {
const config = await loadConfig()
if (!config) {
setIsSending(false)
setInputValue(messageText)
setPendingAttachments(attachmentsToSend)
return
}
const response = await invoke<SendMessageResponse>("send_chat_message", {
baseUrl: config.baseUrl,
token: config.token,
const bodyToSend = messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : "")
await sendMachineMessage({
ticketId,
body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""),
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : null,
body: bodyToSend,
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
})
// Adicionar mensagem localmente
setMessages(prev => [...prev, {
id: response.messageId,
body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""),
id: crypto.randomUUID(),
body: bodyToSend,
authorName: "Você",
isFromMachine: true,
createdAt: response.createdAt,
createdAt: Date.now(),
attachments: attachmentsToSend.map(a => ({
storageId: a.storageId,
name: a.name,
@ -312,8 +195,6 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
type: a.type,
})),
}])
lastFetchRef.current = response.createdAt
} catch (err) {
console.error("Erro ao enviar mensagem:", err)
// Restaurar input e anexos em caso de erro

View file

@ -0,0 +1,180 @@
import { ConvexClient } from "convex/browser"
import { Store } from "@tauri-apps/plugin-store"
import { appLocalDataDir, join } from "@tauri-apps/api/path"
import type { ChatMessage } from "./types"
const STORE_FILENAME = "machine-agent.json"
const DEFAULT_CONVEX_URL =
import.meta.env.VITE_CONVEX_URL?.trim() ||
"https://convex.esdrasrenan.com.br"
type MachineStoreConfig = {
apiBaseUrl?: string
appUrl?: string
convexUrl?: string
}
type MachineStoreData = {
token?: string
config?: MachineStoreConfig
}
type ClientCache = {
client: ConvexClient
token: string
convexUrl: string
}
let cached: ClientCache | null = null
type MachineUpdatePayload = {
hasActiveSessions: boolean
sessions: Array<{ ticketId: string; unreadCount: number; lastActivityAt: number }>
totalUnread: number
}
async function loadStore(): Promise<MachineStoreData> {
const appData = await appLocalDataDir()
const storePath = await join(appData, STORE_FILENAME)
const store = await Store.load(storePath)
const token = await store.get<string>("token")
const config = await store.get<MachineStoreConfig>("config")
return { token: token ?? undefined, config: config ?? undefined }
}
function resolveConvexUrl(config?: MachineStoreConfig): string {
const fromConfig = config?.convexUrl?.trim()
if (fromConfig) return fromConfig.replace(/\/+$/, "")
return DEFAULT_CONVEX_URL
}
function resolveApiBaseUrl(config?: MachineStoreConfig): string {
const fromConfig = config?.apiBaseUrl?.trim()
if (fromConfig) return fromConfig.replace(/\/+$/, "")
return "https://tickets.esdrasrenan.com.br"
}
export async function getMachineStoreConfig() {
const data = await loadStore()
if (!data.token) {
throw new Error("Token de máquina não encontrado no store")
}
const apiBaseUrl = resolveApiBaseUrl(data.config)
const appUrl = data.config?.appUrl?.trim() || apiBaseUrl
return { token: data.token, apiBaseUrl, appUrl, convexUrl: resolveConvexUrl(data.config) }
}
async function ensureClient(): Promise<ClientCache> {
const data = await loadStore()
if (!data.token) {
throw new Error("Token de máquina não encontrado no store")
}
const convexUrl = resolveConvexUrl(data.config)
if (cached && cached.token === data.token && cached.convexUrl === convexUrl) {
return cached
}
const client = new ConvexClient(convexUrl)
cached = { client, token: data.token, convexUrl }
return cached
}
export async function subscribeMachineUpdates(
callback: (payload: MachineUpdatePayload) => void,
onError?: (error: Error) => void
): Promise<() => void> {
const { client, token } = await ensureClient()
const sub = client.onUpdate(
FN_CHECK_UPDATES as any,
{ machineToken: token },
(value) => callback(value),
onError
)
return () => sub.unsubscribe()
}
export async function subscribeMachineMessages(
ticketId: string,
callback: (payload: { messages: ChatMessage[]; hasSession: boolean }) => void,
onError?: (error: Error) => void
): Promise<() => void> {
const { client, token } = await ensureClient()
const sub = client.onUpdate(
FN_LIST_MESSAGES as any,
{
machineToken: token,
ticketId,
},
(value) => callback(value),
onError
)
return () => sub.unsubscribe()
}
export async function sendMachineMessage(input: {
ticketId: string
body: string
attachments?: Array<{
storageId: string
name: string
size?: number
type?: string
}>
}) {
const { client, token } = await ensureClient()
return client.mutation(FN_POST_MESSAGE as any, {
machineToken: token,
ticketId: input.ticketId,
body: input.body,
attachments: input.attachments?.map((att) => ({
storageId: att.storageId,
name: att.name,
size: att.size,
type: att.type,
})),
})
}
export async function markMachineMessagesRead(ticketId: string, messageIds: string[]) {
if (messageIds.length === 0) return
const { client, token } = await ensureClient()
await client.mutation(FN_MARK_READ as any, {
machineToken: token,
ticketId,
messageIds,
})
}
export async function generateMachineUploadUrl(opts: {
fileName: string
fileType: string
fileSize: number
}) {
const { client, token } = await ensureClient()
return client.action(FN_UPLOAD_URL as any, {
machineToken: token,
fileName: opts.fileName,
fileType: opts.fileType,
fileSize: opts.fileSize,
})
}
export async function uploadToConvexStorage(uploadUrl: string, file: Blob | ArrayBuffer, contentType: string) {
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": contentType },
body: file,
})
if (!response.ok) {
const body = await response.text()
throw new Error(`Upload falhou: ${response.status} ${body}`)
}
const json = await response.json().catch(() => ({}))
return json.storageId || json.storage_id
}
const FN_CHECK_UPDATES = "liveChat.checkMachineUpdates"
const FN_LIST_MESSAGES = "liveChat.listMachineMessages"
const FN_POST_MESSAGE = "liveChat.postMachineMessage"
const FN_MARK_READ = "liveChat.markMachineMessagesRead"
const FN_UPLOAD_URL = "liveChat.generateMachineUploadUrl"

View file

@ -1,12 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { invoke } from "@tauri-apps/api/core"
import { Store } from "@tauri-apps/plugin-store"
import { appLocalDataDir, join } from "@tauri-apps/api/path"
import { MessageCircle, X, Minus, Send, Loader2, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react"
import { cn } from "../lib/utils"
import type { ChatSession, ChatMessage, ChatMessagesResponse, SendMessageResponse, ChatHistorySession } from "../chat/types"
const STORE_FILENAME = "machine-agent.json"
import type { ChatSession, ChatMessage, ChatHistorySession } from "../chat/types"
import {
subscribeMachineUpdates,
subscribeMachineMessages,
sendMachineMessage,
markMachineMessagesRead,
} from "../chat/convexMachineClient"
interface ChatFloatingWidgetProps {
sessions: ChatSession[]
@ -30,19 +31,22 @@ export function ChatFloatingWidget({
const [isSending, setIsSending] = useState(false)
const [historyExpanded, setHistoryExpanded] = useState(false)
const [historySessions] = useState<ChatHistorySession[]>([])
const [liveSessions, setLiveSessions] = useState<ChatSession[]>(sessions)
const [liveUnread, setLiveUnread] = useState<number>(totalUnread)
const sessionList = liveSessions.length > 0 ? liveSessions : sessions
const messagesEndRef = useRef<HTMLDivElement>(null)
const lastFetchRef = useRef<number>(0)
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const messagesSubRef = useRef<(() => void) | null>(null)
const updatesSubRef = useRef<(() => void) | null>(null)
// Selecionar ticket mais recente automaticamente
useEffect(() => {
if (sessions.length > 0 && !selectedTicketId) {
// Ordenar por lastActivityAt e pegar o mais recente
const sorted = [...sessions].sort((a, b) => b.lastActivityAt - a.lastActivityAt)
const source = liveSessions.length > 0 ? liveSessions : sessions
if (source.length > 0 && !selectedTicketId) {
const sorted = [...source].sort((a, b) => b.lastActivityAt - a.lastActivityAt)
setSelectedTicketId(sorted[0].ticketId)
}
}, [sessions, selectedTicketId])
}, [sessions, liveSessions, selectedTicketId])
// Scroll para o final quando novas mensagens chegam
const scrollToBottom = useCallback(() => {
@ -53,99 +57,73 @@ export function ChatFloatingWidget({
scrollToBottom()
}, [messages, scrollToBottom])
// Carregar configuracao do store
const loadConfig = useCallback(async () => {
try {
const appData = await appLocalDataDir()
const storePath = await join(appData, STORE_FILENAME)
const store = await Store.load(storePath)
const token = await store.get<string>("token")
const config = await store.get<{ apiBaseUrl: string }>("config")
if (!token || !config?.apiBaseUrl) {
return null
}
return { token, baseUrl: config.apiBaseUrl }
} catch {
return null
}
}, [])
// Buscar mensagens
const fetchMessages = useCallback(async (baseUrl: string, token: string, ticketId: string, since?: number) => {
try {
const response = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
baseUrl,
token,
ticketId,
since: since ?? null,
})
if (response.messages.length > 0) {
if (since) {
setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id))
const newMsgs = response.messages.filter(m => !existingIds.has(m.id))
return [...prev, ...newMsgs]
})
} else {
setMessages(response.messages)
}
lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt))
}
return response
} catch (err) {
console.error("Erro ao buscar mensagens:", err)
return null
}
}, [])
// Inicializar e fazer polling quando ticket selecionado
// Assinar updates de sessões/unread
useEffect(() => {
if (!selectedTicketId || !isOpen) return
let mounted = true
const init = async () => {
setIsLoading(true)
const config = await loadConfig()
if (!config || !mounted) {
setIsLoading(false)
return
}
const { baseUrl, token } = config
// Buscar mensagens iniciais
await fetchMessages(baseUrl, token, selectedTicketId)
if (!mounted) return
setIsLoading(false)
// Iniciar polling (2 segundos)
pollIntervalRef.current = setInterval(async () => {
await fetchMessages(baseUrl, token, selectedTicketId, lastFetchRef.current)
}, 2000)
}
init()
let cancelled = false
subscribeMachineUpdates(
(payload) => {
if (cancelled) return
const mapped: ChatSession[] = (payload.sessions ?? []).map((s) => ({
sessionId: s.ticketId,
ticketId: s.ticketId,
ticketRef: 0,
ticketSubject: "",
agentName: "",
agentEmail: undefined,
agentAvatarUrl: undefined,
unreadCount: s.unreadCount,
lastActivityAt: s.lastActivityAt,
startedAt: 0,
}))
setLiveSessions(mapped)
setLiveUnread(payload.totalUnread ?? 0)
},
(err) => console.error("chat updates erro:", err)
).then((unsub) => {
updatesSubRef.current = unsub
})
return () => {
mounted = false
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current)
pollIntervalRef.current = null
}
cancelled = true
updatesSubRef.current?.()
updatesSubRef.current = null
}
}, [selectedTicketId, isOpen, loadConfig, fetchMessages])
}, [])
// Limpar mensagens quando trocar de ticket
// Assinar mensagens do ticket selecionado
useEffect(() => {
if (!selectedTicketId || !isOpen) return
messagesSubRef.current?.()
setMessages([])
lastFetchRef.current = 0
}, [selectedTicketId])
setIsLoading(true)
subscribeMachineMessages(
selectedTicketId,
(payload) => {
setIsLoading(false)
setMessages(payload.messages)
const unreadIds = payload.messages
.filter((m) => !m.isFromMachine)
.map((m) => m.id as string)
if (unreadIds.length) {
markMachineMessagesRead(selectedTicketId, unreadIds).catch((err) =>
console.error("mark read falhou", err)
)
}
},
(err) => {
setIsLoading(false)
console.error("chat messages erro:", err)
}
).then((unsub) => {
messagesSubRef.current = unsub
})
return () => {
messagesSubRef.current?.()
messagesSubRef.current = null
}
}, [selectedTicketId, isOpen])
// Enviar mensagem
const handleSend = async () => {
@ -156,29 +134,15 @@ export function ChatFloatingWidget({
setIsSending(true)
try {
const config = await loadConfig()
if (!config) {
setIsSending(false)
return
}
const response = await invoke<SendMessageResponse>("send_chat_message", {
baseUrl: config.baseUrl,
token: config.token,
ticketId: selectedTicketId,
body: messageText,
})
await sendMachineMessage({ ticketId: selectedTicketId, body: messageText })
setMessages(prev => [...prev, {
id: response.messageId,
id: crypto.randomUUID(),
body: messageText,
authorName: "Você",
isFromMachine: true,
createdAt: response.createdAt,
createdAt: Date.now(),
attachments: [],
}])
lastFetchRef.current = response.createdAt
} catch (err) {
console.error("Erro ao enviar mensagem:", err)
setInputValue(messageText)
@ -194,35 +158,34 @@ export function ChatFloatingWidget({
}
}
const currentSession = sessions.find(s => s.ticketId === selectedTicketId)
const currentSession = sessionList.find(s => s.ticketId === selectedTicketId)
// Botao flutuante (fechado)
// DEBUG: Log do estado do widget
console.log("[ChatFloatingWidget] Estado:", {
isOpen,
totalUnread,
sessionsCount: sessions.length,
sessions: sessions.map(s => ({ id: s.sessionId, ticketId: s.ticketId, unread: s.unreadCount }))
})
// console.log("[ChatFloatingWidget] Estado:", {
// isOpen,
// totalUnread: liveUnread,
// sessionsCount: liveSessions.length,
// })
if (!isOpen) {
return (
<div className="fixed bottom-4 right-4 z-50">
{/* DEBUG: Indicador visual do estado */}
<div className="absolute -left-32 bottom-0 rounded bg-yellow-100 p-1 text-[10px] text-yellow-800 shadow">
unread: {totalUnread} | sessions: {sessions.length}
unread: {liveUnread} | sessions: {sessionList.length}
</div>
<button
onClick={onToggle}
className="relative flex size-14 items-center justify-center rounded-full bg-black text-white shadow-lg transition hover:bg-black/90"
>
<MessageCircle className="size-6" />
{totalUnread > 0 && (
{liveUnread > 0 && (
<>
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{totalUnread > 99 ? "99+" : totalUnread}
{liveUnread > 99 ? "99+" : liveUnread}
</span>
</span>
</>
@ -234,11 +197,11 @@ export function ChatFloatingWidget({
// Widget expandido
return (
<div className="fixed bottom-4 right-4 z-50 flex h-[520px] w-[380px] flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
<div className="fixed bottom-4 right-4 z-50 flex h-[520px] w-[380px] flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
{/* Header */}
<div 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">
{sessions.length > 1 && selectedTicketId && (
{sessionList.length > 1 && selectedTicketId && (
<button
onClick={() => setSelectedTicketId(null)}
className="rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
@ -262,9 +225,9 @@ export function ChatFloatingWidget({
</div>
<div className="flex items-center gap-1">
{/* Tabs de tickets (se houver mais de 1) */}
{sessions.length > 1 && (
{sessionList.length > 1 && (
<div className="mr-2 flex items-center gap-1">
{sessions.slice(0, 3).map((session) => (
{sessionList.slice(0, 3).map((session) => (
<button
key={session.ticketId}
onClick={() => setSelectedTicketId(session.ticketId)}
@ -283,8 +246,8 @@ export function ChatFloatingWidget({
)}
</button>
))}
{sessions.length > 3 && (
<span className="text-xs text-slate-400">+{sessions.length - 3}</span>
{sessionList.length > 3 && (
<span className="text-xs text-slate-400">+{sessionList.length - 3}</span>
)}
</div>
)}
@ -304,11 +267,11 @@ export function ChatFloatingWidget({
</div>
{/* Selecao de ticket (se nenhum selecionado e ha multiplos) */}
{!selectedTicketId && sessions.length > 1 ? (
{!selectedTicketId && sessionList.length > 1 ? (
<div className="flex-1 overflow-y-auto p-4">
<p className="mb-3 text-sm font-medium text-slate-700">Selecione um chamado:</p>
<div className="space-y-2">
{sessions.map((session) => (
{sessionList.map((session) => (
<button
key={session.ticketId}
onClick={() => setSelectedTicketId(session.ticketId)}