Files
open-design/apps/web
c3ec5fda71 fix(web): decode od-card in the thinking stream (#8258)
* fix(web): decode od-card in the thinking stream

The thinking stream was the one prose channel that never saw the shared
od-card parser: `ExecutionShell` handed `kind:'thinking'` text straight to
`ThinkingMarkdown` -> `renderMarkdown`, with no `splitShellCards` anywhere
on the chain. A perfectly valid `<od-card type="memory-applied">…</od-card>`
written into reasoning therefore reached the user as raw tag text — the same
symptom #7956 closed for in-shell narration (OPEND-2607), on the third
channel.

Route thinking prose through `splitShellCards`, the same parser `SayBlock`
and `AssistantMessage` already use, so there is exactly one answer to "which
bytes of a model message are a protocol card and which are user-readable
prose". Streaming semantics come from that parser too: a half-written opener
waits for the next delta instead of flashing raw, and an unclosed card does
not swallow the prose in front of it.

Deliberately unchanged: `splitOnOdCards` still keeps a malformed card as raw
text ("Malformed — keep raw text so the user can still see it"). That is a
separate product call and is not touched here.

Refs OPEND-2745

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(web): keep thinking-stream cards out of the char reveal

Rendering `OdCardView` inside `ThinkingMarkdown` put it under that
component's single char-reveal root, and `useCharReveal` walks the whole
subtree. So the frame where a streaming card closed, the visible-text length
jumped and the reveal shredded the card's OWN text nodes into per-character
`data-char-reveal` spans — truncating React-owned nodes React was still
updating, which is the exact hazard `useCharReveal`'s header warns about.

Mark the card subtree with `[data-no-reveal]`, the opt-out that hook already
declares (`SKIP`) for content that sits inside a reveal root without being
prose the model is typing. This is its first consumer. `collect()` and
`measure()` share the filter, so a card's text no longer counts toward the
length delta either: a card appearing can no longer be mistaken for a burst
of new characters that re-reveals prose which never changed.

`SayBlock` needs no such attribute because its reveal roots live INSIDE
`SayText`, one per prose run, so cards are siblings of every root rather than
descendants. Thinking cannot copy that shape: the reveal budget, the
"settled on mount" state and the module-level `claimHistoryReplayLanded`
token are all per-root, so N roots would mean N independent budgets and a
replay token only the first root claims — breaking OPEND-2590's "replayed
history does not re-reveal" for the thinking slot. One root plus a subtree
opt-out keeps both properties.

Refs OPEND-2745

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:55:10 +00:00
..