feat(B187): links com aparência de link nas respostas do Sollinha
Estilo de links no markdown do chat (primária + sublinhado + hover/foco), externos abrem em aba nova; compõe com o next/link interno do B188. Sem migration; e2e + unit cobrem a superfície.
This commit is contained in:
parent
3ca200e7b0
commit
1016668265
5 changed files with 278 additions and 21 deletions
95
docs/plans/sollinha-links-como-links-impl.md
Normal file
95
docs/plans/sollinha-links-como-links-impl.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Impl: Sollinha: links com aparência de link nas respostas
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #528
|
||||
Intenção: docs/plans/sollinha-links-como-links.md
|
||||
Appetite restante: ~0,5 dia (herdado)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** todo link emitido pelo Sollinha numa resposta aparece sublinhado e com a cor primária do tema, distinguível do texto corrido, com hover e foco por teclado visíveis; links externos abrem em aba nova; o resto do markdown permanece como está.
|
||||
- **O que NÃO negociar:** sem redesenho do bubble/chat; sem trocar o sistema de markdown; **qualquer** link conta (não só internos); sem instalar `@tailwindcss/typography`; navegação interna/clique é do B188 (fora).
|
||||
- **O que reavaliar:** a hipótese da intenção ("classes `prose` sem efeito") foi **confirmada** — `@tailwindcss/typography` não está no `package.json` e o entry CSS (`styles.css`) só registra `tailwindcss-animate`/`tw-animate-css`/`shadcn/tailwind.css`. O `[button,a]:focus-visible:ring-*` do `BubbleContent` **não cobre** links aninhados no markdown (variantes `[button,a]:` só se aplicam quando o próprio `bubble-content` É um `<a>`/`<button>` via `asChild`; um `<a>` dentro do markdown não recebe nada). A "dica" de estado escuro da intenção é futurologia: o app está `forcedTheme="light"` no `ThemeProvider` e o shell `/campanha` não alterna `.dark` — a proteção é usar tokens (`text-primary`), que já é o que faremos.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
R[ReactMarkdown<br/>assistant message] -->|components.a| X[externalLinkTarget]
|
||||
R -->|classes escopadas| A["<a> interno: text-primary + underline<br/>+ hover decoration-2 + focus ring"]
|
||||
X -->|http(s):// | N["target=_blank<br/>rel=noopener noreferrer"]
|
||||
X -->|relativo/mailto| P["<a> padrão (navega no app)"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) classes utilitárias escopadas no contêiner do markdown + renderer mínimo só para links externos | B) classe CSS global (`.sollinha-markdown a {…}`) em `styles.css` | C) renderer `components.a` com as classes inline nele
|
||||
|
||||
**Recomendação:** **A** — as variantes arbitrárias `[&_a]:…` seguem o padrão já usado no mesmo elemento (`[&_h1]:text-base`, `[&_table]:text-xs`, …), mantêm o estilo 100% escopado ao chat (sem vazar para outras superfícies) e não trocam o sistema de markdown. O renderer existe **apenas** para o aceite "externo em aba nova" (que o CSS não cobre) e é fino: delega a decisão a uma função pura testável.
|
||||
**Rejeitadas:** **B** porque espalha o contrato de estilo para o CSS global quando o contêiner já carrega todo o estilo do markdown em JSX (dois lugares para a mesma coisa) e o `prose` morto continuaria lá; **C** porque estilizar via renderer duplica o que o CSS escopado faz (o link renderizado também cai dentro do contêiner) e adiciona manipulação de `node` desnecessária.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`externalLinkTarget`** (`src/lib/ai/markdownLinks.ts`, novo): função pura `(href: string) => { target: '_blank'; rel: 'noopener noreferrer' } | null` — externo = `^https?://` ou `//` (protocol-relative); tudo mais (relativo `/campanha/…`, `mailto:`, `tel:`, vazio) retorna `null`. Segue o precedente de módulo puro pequeno em `src/lib/` (ex.: `sollinhaChatPanelWidth.ts`).
|
||||
- **`CampaignAIChat.tsx`** (`src/components/campaign/shell/ai/CampaignAIChat.tsx`):
|
||||
- `components={{ a }}` no `ReactMarkdown`: renderer que aplica `externalLinkTarget(href)` ao `<a>` e nada mais; `node` é descartado do spread (prop não-DOM do react-markdown v10).
|
||||
- Classes adicionadas ao div do markdown (junto das existentes):
|
||||
`[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a]:hover:decoration-2 [&_a]:focus-visible:outline-none [&_a]:focus-visible:ring-2 [&_a]:focus-visible:ring-ring/50`
|
||||
- `[&_a]:text-primary` usa o token do tema (`#c51414` no claro campaign; ~4,6:1 sobre `--secondary` `#f5f5f4` — passa AA para texto normal); em dark hipotético os tokens flipam sozinhos.
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** N/A — render de cliente, nenhuma escrita/leitura nova.
|
||||
- **UI:** Impeccable B (encaixe no bubble). Sem novos shells; sem mudar bubble/padding/background. shape (classes) → craft (hover/focus) → critique (contraste + escopo) → polish só se a inspeção apontar.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- N/A — aparência apenas; nenhum dado.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Core** — `src/lib/ai/markdownLinks.ts` + testes unitários (`tests/unit/aiMarkdownLinks.unit.spec.ts`): http/https/`//` → target/rel; `/campanha/…`, `mailto:`, `tel:`, `#anchor`, vazio → `null`.
|
||||
2. **UI** — renderer `a` + classes no `CampaignAIChat.tsx`; e2e `tests/e2e/campaignAiLinks.e2e.spec.ts` (padrão `campaignAiChatResize`/`campaignAiTranscribe`: `mockAiChat` com SSE contendo `[Ilhéus](/campanha/municipios/ilheus)` e um link externo):
|
||||
- link interno visível, `color: rgb(197, 20, 20)` e `text-decoration-line` contém `underline`;
|
||||
- hover → `text-decoration-thickness: 2px`;
|
||||
- foco por teclado (Tab) → `toBeFocused()` + `box-shadow` ≠ `none` (ring visível);
|
||||
- link externo → `target="_blank"` e `rel` com `noopener`.
|
||||
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`; PR com `Closes #528`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Estilizar o bubble inteiro (padding/cor de fundo) — proibido pela intenção.
|
||||
- Remover as classes `prose` mortas — débito barato, registrar no capture-review-debts (não nesta entrega).
|
||||
- Navegação interna sem reload / `router.push` — é o B188 (o renderer não intercepta cliques; só decide `target`/`rel`).
|
||||
- Sanitizar URLs (`javascript:` etc.) — react-markdown v10 já filtra via `defaultUrlTransform`; fora.
|
||||
- Instalar `@tailwindcss/typography` — explícito fora de escopo.
|
||||
- Testar dark theme no e2e — o app está `forcedTheme="light"`; a garantia é via token (revisão visual opcional).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
| Risco | Mitigação |
|
||||
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Renderer `a` quebra props do react-markdown v10 | Descartar `node` do spread; renderer devolve `<a {...props}>` — os testes e2e provam render real |
|
||||
| Conflito com B188 (mesmo render de mensagens) | B188 já está em main (renderer `next/link` para `/campanha…`); o renderer deste item **compõe** em vez de substituir: interno → `next/link` preservado, externo → `target/_blank`, resto → âncora pura |
|
||||
| Contraste da cor primária no bubble `secondary` | `#c51414` sobre `#f5f5f4` ≈ 4,6:1 (AA texto normal); hover não muda a cor, engrossa o sublinhado |
|
||||
| Classes `[&_a]` vazam para mensagens do usuário | Escopo é só o div do markdown das respostas do assistente (usuário renderiza `span` puro) |
|
||||
|
||||
## Débitos deferidos (capture-review-debts, gate humano 2026-08-10)
|
||||
|
||||
- **D1 → Issue #556 (B194):** mock SSE v7 duplicado em 4 specs + mock legado em `campaignAiTranscribe` — extrair helper compartilhado em `tests/e2e/fixtures/`.
|
||||
- **D2 (deferido):** classes `prose`/`dark:prose-invert` mortas no `CampaignAIChat` (plugin `@tailwindcss/typography` ausente). **Gatilho:** qualquer toque futuro no div do markdown do chat (B188+, B194) — remover junto.
|
||||
- **D3 (deferido, absorvido por #556):** migrar o mock legado do `campaignAiTranscribe` para o formato v7 — executar junto do helper do D1.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (underline + primária + hover/foco + externo em aba nova + markdown intacto)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem schema/access/Consent; identificadores em inglês; copy pt-BR)
|
||||
- [x] Testes previstos: unit de `externalLinkTarget` + e2e de aparência/interação do link no chat
|
||||
|
||||
## Decisões de engenharia (self-score)
|
||||
|
||||
| Decisão | Recomendação | Rejeitadas |
|
||||
| ------------------- | --------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| Mecanismo de estilo | Variantes `[&_a]` no contêiner do markdown (padrão do arquivo) | Classe global no CSS; classes no renderer |
|
||||
| Externo em nova aba | Função pura `externalLinkTarget` + renderer `a` fino | Renderer com toda a lógica; ignorar o aceite |
|
||||
| Hover | Engrossa o sublinhado (`decoration-2`), cor mantém | Mudar cor (piora contraste) |
|
||||
| Foco | `ring-2 ring-ring/50` escopado (BubbleContent não cobre `a` aninhado) | Confiar no ring existente (não se aplica) |
|
||||
|
||||
**Self-score decision-quality: 4/5** — decisões baratas e reversíveis documentadas; appetite ~0,5 dia respeitado; rabbit holes nomeados; sem schema; seam limpo para o B188.
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { Bot, Mic, Send, Square, User } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Components } from 'react-markdown'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
|
|
@ -11,37 +12,40 @@ import { InputGroup, InputGroupButton } from '@/components/ui/input-group'
|
|||
import { Marker, MarkerContent } from '@/components/ui/marker'
|
||||
import { Message, MessageAvatar, MessageContent } from '@/components/ui/message'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { externalLinkTarget } from '@/lib/ai/markdownLinks'
|
||||
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.
|
||||
* B187+B188 — links in the assistant's markdown must look like links: brand
|
||||
* primary color + always-visible underline, hover thickens the underline and
|
||||
* keyboard focus shows a ring (BubbleContent's own `[button,a]:focus-visible:*`
|
||||
* only applies when the bubble content ITSELF is the anchor). External http(s)
|
||||
* links open in a new tab; app-internal destinations — the B162 link catalog
|
||||
* only ever emits `/campanha…` paths — navigate through `next/link` (B188), so
|
||||
* following a Sollinha link keeps the layout (and the conversation) mounted
|
||||
* instead of reloading the page. Anything else keeps the plain anchor.
|
||||
*/
|
||||
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)) {
|
||||
const markdownComponents: Components = {
|
||||
a: ({ node: _node, href, children, ...rest }) => {
|
||||
const external = typeof href === 'string' ? externalLinkTarget(href) : null
|
||||
if (typeof href === 'string' && APP_INTERNAL_LINK.test(href)) {
|
||||
return (
|
||||
<Link href={href} {...rest}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link href={href} {...props}>
|
||||
<a href={href} {...rest} {...external}>
|
||||
{children}
|
||||
</Link>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const CampaignAIChat = ({ className }: { className?: string }) => {
|
||||
|
|
@ -126,11 +130,11 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
|
|||
return (
|
||||
<div
|
||||
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"
|
||||
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 [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a]:hover:decoration-2 [&_a]:focus-visible:outline-none [&_a]:focus-visible:ring-2 [&_a]:focus-visible:ring-ring"
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{ a: MarkdownLink }}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{part.text}
|
||||
</ReactMarkdown>
|
||||
|
|
|
|||
18
src/lib/ai/markdownLinks.ts
Normal file
18
src/lib/ai/markdownLinks.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export type ExternalLinkTarget = {
|
||||
target: '_blank'
|
||||
rel: 'noopener noreferrer'
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a markdown href rendered by the Sollinha chat should open in
|
||||
* a new tab. Only absolute http(s) URLs (and protocol-relative ones) qualify —
|
||||
* everything the assistant produces today (`/campanha/...` paths) stays as a
|
||||
* same-tab anchor, and non-web protocols (`mailto:`, `tel:`, `#anchor`) render
|
||||
* untouched.
|
||||
*/
|
||||
export const externalLinkTarget = (href: string): ExternalLinkTarget | null => {
|
||||
if (/^https?:\/\//i.test(href) || href.startsWith('//')) {
|
||||
return { target: '_blank', rel: 'noopener noreferrer' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
92
tests/e2e/campaignAiLinks.e2e.spec.ts
Normal file
92
tests/e2e/campaignAiLinks.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
/**
|
||||
* B187 — links in the Sollinha's markdown answers must look like links: brand
|
||||
* primary color + always-visible underline, hover thickens the underline,
|
||||
* keyboard focus shows a ring, and external http(s) URLs open in a new tab.
|
||||
* The AI endpoint is mocked with a fixed SSE stream whose text carries one
|
||||
* internal (`/campanha/...`) and one external link — the internal one is
|
||||
* deliberately LAST so a backward Tab from the input lands on it deterministically.
|
||||
*/
|
||||
|
||||
const ASSISTANT_LINKS =
|
||||
'Confira [o portal da saúde](https://www.saude.ba.gov.br/) e [o município de Ilhéus](/campanha/municipios/ilheus).'
|
||||
|
||||
const mockAiChat = (page: Page) =>
|
||||
page.route('**/campanha/api/ai-chat', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
// 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.
|
||||
body: [
|
||||
'data: {"type":"start"}\n\n',
|
||||
'data: {"type":"text-start","id":"t1"}\n\n',
|
||||
`data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(ASSISTANT_LINKS)}}\n\n`,
|
||||
'data: {"type":"text-end","id":"t1"}\n\n',
|
||||
'data: {"type":"finish","finishReason":"stop"}\n\n',
|
||||
].join(''),
|
||||
})
|
||||
})
|
||||
|
||||
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...' })
|
||||
// Dev-mode defense: a first-hit route compile can trigger a full-page reload
|
||||
// that wipes the just-typed draft mid-test (production/CI never does), so
|
||||
// re-send until the answer actually lands.
|
||||
await expect(async () => {
|
||||
await input.fill('Mostre um link')
|
||||
await input.press('Enter')
|
||||
await expect(page.getByRole('link', { name: 'o município de Ilhéus' })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
}).toPass({ timeout: 60_000 })
|
||||
}
|
||||
|
||||
test.describe('B187 — links com aparência de link nas respostas do Sollinha', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.slow()
|
||||
await mockAiChat(page)
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
})
|
||||
|
||||
test('links internos e externos ficam visíveis como link e têm hover e foco', async ({
|
||||
page,
|
||||
campaign,
|
||||
}) => {
|
||||
const user = await campaign.fixtures.createCampaignUser('coordinator', {
|
||||
name: campaign.fixtures.value('Links Coordenador'),
|
||||
})
|
||||
await campaign.login(page, user.email!, user.password)
|
||||
await page.goto('/campanha')
|
||||
|
||||
await openChatAndSend(page)
|
||||
|
||||
const internalLink = page.getByRole('link', { name: 'o município de Ilhéus' })
|
||||
const externalLink = page.getByRole('link', { name: 'o portal da saúde' })
|
||||
|
||||
// The link is styled like a link: brand primary + always-visible underline.
|
||||
await expect(internalLink).toHaveCSS('color', 'rgb(197, 20, 20)')
|
||||
await expect(internalLink).toHaveCSS('text-decoration-line', 'underline')
|
||||
|
||||
// Hover reinforces the underline (thicker, decoration-2).
|
||||
await internalLink.hover()
|
||||
await expect(internalLink).toHaveCSS('text-decoration-thickness', '2px')
|
||||
|
||||
// Keyboard focus is visible: Shift+Tab from the input lands on the last
|
||||
// link in the conversation and paints the focus ring (box-shadow).
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(internalLink).toBeFocused()
|
||||
await expect(internalLink).toHaveCSS('box-shadow', /2px/)
|
||||
|
||||
// External links open in a new tab with noopener; internal ones stay
|
||||
// plain same-tab anchors (their click behavior is B188's concern).
|
||||
await expect(externalLink).toHaveAttribute('target', '_blank')
|
||||
await expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
await expect(internalLink).not.toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
48
tests/unit/aiMarkdownLinks.unit.spec.ts
Normal file
48
tests/unit/aiMarkdownLinks.unit.spec.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { externalLinkTarget } from '@/lib/ai/markdownLinks'
|
||||
|
||||
/**
|
||||
* B187: only absolute web URLs get the new-tab treatment in the Sollinha chat;
|
||||
* every internal `/campanha/...` path and non-web protocol stays a plain
|
||||
* same-tab anchor (internal navigation behavior belongs to B188).
|
||||
*/
|
||||
|
||||
describe('externalLinkTarget', () => {
|
||||
it('opens absolute http and https URLs in a new tab with noopener', () => {
|
||||
expect(externalLinkTarget('https://saude.ba.gov.br')).toEqual({
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
})
|
||||
expect(externalLinkTarget('http://example.com')).toEqual({
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
})
|
||||
expect(externalLinkTarget('HTTPS://EXAMPLE.COM')).toEqual({
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens protocol-relative URLs in a new tab', () => {
|
||||
expect(externalLinkTarget('//saude.ba.gov.br')).toEqual({
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves internal campaign paths as same-tab anchors', () => {
|
||||
expect(externalLinkTarget('/campanha/municipios/ilheus')).toBeNull()
|
||||
expect(externalLinkTarget('/campanha')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves non-web protocols and anchors untouched', () => {
|
||||
expect(externalLinkTarget('mailto:contato@jorgesolla.com.br')).toBeNull()
|
||||
expect(externalLinkTarget('tel:+557132323232')).toBeNull()
|
||||
expect(externalLinkTarget('#topo')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves empty hrefs untouched', () => {
|
||||
expect(externalLinkTarget('')).toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue