The Web composer now renders a request that declares the `plan-review` intent
as its own surface: the waiting-approval card language — amber "Plan review"
strip, the plan as the scrolling markdown body, the question as the card's
accessible name — over one decision row of Chat about it / Refuse / Approve.
Gone from that surface are the 1/1 pager, the numbered radio rows, the custom
answer row, and Skip/Submit, which made approving a plan read as sitting an
exam.
Approve and Refuse answer with the asker's own option labels and keep its
descriptions as tooltips; Chat about it cancels the request so the composer
returns and the user can simply say what they want. Copy is bilingual under the
existing `question` namespace.
The shape choice lives inside the single composer entry rather than a second
chain registration, so the two surfaces cannot race the same carrier, and
`planReviewOf` falls back to the generic flow for any request it cannot render
as a card — the client sits downstream of a wire boundary and every request
must stay answerable.
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.
Five findings from the #939 review, each reproduced before being fixed.
**Configuration reads are as privileged as writes.** `settings.describe`
returns every exposed namespace's configuration and `credentials.describe`
reports whether an arbitrary environment-variable name is configured and from
where — reconnaissance no anonymous caller should have. Both join
PRIVILEGED_METHODS, so the whole configuration plane is loopback-only until
real authentication exists; `trustedHosts` was never authentication. The model
catalog stays reachable: it carries no endpoints or key state, and a LAN
client's model picker legitimately needs it. Asserted over a real HTTP server,
because the Host header a browser actually sends is what decides this.
**The proxy serves only namespaces a registered model provider addresses.**
The settings seam is general — any plugin may register one — but the Web
configuration plane is the model-provider surface. Without the gate, every
future `settings.register()` would silently become remotely readable and
writable configuration. An unregistered namespace and an unexposed one answer
identically, so no caller can enumerate the registry one probe at a time.
**Path-addressed writes replace the redacted-document rebuild.** The editor
reads the REDACTED descriptor, so rebuilding a section from it and replacing
wholesale deleted every literal secret the wire never returned — reproduced as
`{baseURL, reasoning}` in, stored `apiKey` gone out. `settings.mutate` applies
set/unset ops to the section as it stands at the front of the seam's write
queue, and the client names only fields it can see, so an unseen secret is
untouched by construction rather than by care.
P2s in the same pass: `llm/adapters-updated` now contains async listener
rejections (an uncontained one escaped as unhandledRejection, contradicting
the documented "observer failures are contained"); llm-deepseek's retry-policy
swap uses the atomic `registration.replace` instead of dispose-then-register,
which published `[]` then `["deepseek-official"]` so an observer saw the
provider disappear and come back; and a transport rejection no longer strands
the page in `loading` or a card in `busy`, with removal failures surfaced on
the page banner instead of swallowed.
Review round: the no-caption rule the Agent Note states applied only to the
success texts, leaving `permission · unknown permission preset "bogus" (…)`.
The error text now reads `unknown preset "bogus" (…)` and its exact wording
is pinned; the fixture mirror drops `JSON.stringify` for the host's own
quoting so the two cannot drift on a quoted argument.
The Note now states the line it draws: the rule bans a caption for the
command's own value, not the vocabulary, so `/plan`'s `Plan mode off.` and
`/goal`'s `Goal cleared.` conform as written — recorded with the broader
name-ban as a rejected alternative. The web row assertions are scoped to the
row so unrelated page text reading `permission` cannot satisfy them.
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 effort field's existence check was unreachable — EFFORT_FIELD is
total over the two known families — and a coverage exemption was papering
over the branch, which the merged toolchain no longer honored. Taking the
narrowed family as a parameter makes the lookup total at the type level,
so the check and its exemption both disappear. The rendered output is
unchanged: the browser goldens replay byte-identical.
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.