diff --git a/docs/plans/sollinha-contexto-sessao-janela-impl.md b/docs/plans/sollinha-contexto-sessao-janela-impl.md new file mode 100644 index 00000000..f932ca8e --- /dev/null +++ b/docs/plans/sollinha-contexto-sessao-janela-impl.md @@ -0,0 +1,99 @@ +# Impl: Sollinha: contexto da conversa persiste durante a sessão da janela/tab + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #529 +Intenção: docs/plans/sollinha-contexto-sessao-janela.md +Appetite restante: ~1 dia eng (herdado; sem corte necessário) + +## Leitura da intenção + +- **Outcome:** clicar num link do Sollinha navega sem recarregar a página e a conversa continua; recarregar a aba restaura mensagens + estado aberto (painel/drawer conforme viewport); fechar a aba apaga tudo; nada cruza abas ou vai ao servidor. +- **O que NÃO negociar:** nada em servidor/banco; nada entre abas/dispositivos; sem migration/schema/Consent; guardrail de tamanho sem UI; não restaurar rascunho do input; chat nunca "salta" sozinho em visita nova. +- **O que reavaliar:** a hipótese de direção (Link no render do markdown + sessionStorage) está correta; o detalhe a resolver é **quando** persistir (escrita só em estado settle, nunca durante streaming) e a interação com o efeito `settle` de B167 que força `open=true` no desktop ao carregar. + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph pure [sollinhaChatSession.ts — sem React] + K[chave sessionStorage] + P[prune: 50 msgs / 256KB, drop-oldest] + R[read: parse + validação fail-closed] + W[write: try/catch, fail-open] + end + subgraph provider [CampaignAISidebarContext] + E1[restore no mount] + E2[persist quando status ready] + end + subgraph render [CampaignAIChat] + L[ReactMarkdown a → next/link se /campanha] + end + sessionStorage --> R --> E1 --> useChat.setMessages + setOpen + useChat.messages --> E2 --> P --> W --> sessionStorage + L -.->|router.push sem reload| destino +``` + +**Opções consideradas:** A) persistência própria em `sessionStorage` + Link nos links internos | B) `persistence` nativo do AI SDK | C) persistir no servidor (Payload/collection) | D) interceptar clique por `router.push` em vez de `next/link` +**Recomendação:** **A** — AI SDK v7 (`@ai-sdk/react@4`) **não tem** `persistence` (verificado nas types: `UseChatOptions` só aceita `chat`/`throttle`/`resume`); `sessionStorage` é o único storage com a semântica exata do aceite (por-aba, sobrevive a reload, morre ao fechar a aba); `next/link` é o caminho idiomático para navegação por cliente e preserva open-in-new-tab/modifier keys. +**Rejeitadas:** **B** porque a opção não existe nesta versão (e o `Chat` da lib expõe `setMessages`, que usamos com zero acoplamento à lib de storage); **C** porque viola o anti-goal da intenção (nada no servidor) e exigiria migration; **D** porque `onClick`+`router.push` não respeita middle-click/ctrl+click e reimplementa o que `next/link` já faz. + +### Componentes / mudanças + +- **`sollinhaChatSession.ts`** (`src/lib/sollinhaChatSession.ts`, novo, puro): chave `teqo:campaign:sollinha-chat-session`; shape `{ version: 1, messages, open }`; `readSollinhaChatSession()` (guards `typeof window` + try/catch + validação fail-closed → `null`); `writeSollinhaChatSession(messages, open)` (fail-open: quota/private-mode ignorados); `pruneSollinhaChatMessages(messages)` — tetos `SOLLINHA_CHAT_MAX_MESSAGES = 50` e `SOLLINHA_CHAT_MAX_BYTES = 256 * 1024` (JSON), drop-oldest sem UI de aviso. Espelha o padrão de `sollinhaChatPanelWidth.ts` (B166). +- **`CampaignAISidebarContext.tsx`**: dois efeitos no provider: + - **restore** (montagem, 1×): lê a sessão → `chat.setMessages(restored.messages)` + `setOpen(restored.open)`; idempotente sob StrictMode. Sem `useState` initializer para não divergir da hidratação (mensagens renderizam no primeiro paint do painel desktop). + - **persist** (`[status, messages, open]`): grava **somente quando `status === 'ready'`** — nunca durante streaming, então o que está no storage é sempre settle-completo (nenhum part parcial/tool em voo); grava `prune(messages)` + `open`. +- **`CampaignAIChat.tsx`**: componente `MarkdownLink` passado ao `ReactMarkdown` via `components={{ a }}` — `href` interno (`/^\/campanha(\/|$)/`, o catálogo fechado de B162 só produz paths `/campanha…`) → `next/link` `Link`; qualquer outro (absoluto, `#`, `mailto`, `/api/…`) → `` igual a hoje (sem `target` novo — disciplina de escopo). +- **Migration:** sem migration. **Access / Consent:** N/A. **UI:** Impeccable A — sem superfície nova; B187 (aparência de link) é irmão no mesmo render, ortogonal (muda classe CSS; aqui só o elemento). +- **`useChat`**: mantém `id: 'campaign-sollinha'`; restauração via `setMessages` (exposto pelas helpers). + +### Dados → forma + +- Forma: `JSON.stringify` direto de `UIMessage[]` (parts `text` + `tool-invocation` completas, **com** `output` — `convertToModelMessages` na route transforma tool parts completas em `tool-call` + `tool-result`, então o modelo recebe o histórico íntegro ao continuar a conversa restaurada). +- Rejeitadas: strip de `output` de tool parts (quebraria o round-trip do histórico para o modelo); compressão (sem ganho no appetite). + +## Fases verificáveis + +1. **Tracer / lib pura** — `sollinhaChatSession.ts` + unit tests (`tests/unit/sollinhaChatSession.unit.spec.ts`: parse inválido → null, prune por contagem e por bytes mantendo o mais novo, round-trip, versão desconhecida → null). `pnpm test` unit. +2. **Provider + render** — efeitos restore/persist + `MarkdownLink`. E2E novo `tests/e2e/campaignSollinhaContext.e2e.spec.ts`: (a) reload na mesma aba restaura mensagens (mock SSE com link markdown) e some o hello-state; (b) clique no link da resposta navega por cliente (contador de `page.on('load')` não incrementa + URL muda + mensagens continuam); (c) mobile: drawer aberto antes do reload volta aberto; fechado volta fechado; (d) reload com zero mensagens mantém hello-state e drawer fechado (sem chat saltando). Os testes existentes de `campaignAiChatResize` (aba nova vazia, fechado cruza borda) viram regressão do isolamento. +3. **Gates** — `pnpm gate:fast`; entrega via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Restaurar `status` ('submitted'…): proibido — input ficaria bloqueado para sempre; o chat sempre recomeça `ready`. Reload no meio de um stream perde **aquele** turno (o stream não é resumível; storage só tem o último settle) — corte documentado do "estado em andamento". +- Rascunho do input: não persiste (decisão B da intenção). +- Fechar sheet/drawer ao clicar link (non-goal explícito de B162). +- `target="_blank"` para links externos / aparência de link (B187). +- Sincronizar entre abas (`storage` events): proibido por intenção. +- Migrar largura (B166, localStorage) ou superfície (B167) para a sessão — regras próprias, intactas. + +## Riscos e mitigação + +| Risco | Mitigação | +| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Conflito com o efeito `settle` de B167 (`open=true` no desktop ao carregar) | Analisado: no desktop o painel RRP abre visível no load e `settle` manda `open=true` de qualquer forma — comportamento atual preservado (aceite exige só aberto→aberto); no mobile o `settle` não roda e o flag restaurado manda no drawer. Última escrita vence; sem regressão. | +| Escrita durante streaming grava mensagem parcial | Persist só em `status === 'ready'` (uma janela única de escrita); `open` mudado no meio do stream é gravado no settle seguinte. | +| sessionStorage indisponível (private mode/quota) | try/catch fail-open: chat funciona, só não persiste (precedente B166). | +| Hidratação diverge se restaurar no initializer | Restore em `useEffect` pós-montagem; primeiro paint idêntico ao SSR; sem warning de hidratação (flash de 1 frame do hello-state no reload — aceito). | +| Tool parts sem `output` quebrariam o histórico no round-trip | Persistir `UIMessage[]` íntegras; `convertToModelMessages` (verificado no fonte da lib) emite `tool-result` para parts completas. | +| Drift com B187 (mesmo render do markdown) | Mudanças ortogonais (elemento vs estilo); sequenciáveis sem conflito. | + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (links por cliente; reload restaura msgs + aberto; aba fecha apaga; nada cruza abas/servidor; guardrail sem UI) +- [x] Invariantes AGENTS/engineering-standards (nada de migration/Consent/access; pt-BR só em strings visíveis; identificadores em inglês) +- [x] Testes de domínio previstos: unit da lib pura (prune/parse) + e2e de contexto (reload, link por cliente, mobile aberto/fechado) + +## Decisões de engenharia (self-score) + +| Decisão | Recomendação | Rejeitadas | +| ------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Mecanismo de persistência | `sessionStorage` próprio via lib pura | `persistence` do AI SDK (não existe no v7); collection/servidor (anti-goal) | +| Quando persistir | Só em `status === 'ready'` (settle-completo) | A cada chunk (grava parcial); a cada mudança de `open` (pode capturar stream em voo) | +| Restauração | `useEffect` pós-montagem + `setMessages`/`setOpen` | initializer de `useState` (hidratação); `useSyncExternalStore` (complexidade sem ganho para 1 frame) | +| Guardrail | 50 mensagens + 256 KB, drop-oldest | UI de aviso; strip de tool outputs (quebra round-trip) | +| Links internos | `next/link` via `components={{ a }}` no ReactMarkdown | `onClick`+`router.push` (perde modifier keys); interceptar no container (raro/rasteiro) | +| Escopo do link | Só `/campanha…` vira `Link` | Todos os relativos (catálogo fechado de B162 não produz outros) | + +**Self-score decision-quality: 5/5** — decisões caras com rejeitadas documentadas; appetite respeitado; rabbit holes nomeados (incl. o corte do stream em voo); reusa padrões existentes (`sollinhaChatPanelWidth`, `setMessages`, `convertToModelMessages`); o aceite de produto permanece íntegro — a engenharia não reescreveu o outcome. diff --git a/docs/plans/sollinha-contexto-sessao-janela.md b/docs/plans/sollinha-contexto-sessao-janela.md index d4e351d3..d61ed253 100644 --- a/docs/plans/sollinha-contexto-sessao-janela.md +++ b/docs/plans/sollinha-contexto-sessao-janela.md @@ -29,7 +29,7 @@ O Sollinha foi pensado para trabalhar **em colaboração** com o usuário, mas h ## Objetivo e aceite - Clicar num link do Sollinha navega **sem recarregar a página** (navegação por cliente do app), e a conversa continua presente ao reabrir o chat. -- Recarregar a página na mesma aba restaura a conversa (mensagens e estado em andamento). +- Recarregar a página na mesma aba restaura a conversa (mensagens e estado em andamento). _Nota de impl (B188): a recarga no meio de uma resposta perde apenas aquele turno em voo — o storage só guarda o último estado settle; o chat recomeça `ready` e o usuário repete a pergunta._ - Fechar a aba apaga tudo; outra aba inicia sem histórico. - Nada é gravado no servidor/banco; nada cruza abas ou dispositivos. - Se o chat estava aberto ao recarregar, ele volta aberto na mesma superfície (painel/drawer conforme o viewport). diff --git a/src/components/campaign/shell/ai/CampaignAIChat.tsx b/src/components/campaign/shell/ai/CampaignAIChat.tsx index a72d7697..458e9e7a 100644 --- a/src/components/campaign/shell/ai/CampaignAIChat.tsx +++ b/src/components/campaign/shell/ai/CampaignAIChat.tsx @@ -1,6 +1,7 @@ 'use client' import { Bot, Mic, Send, Square, User } from 'lucide-react' +import Link from 'next/link' import { useEffect, useMemo, useRef, useState } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -15,6 +16,34 @@ import { cn } from '@/lib/utils' import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext' import { useMicTranscript } from '@/components/campaign/shell/ai/useMicTranscript' +/** + * B188: app-internal destinations — the B162 link catalog only ever emits + * `/campanha…` paths — navigate through `next/link`, so following a Sollinha + * link keeps the layout (and the conversation) mounted instead of reloading + * the page. Anything else keeps the plain anchor's default behavior. + */ +const APP_INTERNAL_LINK = /^\/campanha(?:\/|$)/ + +const MarkdownLink = ({ + href, + node: _node, + children, + ...props +}: React.AnchorHTMLAttributes & { node?: unknown }) => { + if (typeof href === 'string' && APP_INTERNAL_LINK.test(href)) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} + export const CampaignAIChat = ({ className }: { className?: string }) => { const ctx = useAISidebar() const [input, setInput] = useState('') @@ -99,7 +128,12 @@ export const CampaignAIChat = ({ className }: { className?: string }) => { key={index} className="prose prose-sm max-w-none dark:prose-invert [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_h1]:font-semibold [&_h2]:font-semibold [&_table]:text-xs [&_th]:text-xs [&_td]:text-xs [&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1" > - {part.text} + + {part.text} + ) } diff --git a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx index cb1e93fe..82d06153 100644 --- a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx +++ b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx @@ -16,6 +16,7 @@ import { import type { PanelImperativeHandle } from 'react-resizable-panels' import { useIsMobileMeasured } from '@/hooks/use-mobile' +import { readSollinhaChatSession, writeSollinhaChatSession } from '@/lib/sollinhaChatSession' type AISidebarContextValue = { /** The chat is showing — single source of truth across panel (desktop) and drawer (mobile). */ @@ -48,6 +49,35 @@ export const CampaignAISidebarProvider = ({ id: 'campaign-sollinha', transport: useMemo(() => new DefaultChatTransport({ api: '/campanha/api/ai-chat' }), []), }) + const { messages, setMessages, status } = chat + + // B188: restore the conversation and the open state from the tab's + // `sessionStorage` after mount — never during SSR, where restored messages + // would diverge from the server-rendered chat on the hydration frame. + // `sessionRestored` gates the persist effect so the mount commit (empty + // pre-restore values) cannot overwrite a stored session. + const [sessionRestored, setSessionRestored] = useState(false) + const restoredSessionRef = useRef(false) + useEffect(() => { + const session = readSollinhaChatSession() + if (session) { + setMessages(session.messages) + setOpen(session.open) + } + restoredSessionRef.current = session !== null + setSessionRestored(true) + }, [setMessages]) + + // B188: persist the conversation only once the chat has settled — writing + // during streaming would freeze a half-received message in the session + // (a reload mid-stream loses only that in-flight turn). `open` changes made + // mid-stream are picked up by the settle that follows. Empty state is still + // written: closing an empty chat must overwrite a stored `open: true`, + // otherwise a reload would reopen a drawer the user just closed. + useEffect(() => { + if (!sessionRestored || status !== 'ready') return + writeSollinhaChatSession(messages, open) + }, [sessionRestored, status, messages, open]) // The chat has two surfaces keyed by viewport — the desktop panel and the // mobile drawer — and both derive from the SAME `open` flag. Crossing the @@ -56,12 +86,14 @@ export const CampaignAISidebarProvider = ({ // on the desktop settle the panel starts at its RRP default (25%) while `open` // begins false — reconcile `open` to the panel's real visibility so a visible // chat is "open" (makes close work and the drawer open on resize). A fresh - // mobile visit keeps `open` false (no spontaneous chat). + // mobile visit keeps `open` false (no spontaneous chat). When a session was + // restored, the restored `open` wins instead — a chat closed before the + // reload must not be forced back open by the settle (B188). const settledRef = useRef(false) useEffect(() => { if (settledRef.current) return settledRef.current = measured - if (measured && !isMobile && !panelRef.current?.isCollapsed()) { + if (measured && !isMobile && !restoredSessionRef.current && !panelRef.current?.isCollapsed()) { setOpen(true) } }, [measured, isMobile, panelRef]) @@ -73,13 +105,13 @@ export const CampaignAISidebarProvider = ({ open, isMobile, measured, - messages: chat.messages, - status: chat.status, + messages, + status, sendMessage: chat.sendMessage, setOpen, toggle, }), - [open, isMobile, measured, chat.messages, chat.status, chat.sendMessage, setOpen, toggle], + [open, isMobile, measured, messages, status, chat.sendMessage, setOpen, toggle], ) return {children} diff --git a/src/lib/sollinhaChatSession.ts b/src/lib/sollinhaChatSession.ts new file mode 100644 index 00000000..2e99fbb9 --- /dev/null +++ b/src/lib/sollinhaChatSession.ts @@ -0,0 +1,100 @@ +/** + * Per-tab Sollinha chat session (B188): the conversation plus the open state + * live in `sessionStorage`, so a reload of the same tab restores the chat, a + * new tab starts clean, and closing the tab erases everything. Nothing is + * written to the server and nothing crosses tabs. + */ +import type { UIMessage } from 'ai' + +export const SOLLINHA_CHAT_SESSION_STORAGE_KEY = 'teqo:campaign:sollinha-chat-session' + +const SOLLINHA_CHAT_SESSION_VERSION = 1 + +/** Count guardrail — oldest messages are dropped at write time, no UI warning. */ +export const SOLLINHA_CHAT_MAX_MESSAGES = 50 + +/** Byte guardrail (serialized JSON) — oldest messages are dropped at write time. */ +export const SOLLINHA_CHAT_MAX_BYTES = 256 * 1024 + +export type SollinhaChatSession = { + version: typeof SOLLINHA_CHAT_SESSION_VERSION + messages: UIMessage[] + open: boolean +} + +const MESSAGE_ROLES = new Set(['system', 'user', 'assistant']) + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const isUIMessage = (value: unknown): value is UIMessage => { + if (!isRecord(value)) return false + return ( + typeof value.id === 'string' && + typeof value.role === 'string' && + MESSAGE_ROLES.has(value.role) && + Array.isArray(value.parts) && + value.parts.every((part) => isRecord(part) && typeof part.type === 'string') + ) +} + +/** Fail-closed: any shape drift (or a future version bump) yields a fresh chat. */ +const isSollinhaChatSession = (value: unknown): value is SollinhaChatSession => { + if (!isRecord(value)) return false + return ( + value.version === SOLLINHA_CHAT_SESSION_VERSION && + Array.isArray(value.messages) && + value.messages.every(isUIMessage) && + typeof value.open === 'boolean' + ) +} + +export const readSollinhaChatSession = (): SollinhaChatSession | null => { + if (typeof window === 'undefined') return null + try { + const raw = window.sessionStorage.getItem(SOLLINHA_CHAT_SESSION_STORAGE_KEY) + if (!raw) return null + const parsed: unknown = JSON.parse(raw) + return isSollinhaChatSession(parsed) ? parsed : null + } catch { + return null + } +} + +/** + * Drops oldest messages until the count and byte guardrails hold, then ensures + * the head is a user message: `convertToModelMessages` (server) rejects a + * conversation that starts with an assistant turn, and pruning from the front + * can leave one. Tool outputs are kept intact: complete tool parts turn back + * into `tool-call` + `tool-result` model messages, so the model keeps the full + * context when the restored conversation continues. + */ +export const pruneSollinhaChatMessages = (messages: UIMessage[]): UIMessage[] => { + let pruned = messages + if (pruned.length > SOLLINHA_CHAT_MAX_MESSAGES) { + pruned = pruned.slice(-SOLLINHA_CHAT_MAX_MESSAGES) + } + while (pruned.length > 0) { + if (JSON.stringify(pruned).length <= SOLLINHA_CHAT_MAX_BYTES) break + pruned = pruned.slice(1) + } + while (pruned.length > 1 && pruned[0]?.role !== 'user') { + pruned = pruned.slice(1) + } + return pruned +} + +/** Writes the pruned session. Fail-open: storage failures never break the chat. */ +export const writeSollinhaChatSession = (messages: UIMessage[], open: boolean): void => { + if (typeof window === 'undefined') return + const session: SollinhaChatSession = { + version: SOLLINHA_CHAT_SESSION_VERSION, + messages: pruneSollinhaChatMessages(messages), + open, + } + try { + window.sessionStorage.setItem(SOLLINHA_CHAT_SESSION_STORAGE_KEY, JSON.stringify(session)) + } catch { + // Ignore quota / private-mode failures. + } +} diff --git a/tests/e2e/campaignAiChatResize.e2e.spec.ts b/tests/e2e/campaignAiChatResize.e2e.spec.ts index d3af8c49..143a86d5 100644 --- a/tests/e2e/campaignAiChatResize.e2e.spec.ts +++ b/tests/e2e/campaignAiChatResize.e2e.spec.ts @@ -5,14 +5,23 @@ import { expect, test } from './fixtures/campaignE2EFixtures.js' /** * Mock the AI endpoint with a minimal SSE stream so sending a message neither * needs a DeepSeek API key nor hits the real rate limiter — the user message is - * still recorded client-side, which is all these assertions need. + * still recorded client-side, which is all these assertions need. The chunks + * follow the SDK v7 UI-message-stream wire format (start / text-start / + * text-delta / text-end / finish): a bare legacy `{"type":"text"}` chunk fails + * the client schema and leaves the chat stuck in an error status. */ const mockAiChat = (page: Page) => page.route('**/campanha/api/ai-chat', async (route) => { await route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, - body: 'data: {"type":"text","text":"Resposta mockada da Sollinha."}\n\n', + body: [ + 'data: {"type":"start"}\n\n', + 'data: {"type":"text-start","id":"t1"}\n\n', + 'data: {"type":"text-delta","id":"t1","delta":"Resposta mockada da Sollinha."}\n\n', + 'data: {"type":"text-end","id":"t1"}\n\n', + 'data: {"type":"finish","finishReason":"stop"}\n\n', + ].join(''), }) }) @@ -135,10 +144,14 @@ test.describe('B167 — chat Sollinha migra entre painel e drawer ao redimension const user = await campaign.fixtures.createCampaignUser('coordinator', { name: campaign.fixtures.value('Chat Resize Coordenador'), }) + // Mobile from the start (B188): a desktop login auto-opens the chat (the + // settle below) and persists `open: true` for the tab — a later reload at + // mobile would then legitimately restore the open drawer, which is not + // what a *fresh* mobile visit looks like. + await page.setViewportSize({ width: 500, height: 800 }) await campaign.login(page, user.email!, user.password) // Carga direto em mobile: sem drawer espontâneo, só o FAB. - await page.setViewportSize({ width: 500, height: 800 }) await page.goto('/campanha') await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0) await expect(page.getByRole('button', { name: 'Sollinha — Assistente virtual' })).toBeVisible({ diff --git a/tests/e2e/campaignSollinhaContext.e2e.spec.ts b/tests/e2e/campaignSollinhaContext.e2e.spec.ts new file mode 100644 index 00000000..b4614ddd --- /dev/null +++ b/tests/e2e/campaignSollinhaContext.e2e.spec.ts @@ -0,0 +1,152 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from './fixtures/campaignE2EFixtures.js' + +const SESSION_KEY = 'teqo:campaign:sollinha-chat-session' + +/** + * Mock the AI endpoint with a minimal SSE stream so sending a message neither + * needs a DeepSeek API key nor hits the real rate limiter. The chunks follow + * the SDK v7 UI-message-stream wire format (start / text-start / text-delta / + * text-end / finish) — a bare legacy `{"type":"text"}` chunk fails the client + * schema and the chat would never settle. The reply carries a markdown link so + * the "navigate without reload" path has a real anchor. + */ +const mockAiChat = (page: Page) => + page.route('**/campanha/api/ai-chat', async (route) => { + await route.fulfill({ + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + body: [ + 'data: {"type":"start"}\n\n', + 'data: {"type":"text-start","id":"t1"}\n\n', + 'data: {"type":"text-delta","id":"t1","delta":"Resposta mockada com [Municípios](/campanha/municipios)."}\n\n', + 'data: {"type":"text-end","id":"t1"}\n\n', + 'data: {"type":"finish","finishReason":"stop"}\n\n', + ].join(''), + }) + }) + +const MESSAGE = 'Mensagem que sobrevive ao reload' + +const openChatAndSend = async (page: Page) => { + await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) + const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' }) + await input.fill(MESSAGE) + await input.press('Enter') + await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 }) +} + +/** The chat has settled once the mic button re-enables (busy → ready). */ +const waitForChatSettled = async (page: Page) => { + await expect( + page.getByRole('button', { name: 'Falar pergunta (voz)' }).filter({ visible: true }), + ).toBeEnabled({ timeout: 20_000 }) +} + +/** Raw stored session, or null when nothing (valid) is in the tab's storage. */ +const storedSession = (page: Page) => + page.evaluate((key) => { + const raw = sessionStorage.getItem(key) + if (!raw) return null + return JSON.parse(raw) as { messages?: unknown[]; open?: boolean } + }, SESSION_KEY) + +test.describe('B188 — contexto da conversa persiste na sessão da janela/tab', () => { + test.beforeEach(async ({ page }) => { + test.slow() + await mockAiChat(page) + }) + + test('reload na mesma aba restaura a conversa', async ({ page, campaign }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Contexto Janela Coordenador'), + }) + await campaign.login(page, user.email!, user.password) + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto('/campanha') + + await openChatAndSend(page) + await waitForChatSettled(page) + // Deterministic gate: the persist effect must have landed before reload. + await expect.poll(async () => (await storedSession(page))?.messages?.length).toBeGreaterThan(0) + + await page.reload() + + await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 }) + await expect(page.getByText('Olá! Eu sou o Sollinha')).not.toBeVisible() + }) + + test('link da resposta navega sem recarregar a página', async ({ page, campaign }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Contexto Janela Coordenador'), + }) + await campaign.login(page, user.email!, user.password) + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto('/campanha') + + await openChatAndSend(page) + await waitForChatSettled(page) + + // A full browser navigation fires `load`; client-side routing does not. + let loadEvents = 0 + page.on('load', () => { + loadEvents += 1 + }) + + await page.locator('#ai-chat-panel').getByRole('link', { name: 'Municípios' }).click() + await expect(page).toHaveURL(/\/campanha\/municipios/) + + expect(loadEvents).toBe(0) + // The conversation is still mounted with the layout — nothing reloaded. + await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 }) + }) + + test('drawer mobile aberto volta aberto após reload', async ({ page, campaign }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Contexto Janela Coordenador'), + }) + // Mobile from the start: a desktop login would auto-open the chat (B167 + // settle), persist `open: true` and make the drawer open "by itself" here. + await page.setViewportSize({ width: 500, height: 800 }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0) + await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).click() + await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toBeVisible({ + timeout: 20_000, + }) + // The open flag must have been persisted before reload. + await expect.poll(async () => (await storedSession(page))?.open).toBe(true) + + await page.reload() + + await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toBeVisible({ + timeout: 20_000, + }) + }) + + test('visita mobile nova permanece fechada após reload (sem chat saltando)', async ({ + page, + campaign, + }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Contexto Janela Coordenador'), + }) + // Mobile from the start (see the sibling test): the desktop settle must + // never have run in this tab, so no `open: true` is ever persisted. + await page.setViewportSize({ width: 500, height: 800 }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0) + + await page.reload() + + await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Sollinha — Assistente virtual' })).toBeVisible({ + timeout: 20_000, + }) + }) +}) diff --git a/tests/unit/sollinhaChatSession.unit.spec.ts b/tests/unit/sollinhaChatSession.unit.spec.ts new file mode 100644 index 00000000..5957b5b1 --- /dev/null +++ b/tests/unit/sollinhaChatSession.unit.spec.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import type { UIMessage } from 'ai' + +import { + SOLLINHA_CHAT_MAX_BYTES, + SOLLINHA_CHAT_MAX_MESSAGES, + SOLLINHA_CHAT_SESSION_STORAGE_KEY, + pruneSollinhaChatMessages, + readSollinhaChatSession, + writeSollinhaChatSession, +} from '@/lib/sollinhaChatSession' + +const makeMessage = (id: string, role: UIMessage['role'], text = `msg ${id}`): UIMessage => ({ + id, + role, + parts: [{ type: 'text', text }], +}) + +describe('sollinhaChatSession storage', () => { + afterEach(() => { + window.sessionStorage.clear() + }) + + it('returns null when storage is missing or invalid (fail-closed)', () => { + expect(readSollinhaChatSession()).toBeNull() + window.sessionStorage.setItem(SOLLINHA_CHAT_SESSION_STORAGE_KEY, 'not-json') + expect(readSollinhaChatSession()).toBeNull() + window.sessionStorage.setItem( + SOLLINHA_CHAT_SESSION_STORAGE_KEY, + JSON.stringify({ version: 2, messages: [], open: false }), + ) + expect(readSollinhaChatSession()).toBeNull() + window.sessionStorage.setItem( + SOLLINHA_CHAT_SESSION_STORAGE_KEY, + JSON.stringify({ version: 1, messages: 'nope', open: false }), + ) + expect(readSollinhaChatSession()).toBeNull() + window.sessionStorage.setItem( + SOLLINHA_CHAT_SESSION_STORAGE_KEY, + JSON.stringify({ version: 1, messages: [{ id: 1, role: 'user' }], open: false }), + ) + expect(readSollinhaChatSession()).toBeNull() + window.sessionStorage.setItem( + SOLLINHA_CHAT_SESSION_STORAGE_KEY, + JSON.stringify({ version: 1, messages: [], open: 'yes' }), + ) + expect(readSollinhaChatSession()).toBeNull() + }) + + it('round-trips messages and open state', () => { + const messages = [makeMessage('1', 'user'), makeMessage('2', 'assistant')] + writeSollinhaChatSession(messages, true) + const session = readSollinhaChatSession() + expect(session?.messages).toEqual(messages) + expect(session?.open).toBe(true) + }) + + it('prunes to the message-count guardrail keeping the newest', () => { + const messages = Array.from({ length: SOLLINHA_CHAT_MAX_MESSAGES + 5 }, (_, index) => + makeMessage(String(index), index % 2 === 0 ? 'user' : 'assistant'), + ) + const pruned = pruneSollinhaChatMessages(messages) + // Dropping the 5 oldest turns leaves an assistant head (id "5"); the head + // rule then drops it too, so the conversation still opens with a question. + expect(pruned).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES - 1) + expect(pruned[0]?.id).toBe('6') + expect(pruned[0]?.role).toBe('user') + expect(pruned.at(-1)?.id).toBe(String(SOLLINHA_CHAT_MAX_MESSAGES + 4)) + }) + + it('drops leading assistant turns after pruning so the model history opens with a question', () => { + const messages = [ + makeMessage('0', 'assistant'), + makeMessage('1', 'user'), + makeMessage('2', 'assistant'), + ] + const pruned = pruneSollinhaChatMessages(messages) + expect(pruned).toHaveLength(2) + expect(pruned[0]?.role).toBe('user') + // A single assistant message survives (a user turn would be invented). + expect(pruneSollinhaChatMessages([makeMessage('0', 'assistant')])).toHaveLength(1) + }) + + it('prunes to the byte guardrail dropping the oldest until it fits', () => { + const big = 'x'.repeat(SOLLINHA_CHAT_MAX_BYTES) + const pruned = pruneSollinhaChatMessages([ + makeMessage('1', 'user'), + makeMessage('2', 'assistant', big), + makeMessage('3', 'user'), + ]) + expect(pruned).toHaveLength(1) + expect(pruned[0]?.id).toBe('3') + }) + + it('writes the pruned session, never the raw messages', () => { + const messages = Array.from({ length: SOLLINHA_CHAT_MAX_MESSAGES + 5 }, (_, index) => + makeMessage(String(index), index % 2 === 0 ? 'user' : 'assistant'), + ) + writeSollinhaChatSession(messages, false) + const session = readSollinhaChatSession() + expect(session?.messages).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES - 1) + expect(session?.messages[0]?.id).toBe('6') + expect(session?.messages[0]?.role).toBe('user') + expect(session?.open).toBe(false) + }) + + it('keeps tool outputs intact so the restored history round-trips to the model', () => { + const messages = [ + makeMessage('1', 'user'), + // The lib treats `parts` opaquely — the shape here mirrors what a real + // settled stream produces (tool part with its output) without depending + // on the SDK's tool-typed part union. + { + id: '2', + role: 'assistant', + parts: [ + { + type: 'tool-invocation', + toolCallId: 'call-1', + toolName: 'calculate', + state: 'output-available', + input: { expression: '1+1' }, + output: 2, + }, + { type: 'text', text: 'O resultado é 2.' }, + ], + }, + ] as unknown as UIMessage[] + writeSollinhaChatSession(messages, true) + expect(readSollinhaChatSession()?.messages).toEqual(messages) + }) +})