mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:35:20 +00:00
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:
commit
07464b8810
8 changed files with 573 additions and 10 deletions
99
docs/plans/sollinha-contexto-sessao-janela-impl.md
Normal file
99
docs/plans/sollinha-contexto-sessao-janela-impl.md
Normal 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.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
100
src/lib/sollinhaChatSession.ts
Normal file
100
src/lib/sollinhaChatSession.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
152
tests/e2e/campaignSollinhaContext.e2e.spec.ts
Normal file
152
tests/e2e/campaignSollinhaContext.e2e.spec.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
133
tests/unit/sollinhaChatSession.unit.spec.ts
Normal file
133
tests/unit/sollinhaChatSession.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue