Files
accounted/components/agent/MarkdownMessage.tsx
T
Jakob Wennberg 4702a63cff fix(assistant): announce answers to screen readers, one label map, links that keep the thread (#1224)
* fix(assistant): announce answers to screen readers, one label map, links that keep the thread

PR7 polish, three items from dev_docs/assistant_redesign_plan.md section 7.

The chat had no live region at all. A screen-reader user got no signal that the
assistant had answered: the reply simply appeared, for people who could see it.
Announcement fires on turn boundaries rather than over the streaming text,
because a live region on token deltas re-announces on every delta and makes the
surface unusable; the finished answer is read once, capped, with a pointer to
the message for the rest.

Two intent-label maps had drifted. The panel opened on the bokslut wizard titled
"Fråga Anna" while the same thread in the history list read "Hjälp med bokslut",
and the list's fallback returned the intent id itself, putting "bokslut.step" in
front of the user as the name of their own conversation. One map now, and an
unknown intent can no longer fall through to its id.

Links inside an answer were plain anchors, so following one did a full document
load: the app rebooted and took the conversation with it, which is the opposite
of what docking the panel was for. Internal links route client-side. External
ones open in a new tab with rel="noopener noreferrer", since the href came out
of a model that reads customer documents and target="_blank" without it hands
the opened page a handle back into an authenticated session.

Reduced motion needed nothing: globals.css already collapses every animation
under prefers-reduced-motion, so per-class variants would be redundant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(assistant): review triage: scope the announcement to its own turn

Six findings, all real.

The announcement searched the whole thread, so a turn that produced no text of
its own (tool-only, or an error) found the PREVIOUS answer and read it out as
though it were new: a screen-reader user would hear a stale answer to a question
that had just been asked. It now receives only the current turn's messages,
bounded by an index captured when streaming starts.

It also read an interrupted answer as a finished one. Stop leaves the partial
text with a visible marker, so announcing it as the answer told a screen-reader
user the opposite of what everyone else could see.

messagesRef was assigned during render. React may replay a render, so the
announcement could read a snapshot the user never saw; the write moved into an
effect declared before the one that reads it.

The 400-character cap applied to the preview only, so the appended continuation
suffix pushed the real announcement past the limit the constant promised. The
cap now covers the whole string, and the test asserts against the constant
rather than a looser number the suffix could sneak past.

INTENT_LABELS was a plain object literal, so intentLabel('toString') resolved
Object.prototype.toString, passed the truthiness check and reached React as a
conversation title. Null-prototype now. intent_id comes from the database.

Markdown link titles were dropped: [text](url "title") carries a title that
react-markdown passes through and the renderer ignored.

Both new guards were mutation-checked: removing either makes its test fail. The
turn-boundary index itself is component wiring, which this node-only unit
project cannot exercise; announceableAnswer is tested against the slice it is
given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 12:48:40 +02:00

69 lines
2.0 KiB
TypeScript

'use client'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import Link from 'next/link'
import { isInternalHref } from './markdown-links'
/**
* Isolated so AgentChat can load the markdown parser (react-markdown +
* remark-gfm and their unified/remark dependency tree) via next/dynamic:
* the chunk is fetched when the first assistant message renders instead of
* being parsed eagerly whenever the chat surface mounts.
*/
export default function MarkdownMessage({ text }: { text: string }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: MarkdownLink }}>
{text}
</ReactMarkdown>
)
}
/**
* Links inside an answer.
*
* These were plain anchors, so following one the agent had written did a full
* document load: the whole app rebooted and the conversation went with it, in
* the panel and in /chat alike. Internal links now route client-side, which
* keeps the thread alive beside the page the user just opened, which is the
* point of docking the panel in the first place.
*
* External links open in a new tab for the same reason, and carry
* rel="noopener noreferrer": the href came out of a model that reads customer
* documents, and target="_blank" without it hands the opened page a
* window.opener handle back into an authenticated session.
*/
function MarkdownLink({
href,
title,
children,
}: {
href?: string
// Markdown carries an optional title: [text](url "title"). Dropping it here
// would silently discard something the answer's author wrote.
title?: string
children?: React.ReactNode
}) {
if (!href) return <>{children}</>
if (isInternalHref(href)) {
return (
<Link href={href} title={title} className="underline underline-offset-2">
{children}
</Link>
)
}
return (
<a
href={href}
title={title}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
{children}
</a>
)
}