diff --git a/src/portal/__tests__/ask-calendar.test.ts b/src/portal/__tests__/ask-calendar.test.ts new file mode 100644 index 0000000..e1a52c4 --- /dev/null +++ b/src/portal/__tests__/ask-calendar.test.ts @@ -0,0 +1,235 @@ +// src/portal/__tests__/ask-calendar.test.ts +// feat/ask-calendar — perguntas de agenda buscam os calendários iCal AO VIVO, +// em vez de varrer o cérebro com brain_search. Fallback: sem iCal → rota search. +// Testes puros (sem rede, sem DB): deps injetadas via __setAskDepsForTest. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + isCalendarIntent, + parseAskWindow, + handleAsk, + __setAskDepsForTest, +} from "../ask.js"; +import type { IndexableDocument } from "../../rag/types.js"; +import type { IcsCalendarConfig } from "../../rag/calendar-ics-source.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockReq(body: Record) { + return { body } as any; +} + +function mockRes(accountId = "friend:test") { + const calls: { status?: number; json?: unknown } = {}; + const res: any = { + locals: { accountId }, + status(code: number) { calls.status = code; return res; }, + json(data: unknown) { calls.json = data; return res; }, + _calls: calls, + }; + return res; +} + +function fakeEvent(overrides: Partial = {}): IndexableDocument { + return { + source_type: "calendar", + source_id: "ics:Pessoal::evt-1", + workspace: "personal", + db_name: "Calendar", + parent_url: "", + text: "# Reunião com João\n**Quando:** 2026-06-20T14:00:00.000Z\n**Calendário:** Pessoal", + metadata: { calendar_label: "Pessoal", data: "2026-06-20T14:00:00.000Z" }, + source_updated: new Date("2026-06-19T00:00:00Z"), + ...overrides, + }; +} + +const cfg: IcsCalendarConfig = { url: "https://x/ical.ics", label: "Pessoal", workspace: "personal" }; + +// --------------------------------------------------------------------------- +// isCalendarIntent +// --------------------------------------------------------------------------- + +test("isCalendarIntent: positivos (agenda/calendário/eventos)", () => { + const yes = [ + "consulta meu calendário", + "quais eventos tenho hoje", + "minha agenda da semana", + "meus calendários", + "o que tenho amanhã", + "quais são os meus compromissos", + "tenho alguma reunião hoje?", + "o que tenho na agenda essa semana", + ]; + for (const q of yes) { + assert.equal(isCalendarIntent(q), true, `deveria ser agenda: "${q}"`); + } +}); + +test("isCalendarIntent: negativos (não-agenda)", () => { + const no = [ + "qual o status do projeto Talos?", + "resume a última reunião do Granola", + "quem é o João da GlobalCripto", + "cria uma tarefa no notion", + "o que decidimos sobre regulação", + "me explica como você funciona", + ]; + for (const q of no) { + assert.equal(isCalendarIntent(q), false, `NÃO deveria ser agenda: "${q}"`); + } +}); + +// --------------------------------------------------------------------------- +// parseAskWindow +// --------------------------------------------------------------------------- + +test("parseAskWindow: hoje → dia corrente", () => { + const now = new Date("2026-06-19T10:00:00Z"); + const { from, to } = parseAskWindow("o que tenho hoje", now); + assert.ok(from <= now, "from <= now"); + assert.ok(to > now, "to > now"); + // janela de hoje deve caber em <= ~1 dia + margem + assert.ok(to.getTime() - from.getTime() <= 36 * 60 * 60_000, "hoje cobre ~1 dia"); +}); + +test("parseAskWindow: amanhã → dia seguinte", () => { + const now = new Date("2026-06-19T10:00:00Z"); + const { from, to } = parseAskWindow("quais eventos amanhã", now); + // from deve ser depois de agora (amanhã está no futuro) + assert.ok(from.getTime() >= now.getTime(), "amanhã começa hoje ou depois"); + assert.ok(to.getTime() > from.getTime(), "to > from"); +}); + +test("parseAskWindow: semana → próximos 7 dias", () => { + const now = new Date("2026-06-19T10:00:00Z"); + const { from, to } = parseAskWindow("minha agenda da semana", now); + const days = (to.getTime() - from.getTime()) / (24 * 60 * 60_000); + assert.ok(days >= 6.5 && days <= 7.5, `~7 dias, mas foi ${days}`); +}); + +test("parseAskWindow: default → próximos 14 dias", () => { + const now = new Date("2026-06-19T10:00:00Z"); + const { from, to } = parseAskWindow("quais são meus compromissos", now); + const days = (to.getTime() - from.getTime()) / (24 * 60 * 60_000); + assert.ok(days >= 13.5 && days <= 14.5, `~14 dias, mas foi ${days}`); + assert.ok(from.getTime() <= now.getTime() + 60_000, "default começa em now"); +}); + +// --------------------------------------------------------------------------- +// Rota calendário: com iCal e eventos +// --------------------------------------------------------------------------- + +test("handler: pergunta de agenda com iCal e eventos → responde via generateAnswer com eventos no contexto", async () => { + let searchCalled = false; + let systemSeen = ""; + let userSeen = ""; + const ev1 = fakeEvent({ source_id: "ics:Pessoal::e1", text: "# Reunião com João\n**Quando:** 2026-06-20T14:00\n**Calendário:** Pessoal", metadata: { calendar_label: "Pessoal", data: "2026-06-20T14:00:00.000Z" } }); + const ev2 = fakeEvent({ source_id: "ics:Pessoal::e2", text: "# Almoço com a equipe\n**Quando:** 2026-06-21T12:00\n**Calendário:** Pessoal", metadata: { calendar_label: "Pessoal", data: "2026-06-21T12:00:00.000Z" } }); + + __setAskDepsForTest({ + search: async () => { searchCalled = true; return []; }, + complete: async (sys: string, user: string) => { systemSeen = sys; userSeen = user; return "Você tem [1] e [2]."; }, + classify: async () => "search", + loadIcalConfigs: async () => [cfg], + fetchLiveCalendar: async () => [ev1, ev2], + }); + const res = mockRes(); + try { + await handleAsk(mockReq({ question: "quais eventos tenho essa semana" }), res); + assert.equal(searchCalled, false, "search NÃO deve ser chamada na rota calendário"); + const body = res._calls.json as any; + assert.equal(body.route, "search", "front só conhece meta/search"); + assert.ok(typeof body.answer === "string"); + assert.equal(body.sources.length, 2, "deve ter as 2 fontes de calendário"); + assert.ok(body.sources.every((s: any) => s.source_type === "calendar"), "source_type = calendar"); + // contexto numerado com os eventos + assert.ok(userSeen.includes("Reunião com João"), "contexto deve conter o evento 1"); + assert.ok(userSeen.includes("Almoço com a equipe"), "contexto deve conter o evento 2"); + assert.ok(userSeen.includes("[1]") && userSeen.includes("[2]"), "contexto numerado"); + // CALENDAR_SYSTEM + assert.ok(systemSeen.includes("AO VIVO") || systemSeen.toLowerCase().includes("ao vivo"), "system deve ser o CALENDAR_SYSTEM"); + assert.ok(systemSeen.includes("Zinom"), "identidade Zinom no system"); + } finally { + __setAskDepsForTest(null); + } +}); + +// --------------------------------------------------------------------------- +// Fallback: sem iCal → rota search normal +// --------------------------------------------------------------------------- + +test("handler: pergunta de agenda SEM iCal → cai na rota search (brain_search é chamado)", async () => { + let searchCalled = false; + let fetchCalled = false; + __setAskDepsForTest({ + search: async () => { searchCalled = true; return []; }, + complete: async () => "resposta da busca", + classify: async () => "search", + loadIcalConfigs: async () => [], + fetchLiveCalendar: async () => { fetchCalled = true; return []; }, + }); + const res = mockRes(); + try { + await handleAsk(mockReq({ question: "quais eventos tenho hoje" }), res); + assert.equal(searchCalled, true, "sem iCal deve cair na rota search"); + assert.equal(fetchCalled, false, "não deve buscar calendário ao vivo sem iCal"); + const body = res._calls.json as any; + assert.equal(body.route, "search"); + } finally { + __setAskDepsForTest(null); + } +}); + +// --------------------------------------------------------------------------- +// iCal mas zero eventos na janela → resposta honesta +// --------------------------------------------------------------------------- + +test("handler: iCal configurado mas zero eventos na janela → resposta honesta sem fontes", async () => { + let searchCalled = false; + __setAskDepsForTest({ + search: async () => { searchCalled = true; return []; }, + complete: async () => "não deveria chamar a IA", + classify: async () => "search", + loadIcalConfigs: async () => [cfg], + fetchLiveCalendar: async () => [], + }); + const res = mockRes(); + try { + await handleAsk(mockReq({ question: "quais eventos tenho hoje" }), res); + assert.equal(searchCalled, false, "não deve cair na busca: a conta TEM iCal"); + const body = res._calls.json as any; + assert.equal(body.route, "search"); + assert.ok(body.answer.toLowerCase().includes("não encontrei"), `resposta honesta, foi: ${body.answer}`); + assert.deepEqual(body.sources, []); + } finally { + __setAskDepsForTest(null); + } +}); + +// --------------------------------------------------------------------------- +// Pergunta NÃO-agenda não dispara a rota calendário +// --------------------------------------------------------------------------- + +test("handler: pergunta não-agenda não dispara rota calendário (não carrega iCal)", async () => { + let loadCalled = false; + let searchCalled = false; + __setAskDepsForTest({ + search: async () => { searchCalled = true; return []; }, + complete: async () => "resposta", + classify: async () => "search", + loadIcalConfigs: async () => { loadCalled = true; return [cfg]; }, + fetchLiveCalendar: async () => [fakeEvent()], + }); + const res = mockRes(); + try { + await handleAsk(mockReq({ question: "qual o status do projeto Talos?" }), res); + assert.equal(loadCalled, false, "não-agenda não deve carregar iCal"); + assert.equal(searchCalled, true, "deve usar a rota search"); + } finally { + __setAskDepsForTest(null); + } +}); diff --git a/src/portal/ask.ts b/src/portal/ask.ts index 22b5d20..0de7c76 100644 --- a/src/portal/ask.ts +++ b/src/portal/ask.ts @@ -22,6 +22,13 @@ import { type StoredCredential, type SubscriptionMessage, } from "../claude-subscription-store.js"; +import { getAccountSecret } from "../secrets.js"; +import { accountIcalConfigs } from "../rag/account-sources.js"; +import { + fetchIcsCalendarDocuments, + type IcsCalendarConfig, +} from "../rag/calendar-ics-source.js"; +import type { IndexableDocument } from "../rag/types.js"; // --------------------------------------------------------------------------- // Model config (env-injectable, analogous to CLASSIFIER_MODEL) @@ -66,6 +73,13 @@ type CentralCompleteFn = ( messages: Anthropic.MessageParam[], maxTokens: number, ) => Promise<{ text: string; usage: { input_tokens: number; output_tokens: number } }>; +/** Load the account's configured iCal calendar feeds (empty = none). Injectable. */ +type LoadIcalConfigsFn = (accountId: string) => Promise; +/** Fetch live calendar events in [from,to], sorted asc, capped. Injectable for tests. */ +type FetchLiveCalendarFn = ( + configs: IcsCalendarConfig[], + window: { from: Date; to: Date }, +) => Promise; interface AskDeps { search: SearchFn; @@ -80,6 +94,10 @@ interface AskDeps { callSubscriptionFn: CallSubscriptionFn; /** Central path (our key): real Anthropic client by default; injectable for tests. */ centralComplete: CentralCompleteFn; + /** Calendar: load the account's iCal feeds (default = vault + accountIcalConfigs). */ + loadIcalConfigs: LoadIcalConfigsFn; + /** Calendar: fetch live events in the window (default = drain fetchIcsCalendarDocuments). */ + fetchLiveCalendar: FetchLiveCalendarFn; } const defaultDeps: AskDeps = { @@ -130,6 +148,34 @@ const defaultDeps: AskDeps = { .join(""); return { text, usage: { input_tokens: resp.usage.input_tokens, output_tokens: resp.usage.output_tokens } }; }, + // Calendar (live): read the friend's iCal blob from the encrypted vault and map + // it to source configs. Empty/absent → [] (the route falls back to brain_search). + loadIcalConfigs: async (accountId: string): Promise => { + const raw = await getAccountSecret(accountId, "ical"); + return accountIcalConfigs(raw); + }, + // Drain the async generator (it re-fetches each feed over the network), keep only + // events whose metadata.data falls inside [from,to], sort ascending, cap at 20. + fetchLiveCalendar: async ( + configs: IcsCalendarConfig[], + window: { from: Date; to: Date }, + ): Promise => { + const events: IndexableDocument[] = []; + for await (const doc of fetchIcsCalendarDocuments({ configs })) { + const data = typeof doc.metadata?.data === "string" ? doc.metadata.data : null; + if (!data) continue; + const when = new Date(data); + if (Number.isNaN(when.getTime())) continue; + if (when < window.from || when > window.to) continue; + events.push(doc); + } + events.sort((a, b) => { + const da = new Date(String(a.metadata?.data ?? "")).getTime(); + const db = new Date(String(b.metadata?.data ?? "")).getTime(); + return da - db; + }); + return events.slice(0, 20); + }, }; let deps: AskDeps = defaultDeps; @@ -336,6 +382,133 @@ Responda perguntas sobre você mesmo de forma clara, amigável e concisa em PT-B Você indexa conteúdo do Notion, reuniões do Granola, Google Calendar e páginas web, e pode criar eventos no Google Calendar, tarefas no Notion e páginas no Notion. NÃO finja ter buscado no cérebro — responda de forma direta e honesta.`; +// --------------------------------------------------------------------------- +// Calendar (live) — intent detection + window parsing + system prompt +// --------------------------------------------------------------------------- + +const CALENDAR_SYSTEM = `Você é o Zinom, o segundo cérebro do usuário (não é Claude/Claude Code). Abaixo estão os eventos AO VIVO dos calendários conectados dele, na janela consultada. Responda à pergunta de agenda de forma concisa e direta, em PT-BR, citando os eventos com [n]. NUNCA diga que não tem acesso ao calendário — estes são os eventos reais. Não sugira gambiarras. Prosa limpa, sem markdown.`; + +// Conservative calendar-intent detector. +// STRONG terms (fire on their own) clearly name the schedule itself: +// calendário(s), agenda(s), compromisso(s). +const STRONG_CALENDAR_TERMS = + /\b(calend[áa]rios?|agendas?|compromissos?)\b/i; +// WEAK terms (evento(s), reunião/reuniões) ALSO show up in pure recall questions +// ("resume a última reunião do Granola"), so they only count as agenda intent when +// paired with a temporal cue OR a "tenho/quais ... tenho" possessive phrasing. +const WEAK_CALENDAR_TERMS = + /\b(eventos?|reuni[ãa]o|reuni[õo]es)\b/i; +// NB: no trailing \b — "ã"/"ê" are non-\w in JS regex, so \b after them is unreliable. +const TEMPORAL_TERMS = + /\b(hoje|amanh[ãa]|semana|m[êe]s|pr[óo]xim|seguinte)/i; +// "o que (eu) tenho" / "tenho alguma ..." — phrasings about one's own schedule. +const HAVE_PHRASE = /\bo que (eu )?tenho\b|\btenho\b/i; +// "o que tenho ..." specifically (the canonical agenda phrasing in the spec). +const WHAT_DO_I_HAVE = /\bo que (eu )?tenho\b/i; + +/** + * Detect whether a question is about the user's calendar/agenda. Pure, exported. + * + * Conservative on purpose: + * - a strong term (calendário, agenda, compromisso) fires alone; + * - weaker terms (evento, reunião) only count with a temporal cue or a "tenho" + * phrasing — so a content recall ("resume a última reunião do Granola") is NOT + * treated as agenda; + * - the bare "o que tenho hoje/amanhã/esta semana" phrasing (no calendar noun) is + * agenda when it carries a temporal cue. + */ +export function isCalendarIntent(q: string): boolean { + const s = q.toLowerCase(); + if (STRONG_CALENDAR_TERMS.test(s)) return true; + if (WEAK_CALENDAR_TERMS.test(s) && (TEMPORAL_TERMS.test(s) || HAVE_PHRASE.test(s))) return true; + if (WHAT_DO_I_HAVE.test(s) && TEMPORAL_TERMS.test(s)) return true; + return false; +} + +function startOfDay(d: Date): Date { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +/** + * Parse the time window for a calendar question. Pure, exported. + * "hoje" → today (start of today → end of today) + * "amanhã" → tomorrow (start of tomorrow → end of tomorrow) + * "semana" → next 7 days from now + * default → next 14 days from now (future events) + */ +export function parseAskWindow(q: string, now: Date): { from: Date; to: Date } { + const s = q.toLowerCase(); + const DAY = 24 * 60 * 60_000; + // "depois de amanhã" / "amanhã" before "hoje" so "amanhã" wins. + if (/\bamanh[ãa]/.test(s) && !/depois de amanh[ãa]/.test(s)) { + const from = startOfDay(new Date(now.getTime() + DAY)); + const to = new Date(from.getTime() + DAY - 1); + return { from, to }; + } + if (/\bhoje\b/.test(s)) { + const from = startOfDay(now); + const to = new Date(from.getTime() + DAY - 1); + return { from, to }; + } + if (/\bsemana\b/.test(s)) { + return { from: now, to: new Date(now.getTime() + 7 * DAY) }; + } + return { from: now, to: new Date(now.getTime() + 14 * DAY) }; +} + +/** Human-readable label for the window, used in the honest "no events" answer. */ +function describeWindow(q: string): string { + const s = q.toLowerCase(); + if (/\bamanh[ãa]/.test(s) && !/depois de amanh[ãa]/.test(s)) return "amanhã"; + if (/\bhoje\b/.test(s)) return "hoje"; + if (/\bsemana\b/.test(s)) return "esta semana"; + return "as próximas duas semanas"; +} + +/** + * Build the numbered context string for live calendar events. Each event is one + * numbered block with its title/date/calendar drawn straight from the + * IndexableDocument text + metadata.calendar_label. + */ +export function buildCalendarContext(events: IndexableDocument[]): string { + return events + .map((ev, i) => { + const n = i + 1; + const label = + typeof ev.metadata?.calendar_label === "string" + ? ev.metadata.calendar_label + : "Calendário"; + return `[${n}] (calendário: ${label})\n${ev.text}`; + }) + .join("\n\n"); +} + +/** Map live calendar events to the AskSource shape the front already renders. */ +export function calendarEventsToSources(events: IndexableDocument[]): AskSource[] { + return events.map((ev, i) => { + const data = typeof ev.metadata?.data === "string" ? ev.metadata.data : null; + const label = + typeof ev.metadata?.calendar_label === "string" + ? ev.metadata.calendar_label + : "Calendário"; + // First non-empty text line, stripped of the leading "# ", as the title. + const firstLine = (ev.text.split("\n").find((l) => l.trim()) ?? "").replace(/^#\s*/, "").trim(); + return { + n: i + 1, + chunk_id: ev.source_id, + title: firstLine || label, + source_type: "calendar", + source_url: ev.parent_url || null, + db: label, + date: data, + snippet: ev.text.slice(0, 500), + cited: true, + }; + }); +} + // --------------------------------------------------------------------------- // E3 — Action proposal shape // --------------------------------------------------------------------------- @@ -567,6 +740,58 @@ export async function handleAsk(req: Request, res: Response): Promise { // F7: meter ask credit usage (best-effort, never blocks). recordUsage(accountId, "ask", 1).catch(() => {/* swallowed */}); + // --- Calendar route (live): agenda questions hit the user's iCal feeds AO VIVO + // instead of scanning the whole brain. Only when the account HAS iCal configured; + // otherwise we fall through to the normal search route below. + if (isCalendarIntent(raw)) { + const configs = await deps.loadIcalConfigs(accountId); + if (configs.length > 0) { + const now = new Date(); + const window = parseAskWindow(raw, now); + const events = await deps.fetchLiveCalendar(configs, window); + + if (events.length === 0) { + const label = describeWindow(raw); + res.json({ + answer: `Não encontrei eventos no seu calendário para ${label}.`, + sources: [], + route: "search", + }); + return; + } + + const context = buildCalendarContext(events); + const userMessage = `Eventos do seu calendário (ao vivo):\n\n${context}\n\n---\nPergunta: ${raw}`; + const messagesCal: Anthropic.MessageParam[] = [ + ...history.map((m) => ({ role: m.role as "user" | "assistant", content: m.content })), + { role: "user", content: userMessage }, + ]; + const calSources = calendarEventsToSources(events); + + let calAnswer: string; + try { + calAnswer = await generateAnswer(accountId, CALENDAR_SYSTEM, messagesCal, 1024, "ask:calendar"); + } catch (e) { + // Same degraded path as search: the live fetch worked, so return the events + // even when the LLM is down. HTTP 200 on purpose (Cloudflare mangles 502/504). + console.error("[portal/ask] llm error (calendar):", e instanceof Error ? e.stack ?? e.message : e); + alertLlmFailure("search", e); + res.json({ + answer: null, + degraded: true, + reason: "llm_unavailable", + sources: calSources, + route: "search", + }); + return; + } + + res.json({ answer: calAnswer, sources: calSources, route: "search" }); + return; + } + // No iCal configured → fall through to the normal brain_search route. + } + // --- Search route: brain_search → filter/dedup → LLM with context --- let hits: BrainResult[]; try { diff --git a/web/app/(app)/consultar/page.tsx b/web/app/(app)/consultar/page.tsx index 68bb7f3..3f34589 100644 --- a/web/app/(app)/consultar/page.tsx +++ b/web/app/(app)/consultar/page.tsx @@ -299,6 +299,7 @@ export default function Page() { ) : ( { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn(); + } +}); + +const noop = () => {}; + +function renderThread(msgs: ChatMsg[], busy: boolean) { + return render( + createElement(Thread, { + msgs, + busy, + pendingActionIndex: null, + onConfirmAction: noop, + onCancelAction: noop, + onRetry: noop, + }), + ); +} + +const USER: ChatMsg = { role: "user", text: "como estamos com o projeto X?" }; + +describe("Thread typing indicator", () => { + it("renders the 'digitando' indicator when busy and the last msg is the user's", () => { + const { getByText, getByRole } = renderThread([USER], true); + expect(getByText(/Zinom está digitando/)).toBeTruthy(); + // accessible: announced via role=status (implicit aria-live=polite). + expect(getByRole("status")).toBeTruthy(); + }); + + it("does not render the indicator when not busy", () => { + const { queryByText } = renderThread([USER], false); + expect(queryByText(/Zinom está digitando/)).toBeNull(); + }); + + it("does not render the indicator once the assistant answered (last msg = answer)", () => { + const answered: ChatMsg[] = [ + USER, + { role: "answer", answer: "tudo certo.", sources: [], route: "meta" }, + ]; + const { queryByText } = renderThread(answered, false); + expect(queryByText(/Zinom está digitando/)).toBeNull(); + }); +}); diff --git a/web/components/consultar/Thread.tsx b/web/components/consultar/Thread.tsx index 4a913c4..a2bf94e 100644 --- a/web/components/consultar/Thread.tsx +++ b/web/components/consultar/Thread.tsx @@ -14,9 +14,12 @@ import { ActionCard } from "./messages/ActionCard"; import { CaptureCard } from "./messages/CaptureCard"; import { LimitBlock } from "./messages/LimitBlock"; import { NetErrorMsg } from "./messages/NetErrorMsg"; +import { TypingIndicator } from "./messages/TypingIndicator"; export interface ThreadProps { msgs: ChatMsg[]; + /** /portal/ask in flight — drives the "Zinom está digitando…" indicator. */ + busy: boolean; pendingActionIndex: number | null; onConfirmAction: (index: number) => void; onCancelAction: (index: number) => void; @@ -34,6 +37,7 @@ function queryFor(msgs: ChatMsg[], i: number): string { export function Thread({ msgs, + busy, pendingActionIndex, onConfirmAction, onCancelAction, @@ -41,9 +45,14 @@ export function Thread({ }: ThreadProps) { const endRef = useRef(null); + // Show the typing indicator while a request is in flight AND we're waiting on + // the assistant's turn (the last pushed message is the user's question). + const last = msgs[msgs.length - 1]; + const typing = busy && last?.role === "user"; + useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); - }, [msgs]); + }, [msgs, typing]); return (
@@ -110,6 +119,7 @@ export function Thread({ return null; } })} + {typing ? : null}
); diff --git a/web/components/consultar/messages/TypingIndicator.tsx b/web/components/consultar/messages/TypingIndicator.tsx new file mode 100644 index 0000000..dea0cbb --- /dev/null +++ b/web/components/consultar/messages/TypingIndicator.tsx @@ -0,0 +1,30 @@ +"use client"; + +// In-flight indicator (SP6): shown while /portal/ask is still in the air so the +// chat doesn't look frozen. Mirrors the AnswerMsg header (ZinomLogo + "Zinom" +// label) so it reads as the assistant's turn warming up, then is replaced by the +// real answer once it arrives. aria-live=polite so screen readers announce it. + +import { ZinomLogo } from "../icons"; + +export function TypingIndicator() { + return ( +
+
+ + + Zinom + +
+ +
+ Zinom está digitando +
+
+ ); +}