- built-boot smoke asserts [data-variant=web][data-tool=web_search/fetch]
(the keyed WebRow) instead of [data-web] (which WebBlock draws even on the
GenericToolCard fallback, so a silent keyed-registration failure passed).
- WebBlock renders an explicit empty-state note when a search returns no
answer and no sources, instead of a blank <ol>; the chat row does not
surface the raw result content, so the backend's 'No results found.' was
otherwise invisible.
- README (ui-conversation, ui-primitives) and the frontend Agent Note record
the unknown-web-kind null arm, the details-panel flattened body, and the
empty-search copy; pairings re-recorded.
The coverage lane caught a regression in the prior commit: FileMutationRow now
passes the tool's raw path to the injected openFile (which resolves against cwd
in apply.ts), but diff-card.spec still asserted the row pre-resolved. Assert on
the raw path instead. Also fix the built-boot diff assertion to match on the
line body and footer text — the `+ ` prefix is a CSS ::before, absent from
textContent.
Bring the TUI diff footer onto the same terminator rule and distinct-path
count the Web DiffBlock uses (a trailing newline terminates its line; two
hunks in one file read as 1 file), so the two front ends' `+A -R · N file(s)`
footers agree. Reserve space in the diff path header for the floating copy
button so a long path no longer scrolls under it. Pass the tool's raw path to
the injected openFile (which already resolves against cwd) instead of resolving
twice. Rename the shared block-body CSS class to a card-neutral cardBody so a
terminal-spacing tweak cannot silently move the diff card. Add a same-file
two-hunk TUI unit test and an assembled built-boot assertion that the write
turn renders +1 -0 · 1 file end to end.
Every expandable tool row shares one interaction (whole-row toggle,
icon-to-chevron hover preview) and one expanded body: an IN/OUT
gutter-labeled card with per-section 150px scroll caps and sticky labels.
toolRowModel derives result output and the error first line, terminalFailed
surfaces a failing exit as the collapsed row's red dot, a hover Inspect
pill jumps to the call's trajectory record through a one-shot store
handoff, and the chat view keeps its scroll offset across view switches.
- webCardModel returns null for an unknown web `kind` (wire from a newer host)
instead of drawing it as a malformed fetch, matching the unknown-`card` and
terminal-model wire-boundary default.
- Fixture WEB_SEARCH_RESULT/WEB_FETCH_RESULT and the source type derive from the
contract's ToolResultView via Extract, so a new contract field fails at the
type level rather than drifting silently.
- built-boot smoke asserts the web_search/web_fetch turns render their keyed
WebRow cards, giving the registration and wire projection an assembled check.
- DetailsPanel comment no longer claims the card omits content for search.
- ui-primitives README inline-Chinese limitation now lists WebBlock's controls.
- DetailsPanel: render the flattened result content below the web card, so a
web_fetch's fetched body (and a search's answer/source markdown) stays
visible on the panel's single-call reading surface — the card is a summary.
- WebBlock: the collapsed source tail keeps each source's original citation
number via <li value>, and the expand control is a marker-less <li> so the
<ol> is valid HTML; an empty-hostname URL (file:/data:) falls back to the raw
URL so a label is never blank.
- web-row / GenericToolCard: both spread WebBlock uniformly with maxSources
(fetch ignores it, like TerminalBlock's maxLines), dropping the duplicated
per-kind conditional.
- Docs: WebBlock added to the ui-primitives README (both languages) with a Web
retrieval section; the ui-conversation README's "inline licensed for this
intent alone" claim de-absolutized and a web-card paragraph added; the Agent
Note's safe-link description corrected to the http(s) subset of MarkdownText's
allowlist (mailto excluded). Fixture source comment aligned with its data.
- Tests: ol numbering + marker-less expander, empty-hostname label fallback,
the fetched body visible in the panel.
Review follow-ups on the approval takeover:
The scroll region is now a tab stop (tabIndex + named role="group"). The
question composer's scroll body needs none — its option rows are focusable and
pull the container along — but this one holds nothing but text, so a
keyboard-only user could reach the buttons and never the command's tail, and
approve what they could not finish reading.
The action row's padding reproduces the 14px gap it had inside the body: the
flex gap of 6 plus its 8px top margin, neither of which reaches it now that the
row sits outside the scroll region. The resting card is unchanged again.
- DiffBlock: an empty side contributes zero lines and a trailing newline is a
terminator, so a create ending in a newline draws one added line (not a
phantom empty one) and a full deletion draws no phantom + line.
- diffCardModel: narrow the wire diffs payload (card is the only validated
field) so a malformed diff card falls back to the generic path instead of
throwing inside DiffBlock.
- FileMutationRow: surface the result text when an errored mutation has no diff
card, so a failed edit/write is more than a red dot.
- copyText ends its closed union on assertNever.
- Docs: drop the "bridge relativizes" claim, record the file-count divergence
from the TUI footer, correct the built-boot overclaim, note why the row title
outranks the view title, and make fixture turn 67 args self-consistent.
- Tests: terminator/empty-side/interior-blank rows, wire-narrowing null arms,
the error-text arm and its name/code fallback, stopped state, no-path summary,
and the registration/disposal shape.
The approval panel replaces the InputBar while a sandbox escalation waits,
and its justification and command are unbounded model text. With no height
cap, a long command grew the card until the refuse/allow row went under the
fold: at 900x700 the action row's bottom landed at y=749, so the user could
read the request and not answer it.
Justification and command now scroll in one region capped at the same height
as the composer's draft area, with the amber strip and the action row outside
it. The cap is one value with two consumers — declared as
--dsh-composer-text-max-height on ConversationRoot's .composerSeat, the
composer chain's only shared ancestor — so the seat cannot cap its two states
differently. The card rebinds the l2 scrollbar pair like every other scrolling
surface on an elevated background.
Covered by a new web e2e scenario that drives the real composition (read-only
session, denied write, the model's escalation retry, answer clicked through
the panel) and measures the live panel at two viewport heights against the
composer's own cap, read off the textarea rather than hardcoded.
Consume the card:'web' result view (structured sources + answer for search, the
URL and HTTP status for fetch) the web backend PR added. WebBlock
(ui-primitives) draws both kinds via the kind discriminant: a citation list of
safe external links (the MarkdownText protocol allowlist, title-or-hostname
label), a truncation indicator, a height cap matching TerminalBlock; a fetch
summary for the other kind. web-card-model is the single resultView derivation;
a keyed WebRow registers under web_search and web_fetch with the card resident
under its summary. The generic fallback and the details panel are web-aware.
Fixture gains web_search and web_fetch turns for the built-boot snapshot.
The web command row renders `title · summary` from one logged command
lifecycle pair, and the two halves were written without knowing about each
other: the title was the dispatched line rebuilt from `command/run` and the
summary was `command/done`'s verbatim text, so every Access-chip pick read
`/permission workspace-write · Permission preset: workspace-write.` — the
command name twice and its argument twice.
The title is now the bare command name (no `/`, no arguments — the summary
already says what the command did), and a command handler's settlement text
never repeats the command's own name, so `/permission` returns `preset
workspace-write`. The row reads `permission · preset workspace-write`, and
the TUI notice still names the preset that now applies. The log is
unchanged: `command/run` keeps its structured name/args split for a richer
registered row.
The write/edit tools already declare card:'diff' with applied hunks on
callView/resultView, but the Web client discarded it: a mutation landed on
GenericToolCard and the details panel flattened the result to a <pre>. Add
DiffBlock (ui-primitives), diff-card-model (the single callView/resultView
derivation), and FileMutationRow (keyed under write and edit), and make the
generic fallback row and the details panel diff-aware. The +/- block form,
per-file path header, same-file gap, and footer mirror the TUI diff card;
the chat row caps at CHAT_DIFF_MAX_LINES against the panel's full height.
fix(web): keep one composer bar DOM across the no-workspace transition
The composer.bar slot moves from session to session-maybe scope: with no
current session the entry still mounts, the machine faces (keyboard, stop,
command) arrive undefined, and the bar renders its normal DOM inert via the
disabled owner prop. DisabledInputBar and its parallel tree are gone, so the
textarea node survives the cold-start workspace pick instead of flashing
through a remount. A blank session whose workspace was deleted takes the
same inert path through owner props.
test(web): add the composer DOM-continuity acceptance probe
Drives a real dsh web server with headless chromium through the cold-start
-> pick-workspace -> type flow and asserts the composer textarea is the
same DOM node throughout (a marker property must survive). Rerun
prerequisites are in the header comment.
docs(web): session-maybe identity is adoption, not hold-forever
fix(web): session-maybe entries adopt the first session, then remount like strict entries
A session-maybe entry used to keep one React instance across every
transition, so component-local state leaked between sessions once the
composer bar moved to that scope (PermissionSelect's optimistic pick, the
IME composition guard). Identity is now adoption: an incarnation born
session-less holds through the arrival of the first session (the blank
shell's DOM survives the workspace pick), and afterwards behaves exactly
like a strict session entry — a switch or a drop to no-session remounts,
clearing local state by construction. The child key is an incarnation
counter kept in the stable outlet wrapper via render-phase setState.
chore: knip knows the root acceptance probe's playwright dependency
scripts/hero-composer-dom-continuity.mjs resolves playwright through
apps/web's devDependency tree (createRequire), which knip cannot follow;
ignore it at the root workspace.