Merge pull request #548 from fsolla/B188-sollinha-contexto-da-conversa-persiste-na-sessao-da-jan

B188 — Sollinha: contexto da conversa persiste na sessão da janela/tab
This commit is contained in:
Francisco Solla 2026-08-10 02:53:54 -03:00 committed by GitHub
commit 07464b8810
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 573 additions and 10 deletions

View file

@ -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/…`) → `<a>` 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.

View file

@ -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).

View file

@ -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<HTMLAnchorElement> & { node?: unknown }) => {
if (typeof href === 'string' && APP_INTERNAL_LINK.test(href)) {
return (
<Link href={href} {...props}>
{children}
</Link>
)
}
return (
<a href={href} {...props}>
{children}
</a>
)
}
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"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{part.text}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{ a: MarkdownLink }}
>
{part.text}
</ReactMarkdown>
</div>
)
}

View file

@ -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 <AISidebarContext.Provider value={value}>{children}</AISidebarContext.Provider>

View file

@ -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<string, unknown> =>
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.
}
}

View file

@ -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({

View file

@ -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,
})
})
})

View file

@ -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)
})
})