Files
lefarcen ad09d38839 feat(chat): rebuild the chat panel against the design spec, and fix what real runs exposed (#7518)
* fix(chat): close remaining scroll and retry gaps

* fix(chat): preserve portrait artifact previews

* fix(chat): reconcile media task files after path changes

OPEND-2543 / OPEND-2544. A media task persisted its generation-time path, so
an agent move/rename left the chat card requesting a 404 path. Resolve the
task file against the project's currently registered files using a bounded
size+mtime identity witness; ambiguous matches fail closed rather than
guessing from filenames. Retry stays gated on run-terminal state so it cannot
race the agent's own provider fallback.

* fix(chat): stop scoring legacy question forms as successful runs

OPEND-2549. An agent emitting the legacy XML question-form shape failed JSON
parsing, and the run still terminated as a success with no artifact. Detection
now recognises both shapes at the daemon boundary so an unanswered form ends
the run as waiting, not done.

* feat(chat): align artifact cards and memory shell with the latest design

Tracks PR #7170 at 8015870095. Adds a --radius-2xlarge (16px) product token
instead of copying raw radii, applies it to the memory shell and summary so
expanded and collapsed keep one silhouette, and unifies artifact action
buttons at 28px with the top-right controls (OPEND-2560).

* fix(chat): register produced artifacts on the turn that made them

OPEND-2550. A finished run could leave the chat rail with no artifact card
because registration depended on a workspace read that had not landed yet.
Registration is now driven by the turn's own file operations, so the card
belongs to the turn that produced it.

* docs(chat-panel): record the PR #7170 design diff and artifact version design

The design-to-implementation audit against PR #7170 at 8015870095, the chat
artifact version-semantics design awaiting review, and the rolling issue
ledger. Plane attachment binaries stay out of git; the manifest records their
SHA-256 and where they came from.

* fix(chat): keep agent-written next steps on turns with no file output

OPEND-2497. The host gated next-step suggestions on the turn having produced a
file, so a turn that only answered in prose - add-to-chat follow-ups, a wording
change, a proofread - silently dropped suggestions the agent had already
decided were worth offering. Delivery evidence is now named per lane: the
default lane trusts the agent's own suggestions, while host-authored lanes
still require a deliverable to anchor to. Failed and canceled turns stay
withheld. Clicking a suggestion still only fills the composer draft.

* feat(media): publish batch coordinates for sequential image generation

OPEND-2195. The chat rail could only guess how many images a turn was making
by counting 'media generate' in the command string, so parallel tasks drew
separate one-cell rows instead of one row of N. Tasks now carry the batch they
belong to, their position in it, and its size, derived from overlapping
lifetimes on the same run and surface rather than a timing constant.
One-at-a-time generation overlaps nothing and reports a batch of one.

Also closes the OPEND-2543 gaps this exposed: a file another task resolves by
its own path is off-limits, a file more than one moved task can claim is
withdrawn from all of them, and kind must agree - so two tasks recording the
same size and mtime can no longer both take the same image. A rename landing
just after run-terminal is picked up by a bounded reconciliation on runs this
pane watched go live, instead of pinning the card to the pre-rename path.

* feat(chat): rebuild the upgrade card against the latest design

Tracks PR #7170 at 8015870095. The CTA moves out of the balance header into a
bottom row beside the explanation, and the colour contract flips from green
ink on black to a green surface with dark ink. Low-balance and zero-balance
share one layout. Identity branching is unchanged - the card only draws the
exit and calls onUpgrade.

The error card takes the 16px radius through the chat seam with the product
token as fallback. The reconnect counter inherits its weight rather than
pinning 500: the design raised its own body weight in the same commit, so a
literal 500 here would put a heavier number inside a lighter sentence.

* fix(chat): treat an empty produced-file list as unknown, not as proof

A turn's producedFiles is a client-side diff of project file names taken
before and after the run, not a daemon verdict. It is empty whenever the turn
edited a file that already existed, whenever the baseline could not be read,
and whenever a settled file-list read wins the race with file-change
invalidation - the last of which is OPEND-2550's own scenario. Treating empty
as 'this turn produced nothing' therefore recreated the missing card it was
meant to fix. Whether a turn has an artifact is now decided by its own
write/edit tool rows; the produced list only contributes when it is non-empty.

Also covers what was already implemented but untested: the toolbar Share and
Export panels close on a second click (OPEND-2552), and artifact previews keep
their aspect ratio across image, video, HTML, doc and pending forms without
leaking onto execution-record thumbnails (OPEND-2547). Two assertions that
passed vacuously - comparing two heights that both computed to auto, and
reading a fit attribute without its computed style - now pin real values.
Next-step suggestions take the design's medium weight (OPEND-2558, partial).

* fix(daemon): emit each Claude tool call once, from whichever frame arrives first

The stream had two paths that could emit a tool_use - the incremental
content_block_stop and the assistant wrapper frame - but the guard set was
named for the incremental one and only written by it, while the wrapper read
it as if it meant 'already emitted'. Every tool in a recorded session came
through twice.

Adding a check to the wrapper alone would have been wrong in both directions:
the set is never written by that path so the check could not fire, and a
canceled turn where only the incremental path arrives would have lost its one
emission. The set now means what its readers assume - emitted by any path -
and both paths check and write it, neither waiting for the other, so a turn
that produces only one of the two frames still emits exactly once.

Flipping the order exposed a second defect the old sequence had hidden: the
wrapper frame often carries an empty input object, which would now overwrite
the real command. The wrapper prefers the delta-assembled input when that
block is still open and parses, and falls back to its own when truncated.

* fix(chat): make every composer send path agree on what it will send

OPEND-2551. The Send button asked whether the composer had a payload, which
counted quotes, while Enter asked whether the composed prompt was empty, and
quotes fold into that prompt - so the two hands disagreed and the disabled
button could still send. Both now read one canSend.

The deeper half: four send paths existed and only one folded the quote prefix,
so sending an annotation from the Mark panel cleared the chip and delivered
nothing to the agent. All four now compose the outgoing prompt the same way.

Also maps the design's user-bubble ground and muted action tier to chat-scoped
tokens rather than literals, splits the queue grip and action icon sizes that
an override layer had merged, and hides the default design router chip while
leaving the underlying plugin binding intact (OPEND-2412).

* feat(chat): give question forms a real color and amount control

Tracks PR #7170 at 8015870095. The color question was a bare native picker and
the amount question a read-only slider. Presets, the system picker and a typed
hex now drive one answer, an unparseable hex disables Next, and the number
above the slider is editable and clamped to the contract's min/max/step.
Canonical hex is six lowercase digits at one site in the parser, matching what
the native input sanitizes to, so the controlled value cannot fight the DOM.
Alpha and three-digit shorthand stay rejected rather than widening the
protocol; a value that cannot be normalized renders as written, never rewritten.

The selected counter splits at its existing translation's count slot, so each
locale keeps its own word order rather than concatenating fragments.
Also fixes option punctuation colliding with the card border (OPEND-2402) and
the cramped option row (OPEND-2401).

* fix(chat): let option descriptions wrap and give confirmed answers a surface

An option row is a button, so it inherited the global button rule that forbids
wrapping - the description then overflowed the card and was clipped. The
earlier min-width and overflow-wrap were correct but idle: when wrapping is
forbidden outright, neither is ever consulted. The option row's existing
button reset now zeroes white-space alongside the other global defaults it
already undoes; the global primitive keeps nowrap, which is right for genuine
single-line buttons.

The confirmed-answer block had no padding, ground or radius at all, so it read
as bare text rather than the design's panel. All three land through the 16px
token, and an answer carrying a thumbnail drops to the 12px tier - keyed on the
block actually having one, since a catalog card without a preview collapses to
plain text like any other answer.

* feat(chat): drop the thinking scroll window and collapse finished runs

Product ruled against the design here: reasoning now reads like ordinary body
text - natural height, no fixed viewport, no auto-scroll, no fade mask. The
mask was what made it hard to read. The gray container and the blur-in reveal
stay; the scroll lifecycle is deleted rather than left dormant. The indent
moves onto the container so text does not shift when a stream ends.

OPEND-2557: a finished run stayed open because details' toggle event cannot
say who opened it - React writing the controlled value back fires one too, and
that echo latched 'the user chose this' on the frame the shell first opened,
disabling the collapse for the rest of the run. An echo always reports the
value we already hold; a real click always reports the opposite.

OPEND-2195: media cells are placed by their batch coordinate, so parallel
generation draws one row of N instead of N rows of one. Placement is
all-or-nothing - a batch missing or colliding on an index falls back to
arrival order rather than silently displacing half its cells.

Three failing tests were outdated rather than regressions: the completion word
is deliberately withheld until a run settles, every turn now states its own
terminal outcome since the pinned todo card was removed, and the icon slot
moved one level down. Each keeps its original intent and gained coverage.

* fix(chat): let jump-to-latest and the plan pill trade places by scroll position

The two share one floating slot, but the plan pill won unconditionally - and
it is present for the whole of any planned run, so scrolling up during one
left no way back to the bottom. Scroll position now decides which occupies the
slot: scrolled up shows jump-to-latest, near the bottom shows the plan pill.
The bottom padding stays keyed on whether the turn has a plan at all, not on
what is currently shown - removing it mid-scroll would shrink the scroll
height, shorten the distance from bottom, and oscillate.

OPEND-2546: re-quoting the same passage now says so. The decision runs in a
synchronous mirror rather than inside the state updater, which StrictMode
invokes twice; the toast key is a counter, since two clicks inside one
millisecond share a timestamp and React would keep the first dismiss timer.

The steer button carries a visible label, which is what the design gives it
and the only thing that separates it from send-now - the two never render
together, so a different glyph on a button seen alone would say nothing. They
are genuinely different actions: steer writes into the live run's stdin,
send-now stops and restarts it.

* fix(chat): anchor the quote bar to the visible selection, above it

Two defects stacked. The bar flipped the wrong way by default: the design puts
it above the selection and drops it below only when the selection is against
the panel's top edge, and the repo's own mirror gallery already renders it that
way - the runtime disagreed with its own gallery page.

The distance came from Range.getBoundingClientRect(), which is the union of
every client rect including the zero-width caret at the selection's end. A drag
that overshoots by a hair puts that caret on the next block's first line -
nothing looks highlighted, but the union's bottom edge has already moved down
there. Anchoring now uses the first and last rects that actually have area,
and centres on the one it is touching rather than on the whole span.

Together that is why a selection in a dense execution list threw the bar
hundreds of pixels down onto the composer, while ordinary prose was only off
by one line height and read as a spacing nit.

* fix(chat): put the whole turn footer on one muted ink tier

The design gives that row a single rule; we had split it into feedback and copy
classes, so muting only the feedback half would have left two greys side by
side. Both now consume the same token the timestamp already uses, and hover
changes the ground rather than the ink - it previously jumped to near-black.
Selected states keep their semantic green and red; the guard that proves it is
order-sensitive for the positive rating, and the constraint is written next to
the rules that depend on it.

The support dialog takes the design's 16px radius through the chat seam, and
its quoted excerpt is corrected - a stale quote of the source is worse than
none.

* fix(daemon): carry the original request when a headless continue cannot resume

od run continue sends only the continue instruction, on the assumption that the
session holds the request it refers to. The daemon may refuse to resume for
reasons the caller cannot see - a changed model, a changed cwd, a moved cursor -
and then that instruction lands in a blank session that never saw the request.
Callers now declare that their message is a continuation, and the daemon seeds
the original request from the rejected session's own cursor when it starts
fresh. The field is optional and the behavior only fires when set, so existing
callers are byte-identical.

The web client already handles both branches: it sends the full transcript and
the latest turn separately, and the daemon picks by whether it resumed. It is
deliberately left alone.

* feat(chat): follow a live thinking stream, and stop the rail cutting the mark

Thinking sticks to the bottom while it streams, parks when the reader scrolls
up, and re-arms when they return to the bottom or reopen the drawer. It reuses
the chat log's own stick-to-bottom module rather than growing a second set of
predicates: intent changes only on scroll, so content growth cannot masquerade
as a gesture, and the geometry baseline refreshes on every resize settle even
while parked - otherwise a parked reader could never get back. The follow is
one write to scrollTop; the slow stepped scroll and the fade mask stay gone,
and the height cap is max-height, so short reasoning is not padded out.

OPEND-2417: the rail started at a hard-coded 25px while the status mark is
centred in its row, so the mark's bottom moved with row height and crossed the
rail the moment a title wrapped to two lines - and this PR's 12px to 13px title
bump had already eaten most of the single-line clearance. The mark now sits at
a fixed offset like the plan card's, and the rail derives its start from the
row's own padding, slot and gap. Single-line rows land on 25px exactly as
before; the difference is that they no longer drift when an input changes.

* test(chat): measure next-step weight through the real cascade

The previous test injected only the component's own module, so it measured a
product that does not exist: the global button rule already sets weight 500,
and the declaration added to 'fix' it changed nothing. The test now injects the
global reset first, in the order the app does, and pins the declaration's actual
job - holding the design's value on the component itself, so it survives the day
that legacy global button styling is cleaned up.

* fix(chat): let a long next-step suggestion wrap instead of being cut off

The suggestion is the sentence that will be sent; a reader who cannot see all
of it cannot decide whether to click it. Two things were cutting it: the text
layer asked for a single line, and the global button rule leaked nowrap, a
fixed 36px height, a line-height of 1 and centred alignment into the row - the
design's own button reset carries none of those. Both are undone at the
component, leaving the global primitive alone, where nowrap is right for a
genuine single-line button.

The arrow now aligns to the first line rather than floating to the middle of a
wrapped row, following the queue row's written precedent for the same shape. A
single-line row still measures 36px, so nothing moves until a suggestion is
actually long enough to wrap.

* feat(chat): let a long single-choice list collapse behind more options

A select with more than a handful of options now renders as a menu: common
entries first, the rest behind a host-owned 'more options' toggle, capped at a
scrolling height. The trigger is the option count or the presence of the new
grouping fields, not the type alone - a legacy two-option select keeps the flat
list it has today, and the one real product select (a hundred voices) gets the
menu without the model changing anything. The toggle's own copy belongs to the
host and is generic; a model's group labels still render as headings inside.

The authoring rules now cap options at six or seven, ask for plain language
over jargon, and put a length target on an option label with long text moving
to its description - the missing constraint behind suggestions that had to be
truncated. They live in the general form layer, so a mid-conversation form is
covered rather than only the opening brief.

Those rules and the type list exist in six hand-maintained copies across the
daemon, contracts and a bundled skill, with no shared constant; a type could be
dropped from any one of them and every test stayed green. A parity spec now
pins all six against the renderer's own union rather than against each other,
which six copies going stale together would satisfy.

* feat(chat): show a tool call the moment it starts, not when it returns

An in-flight call rendered nothing at all, so a run that spent fourteen minutes
inside one command showed the previously finished rows and a ticking total and
nothing else - which is what 'it ran forty minutes and produced nothing' was
describing. Everything derivable from the call itself now appears immediately;
only what genuinely needs the result waits for it. The shell's span also counts
from the in-flight call, so a long stall stops reading as no progress.

The row keeps its identity across both phases, so a result swaps the state of
the row that is already there rather than adding a second one. Pending means
the call has not returned, which is not the same as still running: a cancelled
turn leaves rows pending, and they settle to a neutral mark rather than
spinning forever or claiming a success or a failure that never happened. The
elapsed slot is present but empty while pending, which is what keeps the
chevron from shifting when the value lands.

* feat(chat): give the panel the design's typographic baseline

Every component measured byte-identical to the design and still read wrong,
because the panel inherited the app's baseline rather than the draft's: weight
400 against 500 and 14px against 13px. Line height was already right. Both now
land on the chat seam, so the panel matches the draft while the sidebar,
settings and home keep the baseline they were designed against - the draft only
ever covered this panel.

The mono face moves with it. The design relabels the same font file from 400 to
500, which is bookkeeping rather than a new weight: with one static face the
glyphs are identical either way, and the label simply has to agree with the
baseline that requests it. Doing one without the other would leave every
elapsed time, path and hex asking for a weight the face does not claim.

* fix(prompts): tighten the new form rules and raise the charter ceiling

The five rules landed as explanatory prose and pushed the slim charter past
its byte ceiling, which is paid on every turn of every run. Rewritten as short
imperatives they cost 644 bytes instead of 1,315.

That still does not fit: the charter sat 44 bytes below the ceiling, so no
amount of compression would have. The rules cannot move somewhere cheaper
either - no conditional section can predict that a turn will emit a form, and
the on-demand atom that carries them for OD Next is not reachable from a
skill-less slim run. The ceiling moves, documented beside the constant in the
format the previous raise used, to the next step rather than to just fit: 44
bytes of slack is what made the next sentence hit a wall.

* fix(chat): stop our own scrolling from reading as the reader's

Positioning a question form animated the log upward, and a frame of that
animation looked exactly like someone scrolling up, so following stopped. On a
quiet turn it healed itself - the animation finished at the bottom and re-armed
- which is why it survived. While the model is still streaming it does not:
the destination was fixed when the animation began and does not follow the
growing content, so it lands somewhere that is no longer the bottom and the
escape stands. Nobody touched anything.

Positioning is now instant, which collapses the window to nothing rather than
trying to recognise and ignore our own frames inside it. The two near-identical
copies of this logic - one already fixed in an earlier pass, one not - are one
function now, so they cannot disagree again.

Recovery had the mirror problem: it demanded the layout hold perfectly still,
and a stream never does, so a reader scrolling all the way back to the bottom
had their gesture thrown away on the frames where content grew. Reaching the
bottom while the bottom is moving away can only be a real gesture; reaching it
because the content shrank underneath still is not, and stays refused.

* fix(chat): stop a cancelled turn spinning forever, and give the stream its weight

An image cell kept spinning after its turn was cancelled: the row knew the
call had not come back but not whether anything was still working on it. It
now takes the same turn-state gate the tool rows use, so tool and image rows
share one vocabulary end to end - same field, same mark, same question asked
of the turn.

The thinking stream's body weight had to be written against the stream box
rather than the paragraph class: the text inside belongs to a different CSS
module, so the obvious selector would have matched nothing. The test proves
the two class names differ rather than assuming it, and pins the specificity
margin so a later simplification back onto the paragraph class fails loudly
instead of depending on bundle order.

Three fixtures were typed strictly enough to catch the new required field, and
two more were asserting a shape they did not have - one carried a property that
does not exist on the type at all, hidden behind a cast that is now gone.

* feat(web): measure the chat panel's own runtime health

Nothing measured the panel as a thing: long tasks fired without a conversation
or run attached, boot timing covered the page rather than opening a
conversation, and a panel that mounts and then renders nothing was invisible.
Seven events now cover first paint, DOM growth, memory pressure, stream health,
interaction latency, protocol anomalies and recovery.

They are built to be findable, not just countable. Every event carries the run
id - which is simultaneously the PostHog join key, the Langfuse trace id and the
diagnostics-bundle handle - plus the conversation, project, agent, model and
channel. A pressure event carries the heap trend and the breadcrumbs leading up
to it rather than only the moment it broke. Timing events declare whether the
measurement can be trusted, so a reading taken before stylesheets landed is
excluded rather than reported as a regression.

They are also built not to become the problem they measure: interaction latency
filters in the browser, a run with no jank emits nothing, and anomalies dedupe
per run so a render-time detection cannot end up measuring typing speed.

* docs(chat-panel): record the component gap audit, scroll research and dispatch log

The 24-component / 89-state audit against PR #7170, the streaming stick-to-
bottom research that recommends extracting our own machine rather than adopting
a library, and the rolling dispatch ledger with the product rulings that
override the design draft. Attachment binaries stay out of git; the manifest
records their SHA-256 and where each came from.

* fix(chat): show the retry button on a failed message, and animate the audio waveform

A message that failed to send offered no way to retry: the action row hides
itself with opacity, and opacity does not just fail to inherit - it composites,
so a child cannot opt back in. The design hides the individual controls and
excludes the retry, which is what this now does, along with the pointer-events
the same rule carries: the invisible copy button used to be clickable.

The audio waveform never moved. The keyframes it referenced did not exist and
the per-bar offset was never written, so a stalled render was the only possible
outcome. Bars now stagger off their own index, honour reduced motion, and the
play control sits inside the row as the design nests it rather than beside it.

Plan steps take the design's explicit 400. They inherited 400 before the panel
baseline moved to 500, so they were accidentally right; afterwards a
not-yet-current step would have sat one notch below the current one instead of
two, flattening the card's hierarchy.

* feat(chat): route artifact cards by the version each kind should show

An HTML card's face is the screenshot taken on the turn that made it, while
clicking it opens whatever the workspace holds now. The two deliberately
disagree - the card is a record of that turn, the click is a door to the
current file - and the comments say so, so nobody reconciles them later. An
image card is the opposite: face, click and export all read that turn's own
bytes, which is what stops a later overwrite from rewriting history.

With no snapshot - an old conversation, a capture that failed, no renderer, a
full quota - HTML shows the live latest and an image shows the current file.
Neither says anything about it: a card that announces its own failure is worse
than a card that quietly shows the best thing available.

A snapshot URL is only handed out once the snapshot is ready. A half-written
one renders as a broken image, which is worse than the fallback it replaced.

* feat(desktop): capture a first-viewport thumbnail without growing to the page

The existing image export measures the document and grows the window to it, up
to twenty thousand pixels, which is the long strip it is meant to produce and
exactly wrong for a card cover. A capture mode now says which of the two is
wanted: the thumbnail path fixes the viewport, never issues the height probe,
freezes animation and transitions, tells the page motion is unwelcome before
running a script that finishes what is already animating, and captures the
first screen only. The export path is byte-identical - the branch happens
before the code it used to share.

Resource waiting learned to stop at the first viewport, because a cover that
waits for assets below the fold pays for page length it will never show.

The renderer reports why a capture failed rather than returning a blank or
fabricated image, so a card that cannot get a cover knows to show the live
file instead of a broken one.

* feat(daemon): keep an immutable copy of what each turn produced

A chat card read the workspace file by path, so overwriting an image rewrote
every earlier card that showed it. A turn's output is now copied into a
content-addressed store the moment it exists and referenced by digest, which
separates three things that used to be one: where the file lives now, what it
contains now, and what this turn produced.

Media capture takes the provider's own buffer before the file is written, so
exactness is structural rather than a re-read that could race. The general path
copies at the run's terminal boundary and stats either side; drift fails the
snapshot rather than installing the newer bytes, because a snapshot of the
wrong version is worse than no snapshot. Where both paths cover one file the
media bytes win, scoped to the run so an earlier turn's copy can never stand in
for this one's.

Recovery is deterministic because byte-sourced intents record their digest up
front: an interrupted capture either completes from what is on disk or is
marked interrupted, and a path-sourced one only completes while the source
still matches. Sweeping is off until enabled; identity survives a rename and a
tombstone releases the path without resurrecting the old row.

* fix(chat): pick the main artifacts when a turn declares nothing

Declaring was meant to be the only way a card appeared, but a turn that edits
without creating declares only a quarter of the time - the instruction asks for
'a file you created this turn', and a small edit does not read as creating
anything. So most edit turns showed nothing, and the earlier fix for that
showed everything instead: a page, its script, its stylesheet and its images
all became cards, which is the pile the marker existed to prevent.

Silence now gets an answer rather than a blank or a pile. Pages and documents
win; if a turn wrote none, its images stand on their own; stylesheets, scripts,
icons and data files never do. An unrecognised extension counts as a
deliverable, because the wrong way to fail is to quietly hide what someone just
made.

The instruction now says that changing a file is delivering it, and that
declaring narrows rather than being the only door. Five places claimed
otherwise, two of them tests that had pinned the old sentence in place.

* fix(chat): put the tool rows and step interludes on the muted tier

A tool row read as dark as the step title above it, so the record had no depth:
the thing that happened and the thing that describes it weighed the same. The
row, its elapsed time and its icon now share the muted ink the design gives
them, and a row that failed keeps its own colour.

Interludes between steps were meant to be smaller and lighter than the opening
line, and were neither. The rule excluded any turn that had a plan - which is
most of them - while the thing it was written to protect, the opening line, is
already excluded by being the first child. Removing that exclusion is what
makes the summaries between steps read as summaries.

Thinking now sits on the same surface whether it is still going or already
done; it used to gain a background while streaming and lose it once expanded,
so the same passage changed shape when it finished. The class that was supposed
to carry that surface had never existed, so the rule attached to it had never
run - and a CSS Module proxy hands back a class name for any key, which is why
no unit test could see it.

* fix(chat): hang the chain on steps only, and align surfaces to their own column

The vertical rail and its indent belong to things on the chain - the steps of a
plan and the summaries between them. Thinking, tool rows and prose at the top
level were getting both, so a passage that had nothing to do with any step
still read as hanging off one. A step now says so itself, which means a block
type added later stays off the chain until someone puts it there, rather than
joining by accident.

The grey surfaces were positioned to put their text on the column, which left
the box itself jutting eight pixels past the rows around it - and since the
offset came from nesting depth rather than from where the sibling rows sit, two
surfaces at different depths missed their neighbours by different amounts. The
box now lands on the column and the text keeps the padding the design gives it.

A failed command row says it is failed rather than being inferred from having a
reason attached, which is also what lets the failure colour be written the way
the design writes it.

* fix(chat): let the next-step button size itself the way the design does

The button carried a minimum width and a horizontal padding that appear nowhere
in the design, and the round that added them wrote both into a test, so the
invented numbers were guarding themselves. The padding was also the wrong tier:
the design's footer buttons are all small, and the small tier overrides the base
padding it was copied from - which is why the button read as oversized rather
than merely wide.

Only the height stays, because that is the one number the design specifies
there, and it explains why: a height written down does not drift when the font
size moves, while one grown from padding does. That drift is exactly what
happened when the panel baseline went to 13px.

The shared button already supplies the padding, size and weight the design
gives this tier, which the test now proves by removing that stylesheet and
watching the assertions fail.

* fix(daemon): stop teaching the model a protocol we retired

The critique grammar kept appearing in answers, and four rounds of stripping it
out never held, because the feature had two entrances and retiring it closed
only one. The protocol itself stopped being injected; the atom carrying its
prose stayed in every design run's stage list, telling the model to follow a
tagged protocol that was no longer there - so the model wrote the tags from the
description. None of the attribute names match the real protocol; every one of
them traces to a sentence in that document.

The body now travels with the protocol: injected together or not at all, from
the decision the server already made rather than a second copy of the
predicate, since a second copy is a third entrance. The document itself
describes what to check rather than what to emit, and no longer forbids prose
outside an envelope - which is also why a turn could finish with no closing
summary at all.

Stripping is fixed where it failed and extended where it was absent: a held
fragment no longer gives up the moment a tag carries an attribute, thinking is
stripped before it is persisted rather than only on its way to the screen, and
the client covers the execution record and not just the prose beside it.

A spec now asserts that the assembled prompt contains none of this grammar, so
a future path that reintroduces it fails rather than reaching a user first.

* fix(chat): stop the feedback panel yanking the view to the top

Rating a turn scrolled the page even when the reason panel was already fully
on screen: it asked for the panel's top edge to meet the viewport's top,
unconditionally, so a panel sitting comfortably in the middle still dragged
everything the reader was looking at. Asking for the nearest position instead
leaves a visible panel exactly where it is and, when one really is off screen,
moves the least amount that reveals it - the browser's own primitive rather
than a hand-rolled visibility check.

It was also the one programmatic scroll on the chat log still animating, which
is the invariant the question-form fix wrote down: a scroll we start ourselves
is instant, and anything that wants animation releases follow first.

* fix(chat): keep the quote bar with the start of a long selection

Dropping below the selection exists to get out of its way, which works for a
phrase and stops working for a selection that fills the screen: the bar got out
of the way and landed most of a screen from anything the reader was looking at,
on top of the artifact preview. A selection taller than half the panel now
keeps the bar at its start when it has to flip, which is where the design
anchors it in the first place - flipping below is a correction for having no
room above, not a change of reference point.

Short selections still clear the whole passage, including a two-line one
pressed against the top edge, which is the case the new rule must not capture.

* test(chat): pin the thoughts icon against four ways it can silently degrade

The icon is already the one the design hands over, byte for byte, so this
round has no implementation to change - which is exactly the case where a spec
earns its place. A name that stops resolving does not throw: it falls through
to a stroke fallback with no case for this one and renders nothing, leaving a
blank where the icon was. Asserting the name would not catch that, so the spec
asserts the rendered path, entire rather than by fragment, since the filled and
outlined variants agree on precisely the strokes a fragment would match.

It also pins that the in-progress state keeps its spinning orb, so a later
attempt to make the two states consistent replaces motion with a static glyph
loudly instead of quietly.

* fix(daemon): stop an unfinished marker from swallowing the answer after it

An upstream reconnect cut a next-step marker mid-attribute, and the scanner
tracking quotes paired that unclosed quote with the next tag's, so it never
found an end: every closing bracket after it counted as text inside a string.
The buffer grew past its limit and the overflow branch handed the whole thing
to the screen, and to the database, where a refresh could not remove it.

A value cannot contain a newline or a raw angle bracket, so either one proves
the quote never closed. Seeing one now retires just the broken tag and keeps
reading, rather than waiting for an end that cannot come; overflow discards
rather than releasing. A held fragment at end of stream is discarded too, the
way the artifact marker already did.

A reconnect is also no longer dropped on its way to storage. It is a real
upstream event that happened once, not the polling noise the rest of that list
suppresses - and without it a conversation that was interrupted and rewritten
reads, on reopening, as though a second answer appeared from nowhere.

The qoder stream decodes with a per-handler decoder instead of converting each
chunk alone, so a character split across chunks survives.

* fix(chat): give the thoughts drawer one box instead of two

Hovering the thoughts header lit a background that started seven pixels left of
the passage underneath it - twenty-nine when nested - because the header row
stretched its hover target to the shell edges while the body was pulled back to
the column. Both were right on their own and wrong together.

The column now belongs to the drawer, and the header inside it carries none:
the same arithmetic reaches the same place, so nothing about the title or its
icon moves, and the two backgrounds share an edge because they share a box.
Which is what the thing is - a panel with a lid, not a row that happens to be
followed by a panel. The row rule that stretches hover targets is untouched;
removing it turns eight assertions red, which is the point of keeping it.

* fix(chat): say a generation failed while it still might be retried by the agent

A cell that failed mid-run already could not be clicked - the host withholds
the callback until the turn settles, so the agent's own provider retry cannot
race a manual one. But it still read 'Retry' next to a retry icon, so the only
thing the reader learned was that the button was broken. It now says what
actually happened, as a statement rather than a dead control, and becomes a
real button once the turn is over.

Over includes cancelled and failed: what the gate protects against is a second
retry running beside the agent's own, and once the turn stops there is nothing
to race. Wanting one image after abandoning the batch is a normal next step.

The wording reuses the label the tool rows already carry in every locale rather
than storing the same word a second time.

* fix(web): distinguish auto-expanded folds from user-opened ones

`defaultOpen` is only read on the mounting frame, so a fold that opens
because its subject is running has no way back: when the subject finishes
and the key has not changed, the same instance keeps the same `selfOpen`
and stays sprawled open.

`lifecycleOpen` follows the subject's lifecycle on every change instead of
just the first — until the user touches it, after which the fold is theirs
and neither direction is forced back.

Telling a real click from React's own write-back is the whole problem:
`<details>` fires `toggle` for both. The predicate compares values, not
occurrence — an echo reports the state we already hold, a click reports the
opposite — and reads it through a ref, because the memoised handler's
closure would still hold the first frame's value by the time a queued echo
arrives. Same predicate ExecutionShell already uses, verbatim.

Opt-in: every caller that does not pass `lifecycleOpen` keeps its exact
current behaviour, and controlled folds never reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* style(web): drop the step rail and restore the hover overhang

The designer retired the grey vertical line that chained the steps
together, so both `::before` pseudo-elements go, along with the five
variables that existed only to place them and the two `position: relative`
they were positioned against. Nothing is kept "in case the line comes
back" — re-deriving it from a fresh measurement beats reading stale
constants.

The status dot's own guard survives the line that used to clear it: it was
never about the rail, so its test moves to a file named after what it
actually pins. The offset stays 1.5px even though the step title has since
grown to 13px, which puts single-line centring 0.75px away; the assertion
pins "under a pixel of drift" rather than pretending the two agree.

The hover background reads slightly larger than the panel below it because
the design gives them different insets, not because anything is added: the
row bleeds 7px to each side and the summary pads 7px back, while the body
panel sits flush at 8px. An earlier attempt zeroed the summary's leading
inset to line the two up, which made them one rectangle and read as the
panel itself flashing. Text lands exactly where it did.

Indentation of the between-step notes keeps its 22px for now — the design
justifies it twice, once independently and once by the rail — and the
second half of that rationale is gone. Flagged for a ruling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* feat(web): show elapsed time on rows that are still running

A row that has been running for twenty-eight minutes showed nothing at all
until it finished, which is exactly backwards: the number matters most
while the user is waiting for it. The design leaves the slot open for this
— `ToolRow` needed no code change, only data.

One live end-of-span for the whole turn, `turnIsLive ? nowMs : endedAtMs`,
the same sentence the shell header already uses. Steps, thinking and tool
rows all read it, so none of them can settle to a different endpoint than
the one they were counting toward: a step that reported "1m 2s" while
running would otherwise collapse to "2.0s" the moment the turn stopped,
because the settled path counts to the last timestamped event rather than
to now.

The live value is computed after span bookkeeping, never into it, or the
silence-detection span would be identically zero.

No new timer: every number is `nowMs - startedAt` off the one interval that
already drives the shell header, and a test pins that rendering four live
rows calls `setInterval` no more often than rendering one.

Rows whose start is unknown still print nothing rather than inventing a
zero.

Known gap: a tool row can tick back by one frame when its result arrives
stamped earlier than the last tick. Strict monotonicity needs a per-row
high-water mark, which means component state; the test pins the magnitude
instead so the drift cannot grow silently.

Also lands the one line the fold-lifecycle change was waiting on: a running
todo drawer now follows its subject's lifecycle instead of only its first
frame, so it stops staying sprawled open after the step finishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* docs(chat): pin where the failure card contradicts the daemon

A colleague hit a TLS certificate failure and got a card offering Retry as
its primary action. The daemon had already classified it correctly and said
on the wire, three separate ways, not to retry. The web side reads none of
those three and re-derives the answer itself.

The survey is exhaustive rather than sampled — all 67 failure reasons run
through the real resolver — because the question product has to answer is
not "what about certificates" but "how far apart are the two verdicts".
Forty cells offer Retry against an explicit not-retryable; thirty-two of
those are reachable under an opaque error code. Six more are not missing
rows but opposite conclusions, which is the cheaper half to fix: no new
copy is needed to stop saying the wrong thing.

The blocker is structural. Nine primary actions exist and none of them is
"go fix your environment", while the ladder's own comment claims to cover
that rung. The error catalog already ruled on the shape twice; only the
button has nowhere to land.

The daemon-side test locks the classification that is already correct, so
the gap cannot later be misread as a backend bug. The web-side red test
stays out of the tree until the ruling lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* docs(run-errors): bring the error-UX design back into the repo

The design that settles the failure-card questions has been living only in
Feishu. The implementation audit's header says the source file sits on this
branch, but git has never held it — the audit has been pointing at a file
that does not exist for a week.

That matters more than usual right now: today's survey of the failure ladder
rediscovered a gap this design had already ruled on. The environment class
was never an open question — S30 names the copy, the slots it interpolates,
and the button order. The audit had already recorded that neither of the two
actions those scenarios need exists in the codebase.

Feishu stays authoritative; this copy is for reading alongside the code and
for grep. The screenshots are short-lived signed links, so only their
descriptions survive here.

One line in S30 is now known to be stale: the daemon does classify
certificate failures, and has since the detail was introduced. The web side
never reads the answer. Left as written rather than edited silently, since
the doc is under review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): pin every newly sent message to the top, not just typed ones

Two independent defects were splitting the behaviour in half, which is why
it looked intermittent.

Whether to pin was something each send site had to declare by raising a
flag, and that flag was raised in exactly one place: the composer's own
callback. Seven of the eleven entrances that put a new user message on
screen never went through it — question-form answers, the home page's first
send, annotations, anything the queue released, resume, and image retry.
Pinning now follows from the transcript itself: the trailing user message
changed identity, so a new turn is on screen, regardless of which button
produced it. New entrances are covered by construction, which matters
because no one has ever remembered to add the line.

The other half is the reason it varied run to run. The pin scrolled
smoothly, and nothing distinguishes our own animation from a user dragging
the scrollbar — position is all either one reports. Mid-animation frames
sit far outside the release tolerance, so the first one dropped the pin and
froze the tail spacer; the final frame lands exactly at the bottom while
the reply is still empty, which re-arms stick-to-bottom and drags the
message back off the top. A reply that starts quickly grows the log past
that point and the pin survives; a slow one does not. Same code, opposite
outcome.

Self-initiated scrolls are already required to be instant — the
question-form anchor was moved off smooth for this exact reason — and this
was the last one still animating.

Visible change: the pin now lands on the frame instead of gliding, losing
roughly 300ms of animation. That animation was the defect.

Also updates the feedback-panel assertion left behind by c121d81b17, which
changed that scroll to nearest/auto without carrying its test along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): anchor the quote bar to highlighted text, not to a swallowed box

The bar was not being clamped to the bottom of the panel — it was faithfully
anchoring to a box that had no highlight in it at all.

Selection geometry came from the whole Range's client rects, and per CSSOM
that list contains the border box of every element the Range fully encloses,
mixed in with the text line boxes. Dragging a selection a hair past the end
of a bubble — invisible on screen, since what follows is blank — swallows the
tail spacer: full width, real height, not one character. The earlier fix
asked only whether a rect had area, so that box passed as the selection's
last line, and the bar chased it down to the composer and centred itself on
the panel.

Painted text is what "the visible selection" means: no text, no highlight, no
anchor. Both edges are found by walking a short way in from each end of the
Range rather than over the common ancestor, which grows to the whole log
during a cross-message drag and would re-measure every text node per frame.

The bar also has to leave the pane. It is positioned in viewport coordinates
and declares `position: fixed`, but an ancestor carrying backdrop-filter
becomes the containing block for fixed descendants — so those coordinates
were being read as pane-relative, shifting the bar down by the pane's top
edge and letting the pane's overflow clip it. The composer is portalled out
for exactly this reason and that rule says so in its own comment.

Adjacent, deliberately left alone: a quote taken from the user's own message
carries a null message id, because the lookup reads the attribute the
assistant bubbles use. Its own red test, its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): stop disabled options reacting, and say when a question was skipped

Two defects in the same component.

An option that cannot be chosen because the limit is reached was picking up
the global `button:disabled` fill, which outranks the chip's own transparent
background, and then `.qf-chip:hover` outranked that in turn — so the row
carried a permanent grey pill AND still lit up under the cursor. Disabling a
button stops clicks, never styling. The design draws no disabled state for
options at all: one background, transparent. The repo had already fought
this exact leak for the visual-direction cards and won it back with a second
class; the option row never got the same treatment.

Skipping was reported as "Answers sent". Skip is not a separate path — it is
a submission whose skipped questions carry no value, and the text the model
receives spells out `(skipped)` for each one, so nothing is being hidden from
the agent. The summary was dropping valueless rows entirely, which erases
what the user chose to skip and, when every question is skipped, leaves
nothing at all — at which point the render falls back to a line that is false
by construction, since its condition is "no answers exist".

The summary now reads back what was sent. Three cases, not two: a value is
read out, a submitted-but-empty question says Skipped, and a question absent
from the submission stays silent rather than announcing a decision the user
never made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* docs(chat): record the ruling that the quote bar stays on screen

The half-a-panel threshold it replaces was guessing whether the selection
was too big to dodge. The question that actually decides where the bar goes
is which part of the selection is on screen, and that is measurable.

The threshold was also reading contaminated input: until this morning a
selection could swallow a full-width spacer sitting at the bottom of the
log, which made every such selection taller than half the panel by
construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* test(chat): retire two assertions the D3 reversal left behind

Both reds outlived their premise; neither implementation was wrong.

The image row's running mark was deliberately put behind the same turn-state
gate the tool rows use, so that a cancelled turn stops spinning. That change
updated the tool-row fixtures in the shared primitives file and missed the
image-row one — an omission, not a contract. The fixture now says whether the
turn is alive, and a counter-case pins that staying silent gets the neutral
mark, so the gate cannot quietly go back to spinning unconditionally.

The thinking-inflation case was still written for "a call that has not come
back does not get a row". Product reversed that on 2026-09-02: a call renders
the moment it goes out. Grouping then follows the rule the module already
states — a tool row between two stretches of reasoning makes them two
stretches, so two cells. The old case also called 10s an undercount, which
only held while both halves shared one cell; once split, the second cell owns
that gap alone and 10s is the true figure, now asserted positively so the
file cannot go green by handing out no numbers at all.

The docblock that sent three agents hunting in the wrong place said a call
still in flight does not get a row. It says the opposite now. D3 and B8 in
the spec were still stating the retired rule too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* style(web): win the option row's colours back from the global primitive

The same `button:disabled` declaration that was repainting the option's
background also greys its title, while the description underneath keeps its
own colour — so the title read fainter than the text it was meant to head,
with the opacity on top of that. It reaches a second victim as well: a
chosen option in an already-submitted form is styled by a rule of equal
weight and loses the same way, so answers the user actually picked were
being drawn as if unavailable.

Hovering a chosen option also flashed the checkmark's border, because the
design wins that with two ancestors we dropped when porting; without them
the rule sits below hover instead of above it.

Every fix here restores a design selector rather than inventing weight.
Where the previous pass had reached for `:not(:disabled)` to lift hover
above the checkmark rule, that lift is undone: hover goes back to what the
design writes, and the disabled case takes its own selector — the same
shape the background pair already uses. The chosen state then sits strictly
above both, rather than tying and depending on source order, which is how
this class of leak got in the first time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* feat(chat): give proxy and certificate failures the card the design specifies

Five backend reasons — certificate, proxy, network, host policy, local
storage — had no row on the web side at all, so a failure the daemon had
named precisely arrived as "task failed" with a Retry that could not work.
The design settled this months ago (S30): name the fault, name the likely
cause, and lead with the action that can actually fix it.

The card now says what happened and offers Settings first, with Retry kept
beside it — deliberately, because upstream folds two different faults into
one message and one of them really is a blip.

The copy interpolates the cause rather than repeating a near-identical
sentence five times, so nineteen locales translate five short phrases
instead of five paragraphs. Nothing in it promises that configuring a CA
will help; a test holds that line, since upstream has versions where it
does not.

The unclassified fallback stated as fact that nothing had named the
failure. That was false for exactly the cases it was hurting. It now asks
the daemon: only when the daemon both named the reason and ruled retrying
useless does the card drop out of the retry rung; a genuinely unknown
failure keeps today's behaviour, because most of those are one-offs.

Asking whether the daemon named it has to come first, and is not decoration:
the classifier's own fallback stamps `unknown` with retryable already false,
so reading the verdict without that guard would strip Retry from precisely
the case that deserves it.

Two things this does not reach. The verdict fields are not yet carried on
the SSE end frame or the persisted error event, so the new branch is inert
until three call sites outside this change forward them. And
`local_storage_failure` is a local disk fault wearing a sentence about
corporate proxies; it is marked in the source as needing its own copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): anchor the quote bar to the part of the selection on screen

The rule it replaces was guessing: a selection taller than half the panel
was assumed too big to dodge, so the bar went back to the start. What
actually decides where the bar belongs is which part of the selection the
reader can see, and that is measurable — intersect each edge line with the
visible area and anchor to whichever edge survives.

A selection entirely on screen is untouched, down to the pixel. When the
first line has scrolled above the fold the bar stops reaching for it and
sits under the visible last line instead; when the last line is below the
fold it falls back to the visible start. With both ends off screen there is
no edge to hug, so it takes the top of the visible area and centres on the
panel — the horizontal coordinates of off-screen lines are meaningless, and
the top stays clear of the composer the reader is about to type into.

The invariant that the bar's rendered box always lands inside the visible
area was already true — edge clamping saw to that — and is now pinned across
forty geometries so a future placement cannot skip the clamp. What was
broken was the other half: being anywhere near the selection.

This reverses an earlier call that a tall but fully visible selection should
anchor to its start. That call came from a report of the bar flying most of
a screen away and landing on an artifact preview, but the cause was the bar
anchoring to a swallowed element box, fixed this morning. With the last line
now guaranteed to be painted text that is genuinely on screen, hugging it
cannot throw the bar off screen — and it is where the cursor just was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* style(web): follow the draft's chosen-option weight, and stop hover erasing the tick

The 600 weight on a chosen option was not a transcription slip. The draft
said 600 when the panel was built, and changed it to 500 a week later along
with the ink token. We picked up the colour half of that change and missed
the weight, so the two halves of one edit have been out of step since. The
history is now in the source, or the next reader will "fix" it back.

The own-answer tick was losing to hover outright, not tying: `:hover`
carries a class-level slot of its own, which puts the chosen rule a full
step below. Same defect the fixed options had, one box later — the expanded
row renders a different element, and the earlier pass only reached the first
one.

The draft wins this with two ancestors, so that is what restores it, and the
guard asserts strictly-greater rather than merely checking the colour. A
tie also paints correctly today, purely because of source order; the colour
assertion cannot tell the two apart, and the ordering is exactly what breaks
the next time someone moves a rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* test(chat): calibrate the cascade ruler against the spec

The helper that decides which CSS rule wins was counting `:not()` itself as
a pseudo-class. The spec says the functional pseudo contributes nothing and
is replaced by the weight of its heaviest argument, so every
`:hover:not(:disabled)` in the tree read a full step heavier than a browser
would. `:is()` and `:has()` had the same fault, `:matches()` was not
recognised at all, and `:where()` — correctly zero — was matched by a
pattern that only survived one level of nesting.

Pseudo-elements were being deleted outright rather than counted as elements,
and the single-colon legacy spelling was counted as a pseudo-class, which is
wrong in both columns at once. Selector lists were summed instead of taking
the heaviest branch.

A ruler that reads high hides real leaks; one that reads low invents them.
Either way the readings are not evidence, and this family is where several
of today's cascade fixes were judged.

So the reading change was measured, not assumed: both rulers were run over
every selector in the loaded sheets (313 branches move) and then over every
resolved property of the question-form tree in each state (7619 readings,
none move). The zero is only meaningful because a control case with a
deliberate disagreement was checked first — it goes red, so the comparison
can see a difference when there is one.

Nothing this family asserted was resting on the fault: none of the rules it
actually compares contain `:not()`.

Assertions cite the spec sections they come from, and the cases the
implementation does not model are named in its header rather than guessed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* docs(web): correct why the hover and disabled rules stay apart

The comment claimed merging them would outrank the chosen-option rule.
`:not()` carries no weight of its own, so the merged selector ties with it
instead — and wins today only because of where it sits in the file.

Which is the actual reason not to merge: a tie decided by source order is
the trap the spec's own note forbids, and nothing goes red when someone
moves a rule. The pair above ties too, but a test pins which of them wins,
because a disabled button still matches `:hover` — that being the defect
this pair was written to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* feat(chat): carry the daemon's failure verdict to the error card

The classifier decides two things about every failed run — whether retrying
can help, and what the user should do — and the card was seeing neither. It
re-derived the answer from the failure detail instead, and disagreed with
the daemon in forty places.

`failureAction` at least reached the status endpoint. `retryable` existed
only inside the classifier: the finalize path read the classification and
dropped that field on the floor, so no transport could have carried it.

Both now ride the end frame, the status body, the durable run state and the
persisted error event. All four, because each is somebody's only path: live
listeners take the frame, a reconnect re-reads the status body, a restarted
daemon restores from durable state, and reopening the conversation replays
the stored event. Missing one is a failure that silently downgrades under a
condition nobody reproduces.

Every read and write of `retryable` tests for a boolean rather than truth,
since `false` is the whole point of sending it and `x ? {x} : {}` would eat
exactly that.

The card needs no change — it already asks for the verdict, and the answer
now arrives. Around twenty of the mismatched cells resolve from this alone;
the rest have their own web-side row and need those rows corrected, which
is a separate judgement about which side is right.

Absence still means absence: a replayed event from before this change
carries no verdict and keeps today's behaviour, pinned by an exact-shape
assertion rather than a negative match, which an added optional field would
have made vacuously true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* test(chat): stop the cascade ruler dropping and misfiling rules

Selector lists were split on a bare comma, so any list nested inside
`:where()` or `:has()` came apart. The damage is not that the pieces throw —
it is that they parse. `.markdown-rendered :where(p, li, …, h6) > code`
becomes `li`, `td`, `h1`… , each a perfectly valid selector, so a rule meant
for inline code is applied to every list item and table cell in the tree
while the actual `<code>` matches nothing. One rule, misfiled and dropped at
the same time, silently. Four real rules in the loaded sheets are affected.

Two corrections to what was reported earlier: the parser tolerates the
unclosed bracket rather than throwing, so the `:has()` case quietly loses
one branch instead of failing outright; and the earlier explanation of the
local rulers' error was wrong — parameterised pseudo-classes are skipped
entirely, not mistaken for type selectors.

The catch that hid all this now swallows only vendor pseudo-elements, which
are the sole selectors that legitimately fail to match. It earned that
during verification by naming a fragment that used to vanish without a word.

Eight test files each carried their own copy of the specificity calculation,
each with its own faults: pseudo-elements counted in the wrong column,
parameterised pseudo-classes ignored, attribute values read as element
names, `:where()` not zeroed, one that could not see an id at all, and one
that counted every class twice. They now share the calibrated
implementation, through a shape-preserving adapter so that not one assertion
changes — the readings were surveyed first, and of the sixty-five selectors
these files actually ask about, three move and none of them flip a verdict.

The worst of the eight was never called. It has been replaced rather than
deleted, so it is a correct tool rather than a loaded one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* test(styles): drop the local specificity helper nothing calls

Both functions were dead. Keeping a second copy of a calculation that has
one calibrated implementation is how the eight divergent copies happened in
the first place; a caller that needs it should reach for the shared one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* docs(chat): record that artifact cards open the latest file, always

The thumbnail is the snapshot; the click is not. HTML and images follow the
same rule, so the versioning design's read-only snapshot tab is retired.

Recorded because it was nearly built: the doc listed the tab as outstanding
work and it read like a gap. It was a decision. An item a design names but
no one has implemented is not automatically a debt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* style(web): finish porting the tool icon rule, not half of it

The draft changed size and colour in one declaration when it was refined.
The port cited that exact line and brought the colour across, leaving the
width at the previous draft's 14px — the comment in our stylesheet points at
the line that says 16.

The stroke baseline moves with it. 1.75 is in user units, so what reaches
the screen is that times the viewBox scale: 1.75 × 14/24 reads 1.021, and
1.75 × 16/24 reads 1.167. The constant did not change; the multiplier did.
The size is now asserted explicitly alongside the stroke, because a product
of two factors lets one silently compensate for the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* test(chat): assert the thinking icon's real size, not its attribute

The case asserted the svg's `width` attribute was 14 and titled itself "size
unchanged". The attribute is what `Icon` writes by default; the stylesheet
overrides it, so the number on screen has been 16 since the tool icon rule
was finished — and this case stayed green through that, still claiming
otherwise.

What matters after today's ruling is that the thinking icon and the tool
icons are the same size. That is structural: both render into the same
leading slot, so one stylesheet rule governs both and they cannot drift.
The case pins that they share the slot, that exactly one rule gives it a
width, and that the width is the draft's.

Counting rules by selector was wrong at first — two others target the same
slot to turn it red on failure and say nothing about size. The count is over
rules that actually declare a width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* docs(chat): hold OPEND-2586 pending a clarified description

Its stated expectation is a permanently visible Retry on any network
failure, which is the thing the failure ladder was built to prevent: the
design's fourth principle is that Retry appears only when it can work, and
the ladder's bottom rung exists so an unclassified failure structurally
cannot produce one.

The report also cites a draft that specifies the permanent Retry. Which
draft, and which cell, needs checking — the panel handover and the error-UX
design are different documents and only the second governs this card.

It also bundles two symptoms whose causes are probably unrelated: a failed
turn presented as a completed reply, and a failure card that appears and
then vanishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): say which gate stopped a blocked strategy task

Answering the form and then being told the task failed reads as a
contradiction: the answer is on screen, the agent replied at length, and the
card says it could not continue.

The verdict itself is right. In the clarification stage the contract admits
only three conclusions, and the one requiring a plan contract had none, so
blocked was the only honest call left. Nothing was lost when the form was
submitted — the agent simply omitted the machine block that turn, and unlike
the opening turn, that stage has no inference to fall back on.

What was wrong is that the daemon knows exactly which gate refused and threw
that away. Every structured daemon failure carries its reason on the error;
this path alone raised a bare one, so the diagnostic panel showed no code,
the card matcher had nothing to match, and every gate in the strategy
contract rendered as the same anonymous sentence.

The reason now rides along, and the sentence says what happened rather than
that something did.

Two things this does not fix. The wording is a draft — the error-UX design
has no cell for it, and the nearest one describes an unusable reply, which
this is not — and the string is still hardcoded English on this path. And
the Retry on that card cannot work: the task is terminal, so continuing it
answers 409 either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* test(chat): stop two assertions calling our own values the draft's

Both numbers in this file were labelled as the design's and are not.

The own-answer label is pinned at 600 with a comment saying the draft says
600. The delivery draft says 500; 600 is the previous draft's value. The same
commit corrected the chosen option to 500 two rules away and left this one
citing the old sheet.

The unchosen option is pinned at 400 as "no weight declared, so 400". In the
draft nothing declares a weight there either, but its baseline is 500, so
what it inherits is 500.

Neither value moves here — the weights across this family have to be
reviewed together, and changing one in isolation fights its neighbours. What
changes is what the file claims: both are recorded as our current state with
the deviation named, so a later migration reads them as work to do rather
than as conformance already achieved.

A test that reports the status quo as the specification is worse than no
test: it makes the correct fix look like the regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* feat(chat): give HTML artifact cards a frozen cover, and delete the open-snapshot path

Every part of the first-viewport capture existed and nothing called it, so
HTML cards still mounted a live iframe each — the thing the design set out to
avoid in a long conversation. The wire lands at the run's terminal
chokepoint, on the rows the capture just wrote, in the same window that
already freezes image bytes.

Queuing "screenshot this later" is the wrong shape, because by the time the
renderer runs the page may be the next turn's, and a screenshot of the wrong
bytes looks exactly like one of the right bytes. The freeze is structural
instead: the entry and its local dependencies are read in one window and
fingerprinted, inlined into a single self-contained document, and every
fingerprint re-checked before the document is accepted — drift voids it
rather than stitching two versions together. The renderer then gets that
document with no base address, so a render starting minutes later has
nowhere to read the workspace from. It is not forbidden from reading the
latest file; there is nothing to read.

The spec's fallback path — render against the live file while the entry's
fingerprint holds — is documented rather than built. It hands the live
endpoint back to the renderer and fingerprints only the entry, leaving
dependencies free to move mid-render.

Separately, `openPolicy` is removed rather than pinned to one value. After
the ruling that cards always open the latest file, it carries a constant
while reading as a switch — and the host dropping the click target looked
like a bug whose "fix" would have built the behaviour that was just
rejected, with typecheck and tests both silent. Now no value can mean "open
the snapshot".

The cover half is untouched and pinned: an overwritten image still shows the
turn's version in the older message.

Nine corrections to the design, struck through with dates and sources rather
than erased: the placeholder-on-failure policy and the compat matrix that
product overturned, a state that never existed, the click column, and the
lineage description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* feat(media): tell the user what to do when a picture or video fails

The failure text printed an internal code at the user, because the prompt
told the model to write one. Deleting the code alone would trade exposing
our plumbing for saying nothing, which is not what was asked: a generation
that fails should say enough for the user — or the agent — to decide what
happens next.

Two hundred-odd throw sites across dispatch and the provider bridge land on
nine next steps, so the copy is grouped by what the user does, not by which
code fired. A missing key and a model with no renderer are different bugs
and the same sentence, because both are answered in Settings.

Eight of the nine sentences come from cells the error-UX design already
ruled on, minus the vendor half — the media contract forbids naming the
provider or model. The ninth, for a run that cannot generate at all, has no
cell and is marked as a draft for product to edit.

The agent gets the same nine values as a closed set on every failure, and
the contract says which two it may retry itself. Previously it had a code
meant for our own triage and had to infer intent from wording.

The semantics reuse the run-failure ladder rung for rung and the names line
up with it. The rendering does not: a failed run is a whole turn collapsing
into a card, while this is a successful turn with one missing picture, and
the sentence is written by the model rather than the interface.

No new locale keys: nothing renders this on the front end yet, so keys would
be dead. The value now sits in the task snapshot for whoever builds that card.

Costs about 600 tokens of system prompt per media-capable project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(strategy): name a repeated clarification even when the machine block is missing

Three gates run in order and the general one is first. When a turn omits the
machine block, the parser raises "runtime state missing" and the task is
blocked before the gate that recognises a repeated clarification is ever
reached — that gate reads the parsed state, which by definition does not
exist here.

So the precise code only appeared when the agent both asked again *and*
declared itself properly. The same failure, written two ways, was reported
under two names: the honest half got the accurate name and the sloppy half
got the catch-all.

The order is not changed. The evidence needed — a renderable form, and no
machine block at all — is already observable where the general gate hands
off, so the code is corrected there, after the repair path has declined, and
using the same predicate the precise gate applies to the same shape. It
replaces rather than appends, because the consumer reads only the first
code.

The guard that narrows it to the clarification stage is load-bearing: nine
other shapes reach the same point, and without it an opening turn carrying
two forms would be reported as a repeat. An existing case catches that.

The verdict does not move — the stage still admits only three conclusions
and the one requiring a plan contract still has none. Blocked, but finally
able to say why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): retire the prose an interrupted attempt already wrote

The ticket blamed missing stable ids on text events. It is not a storage
problem: the run genuinely produced the answer twice, and no idempotency key
could recognise two independently generated passages as one replay.

We are what made it write twice. Ordinary retry refuses to rerun a turn that
has already put words on the user's screen; the post-tool resume path checks
only whether a tool ran, so "called a tool, streamed half the answer, lost
the connection" reopens the same assistant message and the model writes its
conclusion again. Persistence appends, and nothing tells it the previous
passage was superseded.

A turn is only reopened when it did not close cleanly, so whatever prose it
left behind is unfinished and about to be rewritten. On the second start for
one message, that trailing prose is dropped before the new attempt's first
delta lands — stepping over the attempt's own closing status row, and
rolling the stored content back only when it matches as an exact suffix.
Tool rows stay: they are already committed to the agent's session and the
resume continues from them.

No new field. The second `start` on one message is the existing, observable
boundary, so replaying an old conversation reads exactly as before.

Two things left alone deliberately. The live view still shows both passages
until reload, since retiring them on screen means reaching into the web
retry callback. And the strategy that decides to rerun at all is untouched:
adding the seen-output guard there would stop rescuing genuinely interrupted
long answers, which is a different question from cleaning up after a rerun
that has already been decided.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): explain the blocked clarification instead of showing a blank failure

The premise this started from was wrong, and the correction changed the fix.
The English sentence was never what Chinese users saw: with no row for this
reason code, the failure fell to the generic fallback, whose null message key
renders the translated "the task failed" line. The English text only ever
appeared in the collapsed diagnostics.

So the card explained nothing at all while the user looked at their submitted
answers and a complete plan. Translating the string would have changed
nothing on screen; the missing piece was a row, which is the repo's own
mechanism for this.

The card now says the reply arrived and could not be recorded — it does not
blame the user, whose answers did land — and keeps Retry, because the
omission is intermittent and rerunning the same prompt usually emits the
block. All four runtime-state codes share the row: to the user they are one
story, and splitting them would ask product for four wordings of one
sentence. The wording is marked as a draft; the error-UX design has no cell
for this, and the nearest one describes an unusable reply, which this is not.

The verdict is untouched, and a guard proves that is not decoration:
mutating the row to a non-retry card turns four assertions red.

Also repairs two assertions orphaned by the open-policy removal, which
pinned a second argument that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): give the repeated-question failure its own card

A reason code added earlier today had no row, so it landed in exactly the
blank-fallback hole the neighbouring case had just been lifted out of — the
same defect, freshly made, because the daemon side and the web side were
worked separately and neither knew about the other.

It does not share the neighbour's row. To the user that one is "the reply
came back but could not be recorded"; this one is "I answered, and it asked
me another question instead of carrying on". Different story, different
sentence.

Retry stays, but not for the reason it first appeared to. The omission is
not flaky: a task admits exactly one clarification round, and a properly
declared second question is refused by the identical code. Retry earns its
place because it does not re-roll the turn — it opens a new task at a fresh
clarification count, where asking is legal again. So the agent will likely
ask the same thing, and that time it renders as an ordinary round of
questions rather than a failure.

The copy therefore promises a restart rather than a coin flip, and a test
fails if the English ever picks up "intermittent" or "usually goes through".

No cell exists for this in the error-UX design, so the wording is marked as a
draft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* test(chat): add the field the pending-tool change owes the history baseline

Showing a tool call the moment it starts added one field to every tool row.
The commit updated three suites and left this baseline behind, so it has been
red since.

The difference is exactly that field, seven times, all false — no block moved,
merged, split or changed owner. None of the fixtures can reach the new branch:
every call in them has its result, so nothing is ever pending, and the field
is inert in rendering too.

Only those seven lines are added. Re-recording the whole baseline would
replace "what the implementation should output" with "what it currently
outputs", which is how a guard quietly stops guarding — a check confirms this
one still catches an unrelated structural change.

Three comments were still stating the retired rule, one of them thirty lines
above the field that contradicts it, and a fourth called the image row the
sole exception to it. That is the shape that burned three agents today on a
different red: the comment sends you to change the implementation. The spec's
own note on why live terminal output is impossible listed two blockers; the
first is gone with the rule, leaving only the missing incremental channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): stop keeping video and audio originals

Product ruled these out of snapshots. They are the wrong shape for the
budget: one revision of a video eats a large slice of a project's allowance,
and once that is full the image snapshots in the same batch start failing —
a semantic nobody ruled on squeezing out one that was ruled on.

The policy comment promised removing a kind from the set needed no other
change. It did. The provider-bytes hook hands its payload straight to the
capture without consulting the policy, so a display-only edit would have left
every generated video in the store while the cards said otherwise. The
exclusion is enforced at the capture chokepoint instead, which is where the
bytes actually arrive.

The gate keys on whether a kind's original *is* the evidence rather than on
the kind alone, because HTML also declines to store originals and a kind-only
gate would have silently killed every HTML cover. A test pins that.

A residual pending intent from an older build would have been re-captured by
the boot reconciler, putting the bytes back on the build that just excluded
them. It is retired instead, under its own code so "product excluded this"
does not sit among real quota and render failures.

Video cards fall back to the live file and the browser draws its own first
frame — the same path an old conversation already takes. Audio was never a
card at all, so every audio snapshot stored so far was bytes no surface read.

Two fixtures described a record their code path cannot receive; corrected,
with the gate they were standing in for now covered directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* docs(chat): track the newest design draft, not the handover one

Standing rule, not a one-off retarget: when the design moves, the baseline
moves with it.

This retires the line in the ledger and both audits naming the handover
commit as the only current baseline, and it means some cells those audits
scored as implemented are deviations again — they were judged against a
sheet that has since changed in five places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* style(web): give the thinking panel a real grey, and settle the terminal's ink

The thinking surface used a translucent fill, so it had no colour of its own
— it took whatever it happened to sit on. The draft names an opaque grey and
we have a token for exactly that value.

The terminal's three tiers really are one ink in the draft, and that is not
a flattening: the draft moved the distinctions elsewhere and says so in its
own comments. Command versus output is carried by the leading `$`, which is
the terminal's own convention rather than a mark we invented; an error line
is marked by a red `✗` at the start rather than by reddening prose that is
log content, not an error state. Success lines keep the brand green, so no
semantic colour was lost. Guards now watch those two marks, because that is
where a real regression would show.

The gap between the command and output blocks was set on each side, so the
two added up to twice the outer padding — the seam between them breathed
wider than the edges.

The change-count beside a written file was one shade darker than the file
name next to it: the last cell the muted-ink pass missed.

A fourth edit was reverted after removal verification left everything green.
It widened the muted-ink ownership to the terminal for a case that cannot
occur — the terminal only renders from a tool row, which only renders inside
the flat shell — and the reasoning is now a comment so it does not get added
back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* style(web): close seven gaps against the draft, and delete two rules that never applied

The memory card's summary had no hover fill. The attachment placeholder was
a flat grey where the draft carries a mark — the artwork is the design's own
svg, taken from the sheet's own base64 and decoded to confirm it matches the
source file byte for byte, not redrawn. The fork divider had no entrance at
all.

Two rules were proved dead rather than argued dead. The queued row's active
highlight is outranked by a later rule of equal weight that resets exactly
the two properties it sets, so toggling the class changes no computed value;
the scrollable class has one origin in the tree and it is not on that
element. In both cases the suite was green before the deletion and green
after, which is the evidence. The class name for the first is still emitted
by a component outside this change and now has no rule reading it.

Two radius tokens were missing from the seam, and the count of consumers
falling through to the fallback had grown to three today. Both scopes get the
definition, and the test enumerates consumers rather than naming them, so the
next omission reports itself.

Bare buttons in the panel were pinned to 400 while the baseline moved to 500,
leaving every one of them a step lighter than the prose around it. The author
had left a tripwire saying so; it fired on the fix and asked to be removed,
which is what happened. What the draft actually inherits — weight, size and
family — is asserted by value.

The fork entrance follows the draft's easing rather than the repo's default,
which the same stylesheet uses two hundred lines above for message entrance.
Flagged in the source, not reconciled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* style(web): align the question card to the current draft, value by value

Every number here is read off the sheet, not derived. The rule I handed over
— "the size ladder bottoms out at 12px, so the 11px cells must be wrong" —
is a rule, and rules are how you end up one step away from the drawing.
Following it literally would have pushed a cell the draft never specifies.

Four of the five 11px cells do have a rule, and it says 12. The fifth is our
own addition — the draft styles an invalid colour value with a border and no
message — so it stays where it is, flagged rather than guessed at. A sixth
cell the list missed sits in the same block and had been left behind by a
half-finished pass.

The value-answer row was keyed on "does it have a swatch". The draft's own
comment says colours *and* numbers, and its example page shows a numeric
answer with no swatch at all, so the key is whether the answer is a value.

Weights follow the baseline that moved to 500. Two of them now agree with
the global button reset, which means deleting the rule entirely would still
read 500 — so the guards name which rule wins, rather than only what the
number is.

The colour block's two captions are one weight in the draft and a lighter
one for the legend specifically. We had collapsed both into the lighter one.

The slider is the newest draft's, all twelve declarations.

Two things flagged rather than resolved: the draft moves the numeric field's
focus ring from outline to box-shadow, which forced-colors modes drop
entirely — and the slider track beside it still uses outline. And 1.35em
leaves room for about two digits, while our own fixtures clamp to 100.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): let the rail and the transcript count the same messages

The body drops the user message that carries form answers — a deliberate
call, so the same decision is not stated twice and the machine payload never
reaches the screen. The rail counted `role === 'user'` on the raw list, so it
reported three where two were drawn, and clicking the extra one navigated to
a message that does not exist.

The fix shares the array, not a predicate. Exporting a predicate for three
callers to apply is still three implementations that merely agree today. The
render items are computed once and handed down, so the rail can no longer see
the unfiltered transcript at all.

The same mismatch had a second victim. Pinning a new turn to the top keys on
the trailing user message changing identity, and after answering a form that
message is the one nothing draws — so it pinned the *previous* turn's bubble
and padded 328px of empty space beneath it. The vote now happens over the
messages that exist in the DOM, which makes "should we pin" and "pin to what"
the same question.

That turn falls back to sticking to the bottom. Not for convenience: the
spacer, the landing point and the release tolerance are all defined in terms
of the pinned user message, and here there is none. Every other entrance
sends a message that is drawn, and a counter-case pins that a real message
after the form still goes to the top.

Side effect worth knowing: a conversation whose only two user messages
include a form answer no longer shows a rail at all, where before it showed
two dots and one was dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): retire the steps a replacing todo snapshot dropped

A snapshot that shares even one item with the previous one is treated as the
same list making progress, and that path only ever appends: an item that
vanished from the new snapshot is never touched, so it sits there as "not
started" while the newly split steps queue up behind everything else.

The pill and the transcript then disagree about which step is current — the
pill reads the newest snapshot, the transcript reads insertion order — and
the card contradicts itself, its header counting the new snapshot's steps
while the rows below it are the union of both.

Vanished steps are now retired the way a full replan already retires them:
struck through, out of the count, out of the ordering, and parked next to the
step they replaced so the change of plan stays legible. Surviving rows follow
the newest snapshot's order.

Partial overlap has no ruling of its own, so this follows D14 from the same
family, and the source says exactly that at the one place a different answer
would change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): recognise a produced image by its extension, not only its kind

Three predicates decide which file a turn should open, and two of them accept
an extension when the kind field is absent. The media one does not — and the
candidates built from the agent's write trace carry no kind at all, so every
image produced that way scored zero and was skipped in silence.

The extension list is copied from the daemon function that assigns the kind
in the first place, because a fallback that disagrees with the field it
stands in for is a second source of truth. Two consequences come with it:
`.svg` is a sketch, not an image, and a root-level sketch raster is the
user's own drawing rather than this turn's output — opening that would be a
worse behaviour than the bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* refactor(web): delete the card skeleton nothing has ever rendered

It arrived with the repository's first commit and no file has imported it
since. Its own docblock aims it at a different grid than the one people keep
reaching for it to solve — an investigation into the design-files panel's
missing loading state found it, assumed it was the intended piece, and had
to be pulled back.

Its stylesheet block goes with it; the shared skeleton primitive stays,
since other panels do use that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): keep the confirmed answers where the user gave them

Two completed shells stacked together with the answers below both, when the
user had answered between them. Neither the cross-run fold nor the form block
put them there: the block order the turn builder produces is correct — shell,
form, shell — and the renderer split it by kind, drawing every shell and then
every piece of prose.

So this was never about how many runs a turn has. A single run that goes
shell, prose, shell would have come out the same way.

The turn now carries the original sequence alongside the two filtered lists,
and the conclusion is grouped by position instead of joined into one string.
The processing chain is unchanged, just run in two halves: the strippers
filter per block and are equivalent either way, while form de-duplication is
first-wins across the whole turn and so runs flat before being sliced back —
a map, so the slice is exact.

An ordinary one-shell turn renders byte for byte as before, because
alternating and "shells then prose" are the same thing when there is one of
each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): one card for running out of credit, not two

Running out mid-turn drew the generic failure card — white, four buttons —
while the upgrade card the design specifies was wired only to the pre-send
gate. Product ruled there is one card per state and no second white card
behind it.

The two states are the design's own: below five dollars reads as a warning in
amber, zero reads in red. The card already splits on the balance, so nothing
new was needed there — what was missing is that the failure carries no
balance at all, only a code, so the wallet is read once after the turn
settles and the card is lit from that.

When the wallet cannot be read the card does not appear and no number is
invented. That leaves a hole: the white card is gone, so the user sees
nothing at all. Flagged, not papered over.

Retry, export logs and contact support go with the white card, because the
design's card carries one button and inventing the others is the thing we
were told not to do. The cost is real: topping up now means sending the turn
again rather than resuming it.

The predicate reads the last assistant message rather than hooking the error
callback, so the card survives a reload and does not have to be written twice
for the send and reattach paths.

Team members still have a path — the same button routes non-owners to a
prompt they can send their admin. The error-UX design would put that on the
card face instead; the two drafts are left unmerged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(chat): land replayed history at once instead of restreaming it

Reopening a running project replays the whole run from the daemon's buffer,
and that replay travelled the same path as live output: chunked per animation
frame, then revealed character by character. So content the user had already
read arrived again as forty separate commits, each with its own entrance,
tangled with the new output still arriving.

Batching alone was not enough, and the numbers say why: forty commits became
one, but the reveal budget counts characters per frame regardless of where
they came from, so the whole history still animated in over two seconds. Both
halves are needed.

The obvious shortcut — treat a large batch as history — is wrong: a
non-streaming agent emits its answer in one piece, and that is exactly the
case the reveal exists for. The question is where the text came from, not how
much of it arrived.

The transport draws no such line. The daemon writes its buffer synchronously
and only then registers the client for live frames; same channel, same id
sequence, same shape, no marker. What can honestly be observed is cadence, so
the replay window closes on the first quiet gap, with a hard ceiling in case
the model happens to be producing densely right then. The ceiling errs toward
landing a moment of live output at once rather than disabling streaming.

Queued updaters are the update functions themselves, so de-duplication,
merging and the ordering of prose against events all survive; and both flush
and cancel hand the queue over, since the message is emptied on remount and
losing the queue would leave a blank screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* test(chat): type the failure-detail fixture to the contract's own union

Written as a free string, a misspelt reason name would never be caught — and
this file's assertions are precisely the ones that branch on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* test(chat): guard the artifact wiring, and give maintenance a loop and a signal

Four seams had code and no test, which is the shape where a break goes
unnoticed: the media hook that captures provider bytes — the strongest
capture path in the design — plus rename, both delete handlers, and the fork
seeding. Three of them sit inside catch-and-warn, so deleting them left the
suite green.

The guards test the seam, not the layer beneath it: the media one runs a real
generation over HTTP, overwrites the workspace file afterwards, and asserts
the snapshot still returns the provider's bytes.

Reconcile and sweep ran once at boot, so anything that stalled during a long
run waited for a restart. They now share one pass with the boot path, because
two implementations drift and the drift only shows in the case nobody
watches. A tick that overlaps the previous one is skipped rather than queued:
the pass is idempotent and rescans from scratch, while queueing builds a
backlog on a slow disk and lets two passes reach different conclusions about
the same grace window. Stopping drains the pass in flight — the next thing
shutdown does is close the database.

The interval defaults to off, because neither section names a period and the
quota section shows the author does write "product decides" when that is the
case.

One assertion was rewritten after it survived swapping the two steps it
claimed to order. It now uses a case that can tell them apart.

Telemetry reports one event per turn that produced artifacts, and turns that
produced none send nothing — a row of zeros from every other turn would bury
the failures in rounding. The source-changed count is separate because it is
the only code that means the capture window itself was wrong: at two percent
of storage used, running out and forging history read identically.

The payload is the return value of a pure function, unspread, so what the
function's test asserts is what reaches the sink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* fix(home): make the handoff surface look like the page it hands off to

Jumping in from Home crossed two blank states that did not resemble each
other, so the layout jumped twice before anything appeared. Every alignment
here is copied from the frame that follows — the sidebar dock, the collapse
control, the user bubble's structure, the centred empty state with its
call-to-action row — rather than styled fresh. Nothing new was drawn, and
no skeleton was needed once every position had something real to hold.

The back arrow goes because the real page leaves that slot empty; one extra
button there shifts the title by a notch, which is the shift the recording
shows.

The staged attachments appear immediately. They are already `File` objects in
the browser, so an object URL draws them with no request at all — this
surface still may not read anything project-scoped, and a test asserts zero
project-scoped calls while they are on screen.

Uploads now run four at a time, matching what the in-project composer has
always done, and only inside the upload step: the working-directory handoff
must still complete first, or the files land in a managed folder the user
did not choose and vanish when the base directory swaps. A per-file failure
also stops condemning every file queued behind it.

Two things this does not reach. The long wait is the upload itself, which
cannot leave this surface until the attachment handshake stops requiring
server paths before the project view mounts. And the split width still falls
back to the default rather than the user's saved one; both live behind
ProjectView.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* style(web): follow the draft's reversal of the progress ink

The draft turned the whole task-progress family back to strong ink — step
titles, elapsed times, plan numbers, the tool rows inside a progress shell,
and the summary between steps, which also returns to 13px at weight 400. Our
muted-ink pass had taken it the other way; product confirmed the reversal
against the sheet.

The terminal block is the half that must not follow. The draft still writes
it literally muted, and only the copy sitting inside a progress shell is
overridden, so sharing one variable would have darkened a block the design
never asked to darken. It gets its own token, and two assertions hold both
ends: one that the progress family is strong, one that the terminal is not.

The remaining four cells were aligned in the same pass rather than left half
done, because they hang off the same variable and the same draft rule — a
partial port would produce a state the sheet does not contain.

Eleven assertions across three files pinned the retired direction. Four of
them had quietly become vacuous: they told two things apart by colour, and
the two are now the same colour, so both sides of the comparison passed.
Those are re-anchored on what actually distinguishes them — a red icon, a
weight, an indent — rather than given new numbers. The file that asserted
"the row goes muted" is renamed for what it now guards: which layers follow
the progress ink and which stay behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* style(web): align the send queue to the draft, hover states included

Three of the four cells flagged for review already matched. What was actually
wrong sat next to them.

The drag handle lit up like a button on hover — our own fill plus a darker
ink, where the draft changes the ink alone and by one step. The action
buttons had the wrong fill and the wrong ink. And the row itself carried a
hover background the draft does not have anywhere: a leftover from when the
queue was a card, missed when the frame, the fill, the floor and the shadow
were all taken out. A queue held together by dividers has no notion of a
selected row.

Long unbroken text could still burst the row: the clamp was there, the
draft's companion rule allowing a break anywhere was not, so a URL neither
wrapped nor truncated.

First-row tooltips flip downward per the draft. Ours are portalled so they
cannot be clipped by the scroller; what the rule earns here is that an
upward bubble no longer covers the last message in the log, and the comment
says so rather than repeating the draft's reason as if it were ours.

The first-row highlight class goes. The draft's only treatment of that row is
removing its top border, so the question the class was left open for has an
answer. The guard now asserts the name is absent from the tree, and explains
that reinstating the highlight means beating the later rule of equal weight
rather than adding another declaration that gets silently overridden.

The drag handle is not decorative: real drag events reorder the queue.

One removal check passed by accident first time round — the script deleted a
different rule with the same declaration elsewhere in the file. Redone by
line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* i18n: one word per locale for the agent

Five locales each called the agent two different things, and one of them was
a mistranslation rather than a style difference: Arabic used the word for
customer, including on the agent picker's own label.

Traditional Chinese turned out to be wider than it looked. The word we were
treating as a synonym also means proxy in seven strings and agency in two —
so the settings page was using one word for three different things, with the
network-proxy hint and the agent picker reading identically. Those nine stay
as they are; the other thirty-two join the rest.

Bare English stays wherever it already is: it clusters on developer-facing
surfaces while native words carry the user-facing ones, and that division
reads deliberate.

Turkish is written per key rather than replaced, because the word takes case
suffixes — and because the same string is the word for tool in eight places
and means "by way of" in five more. A blind replacement would have ruined
thirteen unrelated lines.

The Persian call is the weakest here and is marked as such: both forms are
correct, and the choice rests on which one already holds the interface.

The guard compares the set of keys containing each word against an explicit
allow-list with the English source quoted per entry, so a new offender is
reported by name rather than as a count, and the traditional-Chinese case
also fails on a simplified character.

The glossary gains the row it always said should be added when someone hit a
real terminology choice, including why "agent" must not be translated back to
the proxy word and which Turkish strings look like matches but are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* Revert "i18n: one word per locale for the agent"

This reverts commit 457f6c34e3.

Nobody asked for it. It surfaced as an adjacent observation while another
change was editing the locale files, and I dispatched it off my own list
rather than checking whether it belonged in today's work — a hundred keys
across five locales, none of which any report or acceptance criterion
touches.

Two genuine defects it fixed are worth keeping in mind if this is ever picked
up deliberately: Arabic names the agent with the word for customer, including
on the picker's own label, and Traditional Chinese uses one word for both the
agent and a network proxy, side by side on the settings page. Each is fixable
on its own without unifying anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* feat(chat): freeze a video card's first frame

The video card drew whatever the workspace file currently holds, so
overwriting the file changed the frame shown in an older message — the same
defect image cards had, one medium later.

The original still does not get stored: that was ruled out on capacity, and a
frame is a picture, sized like the HTML cover rather than the video.

ffmpeg is already a daemon runtime dependency and is already bundled on all
three platforms, so nothing new ships. The browser-screenshot path was not
reused — loading a video into an Electron window to grab one frame is neither
cheap nor reliable, and a daemon with no renderer would never get a frame at
all. A test runs without one on purpose.

HTML can freeze first and render later because its self-contained document is
portable evidence. A video has no equivalent short of copying the file, so
extraction *is* the freeze: it runs inside the run-terminal window and checks
the file's identity on both sides, discarding a frame decoded across a write.
The cost is that it counts toward the turn, so it is budgeted and capped.

The card gains a poster attribute and nothing else — the platform's own slot
for a first frame, so no layout, ratio or control changes. Failing to extract
falls back silently to today's behaviour, per the standing ruling against
placeholder copy.

Also fixes an existing leak the change would have spread: attaching a cover
wrote the cover's own mime onto the workspace file record, so an mp4 became
image/jpeg. HTML has been doing this to index.html all along, unnoticed
because nothing reads that column yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* style(web): underline a clickable file reference without waiting for hover

The draft moved the underline out of hover and into the resting state, and
dropped the transition with it: a file name you can open now looks openable
before you point at it.

The name you cannot open explicitly does not get one. The draft's reason for
the underline is that the thing is clickable, so putting it on a plain span
would be saying something untrue — the same call as the ruling that a control
which looks pressable and does nothing is worse than no control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW

* feat(web): split create-file icon from edit, per design 729fa43ce7

The draft's create-file rows switched to a solid node glyph while the
edit row kept the pencil. Our `toolIcon` mapped `write` and `edit` to
the same pencil, so the create rows could never match.

Counted in the draft itself rather than read from a commit message:
`729fa43ce7` renders 4 solid create glyphs to 1 pencil edit glyph, and
the previous baseline `361b78253e` was already 1 solid to 4 pencils --
the solid glyph was settled a revision earlier and this commit only
finishes propagating it.

Adds `FILL_ICON` alongside `STROKE_ICON` instead of making it a variant:
the fill family carries no `stroke-*` at all, so a dead `stroke-width`
cannot mislead the next reader into thinking it honours the 1.75 ruler.
`FailIcon`/`RetryIcon` were already hand-written with exactly those
three attributes and now consume it, producing byte-identical markup.

The stroke-weight spec no longer excludes the new glyph -- exclusion
would leave it unguarded forever. It now asks each family its own
question, and pins `DESIGN_FILL_KINDS` as a membership list so both
directions go red: a cell silently reverting to stroke, or a cell
silently defecting to fill and escaping the 1.75 ruler.

The 1.75 baseline is untouched.

* fix(web): realign non-record chat text to design 729fa43ce7

The reported symptom -- the turn status word "已完成" rendering smaller
than the body text below it -- was real, and not isolated. Recomputing
the draft's own CSS over the draft's own HTML (rather than reading its
prose) turned up the same shape in three places: cells that should sit
on --t-lead had drifted down to the --t-cap/--t-mini rung.

The size ladder itself was never wrong; both baselines share a
byte-identical tokens.css and all six rungs already matched. What was
wrong was which rung each cell drew from.

Ten cells realigned: the turn status word (14px/500) and its tick
(16x16), the confirm-card head and its title (14px) with the count
stepping itself back down to 12px/600, the title dropping to 500 when
the card carries options, the option rows rising to 13px on the new
baseline, the visual-direction card's head inset (16px), and the
reconnect row taking the draft's own #A3A3A3 -- landed as
--chat-tool-ink with light and dark scopes at the same value, since the
draft carries no dark override for it.

Each of the twelve edits was reverted individually by exact line number
and each went red on its own; none is dead weight. One revert first
struck a comment line and stayed green, which is precisely why the
revert pass has to be seen failing rather than assumed.

question-form-latest-spec-drift.test.tsx belongs to another lane but
uses .qf-chip as its measuring standard with the value pinned to the
old baseline, so raising .opt made it unavoidably red. Only its
calibration constant moved; a header note records why it must not be
put back.

* fix(web): make the thinking row read as a tool row at both depths

Product ruling 2026-09-02: the thinking row is ours, not the designer's
-- the draft has no cell for it -- and the requirement is simply that it
be indistinguishable from a standard tool row at the same depth.

It was not. Being a details.fold it drew from the foldable family
rather than the tool family: 500 weight at the top level, and inside a
step drawer the top-level rule stops reaching it, leaving 12px from
`.fold > summary` with the weight still inheriting 500. The icon slot
was skewed too -- the same defect's third landing point, missed by the
earlier survey.

Asserted as an equality against the neighbouring tool row rather than
against literal values, so a future change to the tool row carries over
instead of silently reopening the gap. An anti-vacuity case proves the
reference side reads real values first: without it, two `<unset>` reads
would agree and the whole comparison would pass while measuring nothing.

Size goes on the row box and weight only on the title, mirroring how the
tool row splits the same job -- putting weight on the summary would drag
the drawer's duration text down to 400, fixing one axis by breaking
another.

The thinking body is untouched: the diff is pure insertion, and every
selector is gated on `> summary` so it cannot reach it.

* fix(web): release event streams while the tab is hidden

Measured against the real runtime: the conversation page holds two
permanent SSE, and a browser gives one origin six sockets. lsof shows
that budget is shared across every tab of the profile, not per tab --
four EventSources in one tab plus four in another yields six established
connections and two stuck in CONNECTING forever. So a parked Open Design
tab takes sockets away from the one actually on screen, and requests on
that page queue for 26-35s while the same URLs answer in ~1ms over curl.

The shared manager already dropped its stream 30s after the tab hid;
the two streams that own their own lifecycle held theirs for the life of
the tab. New stream-visibility.ts carries the invariant once -- a stream
nobody can see must not hold a socket -- with the same grace window so
alt-tabbing does not thrash. Reopening re-fires onReady, which
ProjectView already treats as its reconcile point.

The run stream is deliberately excluded: dropping live agent output on a
tab switch would be a worse bug than the queueing.

This is not the packaged app's bug -- desktop and packaged both append
Chromium's ignore-connections-limit for loopback before whenReady. It is
dev, and it is self-hosted users, who are documented as opening the
daemon directly in a browser where plain-text loopback never negotiates
HTTP/2.

Tests assert exact socket counts, never negative matchers, and the fix
was verified by stubbing the binding back to a no-op and watching the
same three cases fail again.

* perf(web): coalesce duplicate boot reads and bound replay reattach

Attribution first, because the reported numbers were not one page load.
A clean cold open of the conversation page issues 71 fetch/SSE calls
across 39 distinct URLs; the 158 that was reported included a Next dev
hot-update firing mid-measurement, which re-ran every effect and
produced a near-complete second copy of the list. StrictMode accounts
for 11 of the 71 -- identified by identical stacks firing 1-6ms apart,
not assumed -- so production boot is nearer 60.

Two of the flagged endpoints turned out to be something else. The raw
file fetched "ten times" is twice per view: one fetch for the srcDoc
path plus the iframe's own URL load, a structural consequence of
keeping both iframes mounted, already guarded against StrictMode by
mtime-keyed in-flight tracking. Papering a cache over it would hide the
architecture rather than fix it. The message id seen "twelve times" is
never a GET at all -- it is twelve PUTs, one per buffered save during
streaming.

Four real duplicates removed via single-flight with ttl 0: join a
request still on the wire, retain nothing once it settles. No caller can
receive a body it did not itself trigger, so this adds no staleness
surface. Two of the four are keyed by workspace identity as well as
project, so a workspace switch cannot serve the previous scope's answer.

The reattach fan-out was real but the obvious fix would have lost
output: reattachDaemonRun resolves only when the stream ends, so a
semaphore around the whole call starves live runs of everything until
their predecessors finish generating. Only terminal replays -- finite
event logs that settle promptly -- go through the limit, and a run the
daemon still reports active bypasses the gate entirely. A 30s max-hold
stops counting a wedged replay without ever dropping it.

Anti-vacuity guards ride along in both specs: distinct projects must not
share a single flight, a read starting after the previous settled must
hit the network, all five recoverable messages must eventually reattach,
and a live run must never be queued. Each single-flight was reverted
individually and failed exactly its own assertion.

* test(web): catch two specs up to their product rulings, drop dead rules

Both failing specs were stale tests, not regressions, and both rulings
were found in specs/ rather than inferred from a source comment calling
itself a ruling.

thoughts-scroll-cap asserted the in-flight thinking cell carries no
scroll cap. The spec had not moved since 08-29; the implementation
dropped `scroll={!live}` four days later. The ruling is recorded in
chat-stream-scroll-research.md in the user's own words -- what was
overturned was the fixed height with stepped scrolling and a fade mask,
never the cap itself.

tool-input-delta-dead-wiring asserted an in-flight tool call renders no
row. D3 is struck through in the decision log and annotated superseded
(2026-09-02, OPEND-2419): a call renders the moment it is made,
regardless of outcome. Flipping the assertion could have turned the case
into one that only ever nods along, so it now also carries a leak probe
on the heredoc delimiter, which can only originate in the delta frames.
The file still guards what it always guarded.

Three dead rules removed with producer evidence rather than a grep:
direct strings, template concatenation, CSS Module camelCase, clsx
branches, e2e selectors, and specs anchoring the name.

Two candidates survived that check. `.run-error__description` is equally
unreachable from src/ but an e2e still anchors on it -- a selector the
same rebuild missed, so that suite is currently broken and the fix needs
an owner. And `.doc .nm` never existed in product CSS at all; the live
class for that cell is `.artifact-card-doc-name`, which two specs pin.
Deleting from the list as given would have taken out working code.

* fix(web): bare buttons in the panel take the draft's rendered 400

The old value was `inherit`, chosen from what the draft *declares*: its
global button reset sets no font-weight, which was read as "the button
follows the panel's typographic baseline". That inference is wrong.

Browsers style `<button>` through the `font` shorthand (Chrome: `font:
400 13.3333px Arial`), and a shorthand sets every longhand it covers --
so a button's weight does not inherit by default. Declaring nothing
leaves it at the UA's 400, not at the baseline. The draft's own
`.tool .fn { font: inherit }` is the counter-example: it is the only way
a button climbs back to the inherited 500, and it measures 500.

Measured rather than reasoned, in real Chrome over the delivered draft
page: 293 buttons read 400x195 / 500x43 / 600x55. The load-bearing
control is injecting a class-less button and a span into the same 500
parent and reading both at once -- button 400, span 500. One ruler, one
position, two values, so this is not a constant read back everywhere.
The span's 500 also shows what inheritance *would* have produced.

The reset rule is byte-identical between the measured page and the
current baseline 729fa43ce7, so the number carries over.

Scope is unchanged: `:where()` keeps this at (0,0,1) so it still loses
to any class, and the size, border and padding lines -- each the result
of its own earlier per-cell count -- are untouched.

typography-baseline.test.ts had encoded the same bad inference, down to
a failure message instructing the reader to write `inherit`. Only that
one case moved.

* fix(web): composer ink and plan-current ink, scoped to the panel

Composer: the draft puts body, placeholder and typewriter carousel on
1.7, and the footer icon buttons on the deepest ink with hover changing
only the background. Ours had 1.6 and a mid ink whose hover went
*lighter* -- so the pointer made a control read as losing focus.

The blocking concern was scope, and one part of it was wrong. The
annotation sidebar does not consume the editor rules at all: it passes
`composer={null}` and renders a bare textarea. The home hero does, so
these land as `[data-chat-root]` rules -- an existing hook that ChatPane
already stamps on the composer's ancestors and that neither the hero nor
the sidebar carries. The hero is untouched. Specificity carries it, not
source order.

All three layers move together: the writing surface, the static
placeholder, and the carousel that sits over the placeholder's position.
They are three states of one line of text, and lifting only two leaves a
0.65px jump at the moment they swap.

The hover background is also realigned, and it was not on the list --
found while editing the rule the list did name, and included because
"colours 1:1" is the standing instruction.

Plan: `--plan-current-text` is #353535 in the draft and has been across
both baselines. The spec pinning `--chat-text-strong` turned out not to
be a product ruling at all -- its anchors are the two cases above it,
and this one was a same-day convenience mapping onto an existing token.
It now consumes a new seam variable, declared in both scopes at the same
value because the draft carries no dark override.

Tooltip is deliberately not touched: there is exactly one live tooltip
in the product, used by 26 files across the whole app, so its 1.2
line-height cannot move without a scope decision. The dark pill measured
earlier was not it -- those are three dead `::after` blocks, all gated
on `:not(.od-tooltip)` while every caller carries that class.

The shared ruler now expands line-height and font-family. Before this
they read back `<unset>`, which is indistinguishable from "nobody wrote
it" -- so an equality between two of them passed while measuring
nothing. Additive only: `resolved()` yields just the targets a caller
names, and none of the 27 existing callers name these two.

* feat(web): move the visual-direction countdown and reshuffle per 729fa43ce7

The draft relocates two controls on this card: the countdown moves into
the card head's right edge, and "reshuffle" moves out of the footer into
the preview's top bar, left of the grid toggle, restyled from a square
ghost button into a pill. The footer slot it vacates becomes "skip".

The draft changed this in three render points, not two -- the scene page
carries the same card and was edited alongside the component page.

On our side the countdown was in the footer at 11px, and on the visual
card it never appeared at all: that footer is merged, and the merge
dropped it. It is now visible there for the first time. Size, weight and
ink follow the draft (12px / 400 / --text-soft).

The display format does not. The draft renders `30s`; product ruled
2026-09-02 to keep `M:SS` because it reads more clearly. That deviation
is annotated in the component, the stylesheet and the spec, so the next
alignment pass does not quietly put `30s` back. Measured in real Chrome
across five widths: `0:30` is 9.1px wider than `30s`, and the tightest
case still leaves 61px before the title, which wraps rather than
colliding.

Behaviour is unchanged on both paths: the countdown still finalizes the
same way, and skip reuses the existing handler -- the merged footer's
button already called it.

Both call sites are covered. The agent-authored one keeps its own shape:
its selector row cannot host the footer, so adding a second skip there
would put two on one screen. Collapsing them into one row means widening
the delegation rule, which is a separate decision.

* fix(web): the tool icon slot is 16px, matching the glyph it holds

Every glyph in the execution record already matched the draft
byte-for-byte -- paths, sizes, inks, stroke weights, both the tick image
and the eight-layer gradient orb. The one real difference was the box
around them: the draft sets the slot to 16px, we had moved only the svg
inside it and left the slot at 15, so a 16px glyph sat in a 15px box and
overhung half a pixel on each side. This has been in the draft since the
previous baseline and was never carried over.

The draft gates its rule on `:has(> svg)` to spare the leading dot and
the plan ordinal. Neither lives in this slot in the product -- the dot
was cut and the ordinal is its own element -- but something else does:
the thinking row's running state puts an Orb canvas here, not an svg.
Copying the condition would leave that state at 15 while the settled
state took 16, making the left edge jump between them, which is exactly
what an existing spec pins. So the rule is unconditional here, with the
reasoning recorded next to it.

Two reverse controls carry the change: the mark and ordinal columns must
stay 15, since widening the whole leading rail would also pass the
positive assertion while enlarging the tick and the orb; and the four
glyph fingerprints must differ from each other. The draft supplies a
third control of its own -- an empty slot in the same file measures 15px
under the same ruler, which is what proves the 16 comes from `:has`.

The gap between icon and text stays at 7px against the draft's 8px. That
pixel was set deliberately for OPEND-2516 and a spec guards it; the
draft's own three rows disagree with each other there (22 / 23 / 24),
so the two accounts cannot both hold. Left for a ruling.

* fix(web): the shell head keeps one size across all four run states

The draft renders this header at two sizes depending on state, and the
difference is not intentional: the running word lives inside `.shimmer`,
which needs its own font-size for the gradient clip and sets --t-body;
once the run settles the shimmer is gone and the plain text falls back
to the summary's --t-mini. So the title shrinks by a pixel at the exact
moment the run finishes. Measured in real Chrome on both sides: draft
and ours agreed at 13px running, 12px settled.

Product ruled 2026-09-02 that the four states read as one size. This is
a deliberate departure from the draft, annotated as such in both the
stylesheet and the spec: neither of the draft's two values is wrong on
its own, product chose the consistency. Aligning it back to --t-mini
would be revoking a ruling, not fixing a drift.

Scoped to `.fold.flat > summary`, which reaches only the outer shell --
the flat variant has exactly one call site and every drawer inside it is
a plain `.fold`. The base rule is untouched, so nested foldables stay at
12. The elapsed time on the same row declares --chat-t-cap for itself
and does not follow.

The four-state check is written as an equality against the running
state rather than four literals, so a later change to either side
reopens the gap loudly instead of silently.

Also records the standing ruling on the icon-to-text gap: 7px stays.
The draft says 8, but that pixel was set for OPEND-2516 with a spec
guarding it, and the draft's own three rows disagree with each other
(22 / 23 / 24) where ours are one column. Product kept ours.

* fix(daemon): a failed render check stays out of the visible reply

The user asked why the assistant was narrating "the desktop render
service is unavailable, so no preview was generated this turn". The
answer is that our own prompt told it to. Both live cores carried the
instruction -- the classic core said to "say so in your reply and move
on", the slim core to "state that clearly".

Model-authored rather than host-injected, and provably so: two recorded
runs report the same event in different words. A template would emit
identical bytes. Nothing in daemon, web or packages concatenates such
copy into message content, and there is no i18n key for it.

This is not a new product rule. OPEND-2577 already settled the same bug
one subsystem over, and its spec opens with almost this question --
"the sentence the user read was not a UI bug; it is what this prompt
told the model to write, verbatim" -- and asserts the disappearance of
"the image generation service is temporarily unavailable". The contract
it set is that operational detail belongs in tool output and daemon
logs, never in the visible reply.

One difference is honoured. For media the user had asked for an image,
so that precedent still owes them a next step. A render check is
something we do on our own initiative, so the correct output is silence
about the infrastructure.

Only the announcement is removed. The model still reports its own static
verification, and a test now guards that half specifically so a later
edit cannot take it out along with the leak. The single-render budget
shares those lines and is preserved -- it is a token guard, not copy.

The slim charter ceiling moves one documented step; the wording was
tightened twice before spending it.

* fix(web): hold the artifact card's loading face until the frame paints

The blank card was two things, and neither was the daemon being slow --
curl returns the file in 2.2ms and the four card frames never touched
the wire after the first.

The visible half is a contract violation in this component. Its own
comment says the pending face is for "not loaded yet", but the code
dropped it at `verified` -- the moment a HEAD probe returned, tens of
milliseconds in -- and revealed a frame that would not paint for another
six to sixty seconds. That gap is what the user saw. The frame now
mounts and loads exactly as before, hidden, and is revealed on its own
load event.

Blast radius is one caller: only cards pass `pendingContent`. The two
project grids pass nothing, and their CSS already hides the glyph, so
holding on for them would display nothing at all -- they are unchanged.

The other half is four identical HEAD probes fired at one URL, because
the same artifact appears once per turn and each card probes alone.
These now join a single in-flight request. Not a cache: the map holds
the pending promise and drops it on settle, so a later mount always
re-probes and a deleted file cannot leave a stale verdict behind. The
merged probe no longer aborts either -- one card unmounting must not
cancel another's, and the response is 300 bytes of headers.

The 59 seconds themselves are not ours to fix. The artifact loads
`https://cdn.tailwindcss.com` as a parser-blocking script, and that host
answers zero bytes in 70 seconds from here, so the body never parses.
Measured 7 requests after the change, down from 10-11, with the card
showing the pixel-liquid face throughout rather than an empty box.

The iframe timings that looked like queueing were neither queued nor
re-fetched: Chrome leaves the timing segments at 0 for iframe
navigations and runs the entry until the child document's load event.

* test(web): pin the turn footer's time and canceled mark, fix lying comments

Four reported defects, none of which turned out to need a code change --
so this adds the evidence and the guards instead.

The two from the gallery README were real once and are fixed. Time now
reaches both branches, and the footer wrapper is full-width rather than
the 220px shrink that had nowhere to push it to; measured, the time's
right edge sits flush with the row's. The canceled turn already renders
a grey dot, not a greyed tick -- which is what the draft asks for, since
a dot reports a state and a tick claims it passed.

The other two were artefacts of how the gallery inlines CSS, and are
worth recording because the same shape will recur. The "one shade too
light" ink is declared on an ancestor the gallery cell does not have, so
the token goes undefined and the fallback shows through; adding that
ancestor back in the page snaps it to the expected value. The divider's
five wrong axes are one rule that the gallery's selector picker never
inlined at all, leaving a bare div's defaults. Chasing either into the
product would have made real code worse to flatter a measurement.

The new spec covers the reverse half the existing ones left open. Two
holes surfaced while writing it: an assertion about the running state
was passing because that row does not render mid-stream at all, and an
existing negative assertion fell back to an empty string when its
selector stopped matching -- it stayed green through a simulated
rename. Both are closed.

Five comments corrected, each dated to the change that outdated it:
three still described a 15px icon column that is now 16, and two more
contradicted code sitting a line or eighteen away.

* fix(daemon): close the host-failure-narration class, not just its instances

Three times now a user has read our infrastructure's status in an
assistant reply -- image generation, then the desktop renderer -- so the
missing piece was a guard over the class, not a third instance fix.

Four more places were telling the model to relay host state the user
never asked about: the research contract's stderr rule, a skill relaying
provider text verbatim, and two lines in another baking our interpreter
version and credential configuration into the deliverable. Each rewrite
follows the wording OPEND-2577 already established.

One sweep hit was overruled. "If X/Twitter is unavailable, say so" is
about the subject of the research the user requested, not our plumbing,
and a coverage gap in their deliverable is owed to them -- the same
carve-out the previous commit made for an explicitly requested export.

The daemon-side redaction changed shape twice on contact with the code.
There was no log copy of the message at all, so redacting alone would
have destroyed the diagnostic rather than relocating it; the added log
line is now the only copy and a test pins the raw path to it. And the
web analytics parser buckets export failures by matching words in this
message, so flattening it to a fixed string would have silently emptied
those buckets -- it redacts paths, loopback ports and pids while leaving
every classifying word, with all three buckets pinned.

The guard scans directory globs so a new prompt or skill is in scope the
day it lands, and every hit needs a written verdict. It deliberately
cannot tell the two kinds apart -- whether the user asked for the thing
that failed is semantic -- so there is no verdict meaning "narrated host
failure, and that's fine": excusing a real leak requires mislabelling it
where a reviewer sees it.

Testing the detector rather than trusting it found three holes in it: it
scored zero on both sentences from the previous fix, it could not see
the canonical rule it was enforcing, and substring matching let a leak
be appended into its own suppression entry.

* test(web): pin four record axes reported as drift, none of which were

Four differences came out of the gallery's full-coverage sweep. All four
were measurement artefacts, so this adds the evidence and the pins
rather than a fix; the record stylesheet is byte-identical to before.

The shell head's orb is 24 in the product -- the component passes it and
the canvas carries it -- but the gallery has no React and reimplements
the drawing script, where the box is hardcoded to 20 and the element's
own data attribute ignored. Patching only the canvas size in the live
page brought the head from 32 to 36, matching the draft exactly, while
the two orbs that really are 20 stayed put.

The step rows showing "not started" under a head reading "completed" are
a fixture shape, isolated by moving one variable: the same tool calls
with a closing todo snapshot mark completed, and without one mark
neutral. That mapping is a deliberate ruling -- calling an unclosed
step complete "says something on the agent's behalf that it never
said" -- and those cells never send the closing snapshot.

The thinking paragraph was two different elements paired together: the
draft's opening narration against our reasoning body. Measured
separately, the draft's own reasoning body matches ours to the value,
and so does our narration against the draft's. The claim that a cell
reproduced the registered selector gap is also wrong -- no cell in the
gallery has that shape at all. The gap itself is real, proven by moving
real narration into a drawer and back in a live browser, but the draft
writes no rule for that position, so following it would be inventing a
value on the designer's behalf.

The terminal's 29px belongs to the inside-a-step column, which we have
exactly; the cell that measured 7 has a fixture marking its todo
complete up front, which sinks the block to the top-level column where
7 is itself a ruling.

The reverse controls pin the three standing deviations -- the mark's
offset, the summary gap, the stream ink -- so a later alignment pass
cannot quietly undo them.

* fix(web): sync the /search failure rule, and guard the pair against drift

The previous commit rewrote this rule in the daemon's research contract
but could not touch its twin in the composer, because seven groups were
working in apps/web at the time. The two are not merely duplicated
documentation: on a /search run the composer expands its copy into the
outgoing user message while the daemon renders its copy into the system
prompt, so both reach the model in the same turn. Since that commit the
model has been receiving "keep diagnostics out of the visible reply" and
"report that error" together.

They were written in one commit and have been identical ever since, so
the web copy now takes the daemon's wording verbatim. That also drops
the provider's name, which the new wording explicitly excludes -- fixing
only the stderr half would have left the leak the sentence names.

The parity guard lives in e2e/tests because this is a cross-app
consistency check and web must not import daemon internals, but the
deciding reason is CI reach: e2e_vitest runs when either app changes,
while a guard on the daemon side would not run at all for a web-only
change -- exactly how this drift got in.

It pins the rule's four clauses rather than its prose, since the clauses
are the product decision and the wording is not. A literal check rides
alongside because the semantic form has a hole the author demonstrated:
appending a leak to a compliant sentence keeps all four clauses true.
The escape hatch for a future platform-specific difference is written
next to it.

Two anti-vacuity cases are permanent residents: the judgment is fed the
sentences that really did ship before the fix and must say where each
one fails, and the extractor must throw rather than return an empty
string when the sentence is renamed away.

Also repairs the daemon spec the previous commit left red -- it still
asserted the deleted wording -- and points it at the parity guard.

* perf(web): remove three boot reads that could never have succeeded

Cold start on a conversation page drops from 68 requests to 58-61.
Attribution came from wrapping fetch and recording call stacks, which is
also how two of the five briefed causes turned out to be wrong.

The team catalog's three requests were not a permission race and it was
not "the second one is the bug" -- all three were. The signed-in
account's active workspace is personal, and the daemon answers 403 for
any non-team workspace, so every one of them was guaranteed to fail
before it left the browser. The catalog now declines locally, and it
throws rather than returning empty so each consumer branch stays
byte-identical to the 403 it replaces. A personal-to-team upgrade goes
to the wire on the very next read.

Analytics was not two owners missing each other's window -- measured,
they already coalesce. The client drops its memo on every null result so
a later opt-in can retry, which means every track() call re-reads the
endpoint for the entire life of any session where analytics is off:
every user who declined, and every dev build. The retry is now tied to
the event that can change the answer.

The design-md hook was reading /files with a bare fetch, so it could
neither join the read ProjectView already had open nor collapse its own
replay. Routing it through the shared reader needed two flags to keep
its behaviour: authoritative reads so "no DESIGN.md" stays distinct from
"could not read the directory", and a fresh read on explicit refresh so
the shared 1s window does not become a cache and freeze the staleness
chip -- an existing test caught precisely that.

Two of the seven vela status reads are deliberately left alone. That
third caller also runs immediately before POSTing a read receipt, so it
is an authority question, not a display read; wiring it to the shared
reader let a post-sign-in check join a request issued before sign-in and
the receipt went to local storage instead of the account. Proven with a
probe, then reverted, with the reason recorded at the call site.

The plugin catalog is likewise untouched: collapsing identical
concurrent reads there does not just remove a request, it makes the
same-key ordering race unreachable that a spec explicitly pins. That is
a change to the module's concurrency contract and wants a ruling.

Two suites needed adapting rather than leaving broken: they drive the
shared file reader through once-queued mocks and now have a second
legitimate caller, so the hook is stubbed there and no assertion moved.

* fix(web): resting option rows and the visual card head take the draft's weight

Two of seven reported differences were real. The rest are recorded as
pins so the next alignment pass does not raise them a third time.

Buttons do not inherit weight -- the UA applies a `font` shorthand -- and
the draft's reset only sets font-family, so its option rows rest at 400.
Ours declared 500 for itself, which is how it escaped the same
correction made for bare buttons earlier. Checked row by row across five
cells: the ten resting rows move, and the nine the draft writes 500 for
stay, matching value for value.

The visual card's head hook was attached to the text-options class only,
while that card renders through the picker class, so it never reached
the rule that drops option-bearing card heads to 500. The draft's
selector keys on the options container, which the visual variant is one
of, so its head is 500 there too.

Three of the reported visual-card differences were the product being
right. The 4:3 preview is a ruling with its own guard -- the catalogue's
96 images are all 4:3, and a portrait card would crop 46% of each. The
stacking offset is derived from the card's own width by one formula on
both sides, not an independent value; a geometry assertion now fails if
one moves without the other. And the four sample tints are our real
per-style palette sitting behind real assets, while the draft's flat
grey is the backdrop behind four placeholder JPEGs its own README marks
for replacement.

Two more reported weights were the brief being wrong: the draft writes
500 for the own-answer textarea and the reconnect line itself.

The option row's right inset is left alone. The draft is symmetric at 5,
ours carries 11 from OPEND-2402, and pushing it back measures no
overflow today -- the same fix's wrapping changes cover it and the text
column is identical at both values -- but reclaiming that margin is a
product decision, not an alignment one. Pinned as-is, including against
being collapsed back into the shorthand.

Two specs had written "resting equals 500" as a fact; only those cases
move, and the sibling constants the draft really does set to 500 are
untouched.

* feat(web): the design-system creation message is the draft's status card

This card was ours to begin with, then deliberately removed in favour of
a plain user bubble carrying a dictionary string, with a spec asserting
its absence. Product ruled 2026-09-02 to follow the draft, so it comes
back and that assertion flips. Both decisions are recorded where the
assertion lives, with the draft commit and line -- this position has now
been decided twice in opposite directions and the next reader should be
able to see that.

The width is the one place the draft cannot be copied as written: its
280px rule hangs off the gallery's cell container, which does not exist
in the product, while the component itself declares a 320 max. The QA
sheet measures 280 x 67.98 and reasons about the description occupying
two lines, so 280 is what the designer accepted. It is pinned on the
component and the unreachable 320 is not carried over -- keeping it
would only suggest a ceiling that can never apply. Measured live: 280
wide, two-line description, 67.98 tall.

The four literal colours become seam variables per the component
contract, declared in both scopes at one value. The draft carries no
dark override for any of them -- every dark block in that file is for
something else -- so inventing one was not on the table. The bubble's
identical black is deliberately not reused: it flips in dark mode and
would drag this card along with it.

The existing dictionary key is kept rather than replaced: two other
callers still use it for the menu item and a first-turn title fallback,
so this adds two keys across all nineteen locales and guards that the
old one survives.

An anti-vacuity check caught a would-be false pass. The description's
weight is inherited, and the ruler only computes rules applied to the
element, so comparing it directly read `<unset>` on both sides. It is
now two assertions: that nobody declares a weight here (with the same
ruler proving it finds one on the title), and that the seam baseline is
what it inherits.

The revert pass found the hole the seam layer's own documentation warns
about: removing only the dark-scope declarations fails one assertion and
leaves every pixel check green.

* perf(web): give artifact cards their own load budget instead of no budget

Artifact cards bypassed the thumbnail gate entirely, and the reason was
sound but too broad: the gate suspends itself the moment a project route
becomes active so background covers do not compete with the project
opening, and artifact cards live on that route, so going through it left
them grey forever. Having a budget and yielding to the foreground were
one switch; they are now two.

The exposure is real. One message can produce as many cards as it
produced files -- measured up to 28 on a single message, with no cap
anywhere in that path -- and `loading="lazy"` barely holds: 24 cards in a
900px viewport start 16 documents without scrolling. Cards of the same
file do not save a request either, because /raw/ answers no-cache, so
eight cards of one file are eight round trips whose documents each run
the page's scripts and re-fetch its externals.

Four rather than the grid's six: the grid's number assumes it steps
aside when a project opens, and this lane never does, so it must leave
more headroom for the project's own reads. Four is also two rows of the
two-column layout, and above the common case -- median two cards -- so
the ordinary shape never queues at all.

Queued cards show the pixel-liquid face, which is what "not loaded yet"
already means here, so the standing rule that a fallback stays an
ordinary card face is untouched.

The positive assertion pins the exact budget rather than an upper bound,
since a ruler that broke and read zero would satisfy `<=`. A second case
pins that all twelve sources eventually mount, so throttling cannot
become dropping.

A `suspendable` flag written along the way turned out to gate nothing --
flipping it left every test green -- and was removed rather than left as
decoration.

* chore(design): rebuild the mirror gallery and make its ruler honest

The checked-in gallery differed from the code by 318 structural blocks,
so anyone accepting against it was reading roughly five hundred
differences that did not exist. It is regenerated, and the draft side --
previously a local artefact that never entered git, most recently served
from a stale commit on a local port -- now has a build script with the
draft's sha written into it, taking its sources through git rather than
a path someone happened to have.

Three fixes to the differ itself. A neutralising rule written for the
old matrix page was overriding the draft's own weight declaration at
equal specificity. The selector filter ignored linked stylesheets, so
the 452KB file holding the visual-direction styles read as "the draft
never wrote this" -- and it now scans the page being measured rather
than fetching a second copy, because that fetch was observed 404ing
silently and emptying the filter, which looked exactly like the property
count improving. Numeric wildcarding lets differing durations pair up,
with any surviving text difference reported in its own column so a real
2/4-versus-3/4 surfaces instead of vanishing into the unpaired lists.

Two more ways the ruler lied, both found by testing it: readings drifted
run to run because one cell animates, and cells measured through a crop
compared a cropped element against an uncropped one.

Six cells the draft added are now populated -- all six turned out to be
implemented -- and two fixtures were measuring the wrong thing: the
queue rows never passed a steer handler, so they rendered the fallback
button rather than the one the draft draws, and the waveform fixture
duplicated the draft's 28 bars into 56.

The inlining guard reconciles per selector rather than per class name,
because a class can arrive through a group rule while its own seven
declarations never do -- which is exactly what one cell was doing. Cells
missing rules now say so in their own note instead of reporting the
absence as a difference.

Ten further classes of stale prose are corrected, the worst being
cross-references that shifted when the draft inserted cells and now
pointed reviewers at the wrong one.

* fix(web): artifact cards carry the project-relative path, not the basename

Clicking a card for a file in a subdirectory changed the route and then
silently did nothing -- no tab, no request, no error -- while its cover
stayed grey forever because the frame was pointed at a 404. The cards
that did work were in the project root, which was coincidence rather
than partial correctness.

`deriveFileOps` truncated the agent's absolute path to a basename, on a
comment asserting the project file API indexes by basename. Measured
against the live daemon, that is false: `provenance.md` answers 404 and
`context/provenance.md` answers 200, and both `producedFiles[].name` and
the file listing carry project-relative paths. The daemon was right; the
truncation was ours. It only surfaces through the fallback branch, taken
when a run is cancelled before `producedFiles` is populated.

One truncation, five consumers, all confirmed broken: opening, the cover
URL, export and share, deduplication -- which merged a root README with
one under assets/ and made the second disappear entirely -- and the
pairing against captured snapshots, whose labels are project-relative,
so a subdirectory artifact could never find its own cover. The text
row's open affordance was gated on the same key and was therefore false
for every subdirectory file, so those rows had no arrow at all.

The resolution reuses the judge that markdown links and record rows
already share, and accepts only positive evidence: the resolved project
directory as a prefix. It deliberately does not take the file listing
and match by name -- a project really can hold two README.md files, and
opening the wrong one is worse than opening none, which is a failure
mode the record-file-open rules call out by name. Without evidence it
falls back to the basename, exactly today's behaviour.

Two user-visible consequences, both arguably corrections: a card for a
nested file now reads `context/provenance.md`, matching the tab that
opens; and same-named files in one turn now produce a card each, which
is what the dedupe comment always claimed to do.

Files the agent writes outside the project are still not openable. That
is left alone on purpose for the same reason as above.

* fix(web): the status card's radius follows our scale, and its spec says so

Asked whether to copy the draft's literal 14px or use the token that its
neighbouring bubble already uses, the answer was to use the token. Our
shape scale runs 2/4/8/12/16 and has no 14, so copying would have meant
hard-coding an off-scale value for this one card while the bubble beside
it sits at 12.

The stylesheet was changed without its spec, which left the suite red.
The radius now has a case of its own rather than riding along in the
box-model one: it is the single axis on this card that deliberately
departs from the draft, and burying that in a shared assertion is how a
later alignment pass ends up "fixing" it back. The draft's value is kept
beside the shipped one, so a future reversal edits one line instead of
re-deriving where 14 came from, and a reverse control fails if the two
ever become equal.

Three type errors from earlier work are also cleared. The worst was
`tForLanguageTag(...) as never` -- an assertion that the value cannot
exist, which type-checks where it is written and only fails at the next
call site, which is exactly what happened when the gallery started using
it. It is a non-null assertion now, with the reason recorded.

The other two: `as const` on a fixture made an array readonly against a
mutable prop, and a context helper was given null where the contract
says string or undefined -- a personal workspace has no team id rather
than a null one.

* test(web): pin four reported defects to the commits that fixed them

Three of the four are genuinely fixed and now have regression pins; the
fourth is half done and says so. Each conclusion comes from putting the
pre-fix implementation back and watching the case fail, not from running
the suite and seeing green.

The turn-boundary defect and the stacked completion states turned out to
be one symptom seen twice: the answers a user gave between two runs were
sorted behind both shells rather than dropped, which is why the two
completion states ended up adjacent. Both halves fail together before
the fix and pass together after, and a preliminary check confirms every
element was on screen throughout, so this was ordering rather than
omission.

The todo pin covers count and order separately, because the report named
both: before the fix the shell listed eleven rows against a pill reading
nine, with the running step at position nine instead of six. Its fixture
copies the event shape out of a real recorded TodoWrite rather than an
idealised one.

History replay is confirmed in the browser as well as in tests -- 2319
characters of history land in one step with nothing revealing, and the
agent's subsequent output still reveals character by character. The
existing spec covered the behaviour but passed the flag in directly, so
the wiring that decides whether replay happens at all was never
exercised; that gap is closed.

The upload surface is where it stops. The waiting screen now paints the
attachments immediately from local object URLs, and that half is pinned
here. The wait itself is unchanged: measured on a real machine the
handoff screen held for nineteen seconds, ending exactly when the last
upload finished, because the attachment handshake still reads server
paths synchronously on the project view's first render. The failing case
for that half is deliberately not committed -- it would redden the suite
for everyone before its fix exists.

* perf(web): recycle artifact card frames by last-seen instead of keeping all

Cards mounted a live document and never released it, so scrolling a long
conversation left every frame it had ever passed alive: measured on a
real machine, twenty frames climbing monotonically and still twenty
after scrolling back to the top.

Distance from the viewport cannot decide this, and that is measured
rather than assumed. Artifact cards live inside a scrolling chat log, so
the intersection is clipped by that ancestor before any root margin
applies; three observers at 0, 160 and 3000px over the same cards at the
same scroll positions returned identical readings. The same measurement
shows the existing overscan margin does nothing for these cards -- they
begin loading only once genuinely visible.

So the policy is least-recently-seen: everything on screen is kept, and
off-screen frames are retained eight deep. Eight is one full screen of
buffer at the measured layout, which means scrolling down past a card
and back never reloads it, and it sits above the ninetieth percentile
session, so nine conversations in ten never evict at all.

Recency also matches where the cost falls. Remounting is a full reload
-- the raw route is no-cache, the page's scripts rerun, its externals
refetch -- which is how a card with an unreachable CDN link stayed blank
for tens of seconds. Under this policy the just-viewed card is always
last to be evicted, so the cheap gesture is free and only a jump back
across many turns pays, which is a wait that gesture already implies.

Nothing in the frame is lost: pointer events are off, an overlay button
takes every click, it is keyboard-unreachable, sandboxed without
same-origin so it holds no storage, and it cannot scroll.

Slot release now also reclaims settled slots, without which a recycled
card would remount holding its old grant and slip past the concurrency
budget entirely.

Two defects in the policy surfaced from its own reverse controls rather
than review: timestamps were recorded on entry, so the card watched
longest was evicted first, and per-card eviction from partial snapshots
over-evicted where one decision per scroll was needed.

Live curve after: rises to sixteen, falls back to eight, and at every
sample equals visible plus eight. A recycled card returning shows the
pixel-liquid face, with no placeholder or unavailable copy.

* fix(web): the feedback panel's translator prop was uncallable by type

Its `t` was declared `(key: never, ...)`, which says no key may ever be
passed, so the prop could not be used from the day it was written. It
went unnoticed because its only external caller asserted its own
translator `as never` to match -- two mistakes covering each other, with
tsc satisfied at both ends. Removing the cast on the caller is what
surfaced this.

The vars type was wrong in the same direction: `Record<string, unknown>`
is wider than the interpolator accepts, and the extra width is values
that cannot be substituted into a string.

Both now match the signature `useI18n` hands out and that the settings
and design-browser panels already declare.

* fix(web): the plan pill's bottom reserve has never actually applied

The reserve was written, the class was attached, and a spec asserting
that class stayed green -- but the pill still covered the last line,
because the value was being erased by the cascade. A skin rule sets
`padding: 18px 18px 20px` at equal specificity and later in source
order, and that shorthand takes `padding-bottom` with it. What survived
was `scroll-padding-bottom`, which a shorthand does not reset, leaving
the rule half alive and looking implemented.

Doubling the class name lifts it to (0,3,0) so it wins on specificity
rather than on where it happens to sit in the import order. The skin
rule is untouched; rewriting it would drag along the gap and background
it also declares.

The 52px is arithmetic the spec re-reads from the stylesheet rather than
restating: 12px of pill offset plus its 32px height, and 8px of air.

Measured in the browser on one page: removing and re-adding the class
moves the bottom padding 20 to 52 and the scroll height by exactly 32,
and the last row shifts 32px relative to the pill. Across a real run the
padding is observed at both values as the reserve follows whether the
turn has a plan at all -- deliberately not whether the pill is visible
this instant, which is what stops the reserve from feeding its own
scroll position.

Streaming stability was checked rather than assumed: sampled every 250ms
across a 32 second turn, the padding never recomputes and the last user
message holds its viewport position to the pixel.

The jump-to-latest threshold is unaffected: at the bottom the scroll
position absorbs the same 32px, and away from it the gap grows by 32
against a floor of 320.

This does not address the large blank that appears mid-run. That space
comes from the tail spacer, which runs an order of magnitude larger, and
collapsing it is a deliberate behaviour with its own reasoning.

* fix(daemon): carry codex's patch through so its file rows show +N -M

Codex file rows have shown a duration where Claude's show line counts,
and a comment explained why: codex reports a path and a kind, nothing
else. That is still true of `exec --json`. It stopped being true of
`app-server`, which became the shipping default the same day the comment
was written, and which carries the patch in a required `diff` field.

The loss is not where the comment sits. `toExecItem` rebuilds each
change as `{path, kind}` before handing it to the shared codex branch,
so from there down the wire genuinely looks unchanged and the comment
kept reading true. Narrowing the frame to match the older transport is
the whole bug: same shape does not mean same information.

The counting reuses the rules already in `diffStat` rather than adding a
second opinion. Codex's two kinds line up with Claude's two cases, and
`add` is the one that would be easy to get wrong: its `diff` is the
whole file body with no prefixes, not a unified patch, so reading it as
one would report every new file as +0. Matching Claude down to its
trailing-newline quirk matters more than being independently right --
otherwise the same file reports two numbers depending on the agent.

The patch itself never reaches storage. Tool inputs are persisted whole,
so the counts are taken in the daemon and the text dropped; a spec
builds a 20k-character patch and asserts the serialised event stream
contains neither it nor an `@@`, and stays under 400 bytes. For
comparison a Claude write on this machine persisted 47k characters of
content.

The rollback transport is covered rather than assumed: its recordings
still produce byte-identical events, and a diff that is absent, null, or
not a string leaves today's behaviour intact.

The parity spec now asserts the superset in both directions, so a
regression that stops emitting counts cannot hide inside the strip.

Three more comments in the same file were describing the older
transport, and the repo's own recorded fixture has carried a `diff`
since the channel landed with nobody reading it.

* fix(web): strike only what is dead, and surface why a tool failed

Every step in the list was struck through, including the one with a
spinner on it. The cause is not a selector reaching too far -- it is
derived per row, and the condition was "abandoned or recalled", where
recalled alone was enough regardless of what the row is doing now.

It is also not occasional. Our own recall block instructs the agent to
re-list the plan whole with finished rows still marked completed, so a
turn that picks up an earlier plan recalls every row by construction.
Traced through a real recording: eleven steps completed in turn one, all
eleven re-emitted in turn two, all eleven struck. Codex reaches the same
contradiction by a second path, where the first unfinished row is lit
implicitly and can be both running and recalled at once.

The condition is now "anything this turn actually touched is never
struck": abandoned rows still strike, running rows never do, rows with
content under them this turn do not, and what remains -- old business
recalled but untouched, or opened once and closed unused -- still does.

This overturns two cells of the spec's own table, which had recalled
rows striking even while being worked on. Three existing cases pinned
those cells; rather than delete them their sampling point moved to the
row that is recalled and untouched, which is what they were really
there to prove, each with a reverse control so that striking everything
cannot satisfy them again.

Separately, the failure reason was hardcoded to null one line after the
failure itself was computed and one line before the same text was stored
elsewhere, with no comment. Two designed outlets were dead as a result.
It now carries the text the call returned, untrimmed beyond whitespace,
and the row that displays it already truncates to one line -- so the
open question is not whether to cut it but how the rest is reached,
which is the same question as the button that was never wired.

The compound-command label is left alone and recorded: segments are
scored by verb and the highest wins, which picks the read half of
`sed && rg` and names a file that read fine, while the failure came from
the search. That scoring exists to name successful calls and mis-serves
failing ones; fixing it needs its own spec.

* fix: an empty thinking frame is a heartbeat, not an empty frame to drop

Claude sends every thinking delta as an empty string -- the content
never arrives, but the frames do, roughly one pair every 1.4 seconds.
Yesterday's fix for a retired protocol added a stripper and dropped any
frame whose stripped text was empty, which is all of them. The panel has
shown minutes of blank ever since.

The test for emptiness was asking the wrong question. It now asks
whether the upstream sent characters at all: nothing sent is a
heartbeat and goes out, characters consumed entirely by stripping is the
markup case the previous fix was for and still drops. That distinction
already existed in the lifecycle tracer under another name, so this
moves an established rule to where it was missing rather than inventing
one.

The frames are also the transport's pulse. Replayed against a recorded
1150-second run through the real function, the old test left 943 frames
with a 300-second silence; the new one restores 1357 and a 73-second
worst case, which is what the silence detector was calibrated against.

A second drop sat downstream and would have made the daemon fix
invisible: the client's buffer also tested the accumulated text for
truthiness, so no thinking event ever reached a message and the shell's
thinking state could not light up during a live turn. It now records
that a frame arrived separately from whether it carried characters,
leaving the array identity untouched so a run of empty frames does not
churn the message.

The guard that empty deltas render no paragraph is kept, and verified by
mutation rather than assumed -- the client has two such checks and
removing only one still passes, so a spec asserting it needed both
removed to go red.

The other six thinking-delta producers are unaffected: four already gate
on non-empty text, and the shared codex path is covered.

* fix: recall the whole task list, and let the pill follow the agent

Stopping a run mid-plan and then asking to continue produced a shrunken
plan: the two finished steps vanished and only the two open ones came
back. The filter that dropped them sits in the daemon, before the prompt
is built, so the model never saw them -- no amount of prompt wording
could have recovered what was already gone.

That filter had no independent reason. It carries no note about token
cost or about keeping the model on task; it was simply the literal
reading of the feature's original name. What it did carry is a gate --
an empty list means render nothing -- and that gate moves to the render
step, asking whether anything is still open, so a fully finished plan
still produces no block and the body stays byte-identical.

Two pieces of evidence say the completed rows belong there. The block
itself already instructs the agent to re-list the plan whole with the
finished rows still marked completed, which the filter made impossible
to satisfy. And the spec's own table has a row for a step recalled after
finishing in an earlier turn -- a row that can only be drawn if the agent
re-emits it.

Three guards against redoing that work, all placed before the list: the
heading no longer promises unfinished items above rows marked completed,
a sentence states plainly that the finished work exists and must not be
redone, and the invitation to decide is narrowed to the open items so
that not-redoing did not quietly become must-continue.

The pill was reading the newest snapshot anywhere in the conversation,
which is why an unrelated question could still show a step count from a
turn that had nothing to do with it. It now shares the primitive the
transcript card already uses, stopping at the newest assistant message:
if the agent declared no list this turn, there is nothing to show. That
is the same rule the recall design states -- what surfaces is whatever
the agent chose to re-emit, never the client's own inference.

The session-wide reader stays, with both call sites documented as the
pinned-card-era lookup that is wrong for a live turn.

Verified end to end after the daemon restart: the recalled list arrives
with three of four rows marked completed, the agent opens by saying the
first three stay done and touches none of them, and the pill and the
card report the same count.

* fix(web): reclaim the anchor spacer once it is the only thing on screen

Sending a message anchors it to the top of the viewport, which needs a
screen's worth of room beneath it that does not exist yet, so a spacer
supplies it. While the anchor holds, that spacer shrinks as the reply
grows. The moment the reader scrolls away it froze -- deliberately, to
keep scrolling down from snapping -- and a turn that then produced a
thousand pixels of text left a quarter screen of content above two
hundred pixels of nothing.

Distance from the bottom cannot decide when to reclaim it: a turn starts
at zero and grows, measured here from zero to 981px, so any pixel
threshold expires within seconds. The question is instead how much of
the spacer is actually intruding on screen -- its height less the
distance below -- acted on only past 52px, which is the reserve the plan
pill already takes and therefore the smallest gap worth moving the page
for. That quantity also says something the distance cannot: once it is
positive, everything below the fold is ours rather than the reply's.

Not oscillating is structural rather than tuned. A frame may move the
view at most 24px, less than one trackpad notch, so the collapse can
never outrun the reader's smallest deliberate scroll. And the decision
is latched: a spacer is asked once whether it should go, then shrinks
monotonically, so crossing the threshold repeatedly cannot toggle it.

While the anchor is still held nothing changes -- that path swaps growth
for shrinkage at constant height, and mixing the two is what would jolt
the anchor, so this runs only after the anchor releases.

Reported as jitter during a live turn and traced to the probe rather
than the change: with every driver removed, two uninterrupted runs of
38 and 37 seconds show the scroll position never moving and the spacer
never reversing while the content grows past 1800px.

The scroll handler also stops scheduling frames for a spacer too short
to act on, which is the idling that would have fed such a loop.

One neighbouring guard moved with it. Its premise -- that following
still works while a spacer is present -- is unchanged, but it pinned the
spacer's height in the one situation this reclaims it, so it now checks
following at every step instead of only at the end.

* fix(web): restore the balance gate for accounts with no plan behind it

An empty wallet in a personal workspace could send freely: an early
return meant to spare subscribers had widened until it caught everyone.
Its condition asks whether a model was selected, and one always is --
the agent's default fills in when the user picks nothing -- so every
send in a personal workspace took it, and because zero is below the warn
threshold it swallowed the hard block too.

The judgment rotted in two steps rather than one. It began as plan tier
crossed with whether that model is unlimited on the tier; one change
replaced the local table with a live list and lost the tier half; the
next removed the list and left only "a model is set". Each step was
locally reasonable and the pair is not.

The intent behind the last step is preserved exactly, and it was
deliberate: the author rewrote those assertions and renamed them to say
we should stop guessing whether a selected model is metered. Every case
he wrote uses a paid tier -- the word "free" appears nowhere in that file
-- so an account with no plan behind it was never considered. His cases
are untouched and still pass.

So the gate now engages only where the account is provably free. The
tier is still available, through a resolver both callers already use in
the same breath as this gate; only the per-model coverage is gone. When
the tier cannot be read at all the request is allowed, matching what the
old check did when its list was missing, so a failed read can never
block someone who paid.

Restoring the zero-dollar block unconditionally would have recreated the
incident this rule exists to prevent -- an empty wallet is the normal
state on an unlimited plan, which is what the original change was called
for -- so it is restored only where correctness is demonstrable.

One cell of behaviour changes for users: a personal workspace with no
subscription and an empty wallet is now stopped before sending instead
of failing partway through the run. Low balance on a free tier resolves
to a soft warning that stays invisible, since both callers already
suppress soft warnings for free tiers by an earlier ruling.

* fix(daemon): read the turn boundary from every frame the CLI actually fills

Claude Code 2.1.259 leaves `stop_reason` null on the assistant wrapper frame,
which is the only place we read it. So `turn_end` never fired, and the
per-turn state hanging off it never reset — a second turn's genuine inline
HTML artifact was silently swallowed as an echo of a file the FIRST turn had
written.

Rather than swap one frame for another, read all three and dedupe by message
id in `emitTurnEndOnce`: the wrapper frame (older CLIs and argv-compatible
forks still fill it), `stream_event` -> `message_delta` (2.1.259 with
--include-partial-messages), and the `result` frame (the only boundary a
build without partial messages emits at all). Which one arrives is a property
of the build in front of us, and the only trustworthy capability check is
whether a given frame carried a value.

Verified against six byte-for-byte recordings of a real 2.1.259 CLI, kept in
tests/fixtures/claude-cli-recordings/ with the flags used to make them. Four
corpus guards assert the recordings still have the shapes the tests depend on,
so replacing them with hand-built fixtures cannot quietly vacate the suite.

* feat(chat): name the file a write is targeting while its arguments stream

Writing a 40KB page took the model 196 seconds, and for 140 of them the row
said nothing but a running clock: `file_path` arrives in the first ~180 bytes
of the arguments, but the row waits for the last byte of `content`.

The daemon now scans the argument buffer it already keeps and, the moment the
path is provably complete, emits `tool_input_target` — id, name, path, under
400 bytes. The client renders it as an EARLY FORM of the same call, so the
verb, icon and file-name button are the existing ones; no new copy, no new
branch. The arguments themselves never leave the daemon, and the row upgrades
in place when the real `tool_use` lands.

Measured against a real 2.1.259 CLI writing 27.6KB: the name appeared 102.8
seconds early, stayed put while the buffer grew from 240B to 27.6KB, and
matched the final `file_path` exactly.

Nothing half-formed is ever shown — a path whose closing quote never arrives
emits nothing, and an in-flight call is not counted as a file operation,
because the model has only announced an intention to write.

* fix(web): show the low-balance reminder to every plan, not just three of them

QA reported a Pro account at $1.79 starting a task with no warning at all
(OPEND-2600). Tracing it found the card was invisible on FOUR tiers of a
personal workspace, and for two different reasons.

A plan-aware early return sat in FRONT of both the hard and soft branches, so
any account that was not literally 'free' returned `allow` before the soft
tier could be computed. Behind it, the render side filtered on a paid-plan set
of exactly {plus, pro, max}, so free, `go`, and an unreadable plan were
dropped even when the gate did produce a warning.

Product ruling 2026-09-03: a reminder is not a block. A subscriber with an
empty wallet still sends — that ruling stands — but they are still told, and
so is everyone else. "They have a plan" therefore stops suppressing the
reminder while remaining exactly the right reason not to HARD block: an
account with no plan behind it has nothing but the wallet.

The plan lookup now happens only where it still decides something, at or below
zero. A wallet at $1.79 no longer makes any upstream call at all, which is
both the fix and a small speedup on the send path.

Spec: specs/current/chat-panel-decisions-sheet.md T37-T40.

* fix(chat): make the queue's steer button interrupt the run and send

The button offered to slip a queued message into the turn already running, by
writing it to the agent's still-open stdin. Two measurements retired that idea.

Only 2 of 27 runtimes declare `stream-json`, so 25 agents never saw the button
at all. And against a real Claude Code 2.1.259, a user message written to
stdin MID-TURN was simply never processed — the process stayed alive and idle
for 180 seconds — while the same bytes written after the turn's `result` frame
started a second turn normally. The daemon closes stdin at that same `result`,
so the window we offered and the window the CLI accepts may not overlap at all.

Interrupting, by contrast, works on every agent. The button now stops the
running turn and sends the queued row immediately, reusing
`sendQueuedChatSendNow` — which already tags the superseded run so its late
terminal callbacks cannot pollute the replacement, and un-sticks preview
comments left mid-apply. The hover copy says so, and the attachment carve-out
is gone: attachments could not ride a steering frame, but they resend fine.

The capability check went with it. There is no such thing as an agent that
cannot be interrupted, so the button no longer has a disabled face to explain.

Fixes OPEND-2602.

* fix(chat): turn the fork icon the way the design draws it

The draft's branch glyph is remix's `git-branch-line` mirrored across y=12:
its third node sits at (18,18) and the branch runs DOWNWARD, where ours runs
up. Verified point by point — all 92 coordinates match the reflection, the
only difference being a float tail (8.417 vs 8.4175) — and it is not a stray
export: all six component cells and the footnote carry the flipped path, and
the prose agrees, "a divider drops in place, and below it is the new turn".
The new conversation is below, so the branch grows down.

Added under its own key rather than editing `git-branch-line`, which is an
upstream remixicon asset: rewriting a name someone else's icon resolves
through would silently change glyphs elsewhere.

Three tooltips also went back to the draft's wording — 没帮助, 复制, 新开会话.
The buttons themselves needed nothing: the 26x26 hit area, the corner radius,
all four state colours and the 2px gap were already byte-identical.

Not touched, and left for a product call: nine differences in the tooltip
bubble itself (radius, padding, line-height, material, border, shadow, gap,
wrapping, and the draft's 100ms fade). Those live on `.od-tooltip-layer`, a
primitive shared by the whole app, so the draft cannot be applied there
without deciding it governs every other surface too.

* feat(chat): give the panel's icon buttons the tooltips the design asks for

`data-tooltip` appeared exactly zero times anywhere under components/chat —
the execution shell, tool rows, primitives, the error card, the upgrade card,
reconnect and audio all shipped with `aria-label` and nothing a sighted user
could see. The draft names three of them outright, and those three are now
wired: the thumbnail strip's 查看大图, reconnect's 查看详情, and the error
card's 联系支持.

The conversation-history key was worse than missing: it carried a native
`title`, the browser tooltip that appears after half a second in a style no
one controls — precisely what the draft's own comment argues against. It now
uses the same layer as everything else, points down as drawn, and wears the
draft's outlined clock instead of a filled speech bubble.

The draft also puts a new-conversation key in the panel header. Ours had the
entry buried inside the history dropdown behind the word 新建, so the icon key
is back, wired to the same handler, same disabled condition, same analytics.
The dropdown item stays for now — a UI test locates it — so there are two
doors to one room until product picks one.

Nothing was changed on `.od-tooltip-layer` itself.

* feat(chat): show tool work while it is happening, not after it ends

Two halves of one product ruling — "show a long action as early as we can,
and keep a clock running on it so people can feel where it is stuck."

**ACP agents (opencode, AMR/vela, and the six others) hid every tool call for
its entire life.** Rows were emitted only at the terminal frame, so across 141
recorded local runs, 855 of AMR's 2779 wall-clock seconds had a tool running
with nothing on screen — and the hidden time almost exactly equals the total
tool time, because a call was invisible for 100% of its span. The daemon had
the first frame in hand within 2ms of the agent sending it. Bash was 58% of
the loss, not file writes. Rows now open on that first frame and upgrade in
place: 57.0s for a shell command, 67.2s for a write, 222.0s for a subagent
task, each as ONE row. Across the corpus the path never changed under a row
(0/202) and the name changed once (Task to Survey — a title-derived upgrade).

**Claude's writes showed a name but no progress.** The path lands in the first
~180 bytes of the arguments while `content` still has tens of kilobytes to go,
so the row sat still for minutes. The scanner already walking that buffer now
also counts newlines, and the row grows: `+128 −0` on the same counting rule
`diffStat` uses, so the number does not jump when the call settles (measured:
last in-flight 734, settled +734). Edit and MultiEdit get the name but no
count — `−M` is unknowable until `old_string` completes, and half a number is
worse than none.

The stopwatch needed no new machinery. It was already ticking once a second,
independent of upstream frames; in-flight rows simply had no `startedAt` to
measure from. Both paths now carry one fixed origin per call.

Throttled twice over — 512 content characters for the write counter, 250ms
plus a changed-payload gate for ACP — so a burst of identical frames cannot
turn into a broadcast: 911 frames produce 350 events, and 1062 delta frames
produce 55 counts. Neither is persisted; after a run the same information
lives in the settled call, and storing it would give a reloaded conversation
two rows for one call.

Also corrects `tool-timing.ts`, whose docblock claimed codex emits `tool_use`
at `item.completed`. It does not, and did not on the day that comment landed:
only `web_search` does. The measurement behind it was real, the attribution
was not — half of codex's shell calls are simply instant.

* fix(daemon): keep a conversation readable when its artifact refs cannot be read

`conversationChatArtifactRefs` carries the comment "a snapshot store problem
must never make a conversation unreadable", but the guard it names sat around
the ref query only — the owning-project lookup ran in front of it. That lookup
reads a different table and can fail on its own, and when it did the throw
travelled out of `listMessages` and the caller got no transcript at all.

The failure was silent, which is why it survived: `langfuse-bridge` catches a
failed message read and reports the run anyway, so a broken lookup looked like
a turn that produced no output. Thirteen of its tests were red on this branch
for exactly that reason.

Artifact refs decorate a conversation. Losing them costs a thumbnail; losing
the messages costs the conversation.

Also updates the codex normalize expectation, stale since file rows started
carrying `od_diff_stat`: a codex write used to show elapsed time where the same
row under Claude showed `+N −M`, and counting the diff is what fixed that. Adds
the missing control — no diff on the frame means no stat invented, so the older
`exec --json` wire still produces byte-identical events.

* feat(web): give tooltips the design's surface, and the panel its outlined icons

Eight of the ten tooltip properties now match the draft byte for byte —
radius, padding, background, text colour, border, shadow, line-height, and the
6px offset from the trigger. Going opaque also drops the backdrop blur, which
is a straight improvement over the preview iframe: translucency was losing
contrast against whatever the user's page happened to render underneath.

Two are deliberately not applied.

`white-space: nowrap` would break the UI, so it is guarded rather than
adopted. The longest current tooltip is 202 characters (French, the
single-file publish description) — roughly 1300px unbroken at 12px — and
several tooltips carry unbounded user data such as a filesystem path or a
document title. The draft's own comment explains the mismatch: there, a
tooltip names an icon-only button in two or three characters. It never
contemplated long copy, and `max-width` is why ours survives it. A reverse
test now pins the wrap so nobody "finishes" this alignment later.

The 100ms fade is unreachable here. Ours is a portal that mounts on show and
unmounts on hide, so the node never travels from transparent to opaque and a
`transition` on it would be dead CSS. Making the fade real means keeping the
layer mounted — a behaviour change, not a value change.

Icons: seven glyphs move to the panel-local outlined set, at the same sizes,
with every call site outside the chat panel left alone. `Icon.tsx` and the
remix map are untouched — the site-wide stroke convention was not adopted.
Two of the seven turned out to be same-name-different-glyph rather than
duplicates: the composer's palette has asymmetric dots where the status card's
is solid, and the queue's remove is a different path from the tool row's
delete verb. Play stays filled, because the draft draws it filled.

* fix(chat): put a running clock on the image batch, and retire the D3 tests

Six tests in the tool-status suite still asserted "a call with no result yet
produces no row". That rule (D3) was struck a day earlier — the spec has said
"a call gets a row when it is made, not when it returns" since 2026-09-02
(OPEND-2419) — and the in-flight work landed the behaviour the spec already
described. Each one now states what it pins, who overturned the old rule, and
which neighbouring rule was NOT overturned.

B47 was not overturned. It only stopped firing here, because a shell with rows
in it is no longer empty; its own guards live in `empty-shell.test.ts` and are
green. Nor is the row deleted when a turn ends without it: `ToolRow.pending`
already ruled that a call that never came back stays recorded and simply stops
being drawn as a spinning orb. Verified by running it — the row settles to a
frozen elapsed and a neutral mark, and never reaches the database at all.

One of the six was a different bug entirely: a failed row now carries its
reason after the name instead of the word "Failed", since `failReason` stopped
being hardcoded null.

The image batch row had no clock while it was generating. Not an empty slot —
the in-flight form has no slot at all, and both data paths returned null
(`pendingMediaBatchRow` hardcoded it; the event path demanded every task have
an `endedAt` first). It now runs from the earliest task in the batch, applied
AFTER the S19 merge — that merge sums settled values, and summing live ones
would count the same wall-clock stretch several times over.

Also fixes an assertion that could never fail: the failed-turn test asserted
its record body was absent, but a failed shell is collapsed and a collapsed
shell defers mounting its body, so that read was null no matter what the shell
contained. It now expands first and asks the real question.

* fix(chat): give the top-up card back when the upgrade card cannot draw

`suppressCard` on the insufficient-balance failure means "another surface is
already saying this" — that is why the reconnect line uses it. The upgrade
card is not that kind of surface: it only renders once a wallet read comes
back with a definite number. When the read cannot settle, nothing renders at
all, and a run that died over money leaves an empty screen with no way
forward. The commit that introduced the hand-off said so in its own message.

So the hand-off now holds only while the surface it hands to is actually on
screen. `failureCardHandedToAmrBalanceCard` names that distinction, and the
white card — with Top up and Retry — comes back exactly when the wallet read
settles with no number. The 2026-09-02 "one card, not two" ruling is
untouched everywhere the upgrade card can draw: this is the case where it
cannot.

That hole was live: the P0 recovery spec had been green for five runs and went
red here, because its Vela endpoint is a dead port and the read never settles.

Two neighbouring fixes ride along. The balance specs clicked send the instant
the button rendered, but it is disabled while the transcript loads, and a
click on a disabled button is a no-op — so `waitFor` burned its full three
seconds and the failure looked like a product bug. The margin was between 1ms
and 5ms of load latency; they now wait for readiness. And `isPaidAmrPlan` is
gone: it had no callers left, and its name answered its own question wrongly
— `enterprise` is a paid plan it called unpaid — which is the exact shape of
the bug that hid the low-balance card from four tiers.

* feat(chat): let the tooltip fade, and leave one door to a new conversation

The draft's 100ms fade could not happen here: ours was a portal that mounted
on show and unmounted on hide, so the bubble never travelled from transparent
to opaque. It now stays mounted and toggles opacity, with `aria-hidden`
keeping the hidden bubble out of the accessibility tree — the thing an
always-present tooltip node gets wrong if nobody checks.

Keeping it mounted did surface one real regression, caught by an existing
spec: the export-screenshot path waits exactly two frames, and two frames into
a 100ms `cubic-bezier(0,0,0,1)` the bubble is still about a quarter opaque —
it would have printed into the image. Dismissal by activation (click, Enter,
Space) is therefore instant, and only hover-out fades. That reads better
anyway: a bubble you dismissed should be gone, not dissolving.

The draft's timing is kept over the repo default (200/140ms) because this is
the chat panel's own draft; the curve is still a decelerate one, so the "no
ease-in" rule holds.

New-conversation had two doors — a header key and a dropdown item — for one
action, one handler, one analytics event. The header key stays, per the draft,
and the dropdown item is gone along with its now-dead CSS. Both were gated on
the same handler and the same disabled condition, so no state loses the
action; three UI specs move to the header key's testid, keeping the step that
opens the menu so their "the menu now holds only the list" assertions stay
meaningful.

The word is unified inside the panel: 新会话, plus the verb the draft itself
supplies at `body-components.html:1243` — "新会话从这里开始". So the family is
新会话 / 正在开始新会话… / 无法开始新会话, and one button no longer says
"fork" the moment you press it.

* docs(spec): record the 2026-09-03 chat-panel rulings and what they cost

* feat(chat): number a forked conversation instead of calling it a fork

Starting a new conversation from a reply produced "{title} fork" — the last
place the word survived after the control labels were unified on 新会话. It now
numbers instead: 「商品列表页」→「商品列表页 (1)」→「商品列表页 (2)」.

The number is assigned by the daemon, not the client. Reading the project's
conversations, picking the number and inserting all happen without an await in
between, and better-sqlite3 is synchronous, so no two forks can land on the
same number — a guarantee the client could not make. It also gives the CLI the
same names for free: `od chat new --fork-after` used to create an untitled
conversation, and now matches the UI, which the dual-track rule asks for.

Only a suffix WE could have written is stripped before renumbering — one
half-width space, half-width parens, one to three digits, no leading zero, at
least 1. So a conversation someone named 「方案 (2024)」 keeps its year, and an
older 「商品列表页 分叉」 keeps the word and becomes 「商品列表页 分叉 (1)」.
Existing titles are not migrated; both shapes will coexist, which is recorded
where a later reader will find it rather than left to look like a bug.

Gaps are not backfilled: with (1) and (3) present the next is (4), not (2).
Reusing a number resurrects a name the user just deleted, which reads as the
deletion having failed.

`chat.forkedConversationTitle` is gone from all 19 locales — a number needs no
translation, and the daemon has no locale to translate with.

* feat(chat): open a running command, and stop fighting the reader for the scrollbar

The draft has said "执行中展开 → 完成收起" all along (component 11,
`body-components.html:1004`), with the in-progress cell annotated "终端实时追加,
限高滚动自动贴底". We only implemented the failed half, so a command that ran
for a minute showed one line and a clock while its output sat behind a fold
that was not merely closed — a closed fold never mounts its body at all.

Opening it exposed two things the fold had been hiding.

The terminal jammed itself to the bottom on every new chunk. Scroll up to read
a line and the next chunk, 250ms later, yanks you back. It now uses the same
follow logic as the thinking card, which tracks the reader's INTENT rather than
inferring it from position — inferring deadlocks, because following writes the
position back to the bottom and the inference then reads that as "still
following", so a trackpad nudge can never accumulate enough distance to escape.

That logic could not see the terminal at all, which is the second thing: the
box stops growing once `max-height` clamps it, and terminal output arrives as
NEW CHILD ELEMENTS, which the one-time `Array.from(box.children)` snapshot
never observed. The thinking card slipped past both — its children are fixed
and it grows its own height. A MutationObserver closes it.

Cost, measured rather than assumed: one batch of new output re-renders the
terminal exactly once; five clock ticks used to re-render it six times and now
render it once. A reloaded conversation with thirty collapsed command rows
carrying fifteen thousand lines between them mounts zero terminal nodes.

* feat(chat): give the AMR command row the same foldable, so its live output has somewhere to go

Until now the two command-row shapes were split by whether the agent sent a
human-readable `description` alongside the command:

  · Claude family (`{command, description}`) -> foldable, terminal body
  · AMR / ACP family (`{command}` only)      -> one line, no body

That split landed exactly backwards against which runtime actually streams
partial output. The daemon emits `tool_in_flight` (carrying up to
ACP_IN_FLIGHT_TOOL_OUTPUT_LIMIT chars of stdout-so-far) only on the ACP path;
Claude's stream-json carries nothing at all between `tool_use` and
`tool_result`. So the one link that really has live output was the one with
no place to draw it, and the one with a place to draw it never had anything
to put there.

The draft never painted this state. `执行 <命令>` appears exactly once in the
whole file (body-components.html:909) and that instance is settled -- static
terminal glyph, a resolved `8.4s`. It does paint running single-line rows
elsewhere (:1037), so the gap is specifically exec-while-running. The second
clue is in the same line: that button's aria-label reads verbatim
「查看 npm run build 的输出」 -- the draft already calls this row "see the
output", it just never drew what comes after "see". This fills that half, on
the product's 2026-09-03 ruling to unify the two shapes.

Built to be the same row, not a lookalike: the summary keeps :909 verbatim
(verb + monospace command + seconds slot), the body reuses the same
`div.code`, and open/close runs through the same `lifecycleOpen`, so
"open while running -> close when done" and "a reader's own click wins" did
not have to be written twice.

Two deliberate restraints, both recorded at the call site: the command is not
`elide`d (that helper preserves file extensions and would turn
`wc -l a.md transcript.html` into something that reads like a different
command -- truncation belongs to CSS here), and `lifecycleOpen` carries only
`row.pending`, because this branch is guarded by `!row.failed` and adding
`|| row.failed` there would be dead code.

Cost is measured, not assumed: 500 output lines still cost 501 line nodes and
a constant skeleton; the stopwatch ticking five times still re-renders the
terminal zero times; and 30 collapsed rows on first paint mount zero terminal
nodes. The skeleton is 14 nodes/row here versus 12 for the titled branch --
the extra two are the `<span class=file><code>` pair that sets the command in
monospace. That is pinned at 14 rather than rounded up, so the next time it
grows we see it.

* docs(spec): settle the running-output and fork-title questions, open the failed-file one

Three of the four items under 「同日未决」 got answered today; recording them
with the evidence, and with the cost, so nobody later reads a ruling as
drift and "fixes" it back.

T47 (running tool output opens, closes when done) closes the three-way
question. Worth writing down that I recommended the wrong branch here: I
suggested showing a tail on the row, from intuition, and the product pulled
up the draft cell that says 「执行中展开 → 完成收起」 in as many words. The
unification across both command-row shapes on top of that is a spoken ruling
the draft never painted -- the note says so, and says which two lines in the
draft argue for it anyway.

T48 numbers a forked conversation instead of calling it a fork. Half-width
parens everywhere for now; the product said explicitly not to spend time on
the CJK full-width question yet.

T49 opens the failed-file-row expansion as in-flight rather than settled,
because it is a spoken ruling against a draft cell that says otherwise --
:917 is a single line with a button. Whoever lands it has to say that at the
call site.

Also split the deferred items out into their own block, so "the product said
leave it" stops looking like "nobody got to it". The English past-tense one
is bigger than I first described it: the whole verb family reads as finished
while the tool is still running.

* fix(chat): a row that never came back is not a row that is still running

Opening a running command row (T47) read `row.pending`, and `row.pending`
means `result == null` -- "this call never came back", not "this call is
running right now". Press Stop and the in-flight call never gets its
`tool_result`, so `pending` stays true forever: that row stays spread open,
and stays open every time the conversation is reloaded afterwards. An
`npm install` with a few hundred lines of output takes the screen on its own.

The fix is not to clear `pending` when the turn ends. The glyph at the head of
the row is picked from it -- `row.pending ? <StatusMark …/> : <toolIcon/>` --
so clearing it paints a finished tool icon on a call that never finished.
`closeRunningSegments` already wrote that rule down for the todo half of the
same problem, verbatim: marking it complete is saying something on the agent's
behalf that it never said.

Both quantities were already in hand one line apart. The glyph reads
`running ? 'running' : 'pending'`; the expansion read `row.pending` alone.
Two adjacent decisions about the same fact, using two different measures --
that was the whole defect. They now share one named `lifecycleOpen` whose
docblock says which measure answers which question and why failure is
independent of both.

Real trigger path, not a constructed one: `ExecutionShell` computes
`running = shell.status === 'running' && !shell.stopped`, and on cancel
`build-turn-blocks` sets `shell.stopped = true` and calls
`closeRunningSegments`, which only walks todo segments. So `running` flips
false while `row.pending` stays true -- exactly the shape the new tests feed.

Worth recording what the probe found, because I had assumed otherwise: only
`canceled` shows this on screen. `failed` and `error` collapse the shell
itself, and with `deferBody` nothing inside is mounted at all -- so those two
never displayed the stuck row. That is now a test with the reason attached,
so nobody "completes" it into three cases and gets a selector miss that reads
like a broken toggle.

Twenty-six existing tests had to say `running` out loud. They were feeding a
pending row with no `running` and asserting the running behaviour -- data that
matched both "in flight" and "left over after Stop", with only the first
asserted. Making it explicit is what the fix is about, so each file now
carries the note rather than a silent prop.

* feat(chat): let a failed file row open, so the error text is actually readable

The failure reason is deliberately not truncated -- `build-turn-blocks` says
so in as many words: a stderr can run to several hundred characters, and how
short to cut it is the product's call. It was then pasted into a single line
as `{verb} {file} · {reason}`, where CSS ellipsised it away. Handed over, and
unreadable.

Product's ruling, pointing at the failed *command* foldable: 「能下拉展开吗?
像这样」. So the file row gets the command row's treatment.

Stating the basis plainly, because the draft says otherwise and the call site
now says so too: :917 paints a file failure as a single line with a button and
a duration; only :1018 is `<details class="fold is-fail" open>`. This is a
spoken ruling against that cell, not draft fidelity.

It also collapses the draft's two failure spellings, which spec S1 has had
open as "are these two intentionally different?". They were: the one carrying
a reason inlined it and dropped the 「失败」 mark; the one without kept the
mark. Now the summary is always :917's line -- verb, filename, 「失败」,
duration -- and the reason moves to the body. What is left of the difference
is only whether there is any text to show: with text it opens, without it
stays the single line. A foldable that cannot open is worse than a row,
because the chevron lies.

Nothing is lost when collapsed, because failed rows default to open through
the same `lifecycleOpen` the command branch uses. The reason is on screen
from the first frame; it just sits on the second line.

The body reuses `Terminal` rather than growing a new box: 104px cap, its own
scrollbar, sticky-bottom following. At this layer a few hundred characters of
stderr and a few hundred lines of build output are the same thing -- text the
agent handed back verbatim.

Two of the new test's own assertions were wrong before the implementation
existed, and the fixture guards caught both: `tool_result` pairs by
`toolUseId`, not `id`, and zh-CN's write verb is 「新建」. Recording that
because a fixture that silently produces no failed row would have made the
whole integration case vacuous.

* docs(spec): mark T49 landed, add T50, and close out what S1 was asking

T49 shipped, and it answered S1 on the way -- the draft's two failure
spellings are now one, differing only in whether there is any text to show.
Flagging that we collapsed them rather than the designer answering, because
S1 was filed under their name.

T50 is the regression T47 introduced and what it cost to fix: reading
`row.pending` for auto-expansion when that field means "never came back",
not "still running". Includes the measured finding that only `canceled`
surfaces it, so nobody widens the test to three cases and hits a selector
miss that reads like a broken toggle.

Three things move into the open list: whether a one-line reason should keep
an inline copy (cost: the same sentence twice when expanded), the designer
sign-off on S1, and `onShowFailure` having no caller anywhere in the repo --
left in place, not deleted.

* chore(mirror): rebuild the gallery so it shows today's rows

The mirror page is the only place a person can judge cell-by-cell whether we
match the draft, and it is a generated artifact -- leaving it on yesterday's
render means a reviewer compares against rows that no longer exist.

54 more `<details>` and the terminal box now appearing on failed file cells
is exactly the two rulings that landed today: both command-row shapes fold,
and a failed file row opens with the error text inside.

* docs(chat): count who actually lives on the raw-command branch, and fix what I got backwards

I wrote in yesterday's commit that "opencode's bash input is just
`{ command }`" and that Claude's Bash "always" carries a description. Both
were written from intuition. Counting the corpus -- 179 langfuse recordings
plus the live vela capture in `w123-acp-inflight-frames.json`:

  claude (stream-json)        47 / 48 carry a description
  opencode, direct CLI        71 / 71 carry one
  codex                        0 / 569 carry one
  AMR / ACP (vela -> opencode) none; the recorded rawInput is
                               {"command": …, "timeout": 180000}

So the opencode claim was inverted. Direct opencode always sends a
description; it is the ACP hop that drops it. The same agent lands on
different branches depending on which wire it comes in on, which is worth
saying out loud because it is not what anyone would guess.

And the branch's largest resident is codex, not AMR: 569 calls across 36
recordings, none of them with a description. That means the change did more
than give AMR's live output a home -- it gave codex a body at all, and 569
calls' worth of settled output had never reached the screen. This file's own
header already said "codex has no description throughout"; I read past it.

Claude's "always" is also not true, just nearly: one call out of 48 arrived
as a bare `{"command": "sleep 4"}`. Left as a measured ratio rather than a
rule, since a rule is what went wrong here.

No behaviour change -- comments and test docblocks only.

* fix(chat): bring the send button back to the draft's box, and drop the border and shadow with it

The draft gives the send button the same 28x28 box as every other footer icon
button and only promotes it with a fill and a pill radius:

  .composer .bar button { width: 28px; height: 28px; … }
  .composer .bar button svg { width: 16px; height: 16px; }
  .composer .bar .send { width: 28px; height: 28px;
    background: var(--text-strong); color: var(--bg);
    border-radius: var(--radius-pill); }

Product shipped 36x36 with an 18px glyph, a 1px border and a --shadow-xs.
None of that came from a decision about the send button -- it is the generic
`button` + `.primary` primitive geometry in `styles/primitives.css`, never
reconciled against the draft. The earlier stroke-icon pass saw it, called it
"another matter", and deferred. This is that matter.

It is also a gap the composer row's own "one control system" pass left open:
that comment names Send among the controls to unify at 28px, and then the
28px selector list does not include it. So this closes a hole rather than
opening an exception.

Two things worth putting on the record.

The executing pill inherits this box, so it goes 36 -> 28 too. The draft
never draws an executing state at all -- three `.send` instances, all
resting -- so nothing there argues either way. Both states moving together
is the point: leave one at 36 and pressing send would resize the control
under the cursor. 13px label in a 28px pill has room.

Removing the border made four `border-color` declarations on the stop states
dead -- there is no border box left to color. Removed rather than left as
decoration that reads like it does something.

The test had to read the border and shadow out of the stylesheet text rather
than from getComputedStyle: jsdom rejects `border: var(--stroke-thin) solid
var(--text-strong)` outright as an invalid shorthand and reports `none`
whether or not the declaration is there, which is a vacuous assertion. That
trap is written down in the test.

* feat(codex): put the search row on screen when the search starts, not when it returns

Product red line, verbatim: 「调用前(流式传输时)就要显示在界面上并开始计时,
绝对不能调用完了才出现在界面上」.

Swept all four codex tool families against it. Three already comply --
command_execution, file_change and mcp_tool_call all emit at `item.started`,
and their `item.completed` handlers are guarded no-ops. `web_search` was the
only one that waited: its started branch consumed the frame and emitted
nothing, so the row appeared only once the search came back.

The reason it was written that way is still true, and it is a trap rather
than a mistake: the started frame's `query` is genuinely empty, the query IS
the row (`toolTitle` and `searchPattern` both read it), and emitting a
`tool_use` there would trip the `codexToolUses` guard and make
`item.completed` a no-op -- locking in a blank 「搜索」 row forever.

`tool_in_flight` walks around that. It is a generic contract event, not an
ACP-only one: it does not touch `codexToolUses`, so the settled pair still
lands with the real query, and the client retires the early row into the
settled one by shared id. One row, one clock, and the term fills itself in.
This is the same shape the ACP family already uses, where `tool_use` is
likewise emitted at terminal.

Sizing it honestly: there is no `web_search` in the 179 local recordings, so
that is not evidence. The nearest measurable call of the same class is
claude's `WebFetch` at 7.42s. Seconds, not milliseconds -- which is exactly
the blank screen the red line is about.

The clock goes in `stampToolTiming` rather than the parser, because the
parser is pure and that gateway already owns "fill the timestamp if it is
missing" for all 27 adapters. ACP's own `firstSeenAt` is left alone.

The id has to be the one `JSON.parse` leaves after codex serialises `id`
twice per frame -- the `exec-…` value, not `item_2`. Both frames resolve to
the same one, which is what lets the early row retire instead of drawing a
second search row.

The old test asserted the opposite behaviour. Rewritten rather than deleted,
carrying its original reasoning forward, since that reasoning is what dictated
using `tool_in_flight` instead of `tool_use`.

* test(chat): prove the codex search row survives the trip to the screen

The daemon-side test only showed the event leaves the parser. That is half a
chain: the event could be emitted correctly and still be ignored downstream,
or drawn as two rows. This walks it to the row.

Three things it pins: with only the early form on the wire there is already a
row and its clock reads 4.0s, not 0 -- "a row exists" and "that row is timing"
are different claims and only asserting the first would pass with a dead
stopwatch; once the settled pair lands there is still exactly one row; and
that row carries the real query, which the early form never had.

Worth recording how the first version of this test went wrong, because it
looked exactly like a broken fix: fed straight into buildTurnBlocks it drew
two rows and no query. The retirement step
(`dropSupersededInFlightToolUses`) runs in AssistantMessage, before
buildTurnBlocks -- so the test had bypassed the very mechanism the fix relies
on, and was measuring a path production never takes.

The last case turns that mistake into a guard: skip the retirement step and
you get two rows with an empty one first. So if anyone later calls
buildTurnBlocks from somewhere new, they are told what they left out instead
of shipping a duplicated row.

No removal verification on this one, and it should not be claimed: it covers
existing web behaviour against a new event shape, so there is no new
implementation here to take away.

* fix(chat): actually shrink the send button -- the earlier change lost the cascade

The previous commit set `.composer-send` to 28px in chat.css and its test went
green. On screen nothing moved. `styles/viewer/routines.css` sets the same
button to 36px through `.app .composer-send`, which is (0,2,0) against
chat.css's (0,1,0) and is also imported later in index.css -- both tiebreaks
favour the file nobody edited.

Measured in a real browser before touching anything: getBoundingClientRect()
returned 36 x 36. Worse than a no-op, in fact -- the icon had dropped to 16px
because that is a React prop rather than CSS, so the button was shipping a
16px glyph in a 36px box, matching neither the draft nor what it replaced.

The test could not have caught this. It injected only chat.css, so the rule
that wins was never in the document -- it excluded the defect from the
measurement and then reported a pass. It now loads chat.css and routines.css
in index.css order and adds `.app` to the body, and it was confirmed red at
36px before the fix, which is the only reason to trust it green now.

routines.css carries a note at the winning rule saying both files govern this
button, so the next person to change its geometry does not repeat this.

Cost of the box change: the executing pill inherits the height, so it moves
36 -> 28 with the button. The draft has no executing state at all -- three
`.send` instances, all resting -- so nothing there decides it. Moving both
together is what matters, otherwise pressing send resizes the control under
the cursor.

Verified after the fix in the same browser: 28 x 28, 16px icon, transparent
border, no shadow.

* docs(spec): the upgrade card has no dismiss, and record why the ticket says otherwise

OPEND-2597's acceptance text asks for "dismiss, then stop prompting for this
session". The product read that back today and said it looks wrong -- it
should keep showing. Recording the ruling with the reason it is not a close
call.

That sentence is a dialog-shaped requirement applied to a card. What ships is
an inline card in the conversation flow, not a modal: ChatPane says so at the
call site, on a 2026-08-26 ruling that a warning you can continue past gets a
card and not a dialog, and it does not block sending.

It also contradicts T41 head-on, which is the part that settles it. Hiding the
card does not change the balance -- the next run still dies on money, and now
nothing on screen says why. That empty screen is the exact hole T41 exists to
close.

And there is nothing to be spared from: the card sits in the flow and scrolls
away with the content, so it was never in the way to begin with.

* fix(chat): keep Add-to-chat with the selection while scrolling, instead of hiding it

Reported: 「选中文本后,"添加到对话"按钮怎么一滚动就消失了? 消失不会再显示吗?」
Both halves were true. Any real scroll called hideBar(), and the only thing
wired to bring it back was `selectionchange` -- which scrolling does not fire.

This was a deliberate decision with a test pinning it, not a slip. It came
from OPEND-2541: the bar is `position: fixed`, so an un-recomputed bar stays
put and becomes a ghost pointing at nothing. Hiding it was the fix chosen
then.

The draft settles which behaviour is right. At 729fa43ce7,
components.css:3136 anchors the bar to the selection itself --
`.sel { position: relative }` + `.selbar { position: absolute }` -- so it
follows content for free and can never go stale. Following is the drawn
behaviour; hiding was an artefact of our `fixed` positioning that got
mistaken for a rule. The ghost is still prevented, by re-anchoring every
frame rather than by disappearing.

Two things had to change together. The scroll handler now re-renders from the
geometry it already measured instead of hiding. And concealing no longer
clears `geometryRef` -- that was the second half of the bug: the handler
bails on `!previous`, so the first hide made every later scroll a no-op and
nothing short of a fresh selection could recover.

`selectionOnScreen` keeps the half of the old behaviour that was right: once
the selection is off screen the bar must go, or edge-clamping parks it on the
panel rim over unrelated prose. It measures the selection's whole span, not
its first rect, so a selection taller than the viewport still counts as
visible when only its middle is on screen.

That span logic arrived untested -- honestly self-reported -- and every
existing case in this area exercises the degenerate union-rect fallback where
first === last, which cannot tell min/max from either endpoint. Added two
cases driving real multi-rect `getClientRects()`, and confirmed they are
load-bearing: narrowing the helper to `firstRect` alone turns the tall-
selection case red.

Removal-verified twice over: reverting the component returns the original
three failures, and stubbing `selectionOnScreen` to `true` alone reddens the
hide/restore pair.

The prior test asserting 「关闭,并等待下一次 selectionchange 才重新出现」 is
rewritten rather than deleted, carrying its OPEND-2541 reasoning forward, and
now also asserts the bar moves by exactly the scroll delta -- "still present"
alone would pass for the very ghost that ticket was about.

* fix(chat): three reported defects, and one my own last fix introduced

Four agents worked these in parallel; I verified every claim before landing.

**Media artifact cards (OPEND-2598, OPEND-2608).** One cause, and it is not
about media or about retry. `produced_files_json` has exactly one writer in
the whole repo -- the browser, computing it inside ProjectView's SSE `onDone`
closure and PUTting it back. Leaving the project, or merely switching
conversations, aborts the controller; the AbortError path returns without
calling any handler, so `onDone` never runs. The daemon still promotes the
row to succeeded -- publishing the terminal state while withholding the
artifact association that terminal state was supposed to carry. On return the
replay path refuses past the five-minute anti-loop window, so the card is
unreachable for good.

The three null columns are the signature: `upsertMessage` writes
`producedFiles ? JSON.stringify(...) : null` and `[]` is truthy, so null means
no terminal write ever happened rather than "the diff came back empty".

The daemon now writes a floor at the run-terminal chokepoint: one idempotent
UPDATE guarded on the column still being null. A floor, not a verdict -- the
client sees the pre-turn snapshot and the daemon does not, so the client stays
authoritative. It fills only `producedFiles`, leaving the delivery-verification
path exactly as it was. Proven at the daemon HTTP boundary with the client
deliberately never played, and end-to-end on a real runtime with real claude
and nothing but curl -- on HEAD that column can only be written by a client
PUT, so its presence is the proof.

**Memory (OPEND-2606, OPEND-2607).** The settings page was more optimistic
than the daemon: one green master toggle bound to `enabled` alone, while
`chatExtractionEnabled` sat false behind a second tab -- and a failed config
read painted every switch on. Both now derive from one invariant, that the
screen never claims more than the daemon confirmed, with a config we could not
read claiming nothing.

The memory card existed and matched the draft already; what was missing was
anything that produced it. The prompt describes memory the model *applied*,
not memory it *wrote*, so a turn that grew the store 22 -> 25 correctly showed
nothing. Extraction finishes after the turn ends, so there is no run event
left to hang a card on; a bounded poll on the turn's falling edge resolves the
written ids to names and emits one host-authored message, which persists and
survives reload. Deliberately not a second EventSource -- the toast already
holds the one connection this surface can afford.

**And one of mine.** `selectionOnScreen`, from the Add-to-chat scroll fix an
hour ago, decided visibility by overlapping the selection with the panel --
which silently assumed the panel is measurable. When its rect has no height,
the overlap is false for every selection and the bar never appears at all.
That is treating "not measured" as "measured false", and it turned up as one
red in the full web suite: a scroll-following test that mocks the selection
rect and not the panel's. An unmeasurable panel is no longer evidence, with
the case pinned so it does not come back.

Verified centrally after all four landed: root typecheck, guard, daemon build
(server.ts is @ts-nocheck, so its build is the only real check), and the full
web suite -- 10,232 passing, no failures.

* feat(chat): a batch of visual directions is four, not six

Product ruling 2026-09-04 on OPEND-2584: 「VISUAL_STYLE_BATCH_SIZE 先改成 4 吧」.
The previous 6 came from a 2026-08-27 ruling about how 「换一批」 walks the
catalogue.

Recording at the constant why this is NOT "matching the draft", because that
reading would be wrong and would come back: chat-panel-feedback.md carries an
earlier and blunter ruling that names this very card -- the counts in the
draft are mock data, and 「不能因为稿子是 4 张就不做「看全部」」. So four is a
number the product picked again, not a spec derived from the drawing.
「换一批」 is untouched; a batch is simply two cards smaller.

Six tests were pinned to six. Where a number was really about the batch, it
now derives from the constant instead of being written out again -- the
keep-two case asserts `BATCH_SIZE - keep.length` rather than 4. Where the
number is genuinely independent, it is recomputed and the arithmetic is shown:
walking 22 catalogue entries four at a time needs six batches, so 「连点四下」
became 「连点五下」.

Two fixtures had to move rather than be renumbered. The stale-entry case fed a
six-long previous batch; it now feeds four, keeping its shape of two retired
plus two surviving. And the B53 gallery test clicked 「Premium pitch」, the
catalogue's fifth entry -- inside a batch of six, outside a batch of four, so
the helper simply could not find it. It now clicks the fourth, which still
proves the point that matters (what is spread out is this batch, not the whole
catalogue) without depending on the batch being any particular size.

* fix: four reported defects, one of which no test could have caught

Four agents in parallel; I verified each claim before landing. Root typecheck
clean, 8,116 web tests passing.

**OPEND-2597 — the upgrade CTA sent paying team-Max owners to buy what they
already own.** The branch that decides where Upgrade leads reads two bits:
plan tier, and whether the viewer can manage billing. The audience bit was
truthful. The tier bit was not: `/api/workspace/context` hard-codes
`planId: null`, because that context is assembled from vela's workspace
directory rows, which carry no plan field at all — and ProjectView passed only
that context to the branch, so it was the sole tier source. Every user on the
project page therefore resolved to below-Max, and the auto-recharge
destination was unreachable in production. Home was never affected; it already
passes the projected billing summary, which is exactly what ProjectView now
does too.

Worth stating why the tests were no help: all of them inject `planId` directly
onto the context — the one field production never populates. The suite was
green against a shape that does not occur.

**OPEND-2588 — a batch turn opened one tab, not all of them.** The ruling was
「打开全部产物 —— 是全部的**主要**产物」, and that qualifier turned out to
matter more than the count. The ranking function does three jobs, not two: is
this an artifact, which *kind* is this turn's deliverable, and which one of the
equally-ranked winners. Only the third is overturned. Opening everything that
passed the artifact filter would have let a plan.md take a tab beside
index.html — widening the criterion, which the ruling explicitly rules out.

That also answers the 16-image worry from OPEND-2571: that turn produces 16
images plus an HTML file, HTML outranks media, so it still opens exactly one
tab. A flood needs a turn with 16 media files and nothing higher-ranked. No
cap was invented for it.

**OPEND-2585 — nineteen seconds of blank screen on batch upload.** First
render snapshotted the uploaded server paths out of sessionStorage, and that
key is only written after the last upload answers — so the creation gate had to
hold until uploads finished, and upload duration was paint duration. The paths
are genuinely needed, but by the send, not by the paint: the gate now releases
once the project row is persisted, a small handoff module parks the picked
files and owns their object URLs, and auto-send reads server paths at dispatch
time instead of at mount. Each URL is revoked when its file's server path
lands, with a sweep for failures.

**OPEND-2500 — next-step guidance after a deck.** Not reproducible: a real
deck generation emitted all three markers and rendered them in the design's
shape. It also closed the long-open "audit five prompt paths" question — there
are not five composers, there is one, and it is not conditioned on project
kind. Proven observationally rather than by reading: the accepted markers had
to carry that run's nonce, which only this run's prompt could have shown the
model.

Two agents declined work honestly and both were right to. One wrote a test,
found its metric was blind — the assertion's precondition never fires in that
harness — and deleted it rather than keep a green reading that proves nothing,
flagging that area as unverified. The other skipped `git show HEAD:… > …` for
removal verification because peers had uncommitted work in the same files, and
neutered its own logic in place instead.

* fix(media): associate a generated file that lands after its run has ended

The floor added earlier only covers files present in the run's own filesystem
diff. A media generation often is not: `od media generate` polls for 25s and
exits 0 with "still running" past that, nothing awaits the in-flight promise
at run end, and video or long audio routinely take longer. The bytes arrive
after the snapshot. (OPEND-2609, and the half of OPEND-2608 left open.)

That ordering is asserted, not assumed: the test checks the output file does
not exist on disk at the instant `runStatus` flips to succeeded, and that
assertion held on every run, red and green.

The second finding changed the fix's shape. The client misses the late file
too — `onDone` computes produced files against the daemon's artifact paths at
that same instant, then persists the result, so the column ends up non-NULL
and wrong, usually `[]`, which is truthy. A NULL-guarded floor could never
have closed 2609, whose whole shape is a user who IS watching: the audio plays
in their project pane. So this had to add rather than backfill.

No linkage was invented. `media_tasks.run_id` already comes from the tool-token
grant and `messages.run_id` is already on the assistant row; one select bridges
them. The association is emitted by the media task, after the wait-notify so
`od media wait` latency is untouched, because the task is the only party that
knows the bytes landed — the terminal chokepoint by definition cannot.

It stays a floor. It only ever prepends, so every client entry survives
verbatim and a re-run is a no-op. And it stands aside while the run is still
live: a task completing mid-run must not write the column, or the terminal
floor reads it as client-owned and drops everything else that turn produced.
That was a regression I introduced and the removal check caught — neutering
the gate turns exactly that case red.

Two guards were deleted for being redundant under the rule. A compare-and-set
was a second belt over the post-await re-read; with no await between the read
and the update, nothing in this single-threaded daemon can interleave, so it
only ever fired when the re-read was already broken. A capacity cap was
unreachable and protected against nothing — one entry per completed task. The
constraint that makes the re-read sufficient is now written at the statement,
so nobody adds an await there later.

Known and not closed: a late file still gets no `message_artifacts` ref row.
Its bytes are already frozen by the existing snapshot hook, but attaching that
snapshot additively needs a different store API than `replaceMessageArtifacts`.
The card renders from producedFiles, so the reported symptom is closed; the
immutable-preview half is not, and deserves its own change.

Also: my previous commit swept in this test's 289-line draft. This is the
finished 550-line version with all four cases.

* fix(prompts): tell each runtime the name of the plan tool it actually has

A user asked, in as many words, 「先用 todo 进行一轮规划」. The agent planned —
and wrote the seven-item plan as body text. The turn made three tool calls and
none was a plan tool. So it was not disobedience; the plan went out through
the wrong channel.

Two sentences in our own prompts sent it there.

The slim charter said: if the runtime supports task lists use one, **otherwise
provide a numbered plan in your response**. That asks the model to self-assess
whether it has such a tool, and hands it a sanctioned prose branch for when it
decides it does not. Prose was the compliant reading.

And it had no way to decide correctly, because the note naming the tool was
gated on the Claude stream format. Codex received no tool name at all. The
gate's own comment asserted the premise that made this look fine — that codex,
opencode and the ACP agents have no such tool. They do: codex has
`update_plan`, opencode has `todowrite`, and the daemon already translates
both into the canonical TodoWrite the Todos card renders. The wiring was never
the problem; nobody told the model the tool existed.

So each runtime is now told its own tool's real name, in the sentence shape
the Claude note already used. Claude's note is returned by the same constant
and is byte-identical. The charter is untouched, so its size ceiling is
unaffected; the cost is a couple of hundred bytes on codex and opencode runs,
which previously paid nothing.

A second prose escape hatch turned up next door: the bundled todo-write atom,
which `ensureCoreQualityStages` puts on every pipeline that writes a design
artifact, said the agent may use "TodoWrite (Claude Code) or an in-prompt
list" — naming a tool codex lacks and reopening the branch, in the same
prompt. It now points at whatever plan tool the session exposes. That is not
the site this incident hit (the conversation carried no plugin snapshot) and
the test says so, to stop it being mistaken for the root cause later.

What the new test proves is narrow and stated as such: that the composed
prompt names that runtime's real tool. Whether the model then calls it is not
something a unit test can pin. One assertion does check wiring — for opencode,
whose parser forwards the tool name unchanged, the named tool must satisfy the
canonical predicate, since naming one it rejects would render the plan as an
ordinary tool row: the same invisible plan, one layer down.

Two runtimes were deliberately left out. mimo and the ACP family are
structurally capable, but I have no verified tool name for them, and guessing
from family resemblance is precisely what the Claude Code 2.1 rename punished.
A recorded trace per agent would settle it.

No prose-scraping fallback, and it would have been the wrong answer: it would
have manufactured a card from a plan the agent never emitted, and hidden this
defect instead of surfacing it.

* fix(chat): stop a reloaded OD Next task from gluing its runs into one turn

Three things the user saw at once after leaving for Settings and coming back:
the previous turn's 「已确认」 summary sitting inside the current turn, two
「思考过程」 blocks in a row, and raw markdown. They are not one bug.

**The turn-gluing is real, and the trigger is the reload.** That conversation
is one OD Next task with three runs, and `strategyTaskRunIndex` has exactly
one writer in the repo — the history GET. Nothing on the live SSE path sets
it, so the fold that joins runs into one turn only happens once rows come back
from `listMessages`, which is precisely what a remount does. Live it never
folds; after Settings it does.

The fold concatenates N runs' events into one flat list, and the block builder
was written for one run: one `doneSeen` latch, one done key, one shell. Run 0
ends with a `<question-form>`, which trips implicit-done, so every later run's
text was appended to run 0's still-open prose block and every later tool piled
into run 0's shell. That single 2048-char block is the 「已确认」 summary glued
to two later answers; the merged shell is the doubled 「思考过程」.

The daemon already mints one `done_key` per run and sends it before any model
output, so a second one in a stream *is* a run boundary — no contract change
needed. At each boundary the ending run gets its conclusion lifted (run 1
emits no done marker, so its answer would otherwise stay buried in the shell)
and its shell settled, then the per-run state resets. The invariant is that
folding is a view-level join: the folded turn's blocks equal the runs' blocks
concatenated.

**The raw markdown is not this bug, and is by design.** The only raw `**` and
backticks in either path are inside the execution shell, which renders plain
text on purpose — the conclusion outside the shell is the part that goes
through markdown. Markdown counts are conserved across the fold and no heading
renders raw. That matches the user's later screenshot, where markdown was
correct while 已确认 was still misplaced. If it recurs it is a separate fault.

**The stray dot in 「搜索 . 14 处」 is independent and pre-existing.** A bare
`ls` with no positional argument resolves to the literal `.`, and the search
row prints it as the target. Present live too. Left for its own change.

Also landing here, two rulings from the same session:

The first thinking cell no longer reports elapsed. Its number is derived by
filling the gap before it, and for the turn's first thought that gap starts at
the turn start — the same anchor the shell header uses, so the two showed the
same figure one line apart. Later thoughts fill a gap between tool calls and
keep theirs. Suppressed at the render layer so the datum stays available to
the header and drawer sums that depend on it.

And an ACP turn that has produced nothing for sixty seconds now says
「等待首批输出中」 instead of an empty shell. This corrects my own brief: I sent
that agent after `waiting_for_first_output`, which turns out to be emitted only
by the ACP bridge — Claude never sends it, so the chain I described was not
the reported problem. Rendering both cases showed the real gap: the header's
clock was never missing, and 「在等什么」 is answered by 「思考中」 for Claude
and by nothing at all for ACP. Gated so it can never replace 「思考中」 or
revive the withdrawn mid-turn silence hint.

Two translations of that key are wrong now that it has its first reader: `tr`
says "first input" where it means output, and `th` reads oddly. Not fixed here.

* fix(chat): render markdown inside the shell, and stop the reveal corrupting text

Product overruled the split that kept shell text plain: 「谁说按纯文本画不是
bug 的?? 都要 markdown 啊」. Narration, thinking paragraphs and abandonment
reasons now render markdown like the conclusion always has.

The hard part was never the markdown call, it was the per-character reveal
sitting on top of it. Its docblock reasoned that attaching to the last `<p>`
is safe because a new paragraph is a new element that starts revealing from
its own beginning — true only while the block is a flat list of paragraphs. In
a markdown tree an element's identity changes as text arrives: `#` renders as
a paragraph and one character later `## S` is a heading. The reveal keys its
state by element, so every flip dropped what had been shown and replayed text
the reader had already read.

So it no longer keys on the last block at all. One stable wrapper hosts the
whole tree, which is what the thinking lane already does — and is strictly
more correct than before, because the reveal already walks the subtree's text
nodes in document order across node boundaries. It never needed a per-paragraph
host; it needed one that outlives the content.

Partial syntax stays visible mid-stream — `**bo`, `**bold`, then bold. The
alternative, plain while streaming and markdown once settled, is worse: it
replaces every text node at once, so a whole block flashes instead of two
stray asterisks. The thinking lane has always rendered this way, so a second
policy would be the new inconsistency.

**And the streaming test caught a bug we already ship.** `restore()` decided
"React has not overwritten this node" by comparing the node's value against
the truncated prefix. When a markdown token closes, React's new value often IS
exactly that prefix, so the guard misfired and pasted the stale tail back:
`先看一下**规` became `先看一下**规规格`, permanently, because React then
believes the node is already correct and never writes again. Reproduced with
the reveal and the renderer alone, no shell component involved — which is the
shape the thinking lane uses today, so **this is live on main** and corrupts
transcripts whenever a throttled snapshot lands with a token closing on the
truncation boundary.

A string comparison cannot distinguish "I truncated this" from "React just
wrote it", so it stops guessing: a per-host MutationObserver reports which text
nodes React rewrote since the last drain, and those are never restored. No
signature change, so the thinking lane gets the fix without knowing about it.

Also landing here, three carried-over defects called in by the user:

A bare `ls` no longer reports the literal `.` as its search target. The draft
has exactly one search row in the whole file and it holds a real term; it draws
no directory listing at all, so nothing there sanctioned a placeholder. `.` was
not a modest stand-in either — the row renders the pattern inside a file
button, so the user saw a dot styled as something clickable. This file states
the rule twice already ("don't guess a target", "don't fabricate a clickable
file"); that `|| '.'` was the one place breaking it. `rg --files` without a
glob had the same fabrication.

Turkish said "waiting for first *input*" for a key that reports output —
inverted meaning, now fixed. Thai is worse than the "awkward" it was reported
as (no word for output at all, and theatrical register), but diagnosing a
translation and being able to write one are different things, so it is left
for a native speaker with two candidate phrasings recorded.

And the OD Next path now carries the plan-tool note. My own brief named the
wrong site: the early return in `composeSystemPrompt` is never reached in
production, so the fix as briefed would have been dead code. It went into the
per-run context block instead, which covers both composers — deliberately not
the cache-stable head, where a per-runtime sentence would split the prompt-cache
prefix for every task sharing a strategy version. Costs 263 bytes on codex,
0.4% of that turn's prompt assets, and nothing at all for runtimes with no
verified tool name.

Mirror gallery rebuilt: the shell's DOM changed from sibling paragraphs to one
wrapper per narration block, so the comparison page was stale.

* feat(chat): 思考中显示实时 token 计数

思考行右侧显示上下箭头 + token 读数,数字随流实时变化。取数走 Claude
stream 自带的 thinking_tokens,不是估算 —— 之前仓库里那句「estimated_tokens
走不通」的结论是在看不见系统帧的前提下量的,而系统帧没进日志是因为
handleObject 把它们丢了。

显示规则按产品口径:token 有变化就显示 token,长时间不变才退回计时;
第一段 thinking 永远显示 token;刷新页面时读数不从零涨上来。

- 契约新增 thinking_tokens SSE 事件与 ExecutionShell 的 thinkingTokens 字段
- hasVisibleBrandAssistantEvent 补上 thinking_tokens / artifact_focus 两支:
  只带着读数的一轮仍然是空的,它描述这轮花了多少,不是这轮说了什么
- historical-turn-baseline.json 重刷:唯一差异是壳上多了恒为 null 的
  thinkingTokens 字段,结构/分张/行归属都没动
- 顺带:zh-CN/zh-TW 队列引导措辞统一为「引导」

* fix(web): 发送后先上屏再做余额预检 (OPEND-2614)

点发送到消息上屏之间只有一个 await:OpenDesign Cloud 的余额预检,而它坐在
setMessages 上面。预检对工作区身份已解析的项目会打两个并行请求,其中
/api/workspace/billing 带 freshness=authoritative,强制一次 Vela 上游读,
不吃缓存 —— 1~2 秒的空窗全在这里,不在渲染。

改成先画后判:用户消息和「进行中」占位先上屏,预检仍在落库和 POST /api/runs
之前结算。被拦截时把画出去的这一轮收回(retractPaintedTurn,三条拒绝路径共用
的同一个漏斗)并照旧进发送队列。收回用的是上屏那一刻的快照而不是按 id 过滤:
重试时画出的数组里含着原有的用户消息和保留的失败尝试,按 id 拼不回来。

两个自己引入的坑一并处理:
- setChatSeed(null) 是 ChatPane 的 React key,留在闸门后面等于「先上屏,
  1~2 秒后再把面板拆了重建」,会亲手制造 OPEND-2615。与上屏同批。
- 预检窗口内第一次能按到停止了。加了流式标记检查,但不收回 —— 停止本身
  已经结算了这一轮,收回会删掉用户自己的消息。

OPEND-2615 不成立:已由 d287845f0e 修复(把钉住的平滑动画去掉)。把那一行
还原回去,chat-anchor-to-top 立刻红 4 条,其中「回复迟迟不来的那一轮」报
scrollTop 4200 vs 3988 —— 正是工单描述的「顶出屏幕」。建议引用该 commit 关单。

* fix(daemon): 机器离线时给出网络卡片和重试按钮

离线机器发起的请求死在 DNS 解析,不是 socket 被重置。
clientEnvironmentFailureDetail 认得 ECONNREFUSED / ENETUNREACH,却不认
ENOTFOUND / EAI_AGAIN / EHOSTUNREACH / getaddrinfo —— 于是这一类掉进最后的
兜底桶:AGENT_EXECUTION_FAILED、retryable:false、正文是 CLI 的生英文,
卡片上只有〔联系支持〕〔导出日志〕,没有重试。用户只能重打一遍 prompt。

run-error-catalog 的 R-054 早就记了这条(「fetch failed 无 cause →
stream_error」),一直没人关。

把判据提成具名的 ENDPOINT_NEVER_REACHED_RE,并在注释里写清「解析失败」和
「连接被重置」是同一个原因的两种说法。与 agent 无关,codex/opencode 同样受益。

修后同样的输入:卡片从「任务执行失败」变成「网络环境不对/网络连不上」,
多出〔去设置〕〔重试〕,恢复端点后点重试 2 秒内重跑成功。

* test(web): 队列引导措辞守卫、问题卡描述换行、Plane 工单证据留档

* fix(codex): 让 codex 重新用上原生计划工具,并接上它的推理 token 读数

codex 0.153.0 出厂就是 `tools.update_plan.enabled = false`,于是计划变成了正文
里的散文。真机 A/B(argv 与 daemon 完全一致、同一句 prompt):不带这个开关
`turn/plan/updated` 是 0 帧,带上是 4 帧。两条传输都补上 `-c
tools.update_plan.enabled=true`,无条件加,理由同 `codexReasoningSummaryArgs`
—— 条件化会动到前缀缓存。

洗清一个此前的误判:失败那条 run 的 `promptTelemetry` 里逐字含有
「Your plan tool is `update_plan`」,模型被告知了名字仍然没调;而
`planToolNoteForRuntime` 从未进过任何发布版,所以「beta .7 还好的」那个包里
codex 是靠自己调的。根因在 CLI,不在我们的 prompt。

推理 token 走 `total.reasoningOutputTokens`(24 次读数 0 次回落),不是 `last`
—— 后者是**每次 API 调用**的量,一轮中途会归零。normalize.ts 里把它称作
per-turn 的那句注释是错的,一并改掉。

token↔计时的切换加迟滞,两个门槛统一 20 秒(产品 2026-09-04 拍板)。同一段真实
语料下:单门限 8 秒会翻 38 次;20/20 与 45/20 都是 8 次,而 20 秒在真卡住时早
25 秒接管,所以取 20。claude 的帧密得多(p50 1.4s),20 秒对它从不触发,翻面
仍是 0 次。两个常量故意保留成两个,以后要按 runtime 拆开只需改数。

* fix(chat): 给早期工具行一个真的起点,别让秒表从「参数流完」才起算

早期行本来就有:参数 JSON 一边流,`tool-input-path-scanner` 一边扫,路径可确定
就发 `tool_input_target`,之后按 512 字符节流报行数。缺的是**时间原点** ——
这个事件不带 `startedAt`,而 `stampToolTiming` 只给 `tool_use` /
`tool_in_flight` / `tool_result` 盖戳。

两个后果:
- `Edit` / `MultiEdit` / `NotebookEdit` / `replace` **只发** `tool_input_target`
  (改动量在流中途算不出来,`tool_input_progress` 永远不发),这几类的行带着
  文件名、秒表是死的。
- 归并时早行没有起点可传给落定行,落定行只好退回「参数流完那一刻」当原点,
  整段流式跨度凭空消失 —— 这就是用户看到的那个 `0.1s`。

改成发 `content_block_start` 的时刻,和 `tool_input_progress` 报的是同一个
不可移动的原点。

证据不是读代码:真跑 claude 2.1.260 写了一个 27,458 字节的页面,逐帧打时间戳
再回放进真实解析器 —— 路径在 +25.5s 就可确定,落定 `tool_use` 在 +121.8s,
本可提前 96.3 秒上屏。

`w115` 那条防参数外泄的守卫把事件的键集钉死了,白名单放开 `startedAt` 的同时
加了 `typeof === 'number'`,守卫仍然证明没有参数字节逃出 daemon。

* fix(web): 升级卡念对钱包,并拆掉会把它永久静音的那颗开关 (OPEND-2597)

**念错钱包。** 补查走的是 `/api/integrations/vela/wallet`,那条路由请求里没有
任何 workspace 参数 —— 它是右上角那颗账号读数。而发送前的闸门为同一笔钱专门走
`/api/workspace/billing?scope=workspace&freshness=authoritative`,两处注释都点名
不许回落到账号钱包。后果不是数字略有出入:团队钱包 $0、个人账号还有 $12.50 时,
卡会用橙色说「余额可能撑不完下一个任务」,而真相是「现在无法开始新任务」,且他
把个人钱包充满也救不了这个团队。

顺带修了 3 条钉住缺陷的旧测试:项目放在团队工作区却只 stub 账号钱包,正是生产
不会出现的形状。补的是被后端证明过的真实夹具,不是放宽断言。

**拆掉低余额「不再提醒」。** 首页那张弹窗勾选后写
`open-design:amr-low-balance-warn-optout:v1`,而项目页发送前闸门的 soft 档读的
是同一个位,读到就返回 `allow` —— 用户以为关的是首页那个弹窗,实际把项目页的
升级卡也永久静音了。与「升级卡不该有关闭态,余额条件成立就一直在」的裁决直接
冲突(产品 2026-09-04:「拆掉吧」)。

影响面限于 soft 档(硬拦从来不受这个位约束)和发送前那道闸门(跑到一半那条路
不读它)。已经写进用户机器的那个键成为死数据,不做迁移 —— 全仓无任何遥测/诊断/
设置页在读它,而一次性清理要挑时机、要处理 localStorage 抛异常,还会留一段永远
跑不掉的死代码。

**阈值维持 $2**(产品 2026-09-04:「然后是 $2」)。工单与交付稿里的「额度 < 5
美金」作废;`chat-panel-edge-audit.md` 里那条「$2 → $5 待产品同意」随之关闭,
理由是它会放大触发面。

* feat(chat): 读数自己数上去,记录卡在 done 标记到达时就收起

**读数不再跳变。** 产品原话「token 最好也有个增长的过程,而不是直接从 100 跳到
200」。一天里这条需求改了三次方法:先是「要滚动」,看到实物后「太花哨了,自然
一点」,最后给了止损授权「实在不行就不要动画了,直接数字自增的那种就行,自增
的单位可以随机一些」。落在最后一版:**没有任何 CSS 动画**,只有数字自己往上走。

中间那版(每一位一条 0–9 字带 + transition)整个删掉了,组件、样式、测试一并
清干净,没留下名不副实的守卫。删的理由记在 `CountingNumber.tsx` 头上,免得有人
再加回来:199→200 时个位和十位要**倒着**滚过 9 格、百位正着滚 1 格,方向相反、
距离差 9 倍,而变化最频繁的恰恰是最闹的那一位 —— 这不是调参能解决的。

步长 `remaining / stepsLeft` 自适应(差 40 走大步、差 3 走小步)再乘 0.6–1.4 的
抖动,最后一步精确补齐。**永远不过冲** —— 屏幕上任何时刻都不会出现比已收到读数
更大的数。目标回落(新一段 thinking)直接落定,倒着数等于替 agent 说「它想少了」。

400ms 的预算不是凭手感:上限由上游帧率定,claude 的 `thinking_tokens` p50 是
1.4s(400ms 约占 29%),数不完就追不上真值,屏幕上就是个落后的假数。

**收起提前到 done 标记。** 判据是两个信号相与:共享契约的
`eventsHaveAuthenticatedDoneConclusion`,以及那之后有非空正文块。只用后者会让
隐式 done(`<question-form>` / `<artifact>` —— 那是 `buildTurnBlocks` 内部的
分块判断,不是产品说的 done 标记)也把壳收掉,`stream-cursor-removed` 当场红:
`deferCollapsedBodies` 会在收起时把身子丢掉,正文整个从 DOM 里消失。

手动展开不受影响 —— `userToggled` 那个闩本来就靠比对开关值区分自动与手动。

顺带撤掉了收起动画那段 `::details-content` 网格:产品要的是**时机**,没要动画,
而它是这次改动里唯一动到壳身子格式化上下文的东西(`.body` 变成
`overflow:hidden` 包装下的网格项),而里面还嵌着一个限高滚动盒。零成本嫌疑,
先摘掉。

* fix(chat): 封面晚到时把产物卡换成快照,别把整个会话留在活 iframe 上

规格 §4.1 写死了:HTML / prototype / **slide** / document 的卡面只渲染
`<img>` 快照,不再挂该文件的 iframe。用户看到 deck 是活的,不是因为快照没做,
而是**输了 466 毫秒**。

真机数据:打包版里 `opendesign-seed-pitch.html` 的 `capture_state` 就是
`ready`、缩略图字节也在。但 ref 落库 17:13:05、封面 ready 17:13:06 —— 间隔
616ms,而客户端只在 run 终止后 **150ms** 重拉一次(`scheduleConversationMessage
Refresh`),之后再也不拉,`cover.ts` 也不推任何东西。于是卡片在整个会话里停在
§6.4 的降级支上,直到整页刷新。

这正是规格第 505 行规定却从未实现的那半句:「pending thumbnail 不出
placeholder,直接走降级支;**后台 ready 后消息投影更新**」。

封面**故意不 await**(「一轮对话不该为了一张缩略图多等几秒」),所以补的是推送
而不是等待:daemon 在 `attachChatArtifactThumbnail` 之后发一个**瘦事件**
(只有 projectId / conversationId / messageId,不带 refs),web 收到后复用现有
重拉。让 `listMessages` 仍然是「ref 是什么」的唯一权威 —— 事件重复或丢失最多
多一次 fetch,不会画错卡。降级支原样保留。

红测钉的是**时序**,不是分支:既有用例全都直接把 `artifactRefs` 当 prop 注入,
所以分支一直是对的、时序从没被测过。daemon 侧刻意在 run 已终止之后才订阅
(真实用户处境),先断言此刻 ref 还不是 ready 再放行渲染;web 侧先断言是
iframe 且没有 img,投递事件后断言换成 img 且 iframe 消失,另加一条反向控制
——别的 projectId 的事件不得触发重拉,否则「见事件就重拉」也能蒙混过关。

顺手拦下一个真 bug:`projectEventToAgentEvent` 是一条**结尾没有守卫的收窄链**,
排除已知类型后直接假定剩下的是 `live_artifact_refresh` 并读 `evt.phase`。新事件
加进联合后会掉进去,变成一张 phase 为 undefined 的假 live-artifact 卡。

* fix(collab): 别把 daemon 已确认的个人项目当成共享只读 (OPEND-2624)

`sharedReadOnly` 里的 `unknownStatusReadOnly` 是一条 fail-closed 且**没有出口**
的判据。`statusUnknown` 的条件是 `syncState === null`,而 `CollabClient.get()`
对任何非 2xx 直接 throw、`pollStatus` 吞掉异常 —— 所以 `/collab/status` 只要对
这一个项目持续失败,`syncState` 就永远停在 null。

唯一的解药 `knownUnshared` 读 `cachedTeamProjects(context)`,而那个缓存的 key
含 `role`。实测两个端点的 role 不一致:`/api/workspace/context` 给 `owner`
(目录里的真实角色),而 `/api/projects/:id/workspace-scope` 是
`project-workspace-scope.ts:65` **硬编码的 `member`**。两个 key 不同 ⇒
**只要你是 workspace 的 owner/admin,这个缓存必然 miss**,历史上加过的两条
「别 fail-closed」补丁对 owner 全部失效,一次 status 失败就是永久只读:
聊天、新建、上传、编辑、导出全灭。

「为什么同工作区其他个人项目正常」也是这条解释的:缓存 key 不匹配是工作区级的,
但只有在 status 失败时才致命 —— 其他项目 status 正常返回 local_only 就没事。

改法是给判据一个出口:daemon 已经确认这个项目是 `personal` 时,让它压过一次
拿不到的 `/collab/status`。这不是新规则 —— `ProjectWorkspaceScope.visibility`
的契约注释原文就是「visibility answers whether the project itself is a private
draft or shared with the team」,而 `materializePulledTeamMirror` 对每一个拉下来
的 team mirror 都写 `visibility: 'team'`,所以本机的 `personal` 严格意味着
「这台 daemon 没有任何记录说它是谁的共享项目」。daemon 的写闸读的正是这一行,
所以这是让 UI 的只读闸和执行闸用同一个权威。工作区级冻结和 materializationPending
两条保守路径原样未动。

红测的夹具把壳层 context(owner)和项目 context(member)**拆开**喂 —— 既有的
`use-project-collab.context-seed.test.tsx` 用同一个 `role: 'member'` 对象喂进去,
所以它天然看不见这个缺陷。403 响应体是从跑着的 daemon 上 curl 实测抄来的。

* fix(chat): 历史内容一挂载就是落定态,不再重播一遍逐字显示

`useCharReveal` 没有「这个宿主刚挂载」这个概念:新宿主一律算 `shown = 0`,
于是 **DOM 里已经有的东西全被当成刚到达的字符**,切成 `.rv` 片段排进动画预算。

触发路径不是边角情况,是默认的「先问后做」流程:`foldStrategyTaskTurns` 把两个
物理 run 折成一条 assistant 消息(run 0 发表单、用户答、run 1 干活);run 1 还在
跑时整条消息的 `runStatus` 是 `running`,`isAssistantMessageStreaming` 因此返回
true;这个窗口里任何一次重挂载,正文块就带着 run 0 的全部历史进场,整个重播。
刷新页面和从设置页返回(`chatSeed` 翻转导致 ChatPane 换 key)各是这条的一半。

`markHistoryReplayLanded` 永远抓不到它 —— 它只标 daemon 重推的增量,挂载时就
已经在 DOM 里的内容根本不走那条路。

判据钉在**首帧**:`.rv` 数为 0,且没有排任何一个等于 reveal 自身时长的 timer。
只断言「最终会显示完整」的测试在改动前就是绿的,钉不住任何东西。

⚠️ 这条和 2026-08-27 的裁决(「不是真正流式的也保留一个流式效果」)在**唯一
可观测的那个形状上**正面冲突:「历史随宿主一起到达」和「非流式 agent 一次性
吐完、也随宿主一起到达」在 DOM 里逐字节相同,渲染层分不出来。这里执行较晚的
那条(挂载即落定),代价是非流式 agent 的一次性回答不再淡入。要两个都要,必须
由数据层告诉渲染层「这一段是刚到的」,那在 ProjectView 的 createBufferedTextUpdates
——已在文件头标注待拍板。

08-27 那批测试没有被静默删除:8 条「挂载时带内容、期望有 span」改写成「先挂载
再增长」,每条守的性质(预算上限、单位加厚、片段上限、不丢字符、空白不重复)
都还在测,并补了一条反向对照钉住「同一个字符串随宿主到达时现在是落定的」。

* fix(chat): 音视频生成不再画成生图,停掉的历史回合不再自称进行中

**OPEND-2625 音视频被画成生图。** `build-turn-blocks.ts` 里一行
`.filter(task => task.surface === 'image')` 把音频/视频任务**整条丢掉**,
不是「少画一行」而是导致画错:任务被丢掉后拿到空切片,退回解析命令输出,
`total` 取 1,`line.mp3` 被塞进 `<img src>` —— 破图。引入者是媒体任务首次接线
那次(`bb4292e82b`),当时这一行只装得下图片。事实一直都在:daemon 的
`media_tasks` 记了 `surface`,路由逐字回传,命令行的 `--surface` 还是必填。

去掉 filter 之后三类任务混进一条全局队列会出新问题:daemon 的批本来按 surface
分组,而取批靠「连着同一个 batchId」认边界,两类批并行在飞时列表交错,游标撞到
第一条异类任务就停、该批剩下的格子永久取不到。所以不是加个 if,而是**每类一条
队列 + 各自游标** —— 纯生图会话的那条队列就是从前那份列表本身,行为逐字未变。

新增的 audio/video 文案与图标在规格里**一个字都没有**,是按工单的「预期」拟的,
待产品过目。D34 那条两形态切换的裁决没动,只在每一档里换文案/图标/单位。

**OPEND-2626 停掉的历史回合显示 Working、步骤回退成 Not started。** 终态其实是
对的:`end` 帧落盘了,`run_status` 也是 canceled,`buildTurnBlocks` 走到
`shell.stopped = true`。错在画这一层,两处各说了一句假话——壳头
`if (shell.stopped) return t('chat.record.running')` 写出「进行中」,和真在跑的
回合**逐字同一个词**;`markFor` 把 `stopped` 落回 `'pending'`,于是三个步骤全部
读成「未开始」。

为什么只有历史回合中招:唯一说实话的那句「已手动停止」在 AssistantFooter 上,
而它 `opacity: 0`,只有最后一轮或 hover 才现形(OPEND-2542)。发过后续消息之后
这一轮不再是最后一轮,那句话在鼠标划过去之前看不见 —— 屏幕上常驻的唯一陈述
就成了「Working 12m 8s」。

步骤记号的名字用「未完成 / Unfinished」而不是「已取消」,因为 `stopped` 有两个
来源:用户按了停止,以及轮次 succeeded 但 agent 收尾没再发一次清单
(`closeRunningSegments` 的原话:「我们只知道它没跑完就结束了,不知道它成没成」)。
对第二种写「已取消」是编一个没发生过的事实。实线圈区别于「从没开始」的虚线圈。

⚠️ 这一条推翻了 `build-turn-blocks.ts` 里一条旧裁决(「手动停止不是第四态,
壳头仍写进行中」)。依据是那条裁决的前提已经不成立——它当时说「又和紧跟在下面
那行『已取消』自相矛盾」,而那行后来被 2542 改成 hover 才可见。**待产品追认。**
四条钉着旧裁决的既有用例逐条写了翻案理由后更新,没有静默改。

* fix(chat): 滑杆默认值不许越出模型自己声明的范围,轮播箭头不许盖住卡片

**OPEND-2622 声明 1–5 却提交 0。** 0 产生在 web 组件的「默认值进状态」这一条
路:`buildInitialState` 把模型给的 `defaultValue` 原样落进答案、**从不 clamp**,
而拖滑杆和敲数字两条路**都过** clamp。于是**人一旦动过控件值就合法,从头到尾
没动过的题反而是越界的**;屏幕上还同时摆着两份真相(数字框念 0、滑杆停在 1)。
序列化和解析器都是无辜的,它们如实转抄。

修的是「让模型的推荐值服从模型自己声明的范围」,用户自己写下的东西(提交历史、
恢复的草稿)一个字不动 —— 专门留了一例钉住「历史越界标量照原样念」,防止修过头。
撤掉实现复验时发现第三个调用点撤了仍全绿,补了「流式后到的题」一例才让三处都承重。

更根上一层没修:`core-slim.ts` 的 `<question-form>` 契约只列了 `range` 是合法
type,**整份契约从没提过 min/max/step**,而 §5 又要求模型给每道非视觉题一个
合理默认值 —— 等于让模型自由发挥数值、宿主照单全收。要根治得在契约层要求 range
题必须带 min/max,那是另一件事。

**OPEND-2633 轮播右箭头盖住卡片。** 我们自己错了两件:
- **11px 被数了两遍**。稿子的 `.vnav { inset-inline: 11px }` 定位祖先是整张卡宽,
  而我们的祖先 `.qf-visual-stage` 已经用 `margin-inline: 11px` 吃掉了这条 gutter,
  里面又写一次 → 箭头往里挪 11px 扎进卡片区。讽刺的是代码注释自己写着「我们的
  定位祖先已经正好是这一沓了」,然后还是按旧祖先写了 11px。
- **`z-index: 6` 是我们自己加的**,稿子的 `.vnav` 一条层叠都没写,`.vopt` 带
  4/3/2/1 —— **稿子里是卡片盖箭头**,我们写 6 把关系反过来了。

改后重叠阈值从卡宽 360px 降到 338px。窄于 338px 仍会重叠,但此时按稿子的规矩
是卡片盖箭头而不是箭头切卡片。要在极窄栏彻底不碰得动 `--qf-fan-w`(我们 200px、
稿子 152px,是 2026-08-27 产品裁决横过来的),没动。

* fix(web): 接上前两条修复在 ProjectView 里的落点

这个文件同时是 `1bc480a340`(封面晚到换快照)和 `a17a22e32a`(个人项目被判只读)
的接线处,两条改动在这里没有重叠的 hunk,但**分开提交会让那两个 commit 各自
不完整** —— 前者少了收 SSE 事件后触发重拉的那一支,后者少了把
`projectVisibility` 传给 `useProjectCollab` 的那一行。合并成这一条补上,并在此
写明依赖关系,免得将来二分时把它们当成独立可回滚的点。

- 封面晚到:`handleProjectEvent` 新增分支,复用现有的会话消息重拉;配套一个 ref
  破除 TDZ(重拉函数定义在 handler 之后)。
- 个人项目只读:从 `useProjectWorkspaceScope` 取 `projectVisibility` 传下去。

* test(daemon): 把「表单答完之后约束蒸发」这件事钉成红测(暂 skip,等产品裁决)

OPEND-2623:用户明确写了 "Do not create or modify files",答完 Question Form
之后系统照样进生产、开始写文件,靠人手动 Stop 才没落盘。

查下来不是「prompt 里漏了一句」,而是**根本没有机制能表达「这一轮不产文件」**。
四道闸门各自单独就够让它落空:
- 路由只有 `direct_edit` / `full_plan` 两个值,两条都以「必须有一个可运行入口
  文件」收尾;
- 计划契约里 `canonicalDeliverable` 是必填字段,「这次不产文件」序列化不出来;
- 结局枚举没有「答完了、无需产出」这一档;
- `plan_ready` 即自动发车,零条件。

更根上一层:决定要不要进这套编排的地方**压根不读用户说了什么**,只看项目的场景
画像。而 production 阶段实际发出去的 5,270 字节里**没有一个字是用户说的**。

产品其实有一个「不写文件」的载体(Ask 模式的 `CHAT_MODE_OVERRIDE`,措辞已写好),
但两头都断了:UI 入口被 B57 拿掉、Home 固定 design;而 OD Next 路径会把
`composeSystemPrompt` 整个换掉,那段 charter 从不组装。

六条红测分别钉住六个断点,**故意不去让它们变绿** —— 要绿必须先有产品裁决
(做成模式还是意图 / 契约要不要能表达「无交付物」/ 自动进生产要不要闸门),
自行选一种形状就是编产品规则。因此整个 describe 暂时 `.skip`,文件头写清了依据
和解封条件,CI 保持绿。

夹具字符串全部照抄打包版真机落盘的 `strategy_task_runs.final_text`。

顺带记一条:daemon 在四个调用点把 `nativeSessionResume: true` 写死,那是断言不是
校验 —— CLI 上下文被压缩后原话还在不在,从落盘数据看不出来。所以 production
阶段的模型可能连约束都不剩,这只会让结论更糟。

* fix(daemon): 一条 assistant 行只装一个 run 的流,别让后继 run 被吞进上一行

用户报「刚打开一个 project,对话里的结论重复了两遍」。不是画重了,是库里真有
两份:同一个 production run 的事件既写进了 clarification 那一行,又自己存成了
下一行。

## 第二个写手是 web,不是 daemon

- daemon 那侧 run 的 `state.json` 证明 production run 的 `assistantMessageId`
  自始至终指向它自己那行,从没指向被污染的那行。
- 被污染那行第 239–1009 条里有 **341 个 `thinking_tokens`** —— 全仓 daemon 源码
  搜这个词是**零命中**,唯一生产者是 web 的翻译层(还带客户端时钟的 `at`)。
- 同一条 usage,pos 4 带 `stopReason`(daemon 才加的),被污染那行里没有。

客户端为什么不换行:`consumeDaemonRun` 在任务链继续时**故意不换消息对象**,
三处 `onRunCreated` 把同一条消息重指到后继 run 并继续追加 —— 而契约注释
(`packages/contracts/src/sse/chat.ts` 约 446 行)写的是客户端应当只在**渲染时**
按 `strategyTaskRunIndex` 折叠、不该重指。

## 现有守卫为什么没拦住

`mergeMessageWriteForDaemonBacked` 是按「**客户端声称的 runId**」判的。在真实 HTTP
边界上把五种形态各跑一遍(每种用一对全新的 daemon 托管行,互不污染):

| PUT 形态 | 落盘? |
|---|---|
| runId=后继 run,runStatus=succeeded | 拦住 |
| **runId 省略,runStatus=succeeded** | **落盘** |
| **runId=本行自己的 run,runStatus=succeeded** | **落盘** |
| runId=后继 run,runStatus=running | 拦住 |
| runId 省略,runStatus 省略 | 拦住 |

只要 PUT 不点名后继 run、并附和本行已有的终态,整条后继流就能进来。

## 改法

按 **payload 里装的是谁的流**判,不按客户端自称的身份判。新增
`payloadCarriesAnotherRowsRunStream()`:一条行的**第一个** done_key 才是它的 run
身份,后面的都是要拦的脏数据;再读同会话其他 assistant 行的自有 done_key 做比对。

不变式:**一条 daemon 托管的 assistant 行只装一个物理 run 的流;客户端写入无论
声称哪个 runId,都不许把兄弟行的 run 流塞进来。** 重试不受影响(重试是同一个 run、
同一个 key、同一行)。

放在服务端而不是客户端,是因为这样**连已经发出去的打包版客户端一起保护**。

## 不是今天引入的

三个 run 的 `state.json` 里 `appVersionInfo` 是 `0.21.1-beta.7`,会话跑在 17:21–17:51,
而本分支当天那几个 commit 都晚于会话结束。更决定性的:同一个库里 **08-28 还有一例
一模一样的**(`7fd150ee`),那条记录 `appVersionInfo` 是 null,更老的包。

全库 165 条 assistant 行用五种判据各扫一遍(单行 ≥2 个 done_key / 同 key 出现在两行 /
前一条 content 以后一条结尾 / 单行 ≥2 个 run-start 帧 / 部分吞并),受害者都是这两条。
判据四多出的一条是「卡死后重试」,不是吞并 —— 它正好证明**数 start 帧会误伤重试**,
所以判据必须用 run 身份,不能数帧。

## 两件没做

- **已损坏的两行不会自愈**,这个守卫只拦新的写入。产品 2026-09-05 拍板:
  「新的不会就行,历史的先不管了」。
- **web 侧的根治没做**:任务链续跑后,客户端不该再往那条被折叠的消息里持久化,
  折叠只该留在内存里给渲染用。服务端这道是最后一道闸,不是病根。

* fix(web): 从折叠回合分叉时,边界落在这一链的最后一条消息

用户在对话末尾 fork,fork 出来的会话只剩第一轮。库里坐实:原会话 position
0,1,2,3,4,两条 fork 都只有 0,1 —— 丢掉了用户填表单那条、澄清阶段的回复、
以及最终结论。

## 根因:折叠复用了头一条消息的 id

`foldStrategyTaskTurns` 把 OD Next 的 run 1..N 合进 `folded[headIndex]`,展开
`...head` 时**从没覆盖 `id`**。于是屏幕上那个气泡显示着三个 run 的正文,却挂着
**request run 的 id**。fork 按 id 切,daemon 忠实执行契约(「copy only source
messages up to and including this message」),自然切在 position 1。

真实数据对得上:

```
task_run_index 0 request       -> position 1  id home-auto-send-0qzyd9dzs8wn2-assistant
task_run_index 1 clarification -> position 3  id 5ba87c0e-…
task_run_index 2 production    -> position 4  id odnext_assistant_26e1ba4b…
```

折叠后是 `[u0, merged(id=position1 的 id), u2]`,而 u2(`[form answers…]`)被
`buildChatRenderItems` 藏起来 —— 所以屏幕上「对话末尾」就是那个合并气泡,它的 id
是 position 1 的。`slice(0, 2)` → 0,1。两条 fork 的 assistant 正文都是 1995 字,
正是 request run 那条。

## 只影响 OD Next 的折叠链

没有 `strategyTaskRunIndex > 0` 时 `foldStrategyTaskTurns` 原样返回,普通单 run
会话点到的就是转录里那一条,不受影响。

还有一条时序:`strategyTaskRunIndex` **只由历史 GET 写入**,SSE 从不写。所以
**跑的过程中**分叉是好的,只有会话被重新拉过历史之后才出问题 —— 和现场吻合
(原会话 09-04 17:21,两次 fork 09-05 01:17,隔了 8 小时)。

CLI 的 `--fork-after <id>` 由操作者直接给、看不见折叠,不受影响。修在 UI,
daemon / CLI 的契约保持「按 id 字面切」。

## 改法

新增具名不变式 `forkBoundaryMessageIndex()`:点击的是折叠气泡时,边界要走到
**这一条逻辑回合在转录里的最后一条物理消息**,而不是它借来的那个头 id。

## 同一处引起的第二个 bug,一并修掉

`forkFallbackMessage` 以前发的是**折叠后**的消息,正文是几个 run 拼在一起。走
「分叉点尚未持久化」那条路径时,daemon 会把这段拼接追加到已持久化的前缀之上 ——
同一段文字出现两遍。现在发的是边界那条转录消息。

## 全库扫描

8 个库全扫(7 个打包版 namespace + 开发数据目录),受害者就是这两条 fork。
⚠️ 扫的时候必须连 `-wal` 一起拷:`app.sqlite` 本体比现场旧 8 小时,**两条 fork
只存在于 WAL 里**。其余 6 个库根本没有 `strategy_task_runs` 表,结构上免疫。

## 已知遗留(不在本次范围)

fork 出来的消息 `run_id` 是 `undefined`(有意的,见 daemon 的 fork 播种代码),
因此永远拿不到 `strategyTaskExecutionId`。所以修好之后,fork 一个 Full Plan 回合
会得到**三个分开的气泡**而不是一个折叠回合 —— 内容完整,分组没复原。

另外同一个折叠 id 还被交给 `onSubmitQuestionForm` / `onAssistantFeedback` /
`onContinueRemainingTasks`。**没有证据表明它们出错**,只是形状相同,单独跟。

* test(chat): 按 2026-09-05 的裁决更新输入区那几条判据

合并 main 时产品拍板:项目输入区取 main 的 #7635 / OPEND-2553,本分支按交付稿
`729fa43ce7` 做的那一版作废。下面三个文件里 6 条判据跟着翻面,每条都在原处写清
了「被哪条裁决作废、依据是什么」,没有静默改数字。

**w134 发送键几何** —— 28×28 / 图标 16 → **32×32 / 图标 32**。

⚠️ 是 32,不是我先前转述的 36。`viewer/routines.css` 里 `.app .composer-send`
先声明 36px,但同一个文件更后面的规则把它压到 32 —— **只读源码会读成 36**,把
两份样式表按 `index.css` 顺序装进来量才是 32。这正是这个文件存在的理由:
2026-09-03 有一轮只改了 `chat.css` 那半边,测试绿了而真浏览器一动没动。那套
「两份样式表一起装再问 getComputedStyle」的机制原样保留,只有数字换了。
32 也和 main 自己的注释自洽:运行态「共用每一行,底.svg 就是这个盒子把箭头
拿掉」—— 发送和运行是同一个 32 方框,不是两档。

**w126 字形** —— 发送键 `ChatSendArrowIcon` → `Icon arrow-up-fill 32`;plus 菜单
的 else 分支(首页)`Icon plus` → `Icon attach`(main 把首页那颗触发器从「+」
改成了回形针)。**聊天面板那一侧全都没动**:`ChatPlusIcon`、`strokeGlyph`、
`ComposerPaletteIcon` 三条判据原样通过 —— 这也是我确认「红的是首页那一半、
不是回归」的依据。

**两条我改的是查法,不是数字**(它们的夹具其实没过时):

- **w73 那颗 `+` 的类名**:main 改成了条件拼接
  (`triggerLabel ? ' --labeled' : ' od-tooltip'`),按整段字面量搜源码就对不上
  了。但聊天面板不传 label,**运行时拼出来仍然是 `icon-btn plus-menu__trigger
  od-tooltip`** —— 手搭的夹具是对的,过时的是查法。改成分别钉「基础类名还在」
  和「不带标签时挂 od-tooltip 这条分支还在」。
- **w126 菜单条目的加号**:main 加了加载态,单行变多行
  (`name={attachLoading ? 'spinner' : 'plus'}`)。按整段字面量匹配等于在钉排版,
  改成钉真正要守的:仍是共享 Icon 的 plus、仍是 15、仍挂 `plus-menu__item-icon`。

全量:web 1,038 文件 / 10,513 测试 0 失败。

* feat(observability): 聊天区滚动被冻住时自动上报现场

把「聊天区滚不动」从**只能靠用户报障**变成**卡住那一刻自己上报**。只观测,不自愈。

## 为什么需要它

这个缺陷已经完全刻画、但**触发条件未知**:合成器手里那份最大可滚距离被冻在某个
早期值,再也不刷新。布局是对的(`scrollHeight=2347 / clientHeight=583`),程序
赋值是对的(`scrollTop=1700` 生效、`99999` 正确夹到 1764),**只有真实输入那条路
错** —— 12 下滚轮请求 1440px 只走到 91,从 800 滚一下瞬间弹回 91。内容变三倍、
改 clientHeight、换光标位置,上限都还是 91。

`583 + 91 = 674` 不是巧合:那正是内容 674px 那一刻的正确上限,也就是说合成器的
拷贝在助手消息长到约 516px 时冻住了。唯一能治好的是销毁重建布局盒。

原始坏现场在排查过程中被销毁,复现要从头来 —— 所以先把观测埋下去。

## 判据:JS 读不到合成器的边界,但读得到症状

写成纯函数吃三个数字,不碰 DOM:
- **wheel_stall**:向下滚轮 + 按 layout 还有空间(> 24px)+ 连续 4 次、累计请求
  ≥ 240px 而 `scrollTop` 一动不动
- **wheel_snap_back**:向下滚轮之后 `scrollTop` 反而倒退 ≥ 8px(就是 800 → 91
  那一幕),一次即可

上报前还要确认**滚轮目标到 chat-log 之间没有任何还能滚的内层盒子** —— 这条
先前是手工排除的「滚动链」,现在由代码自己排除。

## 上报里带什么(价值不在「发现卡住」,在把现场固定下来)

- `compositor_content_px` = 观察到的天花板 + 视口 —— 这次就是 **674**,和
  `layout_content_px`(2347)一比就知道冻在哪一刻
- 最近 20 条几何转变的环形缓冲,尤其**「不可滚 → 可滚」那一次**(合成器在那一刻
  新建滚动节点,是头号嫌疑时机),外加 `content_px_at_scrollable_on` —— 它和
  `compositor_content_px` 相等就说明「滚动节点一出生边界就是错的」
- 挂载时 vs 冻结时的层数差、祖先链的 transform/filter/contain
- 是否流式、有没有 question-form 骨架屏、Chromium/Electron 版本

## 零成本

两个监听器都是 capture + passive;**回调里一行布局都不读**(读 `scrollHeight`
会强制重排,那正是要检测的卡顿本身),几何读取全在 `requestAnimationFrame` 里、
一帧最多一次;滚动驱动的采样限到 250ms 一次(流式贴底每帧都发 scroll 事件);
层普查每个 chat-log 只跑两次(挂载时塞进 `requestIdleCallback`、冻结时);内层
滚动盒排查只在准备上报那一刻跑一次。每个元素只报一次,每会话上限 3 条。

更要紧的是**没找到聊天区时、以及全 app 其它地方滚动时的开销**,这里收紧了三处:

- **滚轮监听在找到聊天区之后就从 window 搬到元素本身**。挂在 window 上是为了
  「发现」,一旦发现就没必要再听全局 —— `detach()`(切会话、卸载)时才重新武装。
  于是页面上别处的滚轮**一个回调都不进**。
- **scroll 的热路径是一次身份比较**。全 app 每一次滚动都会打到这个 capture
  监听器上,所以不匹配就立刻返回:没有取时钟、没有分配、没有排任何东西。
- **拿不到 `requestIdleCallback` 就不做层普查**,而不是退化成内联或 `setTimeout`。
  这个回调要对最多 600 个元素调 `getComputedStyle`,而它是从 scroll 处理器里
  发起的 —— 退化成内联,等于在**首次滚动**时同步解析 600 个元素的样式,正好是
  本模块声称不会造成的那种卡顿。`layer_count_at_attach` 因此**缺失而不是昂贵**,
  这是对的取舍。

拆卸也补齐了:`detach()` 会取消在飞的 rAF 与 idle 回调、摘掉元素上的滚轮监听、
断开 `ResizeObserver`,不留悬挂引用。

## 刻意不做自愈

`display:none → flex` 能治好它,但没加:会闪、会丢滚动位置,而且**会把我们要找的
触发条件抹掉**。要不要自愈是产品决定,标待拍板。

这条不是靠注释守的 —— 变异测试里**故意加了自愈代码**(改 display + 写
scrollTop),「never writes to the DOM」当场变红。

## 红测

19 条,先红(模块不存在)→ 绿 → **8 个变异逐个撤掉实现再红**:判据、snap_back
分支、只报一次、到底保护、转变记录、内层滚动盒排除、切会话重新发现,以及上面
那条反向的自愈守卫。

⚠️ 只有真机能确认的部分(报告里已标明,单测假装不了):真实冻结时 `scrollTop`
是否真的一帧不动、4 格/240px 这两个阈值对真实触控板是否合适、层普查的数字在
真实 DOM 上是否有意义、以及事件确实发得出去。

## 已知盲点(没顺手补)

- `run_id` / `conversation_id` 为空:`chat-context.ts` 的 `setChatCorrelation`
  全 app 零调用(`chat-health.ts` / `chat-protocol.ts` 也都没接线),接起来是
  独立一件事
- 流式状态用的是 DOM 启发式(`[data-streaming="true"]`),不是 run 生命周期,同上
- 用户停在半中间且没有自动贴底时,内容变高不发 scroll 事件,那段 `content_grew`
  可能漏记 —— 要补得上 MutationObserver,流式期间是真实开销
- 只覆盖主聊天流水,思考区那个 96px 内层框不在范围内

## 三条守卫测试

上面三处收紧各配一条,并逐条撤实现验红(一一对应,只红自己那条):

- 聊天区以外的 scroll / wheel **什么都不排**
- 浏览器说不出自己空闲时,挂载**不遍历子树**
- 卸载时取消在飞任务并放开元素

* feat(observability): 记下滚动卡住那一刻,界面上还有谁在动

真机探针抓到一次现场,推翻了原来的模型 —— 于是这次补的不是「卡住时的快照」,
而是**亏空是从哪一次内容变化开始出现的**,以及**当时周围各个模块在干嘛**。

## 现场怎么推翻了「一诞生就冻住」

用户客户端上的被动探针记下了一次 `wheel_snap_back`:

    天花板 824   layoutMax 851   scrollHeight 1434 / clientHeight 583
    合成器认为的内容高度 1407,布局是 1434,差 27px

连续几轮滚动扫描里,够不着的量**单调增长**、天花板**单调下降**:

    第78轮 到851 差0    第79轮 到850 差1    第80轮 到846 差5
    第81轮 到842 差9    第82轮 到839 差12   第83轮 到824 差27

不是一次性冻死,是**每次内容变化都少跟一点,亏空累积**。之前见过的天花板 91
(差 1673px)是同一机制掉到底的极端形态,不是另一种缺陷。

而且**冻结那一刻界面上什么特殊东西都没有**:没有 thinking、没有工具行、
没有 question-form、没有错误卡、没有 iframe、没有内层滚动盒,只有一条用户消息
和一条 1188px 高的助手消息。**question-form 骨架屏那条猜测就此排除。**

## 亏空账本(这次的重点)

每一次 `scrollHeight` 变化都**不过滤**地记一条,配上当次的 `layoutMax`
和**是哪个子节点长高了、长了多少**(只在内容高度真的变了的那一帧,读最后 32 个
直接子节点的 `offsetHeight`,而且是在已经读过 `scrollHeight` 的同一次 rAF 里
顺带做完,不额外触发重排)。角色分 `assistant_msg` / `user_msg` / `tail_spacer`。

每一次「向下滚轮但没能前进」记一轮天花板探测。**第一次出现 reached < layoutMax
的那一轮被永久钉住,永不被后来的挤掉** —— 上报里因此能直接指出亏空是从哪一次
内容变化开始的,而不只是「卡住时差多少」。另外记了 `tail_spacer_px`:钉顶机制的
占位块是等量置换,它归零那一刻正是内容开始真涨的时刻。

回放测试直接吃上面那串真实数字(851→850→846→842→839→824,layoutMax 恒为 851),
断言报告把 850 那一轮、内容 1434px 处认成亏空的起点。

## 并行活动环形缓冲(64 条,满了丢最旧)

兄弟节点挂卸、底部浮层里的胶囊替换、**「回到最新」按钮点亮/熄灭**、日志与祖先链的
class/style 变动、动画与过渡的起止、流式翻转、标签页可见性、日志与其 offsetParent
的尺寸变化,以及作为锚点的「滚动节点诞生」。

上报里给三个切片:全量轨迹、**滚动节点诞生前后 ±500ms**、冻结前 2 秒,外加
`jump_active_at_attach` / `jump_first_active_ms` / `jump_active_vs_scroll_node_ms`
—— 「回到最新」只可能在聊天区变可滚之后才出现,它相对滚动节点诞生的先后与毫秒差
是当前头号嫌疑,值得单独拎出来。

## 成本

变动与动画回调里:两次 `getAttribute` + 一次枚举查表 + 写一个环形槽位。
**零布局读、零调度** —— 这条由测试钉着,往回调里塞一次 `scrollHeight` 当场变红。
帧里最多多读 32 个 `offsetHeight`,且只在内容高度真的动了时才读。
`detach()` 负责拆干净:2 个 MutationObserver、1 个额外 ResizeObserver、
4 个外壳监听器、1 个 document 监听器。

**两样判断为太贵、明说不加**(而不是偷偷塞进去):`.chat-log` 的 subtree childList
—— 所以 question-form / 骨架屏**消失**不会被记(出现能通过 `anim_start:skeleton`
看到);以及逐帧轮询标记。

## 红测

52 条,**33 处逐个撤实现验红**:环形缓冲、每个观察器与监听器、每处拆卸调用、
往变动回调里注入布局读,以及账本这边的增长配对、首次亏空的钉住与不可覆盖、
1px 下限、淘汰、连跑合并、天花板探测、不过滤的内容步、子节点归因、内容变化门、
每个 surface 的账本隔离、角色区分。

## 只有真机能确认的部分(单测假装不了)

- 任何一条活动记录**是否真的与漂移相关** —— jsdom 没有合成器,测试只能证明管路通,
  永远证明不了因果。
- 时间戳精度:变动记录自己不带时间,是在微任务派发时打的戳(即造成变动那个任务的
  末尾)。同一批内顺序保留、时间戳相同。`jump_active_vs_scroll_node_ms` 还继承了
  滚动采样 250ms 节流的量化误差,小于 250ms 的差值只有方向意义。
- jsdom 没有 `ResizeObserver` 和 `AnimationEvent`:`log_resize` / `host_resize` 和
  真实的 offsetParent 解析在这里测不到,`anim_*` 靠合成事件驱动。

## 已知盲点

`MIN_UNREACHABLE_PX` 仍是 24,没动。所以早期漂移(差1、差5)只有在**最终真的发出
一次上报**时才会随报告一起送达;漂移到 12px 就停住的 surface 什么都不发。
要不要把这道门槛调低,是检测灵敏度的产品决定,标待拍板。

* fix(chat): 回合成功却挂红色失败卡 —— 别让服务端的沉默抹掉策略判决

用户在 dogfood 包上撞到:agent 完整回答了问题(明确要求「只输出文字、不要
创建文件」),消息上标着「已完成」,**下面同时挂着一张红色的「任务执行失败」**。

## 不是 agent 没照做

那一轮 run 本身是成功的:`status: succeeded`、`exitCode: 0`、`error: null`,
events 里零错误。被判失败的只有协议层 —— OD Next 要求每条回复带一个
Runtime State 块,而这一轮什么都不产,于是策略任务落成 `blocked`,
原因码 `od_next_protocol_runtime_state_missing`。

把 request 阶段允许的三种声明逐个喂给这条纯文字回答,**全部 blocked**:
`clarification_required` 缺表单、`plan_ready` 缺 Plan Contract、
`direct_edit + completed` 缺产物。契约里没有「答完了,没有要交付的东西」
这一档 —— 那是策略层的缺口(别的 module 的 OPEND-2589),这里不碰。

## 这里修的是显示成泛化红卡这一半

分支上**已经有**对症的卡片(`amr-guidance.ts:901` 的「回复已收到,但没能
记录下来」,19 语齐全),但它永远画不出来:约 150ms 后拉消息列表对齐时,
服务端那一行把本地判决整个盖掉了。

工单说只丢了 `runStatus`。**实际两样都丢**:daemon 落库 29 条事件(其中 14 条
是客户端从来不造的 `diagnostic`),客户端手里只有 9 条,
`local.events.length > server.events.length` 不成立 → 服务端事件整体胜出 →
error 事件被一起抹掉。这正好解释了为什么用户看到的是**泛化红卡**而不是
「什么都没有」:error 事件还在的话 `isRecoveredAssistantRunError` 会把面板级
报错整个吞掉、一张卡都不出。**所以只补 `runStatus` 修不好。**

## 守的是什么

不是「本地 failed 压过服务端 succeeded」—— daemon 记 succeeded 是对的,
失败的是策略任务不是 run。守的是:

> 服务端那一行只带进程状态,**根本没有字段能表达策略判决**;
> 它的沉默不是对本地判决的更正,不该被当成更正用。

见证收得很窄,四条同时成立才生效:本地被 daemon 终态投影盖过
`strategyTaskBlocked`、本地已判 failed、两边是**同一个 runId**、
本地有 error 事件而服务端没有。

另一个方向(让 `isRetryableAssistantTerminalFailure` 直接认
`strategyTaskBlocked`)试过并否掉:会让「blocked 但已交付」那一路凭空冒出
一张无码的泛化红卡,是回归。测试里留了这条反向对照。

## 顺带修好 OPEND-2422

「阻断错误卡缺少重试按钮」是同一处根因的下游 —— `ChatPane.tsx:4063` 整个
动作组挂在 `runFailureUi` 上,它为空时一颗按钮都画不出来。

## 红测

8 条,先红后绿(实现未动时 4 红)。三段实现各撤一次验红:

- 撤「保留 `strategyTaskBlocked`」→ 原因码消失、连续两次对齐掉回 succeeded
- 撤「保留 `runStatus`」→ 上面两条 + 整张卡变 null + 找不到重试按钮
- 撤「追加 error 事件」→ 卡出得来但退回泛化文案

没有一段撤掉仍绿。

## 只有真机能确认的

**刷新之后专用卡不会回来** —— 刷新后客户端没有本地副本,服务端仍然只给
succeeded + 无 error 事件,一张卡都不出。泛化红卡确实消失了,但专用卡是
会话内的。要做成持久的得让 daemon 把 blocked 判决投到消息行上(契约里
`strategyTaskBlocked` / `strategyTaskBlockedText` 早就有、从来没人写)——
**那是产品规则,待拍板。**

* fix(observability): 冻结上报被裁切的盒子吞掉了 —— 顺带把探针变成可问的

真机上发生过一次够不着 1493px 的滚动冻结,远超上报阈值,**PostHog 上一条
`client_chat_scroll_frozen` 都没有**。管道是活的(同一条 `reportSafetyEvent`
路径的 `client_long_task` 两百万条、`client_resource_error` 七千条都在到达),
安装也执行了(和 `installLongTaskObserver` 在同一个数组里)。

**为什么没报,是能从代码直接看出来的。**

## 裁切盒被误判成「能吃掉滚轮的内层盒」

`countAbsorbingScrollers` 判断滚轮是否被内层盒吸收时,只看
`scrollHeight - clientHeight > 1`,**从不读 `overflow`**。于是一个
`overflow: hidden`、内容被裁掉的盒子在它眼里就是「可滚内层盒」→ 抑制上报,
**并且清零连击计数**。

而聊天记录里这种盒子遍地都是,是设计的一部分:

- `.msg.user .user-text-txt` —— `-webkit-line-clamp: 6; overflow: hidden`,
  **每一条长用户消息**
- 折叠代码块 `max-height: 7em; overflow: hidden`
- 折叠手风琴、`.action-card`、`.question-form`、`.live-code-box`…

这和证据形状严丝合缝:唯一一次**成功上报**的现场,正是一个没有 thinking、
没有工具行、没有 question-form、没有代码块的会话 —— 没有东西可吸收。

判据改成两件事同时成立:几何上还有向下余量,**且** computed
`overflowY ∈ {auto, scroll, overlay}`。顺序刻意是先几何后 computed style:
几何在刚读过 `scrollHeight` 的干净布局上取,祖先链上绝大多数盒子一步否掉,
`getComputedStyle` 只有真正溢出的那几个才付费。

`overflow-x` 没有特判,**是刻意的**:CSS Overflow 3 规定
`overflow-x: auto` + `overflow-y: visible` 在计算值阶段会把 `visible` 强制成
`auto`,那个盒子**真的**是竖直滚动端口。自己去读 `overflow-x` 再推「横向专用」
反而会和浏览器行为矛盾。真正只能横滚的写法 computed 就是 `hidden`,自然排除。

同一段逻辑在诊断面 `scanAbsorbingScrollers` 里有**第二份拷贝**,一并合成一份
—— 只修门不修镜子的话,以后在真机上敲 `snapshot()` 会看到一屏被裁切的用户
消息被列成「抑制源」,运维会得出和我们一模一样的错误结论,而这个把手存在的
全部意义就是避免那个结论。

## 运行时把手 `window.__chatScrollFreeze`

诊断模块自己不可观测,是这次查不动的直接原因 —— 没法问它「attach 了吗?
攒了多少?为什么没报?」。

- `why()` 一句话给判决:`blocked_by=inner_scroller_free`
- `snapshot()` 给全部:几何(**读取那一刻的**,另存一份帧回调的陈旧拷贝以便
  看漂移)、攒着的滚轮、亏空账本、并行活动轨迹、「回到最新」的时间关系、
  以及**十二道闸逐条的 `{ 当前值, 需要值 }`**
- `writes.enable()` 打开 `scrollTop` 写入拦截(带调用栈),**默认关**,
  复用仓库已有的 `open-design:` 键约定;还原时逐字节还原原始描述符,
  且全有或全无 —— 任何一个成员拒绝替换就整个回滚

**不调用时零开销**:安装只多一次对象发布和一次 `localStorage.getItem`,
没有监听器、没有定时器、没有帧、没有几何读。测试钉着这一点:调用 20 次,
`addEventListener` / `setTimeout` / `requestAnimationFrame` 全部零调用。
`snapshot()` 自己是昂贵的(强制重排、走最多 400 个后代)—— 那是对的,
它只在有人在看的时候跑。

## 阈值 24 → 8

真机那次漂移**从 1px 开始**长到 27(0→1→5→9→12→27),24 的门槛把前五轮
全丢掉;漂移到 12px 就停住的场景什么都不发。取整误差实测 ±0.5px,8 在噪声
地板一个量级之上;而且它只在「连续 4 格向下、累计请求 ≥240px 却没动」之后
才被查。

**这个阈值此前零测试覆盖** —— 现在补了三条(12px 漂移会报、1px 取整仍判
「已到底」、snap-back 仍要求真实亏空)。

## 撤实现验红

把手侧 33 处逐个撤、全部变红;吸收判据两个方向各撤一次:换成只剩几何
(等于修复前)红 3 条,直接 `return false`(抑制机制整个没了)红 4 条 ——
后者证明反向测试真的看得见「把抑制修没了」。

## 待拍板(没动)

连击计数在「位移 ≠ 0」时清零。用真机那串序列实测,**那一次并没有被它挡住**
(第六轮单步掉 15px,走 snap-back 报了);但存在一条窄带:每轮都动 1–7px
且永不稳定时,两条上报路都不触发,天花板一路掉到用户明显够不到底也不会报。
流式持续增高时这是合理场景。「向下挪了 3px 而 1493px 够不着」算不算健康,
是产品判断。

* feat(daemon): 按原始时间线 1:1 回放一条录下来的 run

排查滚动冻结时反复撞到同一堵墙:**它只在真实使用中偶发**,225 次合成试验、
540 轮真实滚轮扫描,全是零复现。而内容增长的**真实节奏**很可能就是触发条件
的一部分 —— 合成页面永远造不出那个节奏。

打包客户端的数据目录里躺着 109 条真实 run、147,782 条事件、5.8 小时录像,
每条事件都带 `timestamp`。**这就是现成的语料,而且回放它零 token。**

## 只替换 agent 子进程,下游全部是真产品

回放挂在 daemon 自己的 run 启动器上,替换的**只有事件的来源**;每一条录下来
的记录都交给活路径同一个 `emit()` / `finish()`。持久化、到 `run.clients` 的
SSE 扇出、run 分析、终态对账、前端消费 `GET /api/runs/:id/events` —— 全部
原封不动,而且不知情。

时间从记录自己的 `timestamp` 还原(daemon 在原始 `emit()` 时的墙钟)。记录
按**单一单调原点**排程,而不是逐个间隔 sleep —— 否则几千条下来会累积定时器
自身的过冲;目标时刻已经过去的记录在同一 tick 内冲刷掉,原始那些亚毫秒突发
(41% 的间隔是 0ms)本来也是这么上线的。

## 保真度(实测,两层)

**时间线**(run 8f451ad3,756 事件 / 374 秒):756/756 帧全达,与录像的时间
误差 p25=3ms、**p50=4ms**、p75=9ms、p99=87ms、max=114ms;总跨度 374462ms
vs 录像 374378ms —— **6.2 分钟累计漂移 +84ms(0.02%)**。

**内容**(run 25e62d4b,过真实 UI):回读出来的助手消息与录像**逐字节相同**
——正文 2033/2033、思考 14298/14298;工具事件对得上;`<question-form>` 在
聊天里渲染成了真实的表单。

**没有证明的**:回放与**原始**的 DOM 几何一致 —— 原始会话没人录过
`.chat-log` 的 `scrollHeight`,没有基线可比。只能声称事件时间线近乎精确、
内容完全精确。

## 只在环境变量下武装,默认完全惰性

    OD_REPLAY_EVENTS      指向一份录下来的 events.jsonl
    OD_REPLAY_DIR         `<runId>/events.jsonl` 的目录;下一轮放哪一份写在
                          同级的 `.selected` 指针文件里,每条 run 现读,不缓存
                          —— 一个 daemon 不用重启就能换任意录像
    OD_REPLAY_SPEED       墙钟倍率,默认 1
    OD_REPLAY_MAX_GAP_MS  空闲间隔的钳位,默认 0 = 不钳

不设 `OD_REPLAY_EVENTS` / `OD_REPLAY_DIR` 时这段代码一行都不跑。

* fix(chat): 思考想完之后,把头一格的耗时还回来

用户原话:「即使是第一个 thinking,思考过程中不显示耗时,**但结束还是要显示的吧?**」
以及确认现象:「现在思考耗时好像只有第一次的 thinking 没显示耗时?」

## 那一格为什么会两头落空

思考行右边只有**一个槽**,规则是「有实时 token 就写 token,没有才写耗时」。
而 2026-09-04 的裁决把**整轮头一格**的耗时压掉了,理由写在
`specs/current/chat-panel-feedback.md:876`:

> 头一格没有计时可让(今天刚因「与壳头重复」收走),所以那一格**恒为 token**。

这个前提在两处塌陷:

- **AMR 从来就没有实时 token** —— 全仓 `thinking_tokens` 只有 claude 和 codex 两个
  生产者,ACP 那条路一个都没有(实测 25 条 amr run 出现 0 次)。「恒为 token」对它
  从来不成立。
- **claude 收尾之后 token 也没了** —— tokens 只挂给还在流的那一格
  (`group-thinking.ts:106` 的 `if (live)`)。

于是槽里既没有 token 也没有耗时,渲染成一个**空的 `<span>` 占着布局**
(`Foldable.tsx:135` 判的是 `!= null`,而 `''` 不是 `null`)。

## 「与壳头重复」这个理由在收尾后自己消失

跑着时壳头和头一格同起同终(`build-turn-blocks.ts:1501` 的 `isFirst` 分支 +
`:1509` 的 `running ? nowMs`),确实是同一个数;收尾后壳头被 `isLast` 推到
`endedAtMs` 变成**整轮**跨度,而头一格只覆盖到第一件带时刻的事,**两个数不再相等**。

## 改动

一行:

    const elapsed = muted && live ? '' : formatElapsed(elapsedMs);

`live` 核实过是**逐格**的,不是整轮:`groupThinking` 只给队尾那一格置位,含义是
「模型此刻正在往**这一格**里写」。这正是要的开关 —— 它同时驱动那一格的视觉形态
(转球 +「思考中」↔ 脑图标 +「思考过程」)和 React key,所以**耗时出现在那一格
翻成完成态的同一帧**,结构上不可能在还写着「思考中」时漏出来。用整轮 `running`
反而会让两者错拍:格子已经写「思考过程」,槽却还空着好几分钟。

「还在跑的数会不会等于壳头」这条也是关的:非时刻项落在思考之后时 `closeThink`
的 `ownsGap` 会作废整段 → `elapsedMs` 为 null → **连槽都不出**,不是空槽。

## 既有判据逐条处置(不是删,是判)

两个文件把「当年的现状」钉成了预期。逐条分:守「**进行中**不显示」的**原样保留**,
守「**收尾后**也不显示」的**更新并写明前提为何失效**。

有一条**拒绝转绿**:`amr-thinking-slot-blank.test.tsx` 里那条断言 AMR 头一格必须
有字的用例,它的夹具**是 live 的** —— 断言它非空等于断言「思考进行中显示耗时」,
和裁决直接冲突。改成守卫,并记下 AMR 缺 token 的真因在 ACP 不产
`thinking_tokens`(daemon/协议层),**在渲染层拿耗时去顶 token 的位置就是自造规则**。

## 撤实现验红(两个方向)

- 撤掉修复(`muted && live` → `muted`):**14 红**,正是「想完之后要有数」那一组
- 改成完全不压(去掉 `muted`):**5 红**,正是「进行中不显示」那些守卫

两个方向都咬得住,说明既没修漏也没修过头。`tests/components/chat/`
187 文件 / 1543 测试全绿。

## 只有真机能确认的

1. **收尾后那两个数的实际差值没量过** —— 整条裁决建立在「不再相等」上,而这是读
   代码推的。真机上典型 run 如果第一件工具调用就在开头一两秒,两个数看起来还是会
   很像,产品可能仍然认为重复。
2. **AMR 可能仍然什么都不显示。** 夹具是手喂 `elapsedMs` 的;ACP 那条路上这一格的
   耗时只有在后续事件带可用时刻时才存在,若 ACP 工具事件没有 `startedAt`,
   `ownsGap` 会作废整段、**连槽都不出**。**在对用户说「AMR 修好了」之前,必须真跑
   一轮 AMR 看。**
3. 转场那一帧的观感(数字与图标同帧出现是干净还是突兀)jsdom 看不到。
4. 交付稿只画了 `thinking-live`,**没有画「思考结束」这一态** —— 视觉处理仍待产品过目。

顺带在 `chat-panel-feedback.md` 那条 2026-09-04 记录后面追加了带日期的更正,
因为那句「恒为 token」是这个前提的共享记录,留着会误导下一个人。

* fix(chat): 模型跑不起来时,把 daemon 已经算出的病因和处方露出来

用户跑 codex 撞到:壳头「运行失败 13s」,底下一张泛化卡「这次没能顺利完成」,
只有〔联系支持〕〔导出日志〕。**连是哪个模型不行都没说。**

而 daemon 那一侧其实全算出来了:

    error.code        AGENT_EXECUTION_FAILED
    failureCategory   model_unavailable
    failureDetail     cli_version_incompatible
    failureAction     switch_model
    retryable         false

上游原句:`The 'gpt-6-astra' model requires a newer version of Codex.`

## 为什么露不出来

「模型不可用」那张卡(19 语齐全,带〔更换模型〕按钮)**挂在错误码上** ——
`AMR_MODEL_UNAVAILABLE`。AMR 是我们自己的后端,错误从产生到消费都在手里,
所以能给专属码。

而 BYOK agent(codex / claude / opencode)是**第三方 CLI**,失败以 stderr 文本
和 stream error 的形状过来,只能落在泛化的 `AGENT_EXECUTION_FAILED` 上,细分
信息在 `failureDetail` 里 —— **结构上永远够不着那一行**。

这个缺口在 `amr-guidance.ts:607` 的注释里就写着,还拿 `cli_version_incompatible`
当例子。

## 改动

`AGENT_AGNOSTIC_DETAIL_FAILURE_UI` 加 5 行,全部指向现成的
`switchModelWithGuidance`:`cli_version_incompatible` / `model_not_found` /
`model_not_supported` / `model_disabled` / `local_model_not_loaded`。

**零新增 i18n key、零契约改动、零删除** —— 文案、19 语翻译、`switch-model` 按钮、
埋点全是现成的,只缺表里这几行。

## 为什么放 agent-agnostic 那张表

原本的理由(「要在 AMR 分支之前解析」)**不完整**:六个 agent 分支条件里有五个
根本不可能和这些 detail 同时出现 —— daemon 分类器在模型分支(`:1059`)之上就
返回了 `AMR_INSUFFICIENT_BALANCE` / `AMR_TIER_UPGRADE_REQUIRED` /
`*_AUTH_REQUIRED`(`:945-1055`)。对那五个,两张表行为相同。

**真正成立的理由是 Antigravity**:分类器的 `RATE_LIMITED` 分支在 `:1231`,
**排在模型分支之下**,所以 `RATE_LIMITED + model_not_supported` 是真实可达的组合。
从 `DETAIL_FAILURE_UI`(在 agent 分支**之后**解析)出来,它会被 Antigravity 的
限流分支抢先、标题成「速率受限」;从 agnostic 表出来才拿到 daemon 的真实诊断。

另外两条:code 版的同族 `AMR_MODEL_UNAVAILABLE` 本来就在 `AGENT_AGNOSTIC_FAILURE_UI`
里、同一优先级层,把一张卡拆到两层会让「会不会被 agent 分支抢走」取决于
「daemon 恰好有没有结构化码」;而 `DETAIL_FAILURE_UI` 的 docblock 把自己限定为
**覆盖**一个错的或太笼统的 code 映射,这几行没有覆盖任何东西
(`AGENT_EXECUTION_FAILED` 在任何表里都没有行)。

## 两条约束测试

- **不出重试,且与 verdict 无关** —— 故意**不传** `verdict`,因为 `retryable` /
  `failureAction` **还没上线到 wire 上**。不修的话这个形状会掉到解析器最后一行
  拿到 retry。这样钉住的是「这一行本身就对」,不用等契约补齐。
- **AMR 那条路没被影响** —— 用 `detail: null` 作判别用例。daemon 给那个码配的
  正是 `model_not_found`,而它刚进了 detail 表,**只测 detail 那一档绿了可能是
  巧合**;只有 code 行能答 null 这一档。

## 撤实现验红

三个变异,每次都还原并校验校验和:删掉 5 行 → 11 红 2 绿(那 2 绿正是反向对照
和 AMR 测试,与设计一致);改成 `retryWithGuidance` → 11 红,报的是
`expected 'retry' to be 'switch-model'`(证明不重试那几条断言独立承重);
删掉 code 表里的 `AMR_MODEL_UNAVAILABLE` → 只有 AMR 那条红、且只在 `detail=null`
(证明它确实隔离了 code 行)。

## 刻意没做的两件

**`provider_routing_error` 排除在外**,它是这个家族的第六个成员:
`modelUnavailableDetail()` 把它发成 `model_unavailable`/`switch_model`,而
`upstreamDetail()` 把**同一个字符串**发成 `upstream_unavailable`/`retryable: true`。
`amr-guidance` 收到 detail 时**不带 category**,加一行会把上游那一半错标。
**要等 `failureCategory` 上线到 wire 才能收**,代码里留了 ⚠️。

**`local_model_not_loaded` 的文案只是勉强合身** —— daemon 对它同样开
`switch_model`,家族卡是诚实的路由,但它字面上的解法是「去 LM Studio 里加载一个
模型」,不是「在这儿换一个」。**没有自造新句子**(那要 19 个 locale),按文件里
既有的 `local_storage_failure` 标记方式标了待拍板。

## 待另外开单的契约缺口(只记录,未改)

1. daemon 发的 `failureAction: "switch_model"`(`run-failure-classification.ts:1070`)
   **不在** `RunFailureAction` 联合类型里(`packages/contracts/src/api/chat.ts:50`
   只有 `relogin | recharge | upgrade | retry | none`)。
2. `agentVersion: null` —— OD 不记录 agent CLI 版本,所以卡片上说不出「你装的是 X,
   需要 Y」。解析器自己的注释说,要拿到它得在每次启动前加一次探测。

## 只有真机能确认

这几行只决定**判决**,`resolveRunFailureUi` 是纯函数。这条 run 的 `failureDetail`
能不能活着穿过 SSE `end` 帧 → `markErrorRunFailure` → `appendErrorStatusEvent`
一路到 ChatPane 读的 props,**没有任何东西在这里证明** —— 而那条链正是
`RunFailureDaemonVerdict` 的 docblock 说「字段今天会丢」的地方。

* fix(chat): 「问完就交棒」的回合不再被判成「已停止,仍有未完成任务」

用户回答完 question-form 之后,那一回合底下冒出一行「已停止,仍有未完成任务」,
而**同一条消息的壳头写着「已完成 56s」** —— 屏幕上两个互相矛盾的状态。

## run 从来没被停过

真机那条 run(`441ff961-…`)的末帧逐字:`status: succeeded`、`code: 0`、
`signal: null`、`error`/`errorCode`/`failureCategory` 全 null。
用户提交表单后的下一轮正常做完了 34 个产物 —— **「仍有未完成任务」连事后看都不成立。**

页脚那行只读最后一个 TodoWrite 快照(4 条全非 completed),**从不看 run 是怎么结束的**。
而这一轮以 `</question-form>` 收尾:它的计划**本来就该没做完**,那是等待,不是烂尾。

「答完才出现」是因为 `hideRunStatus` 里有 `hasPendingQuestionForm` —— 表单悬着时整行
被藏住,一提交就翻假、闸门打开。那道闸当初防的是**假的成功**(注释原话),
没人看过它另一头掉出什么。

## 判据放在契约层,因为有三个面在判完成度

daemon 的 stamp、`db.ts` 的项目卡投影、web 的页脚 —— 而**页脚是自己从 events 重算的**,
不读 stamp。所以只改 daemon 修不好页脚,只改 web 修不好项目卡。

`turnEndedByAskingUser` **不是新产品规则**:它就是 daemon 已有的 `awaiting_input` 规则
(`listPartitionsAwaitingInput`、`run_finished.asked_user_question` 都在用
`emittedRenderableQuestionForm`)。把那个扫描器从 daemon 提到
`packages/contracts/src/api/question-form-markup.ts`,daemon 侧留 re-export 垫片,
6 个导入方和 e2e 的 parity 测试路径都不用动。

它要求的是**可渲染的闭合 `<question-form>`**,不是裸标签 —— 所以产物 HTML 里印了
这段文本不会误判。反向孪生测试逐层钉住:被引号包住的裸标签**仍然**报未完成。

## 测试抓出了一个真 bug:文档说的收口点其实不收口

第一版把文本收拢在 `captureRunWorkCompletenessSignals` —— 那里的注释明写着
「single choke point」。**错的**:`emitGuardedTextDelta` 把守卫过的文本直接送给
`send('agent', …)`,**绕过 `emitAgentEvent`**,整个 json-event-stream / copilot / ACP
家族都走这条。全链路测试当场变红。收拢点移到 `send` 本身,并在原处留注释说明
为什么不能折在那儿。

## 是「移动」不是「新增」

`server.ts` 里那个路由局部的 `clarifyingQuestionText` 缓冲被**挪到 run 上**,
而不是加第二个缓冲 —— 于是缺产物守卫和 `finish()` 读同一份。

## 撤实现验红(逐处,含两道状态闸单独隔离)

| 撤掉 | 变红 |
|---|---|
| contracts 的 `eventsEndedByAskingUser` 判据 | `does not call a clarification turn unfinished` |
| daemon 的 `&& !endedByAskingUser` | `runs.test` + `chat-route.test` 各一条 |
| `server.ts` 的 `askUserScanText` 累积 | `chat-route.test › does not stamp unfinished work…` |
| web 的 `messageEndedByAskingUser` | 原红测 + 2 条 todos 单测 |
| **只**撤 daemon 的 `status === 'succeeded'` 闸 | `does not let a rendered form erase unfinished work from a canceled run` |
| **只**撤 web 的 `runStatus` 闸 | 「取消后仍报已停止」+「仍提供剩余任务」 |

取消那条守卫原本是**空的** —— `canceled` 在标签排序里本来就压过 `hasUnfinishedTodos`,
只断言标签会永远绿。加了「继续剩余任务」按钮的断言才让它非空。

## 文案没动(产品的)

`assistant.unfinishedLabel` 19 语原样。原样转述建议给产品:**一个只知道 todo 快照的
谓词,没有资格断言「终止原因」;在这一档「已停止」永远是错的。** 这次修复让
「问完交棒」不再落进这一档,但其它落进来的回合(比如 agent exit 0 直接不干了)
措辞仍然在硬说「已停止」。值得产品拍一次。

## 只有真机能确认

- **纯 stdout 运行时**(antigravity / BYOK / deepseek TUI):它们的文本是 `stdout` 块
  不是 `agent` `text_delta`,`run.askUserScanText` 会是空,**daemon 那侧维持现状
  (fail-closed)**;web 侧照样修好,因为它读 `message.content`。这是那个缓冲的
  既有局限,没有顺手扩大。
- 打包客户端上那条 run 的页脚真的读「已完成」、项目卡不再翻 `incomplete`。
- **「继续剩余任务」按钮在已回答的澄清回合上会消失** —— 这是把
  `continuableUnfinishedTodos` 也接上判据的直接后果,判断是对的
  (表单本身就是那个「继续」),但**是超出状态词的可见行为变化,值得看一眼**。

* feat(chat): 余额只剩一档提醒 —— 拿掉软弹窗,首页直接放行

产品裁决(用户 2026-09-06 原话):「**软提醒弹窗就是产品告诉我不要这个的,
只用弹那个插画的就行**」,以及首页那一档「**什么都不显示,有余额就允许运行**」。

## 三个界面的终态

| 界面 | 余额 > $0 但 < $2 |
|---|---|
| 项目页 | 交付稿那张对话内 `UpgradeCard`(**本来就是**,2026-08-26 的 D-01 已裁决) |
| 首页 | **什么都不显示,直接放行** |
| = $0 | 不变,仍是带插画的 `AmrBalanceDialog` |

首页原来是 `AmrLowBalanceDialog` —— 一个**挡住这次发送**的居中弹窗,用户得点
〔仍要发起任务〕才继续。现在 `soft` 那个分支整段删掉,**落下去就是放行**,
不是留一个不显示但仍然挡着的空壳。原位留了注释写明「故意没有分支,别再加回来」。

## 为什么首页至今还留着它,不是漏做

2026-08-26 的裁决 D-01(`specs/current/run-error-catalog.md:303`)逐字写着
「告警可继续的不弹窗,只有卡片;余额不足再弹窗」,但**落点栏只点了
`ProjectView.tsx` + `ChatPane`,没点 EntryShell** —— 首页从来没被那条覆盖过。

而且 `UpgradeCard.tsx` 里那句「那个的去留另记(见规格 T40)」是个**断指针**:
T40 讲的是「软提醒不许拖慢运行」,和去留无关。**软弹窗的去留至今没有任何书面裁决**,
这次是第一次。断指针一并修掉(组件注释、CSS 注释、`AmrBalanceDialog`、
镜像页两处注记与两行 HTML)。

## 测试是改判据不是删

`w116-entry-shell-low-balance-tiers.test.tsx` 保留并翻面:free / pro / go / max / null
各档 soft 都断言**不出任何弹窗**且 `onCreateProject` 被调用。另加两条:

- **屏幕上一个 `role="dialog"` / `alertdialog` 都没有** ——「什么都不显示」不等于
  「换一张显示」
- **反向对照**:硬拦档照旧弹 `amr-balance-dialog` 且不建项目;`checkAmrBalanceGate`
  确实被调用过。**没有这两条,把整段闸门删掉也会全绿。**

T40 那条红线用例(套餐读数吊死也不许卡住发送)原样保留。

## 撤实现验红

把旧行为的形状恢复(soft 分支里等一个决策的 `await` + 一个阻断元素):
**9 条里 6 条当场变红**,正好是全部 soft 用例;绿的 3 条是不走 soft 路的。
还原用字节备份 `cp`,`shasum` 前后一致。

## 顺带落地的两条裁决记录

`specs/current/chat-panel-decisions-sheet.md` 新增「五之五、2026-09-06」:

- **T53** 软弹窗去留 + 三界面终态 + 首页静默放行,引用户原话;**并写明代价** ——
  首页 $0–$2 彻底静默,任务可能跑到一半停而事前无提示,**产品知情**
- **T52** 阈值保持 `$2`、不跟交付稿的「< 5 美金」,依据是用户原话
  「<2 就行,不用管设计稿的 5 美金」,并**明写「产品没有给出依据,这里不代为补写理由」**

`amr-balance-gate.ts` 的阈值常量上方加注释,标明这是对交付稿的**有意偏离**、
指向 T52,并说明硬拦那条($0)**不是**偏离。

## 遗留未清(已记进 T53)

死埋点 `chat_low_balance_warn_recharge` / `home_low_balance_warn_recharge` 留着:
它们在 `packages/contracts` 的 `TrackingAmrEntrySource` 联合类型里,而
`ENTRY_PAGE_BY_SOURCE` 是穷举 `Record` —— 删一个值要同时改跨包分析契约、映射表,
还要重建 contracts dist。**牵连大于收益。**

另:`docs/design/chat-mirror/mirror-exec.html` 只手改了 4 处注记,没整页重生成 ——
重生成会带出 +98/−9 的 `tokens.css` 内联漂移(别处留下的陈旧),不该裹进这次改动。
那页的 token 内联仍是陈旧的,下次正经重生成时会一并带上。

`specs/current/chat-panel-edge-audit.md` 仍把这张弹窗描述成现存组件 ——
它自己声明了是 2026-08-25 的带日期盘点稿、以 `chat-panel-next.md` 为准,不是权威源,
这次没动。

* fix(observability): 浏览器自己的滚动锚定不该被判成「滚轮倒退」

探针的 `wheel_snap_back` 会误报,触发方式毫无对抗性:**打开一条长会话 →
滚到中间 → 500ms 一格慢慢往下滚 20 格**。实测 **11 次中 5 次**误报;命中时
20 格里 19 格被判定,而日志全程能正常滚。

(对照:已稳定的长会话上 1708 格滚轮,**0 / 14** 误报,连 stall streak 都没起过。)

## 机制

1. `SCROLL_SAMPLE_MIN_INTERVAL_MS = 250` —— 探针最多每 250ms 才把滚动位置并入
   `lastScrollTop`,而且节流是**丢弃**跳过的采样,不是延后。
2. 这 250ms 里,**浏览器原生 scroll anchoring** 因为视口**上方**内容变矮而把
   `scrollTop` 调小(**实测一次调了 1036px**,而 `SNAP_BACK_MIN_PX` 只要 8)。
3. 下一格向下滚轮拿去和**过期的基准**比 → 判成「向下滚却倒退了」。
4. **这条路一格就报**,`FREEZE_WHEEL_COUNT = 4` 那套门槛完全保护不到它。

## 为什么不是「报之前重读一次 scrollTop」

那条治不了:`runFrame` 里的 `geometry.scrollTop` 本来就是当帧刚读的、新鲜的;
**旧的是基准**。在 rAF 里再读一次会拿到同一个值,一格都拦不下来;而放到监听器
里读又会强制重排,正好破掉这个模块的立身之本。**成本照付,一个误报都不少。**

## 改法:布局稳定性排除条件

原生 scroll anchoring **只在视口上方内容高度变了的时候**才会修正 `scrollTop` ——
所以「高度动没动」就是把浏览器的记账和用户的手分开的结构性判据。判据复用
`stick-to-bottom.ts` 里 `layoutStable` 的推理,没另造一套;**没有用时间窗**
(「基准是不是 250ms 内取的」),因为那种参数离线验不了,而且落在窗口内的
修正照样会被定罪。

`lastScrollHeight` / `lastClientHeight` 和 `lastScrollTop` **绑在一起写**
(收成一个 `sampled` 对象,所有 return 路径 spread 它)—— 位置和它所在的布局
必须同拍,否则下一次判定就是拿新位置比旧布局。

**`wheel_stall` 那条四格路刻意不加这个门**:流式期间内容几乎每帧都在长,
一刀切会把「一边流式一边真卡住」整类漏掉。

诊断面 `describeSnapBackRoute` 调同一个谓词并新增 `layoutStable` 字段 ——
否则运行时把手会告诉操作者「这条路已就绪」,而探针其实不会报。

## 为什么这条比看起来重要

`MAX_REPORTS_PER_SESSION = 3`,而 `attach()` 在报满 3 次后**不再挂载**。
所以误报会**烧光预算**,真卡住时反而抓不到。撤实现验红时这一点是可测的:
去掉门之后,**第一格 anchoring 修正就把 `reported` 锁死了**,后面那次真
snap-back 根本没机会上报。

## 撤实现验红

- 撤 snap-back 条件里的 `&& stable` → 3 红(算术层、DOM 层、以及「reflow 之后
  紧接着那一格仍要报」)
- 整支 snap-back 分支删掉 → 7 红(含 drift 回放那几条既有用例)
- 诊断面去掉 `stable &&` → 1 红

反向测试两条(算术层 + 走完整探针链路到 PostHog payload),外加一条防修过头的
「内容一直在长时四格路照样要报 `wheel_stall`」。

## 待拍板(没动)

`MAX_REPORTS_PER_SESSION = 3` 本身合理,**不合理的是把「停止上报」和「停止观测」
绑在一起** —— `attach()` 预算耗尽后直接返回 null,于是活动轨迹、账本、
`window.__chatScrollFreeze` 的 `attached` 全部永久失效,而且**静默**,长得跟
「后来没再卡过」一模一样。建议上限只加在 `reportSafetyEvent` 那一步。

## 顺带发现(没改)

天花板探测用的是同一个过期 `previousTop`,anchoring 修正会写进一条假的
`shortfallPx`;而 `ledger.first` **永不淘汰**,所以「drift 从哪儿开始」这个头号
字段可能已经被污染。**不建议照搬这个门去堵** —— 账本的立意是把内容变化和够不到
的距离配对,要求布局静止会把真正的首次 shortfall 一起杀掉。语义不同,单开一轮。

* feat(diagnostics): 卡住时一键把滚动现场装进诊断包

我们复现不出「聊天区滚不动」,但**同事们能撞到,而且都愿意提供数据**。所以要
一条他们自己就能走完、产出可发送文件的取证路。

## 一个采集器,三条触发路,一个目的地

目的地是同事**已经会用**的那个 zip(`open-design-diagnostics-*.zip`),不新造流程。

`useDiagnosticsExport` 是设置页「导出诊断日志」和报错卡「导出日志」共用的那个 hook,
补一处就覆盖两个入口。

## 但走到设置页会毁掉现场 —— 所以必须有第三条路

`App.tsx:4804` 的 `openSettings()` **永远走 `navigate({kind:'home', view:'settings'})`**,
而 `:5213` 把设置渲染成 `appMain` —— **设置是路由不是浮层**。走过去点导出,
**聊天区会被卸载,冻结的那个面一起没了**。(浮层形态的代码存在,但没有任何地方路由到它。)

所以加了唯一一条**不导航**的入口:**菜单栏 Help → Export Diagnostics…**。
那条走 main → daemon,全程不碰 renderer;main 再通过
`window.__odCaptureChatScrollForensics`(`executeJavaScript`,5s 超时,失败吞掉)
反向取现场。**原生菜单不重绘页面,所以卡住时够得着**,而且没有新增任何界面元素。

另配一道**冻结时留存闩**,订阅探针已有的 `subscribeChatScrollFreeze`:探针一旦
判定冻结,整份现场当场存进 renderer 内存(30 分钟 TTL,首次冻结优先)。这样即使
之后走到设置页,证据仍在。信封里 `live` 和 `retained` 两个槽都在,**空的时候各自
说明为什么空**。

## 现场带什么

`summary/chat-scroll-forensics.json`:

- **`probe`** —— `__chatScrollFreeze.snapshot()` 原样(账本、十二道闸、活动轨迹、
  层普查、祖先链合成属性)
- **`composition`** —— 祖先链(由外向内)+ 全部直接子节点,每个带 computed 的
  `display` / `position` / `overflow-x,y` / `transform` / `filter` / `backdrop-filter` /
  `contain` / `will-change` / `content-visibility` / `isolation` / `z-index` / `opacity` /
  高度族 / offset 三元 / scroll 三元 / 包围盒 / 在跑的动画数与名字
- **`dom`** —— 聊天区完整 `outerHTML`(8MB 上限,截断会标出来)
- **`animations`** —— `document.getAnimations()` 全量
- **`assignment`** —— **判决式的那一条**:
  `assignment_reached_layout_max`(JS 能到底 ⇒ 滚轮/合成器那一半坏了)/
  `assignment_clamped`(滚动盒自己不让走)/ `no_room`(布局真没空间)
- **`runtime`** —— Chromium 与 Electron 版本、平台、DPR、窗口/屏幕/visualViewport
  尺寸、`zoomInputs`(原始输入,不猜缩放级别);daemon 那侧再盖上权威的
  app 版本/渠道/打包与否
- **`writes`** —— `writes.list()`,没开拦截时**明说没开**
- **`timeline`** —— 各段耗时。赋值探测永远是最大的那一段,这本身就是顺序证据

## 赋值探测放在最后,而且必须还原

它排在 probe / runtime / scroller / composition / dom / animations **之后**,
做完就地还原,`finally` 再还原一次。`warnings` 里带一条:
**「还原过的 scrollTop 不能当作滚动正常的证据」**。两条测试钉着:一条断言元素
回到 137 且 `writes` 含 4200;另一条让 setter 在**移动之后**抛错,断言
`writes === [4200, 77]`。

## 为什么用会合点而不是请求体

两条导出路到 daemon 都是**无体的 `GET`** —— 打包版里点击走
renderer → preload → Electron main → 原生保存对话框 → daemon GET,**根本没有
renderer 能附上的 body**。所以 renderer 先 POST 到
`/api/diagnostics/chat-scroll-forensics`(仅本地、24mb 上限),daemon 在**内存里**
存最新一份,导出时排空进 `summaries`。存内存不落盘,于是它不会变成一个
`OD_DATA_DIR` 路径问题。

## 撤实现验红

13 处逐个撤、逐个确认对应测试变红(含:赋值探测移到 DOM 采集之前、还原逻辑、
祖先链遍历、`outerHTML`、动画枚举、Help 菜单的反向调用、window 钩子的发布、
daemon 排空、空态说明结构、请求体校验)。

顺带撞上那个已知的坑:**web typecheck 绿而 `--filter daemon build` 红**
(`express.json()` 推断出的 connect 类型触发 TS2742),补了显式 `RequestHandler` 标注。

## 残留缺口

**浏览器里**没有不导航的入口,除非屏幕上正好有报错卡 —— 设置页会导航、毁掉现场,
只剩「冻结时留存」那一份,而它要求探针**真的报过**,恰恰是我们还不敢信的那一环。
补它需要在聊天区加一颗按钮,而聊天面板头部正在做 1:1 对齐重构,**没有单方面加**。
打包桌面端(同事们在的地方)由 Help 菜单完整覆盖。

* fix(amr): 余额四格矩阵按产品文档收口(T54–T58)

产品文档第四节「四、升级情况」那张四格表(付费/免费 × 个人/团队 Owner),
这次一次性对齐。五条裁决记在 `specs/current/chat-panel-decisions-sheet.md`
的「五之六」「五之七」。

## T54 通用「升级」改跳当前环境的 console 套餐页

`<console origin>/dashboard?source=open_design&billing=plan`,不再跳写死的公开
Pricing。origin 复用现成通路(daemon `/api/integrations/vela/status` →
`setRuntimeAmrConsoleOrigin`),没造第二份。

**这条同时修掉一个真缺陷**:`amrPlansUrlForProfile(_profile)` 的参数带下划线
前缀、刻意不用,所以 test / local / feature-test 的包**一律跳生产 Pricing**,
而那页选中套餐会带着 plan + interval 回**生产** Vela 直接结账。

## T55 矩阵管个人工作区:个人版付费档余额 $0 也要硬拦

让位判据从「有套餐 ⇒ 让位」改成「**档次读不出来 ⇒ 让位**」。
`planMayFundRunOutsideWallet`(`!isFreeAmrPlan(...)`)删除,换成
`amrPlanTierUnreadable`(`resolveAmrPlan(...) == null`);`isFreeAmrPlan` 因此
没有生产消费者,一并删除 —— 留着它会以「free 还是那个轴」的名义把老逻辑招回来。

缺陷形状:`isFreeAmrPlan` 只精确匹配 `'free'`,连 `'basic'` 都算「非 free」,
于是个人版 Basic/Plus/Pro/Max 在 $0 落到 soft 档,**四格弹窗一张都不出**。

⚠️ 推翻 T15 / R-010 / #7190「付费档余额 0 = 不限量,不拦」。
 `cf00c80bd1` 的另一半原样保住:套餐读不出来时放行,由远程兜底。
⚠️ 代价:个人版 Max 用户 $0 时不能再靠套餐额度直接开跑。**这格只有真机能确认。**

## T56 「找所有者充值」改用产品稿正式文案,删掉「复制请求」

终态只剩 标题 / 正文 / 「知道了」。原来那份是**有授权的临时文案**
(§6.V 逐字写「文案由研发拟,产品复核」),这次是正式替换,不是推翻设计。

⚠️ 代价:这一档回到单出口,能给的只有「该找谁」,不再替他把话写好。产品知情。
i18n 三个 key(`requestTemplate` / `copyCta` / `copiedCta`)从 19 个 locale 删除。

## T57 Owner 名字拿不到时的降级文案

两个变体两个 key。**现在一定走降级那条**:契约里唯一的 owner 名是
`CollabProject.ownerDisplayName`(**项目级**,且注释自己写着 "STUB"),
`WorkspaceCollabContext` 上没有工作区 owner 名。后端补上名字来源后自动生效。

## T58 Max × Team Owner(第 3 格)出转化弹窗,Upgrade 跳自动充值

那张弹窗**和第 1 格是同一张,文案一字不差** —— 没有新文案、没有新 i18n key、
没有新组件。两格唯一差别是主按钮落点:第 1 格 `billing=plan`,第 3 格
`billing=auto-recharge`。

`AmrBalanceBlockedDialogKind` 的 `null` 那一支整个删除(留着它只会让每个调用点
继续背一条 `?? 'upgrade'` 兜底 —— 首页那条正是这么活下来的)。

⚠️ **顺带更正一条被当成产品文案的机器描述**:此前记录引用过第 3 格
「未达到 $100.00/月的额度」—— 那不是产品文案,是飞书导出时 AI 为截图自动生成的
**图片 alt 描述**。以图为准,截图上写的和第 1 格一字不差。凡照着那句推出的结论
一律作废。

 链路另一半同日通了:vela **#1900** 已合并,`?billing=auto-recharge` 现在真的
弹出自动充值设置弹窗。我们这侧发出去的参数不用改。

## 撤实现验红

四次,逐一对应:`amrBalanceBlockedDialog` 回到 `max → null`(红 5 条)、
弹窗里 `upgradeIntent` 落点分支(红 4)、只撤首页调用点(红 1)、
只撤项目页调用点(红 3)。

**首页此前根本没有分支矩阵覆盖** —— `EntryShell.amr-balance-branches.test.tsx`
是本次新增,那条 `?? 'upgrade'` 兜底就是这么活下来的。

## 只有真机能确认

Max 档**在 prod 造不出来**,第 3 格今天只有单测;「弹窗真的弹出来了、按钮真的
把浏览器带到自动充值面板」需要一个真 Max 账号走一遍。

* feat(observability): 滚动冻结的复现工具链与滚轮接管(默认关)

三样东西,都服务于同一件事:**我们复现不出「聊天区滚不动」,只能靠同事的真实
现场**。所以先把「怎么留证」和「怎么回放」搭起来,不急着改产品行为。

## 1. 滚轮接管 —— 默认关,且必须手动打开

`open-design:chat-scroll-takeover` 设成 `'1'` 并重载才生效,否则挂上去也是惰性的。

**为什么默认关是硬要求**:产品原话「**我们要观测旧的实现问题,不要影响**」。
接管会把滚轮从合成器手里接过来自己算位移 —— 那正好会**掩盖**我们要观测的那个
缺陷,让冻结不再出现,而我们既拿不到取证也说不清是修好了还是只是把时序推开了。
所以它只是一个**逃生口**(同事真卡死时能自救),不是修复,更不能默认参与。

## 2. e2e 回放骨架

`chat-scroll-replay.ts` + `chat-scroll-recording.ts`:把真机采到的滚轮/内容变化
时间线 1:1 喂回去。

⚠️ **`chat-scroll-wheel-reach.test.ts` 目前是 `test.skip`,这是有意的。**
回放能重放时间线,但**造不出真的冻结** —— 一个从没红过的守卫什么都不证明,
把它留在绿灯里比 skip 更糟。等真现场回来、能稳定复现了再解开。

## 3. 取证路径的测试补齐

给已落地的 `chat-scroll-forensics.ts` 和 Help → Export Diagnostics… 补上用例。

## 不在这条提交里

`.chat-log-viewport` 的 grid 轨道候选修法(`minmax(0,1fr)` → `100%/100%`)
**故意没有提交**。它是一个**候选修法**,而验证它的那次 A/B 已作废;这个包的
用途是收同事的取证现场,掺一个候选修法进去,万一冻结不再出现,取证和结论
两头都落空。改动留在工作区,等产品拍板。

* chore: 把发布列车抬到 0.21.2

**动机是取证的可溯源性,不是走流程。** 我们连打三个 beta 给同事和 QA,
三次版本号都是 `0.21.1-beta.7`,于是「QA 报了个问题」没法对应到是哪个包。
产品原话:「不然 qa 反馈问题我都不知道是新包还是旧包」。

**为什么版本号就是全部身份**(逐条查证过):

- `tools/pack/src/versioning/index.ts` 的 `readRuntimeAppVersion()` 只有两个来源
  (`--app-version` 或 `apps/packaged/package.json`),没有 sha 输入口
- `apps/packaged/src/sidecars.ts` 交给 daemon 的身份信息只有 `OD_APP_VERSION`
- `AppVersionInfo` / `DiagnosticsAppInfo` 都没有 commit 位 —— **诊断包里也不带**
- electron-builder 没配 `buildVersion`,macOS `CFBundleVersion` == 版本号;
  Windows PE 第四位硬编码 0。**没有任何隐藏构建号可以当区分符**
- sha 目前只进了 R2 的 `metadata.json`,而 `publish=false` 的包连这个都没有

**为什么是 0.21.2 而不是 beta 序号**:`open-design-v0.21.1` 这个 stable tag
已经存在,而仓库基线还停在 `0.21.1` —— release bot 的常规节奏本该已经抬上去了,
只是还没做。抬完顺带让 `prepare-beta.ts` 那条「基线必须严格大于最新 stable」的
判据自然成立,`force=true` 不再是必需品。

**17 个 manifest,不是 18** —— `apps/landing-page` 已在 `1d2150007b` 移出仓库。
全集由两个独立方法交叉验证(遍历所有非 node_modules 的 package.json 取
version==0.21.1;以及 `git grep '"version": "0.21.1"'`),结果一致。
workspace 互相依赖全是 `workspace:*`,`pnpm-lock.yaml` 里 `0.21.1` 出现 0 次。

⚠️ **这次抬版本只能救一个包。** `publish=false` 永远不推进 R2 metadata,而
`prepare-beta.ts` 只在「仓库基线 == metadata 基线」时才递增 beta 序号,基线更大
时固定为 1。所以下一个包是 `0.21.2-beta.1`,**再下一个还是它**。
永久解是把已经躺在打包 job 环境里的 `RELEASE_COMMIT` 接进诊断包 ——
分支 `chore/pack-build-commit` 已备好,未合并。

* docs(specs): 落 T52–T59,并把 §6.V 里被推翻的三处同步掉

## 决策表新增

- **五之六 T54–T57**、**五之七 T58**:余额四格矩阵的终态与代价(见上一条代码提交)
- **五之八 T59**:**交付稿的标注文字与它自己渲染出来的 DOM 打架时,以 DOM 为准**

## T59 首次适用:组件 18 升级卡不显示用量条

交付稿(git ref `729fa43ce7`)自相矛盾,矛盾在两处:

- `:5739` 组件 18 自己的说明写「**展示**当前用量与受影响的能力」
- `:5585` 组件 15 解释「这里不报用量」时把用量归属指给 18,写「剩余额度 +
  **进度条** + Upgrade」

自洽的那一半是 DOM + CSS:

- `:3337` 稿子自己的 CSS 注释写「保留旧刻度样式,**当前额度卡不显示刻度**」
- `class="meter"` 在**整份稿子的 DOM 里出现 0 次**
- 组件 18 那张卡的实际 DOM 是「剩余额度 $3.20」+ 一句话 + 一颗按钮,**没有条**

那条留着没删的 `.meter` 样式就是物证:曾经有过刻度,后来去掉了,样式没删、两处
说明也没跟着改。产品口述 2026-09-07:「**对的以 dom 为准**」。

⚠️ 通则不等于可以自行发挥:发现打架**要在决策表记一条**,不能默默按 DOM 做完
就算,否则和「照着标注做」一样不可追溯。

## §6.V 同步(它是我们自己的规格,不是产品那份不能动的文档)

三处已被后来的裁决推翻,留着就是给下一个人埋雷:

1. 「非 Max · owner 卡和弹窗都直接跳 Pricing」→ 改跳当前环境 console 的
   `?billing=plan`(T54)
2. 「Max · owner ── 不弹窗」→ **和第 1 格同一张弹窗,文案一字不差**(T58)
3. 「付费档余额 0 = 不限量,不拦仍然成立」→ **已被 T55 推翻**,个人版付费档 $0
   现在也硬拦;让位判据改成「档次读不出来才让位」

另补 §6.V「文案由研发拟,待产品复核」那一条的**复核结果**:改用产品正式文案、
删掉「复制请求」,于是这一档回到单出口(T56/T57)。

* test(amr): 补上 T54 漏在上一条提交外的那个断言

`ChatPane` 会话切换器里那条「AMR 档位升级」动作的用例,断言的还是**生产**
Pricing URL(`https://open-design.ai/pricing/`,且 `billing` 为 null)。T54 之后
它应当跟随当前 profile 落到 console 的套餐页。

这条本该和 T54 的代码改动同一条提交,是我分拣文件时漏的 —— 单独看那条
`fix(amr): 余额四格矩阵按产品文档收口` 会是红的,记在这里以免后来人对着它二分。

顺带说明它守的正是 T54 提到的那个真缺陷:渲染 profile 是 `test`,而旧断言要求
跳生产 —— 也就是**非生产的包把人送去生产结账**。现在断言
`https://vela.powerformer.net/dashboard` + `billing=plan`。

* fix(observability): 三处让探针自己变瞎的缺陷

都不是产品缺陷,是**我们的诊断代码在损害我们自己的观测**。而现在整条链路的
用途就是靠同事的真实现场定位滚动冻结,所以它们是高优先级。

## 一、报满 3 条之后连观测都停了(而且是静默的)

`MAX_REPORTS_PER_SESSION = 3` 本身还算合理,**不合理的是把「停止上报」和
「停止观测」绑在一起** —— `attach()` 在预算耗尽后直接返回 null,于是活动轨迹、
亏空账本、`window.__chatScrollFreeze` 的 `attached` 全部永久失效,**而且长得跟
「后来没再卡过」一模一样**。同事装上包,切到第四条会话就变成瞎子。

产品原话:「**为了揪出问题,报一万条都行啊... 定位到问题后再删了呗?**」

所以**整条会话级限流删掉**,不是改成一个更大的数,也不加可配置开关或环境变量
(这是临时取证配置,定位到根因后整套探针会删掉,不要引入新变量)。
**per-surface 的一次锁保留** —— 那是去重不是限流。

顺带查证:`chat-scroll-forensics.ts` 的 `handleFreezeSignalForRetention` 有
`if (retained != null) return`,所以取证快照全会话仍只存第一份。放开上限只增加
PostHog 事件量,**不会**让取证存储无限长。

## 二、天花板探测用过期基准,污染「drift 从哪儿开始」

和刚修的 snap-back 误报同一个病:`SCROLL_SAMPLE_MIN_INTERVAL_MS = 250` 的节流是
**丢弃**跳过的采样。原生 scroll anchoring 一修正位置,就会写进一条几百上千像素的
假 `shortfallPx`;而 **`ledger.first` 永不淘汰**,那正是「drift 从哪一次内容变化
开始」这个头号字段,**污染一次永久留着**。

**没有照搬 `layoutHeldStill`** —— 账本的立意是把内容变化和够不到的距离配对,
要求布局静止会把真正的首次 shortfall 一起杀掉(真冻结时内容往往正在长)。
新判据 `ceilingProbeAttributable` 问的不是布局静不静,而是**这一帧的落点是不是
滚轮造成的**,两条各自自洽的路:

1. **位置和基准分毫不差** → 无条件收。能改 `scrollTop` 的只有 anchoring、我们
   自己的写、和滚轮;位置没动说明前两样都没发生,而滚轮明明来了。
   **内容在下方长高只改天花板、不碰 `scrollTop`**,所以「内容在长」这一整类
   真冻结全部保住。用精确相等而非容差,是因为 `observeWheelBatch` 判「动没动」
   就是 `top !== previousTop`,两边对「没动」的定义不许分家。
2. **位置往回退了,且布局没变** → 才收。往回退的竞争解释只有 anchoring,而
   **anchoring 修正必然伴随高度变化**。所以只在这条路上要 `layoutHeldStill`。

拒错一次只少一条 probe;收错一次会把一个**永不淘汰**的字段永久占掉。

## 三、`syncFollowState` 的恒真守卫,每帧白写一次 scrollTop

`if (el.scrollTop !== el.scrollHeight)` —— `scrollTop` 最大只到
`scrollHeight - clientHeight`,**永远不可能相等**,于是贴底跟随时每帧都写一次。
功能上无害,但**它会污染我们用来区分「是我们在写」还是「合成器不动」的写入记录**。
换成 `isPinnedToLogBottom()`,**写入值不动**(仍写 `scrollHeight` 交给浏览器夹取),
变的只有「要不要写这一帧」。

容差取 1px 而不是 `AT_BOTTOM_TOLERANCE_PX`(8px):那 8px 回答的是另一个问题
(「用户算不算还贴着底」),拿它当写入判据,流式期间每帧长高不到 8px 的内容会
一直攒到超过 8px 才被追上,跟随会肉眼可见地一顿一顿。

## 三个夹具假象(缺陷三暴露出来的)

`chat-scroll-preservation` / `chat-scroll-following` / `chat-anchor-to-top` 三条
在合并后变红。逐条判定结果是**夹具假象,不是行为改坏**,共同特征是断言时刻
`scrollTop > maxScrollTop()` —— 一个真实浏览器造不出来的状态。两个成因:

- **setter 根本不夹取**(同 `ChatPane.streaming` 那条):断言的是「发生过一次写」,
  那次写在真浏览器里会被夹回去、**一个像素都不动**。改成断言真实落点。
- **setter 夹了,但内容变矮不经过 setter**:W105 收占位块让 `scrollHeight`
  一口气掉 500,而停在旧底部的 `scrollTop` 没人夹。补
  `settleScrollAfterLayout()` 挂在帧回调批次之后(正是浏览器夹取发生的位置),
  **期望值一个没改**。

**「守卫漏了一个真实需要写的时刻」这条可能性被排除**:占位块是最后一个孩子,
它变矮 → 上限下降 → 浏览器把 `scrollTop` **往下**夹 → 结果是**更贴底**。
夹取只会把人推向底部,永远不会推离。反向情形(占位块收 500 同时内容长 200,
上限净涨 80)守卫读到 `80 > 1` 照写。

⚠️ **一处覆盖能力的净损失,记在这里**:`chat-anchor-to-top` 的
`blankOnScreen ≤ 52` 原本确实能检出「跟随没写」(旧夹具 + 删掉写 → 红),补上
夹取之后没有了。判断是**那份能力本来就是假的** —— 真机上占位块一收,浏览器自己
就把人夹到底,写不写都成立。该用例的主断言(占位块必须收到 8)不受影响,跟随的
覆盖由另外 5 个用例扛着。

## 撤实现验红

上限:6 条(含「报满旧上限后仍在观测」—— 实现前第 7 条报告 `undefined`)。
判据二:红测 `shortfall_first_px` 实现前 980(anchoring 假值)、实现后 380;
另有两条反证 —— 换成朴素的 `layoutHeldStill` 会红掉「内容在长时的真 drift」,
只留「位置相等」会红掉回放用例,证明两条路都不多余。
缺陷三:实现前 10 次 `syncFollowState` → 10 次无位移的写,实现后 0 次;
把跟随的写整个删掉,5 个用例仍然变红。

## 教训

按文件名前缀窄跑不够 —— `ChatPane` 的改动,blast radius 是整个
`tests/components`(658 文件 / 6362 条,已全跑绿)。

## 只有真机能确认

分数 `scrollTop` 下 1px 容差够不够(jsdom 无布局,几何全是塞进去的整数);
真实 Chromium 的 anchoring 是否总是同步改 `scrollTop` 和 `scrollHeight`
(判据二的物理前提);放开上限后的真实事件量;跟随观感是否完全无感。

* fix(chat): 颜色块 / 思考滚动条 / 问卷被误锁三个缺陷(OPEND-2642 2643 2644)

## OPEND-2642 颜色题已确认摘要仍缺色块(回归)

**根因:「已确认」摘要有两份实现,而 OPEND-2579 修的是产线永远走不到的那一份。**

- `QuestionForm.tsx` 的 `AnsweredValue` → 会画色块
- `AssistantMessage.tsx` 的 `FormBlock`(历史回放块)→ 自己内联 `<b>{value}</b>`,
  无色块、无 `.ab.mod-value`

关键事实:`QuestionFormView` 的 `submittedAnswers` 这个 prop **全仓库没有任何
产线调用点**(传参方全是测试)。用户看到的那块「已确认」永远是第二份。

**2579 为什么没挡住**:它的用例直接渲染 `QuestionFormView` 手工喂
`submittedAnswers` —— 走的正是那条产线到不了的路。新用例改成**从
`AssistantMessage` 进**,走 `formatFormAnswers → parseSubmittedAnswers → 摘要`
的真实回放路径。

改法是导出 `AnsweredValue` / `isShortValueAnswer`,回放块改用同一对函数,
注释里写死「值只有这一处画法」。

## OPEND-2643 思考过程滚动条遮挡正文

**根因两层**:

1. `padding-inline-end: 4px`(注释说「留一点气口」)**从来没生效过** ——
   它是 (0,3,0),被灰底容器的 `padding: var(--stream-pad)` 简写 (0,4,0)/(0,5,0)
   整条盖掉,右边实际一直是 8px。
2. 8px 也不是让位:Chromium 在 macOS 默认覆盖式滚动条,不占布局、直接画在
   padding box 右缘,而 `scrollbar-gutter: stable` 对覆盖式**是空转的**。

改法:`::-webkit-scrollbar { width: 8px }` 让 Blink 退出覆盖式(滚动条变成占
布局的经典滚动条,宽度从内容盒扣),加 `scrollbar-gutter: stable` 防止它中途
冒出来时正文横跳。⚠️ 注释里写死**不要**加 `scrollbar-width` / `scrollbar-color`
—— Chromium 121 起它们会让 `::-webkit-scrollbar` 整体失效、**静默撤销这个修复**,
并配了一条守它的用例。

**测试能证明什么**:用共享层叠量尺量「哪条规则赢了」,包括把「4px 被简写盖掉、
实际 8px」钉成事实基线。**证明不了**:jsdom 不做布局也没有滚动条实体,
「字和滚动条不再重叠」**只有真机能确认**(需分别看 macOS 两种滚动条设置和
Windows/Linux 经典滚动条)。

## OPEND-2644 未提交的问卷被锁住并误标「已回答」

两处缺一不可:

1. **`ChatPane.tsx` 配对放宽** —— `nextUserContentByAssistantId` 老写法只认
   **物理相邻**的下一条消息,记忆卡插进来配对就断了。改成从后往前扫,把最近
   一次看到的 user 正文发给它上面的助手消息。放宽配对**不会凭空造答案**:
   `parseSubmittedAnswers` 只认 `[form answers — <id>]` 开头且标签对得上的文本。
2. **`AssistantMessage.tsx` 判据换掉** —— 抽成具名
   `questionFormAnswerable = isLastAssistant || nextUserContent === undefined`。
   `isLastAssistant` 留在或的前半,是因为流式当轮里 `nextUserContent` 本来就是
   空的 —— 它是判据的一个特例,不是替代品。
3. **「已回答」pill** 原来挂在 `locked` 上,而 `locked` 的三种来路里有两种跟
   用户答没答过无关。现在只由真实提交答案兑现。

**只改第 2 处会把「答不了」换成「答完了看不出来」** —— 答案落在记忆卡后面,
`parseSubmittedAnswers` 认不出来,永远收不成「已确认」摘要。
**意外收获**:撤掉第 1 处时,**历史里所有没答完的问卷都会被重新打开**(那些
消息的 `nextUserContent` 全配不上了)。所以第 1 处不只是让摘要收口,它同时是
「锁住」的判据来源。

## 撤实现验红

2642:回放块改回 `<b>{value}</b>` → 2 条红。
2643:删掉 gutter + 三条 webkit 规则 → 2 条红。
2644:**分开撤,一处一次** —— 只撤第 1 处红 2 条(摘要收不成、走过去的表仍锁住),
只撤第 2 处红 2 条(表填不了、没有那句「此前回合的表单」)。

## 上一轮我自己写错的两处,一并更正

1. `opend-2644-unanswered-form-not-marked-answered` 里有一条断言「记忆卡之后
   显示『此前回合的表单』」—— **那是修复前的行为**,当时被当成了不变量。现在
   改成断言它不出现,并新增一条把那句说明的正确触发条件钉住。
2. 第一版红测的判据本身是坏尺子:`formControlsEnabled()` 拿「卡片上所有 button
   都没 disabled」当「表能填」,但底栏那颗「下一步」在必填项填完前本来就是灰的。
   dump 出来的 DOM 是 `class="question-form"`(没有 `-locked`)+ 只有
   `Next step` disabled —— **修复其实早就生效,是尺子读错了**。改成看
   `question-form-locked` 类 + 排除底栏的答题控件,并把这个坑写进 helper 注释。

## 未做

工单评论里的第二件事(同一回合两组操作栏)查清了但**没动**:那条 memory-applied
是**宿主自己补发的消息**(`ProjectView` 拿 `useMemoryWrittenCard` 的批次
`appendConversationMessage` 一条完整 assistant 消息,所以没有 run_id/run_status
却照样渲染完成状态和操作栏)。要收成「回合附属组件」得改消息编排语义 ——
**这是产品裁决**,已在 Plane 上请示。

## 只有真机能确认

记忆卡是 turn 结束后**异步轮询**出来的(不是同步落库),所以它真实出现的那一刻
问卷能否正常填写并提交、以及提交后刷新/重进项目摘要是否稳定收口,单测覆盖不到。

* docs(specs): 量清「纯问答被判失败」那条裁决的边界,并记下它盖不住的一条真缺陷

为把 beta.7 现场(task `odnext_c4ee010be6b748dc9b92984946bc10a8`)钉成可回归的
形状,写了一条红测(**故意留红、未提交**),顺着它把判定链逐跳查清了。

**结论:那条红测断言的是「还没设计的产品行为」,不是缺陷。**
实测证明「接受纯文字回答」和既有的「拒绝把 silent no-op 洗成 completed」两条
互斥 —— 放开 `deliverableValid` 要求后红测转绿、既有绿测立刻转红,而两条 fixture
除了 agent 那段散文**输入完全相同**。唯一剩下的杠杆是按散文内容猜,正是
`chat-panel-issue-log:58` 那条裁决点名禁止的(「不做关键词猜测」)。

⚠️ **但同一个坑里挖出一条不在该裁决覆盖范围内的真缺陷**:
**真干了活、却不产生新 artifact 的 Direct Edit**(删文件、改名、只读审计,或
artifact 记账漏算的编辑)—— `validateRunDeliverable` 一律给 `no_artifact`,于是走进
和纯问答一模一样的终态 blocked。**这里用户明确要的是改动,agent 也确实做了**,
和「纯问答没交付物」是两件事,**该单独立项**。

另记:同链上另外三种合法产出的同类坑、一处规范自相矛盾(`general-orchestration.md:496`
说澄清轮不输出 machine block,而 `od-next-strategy.ts:752` 说每轮都必须有)、
前端那条 succeeded→failed 重映射为何不用单独改(以及为何**不能只改前端**),
以及唯一需要翻现场持久化事件才能定死的一点。

* fix(chat): 把 .chat-log-viewport 的 grid 轨道换成确定值(滚动冻结候选修法)

`grid-template: minmax(0, 1fr) / minmax(0, 1fr)` → `100% / 100%`。

**这是假设,不是已证实的修复。** 产品拍板放进包里,理由是「免得到时候阻塞上线」。

## 假设

`1fr` 是**不确定轨道**:尺寸是剩余空间的一份,所以轨道要在拥有它的盒子之后的
另一趟里定尺寸,里面那个滚动盒再被拉伸到轨道最终定下的值。`100%` 直接从这个
viewport **已经确定**的内容盒解析(它自己的高度经 `.chat-log-wrap` 的 `flex: 1`
确定,再往上由 `.split` 的 grid 轨道确定),于是这一格第一趟就是对的,之后不再被
修订。

猜的是:滚动节点在轨道尺寸修订落地之前就建好了,而那次修订没有把绘制属性标脏。
**这条因果链没有建立。**

## 已知的缺陷形状(三次真实现场)

天花板被冻在一个早期的内容高度上(实测 91+583=674、824+583=1407、25+583=608 ——
**天花板 + 视口 = 冻结那一刻的内容高度**),程序性写 `scrollTop` 仍然有效,
只有销毁并重建那个布局盒才能解除。

## 怎么判它对不对

**读 `client_chat_scroll_frozen`**:如果带着这条规则的包仍然在报,假设就是错的,
这一行该换成下一个候选需要的写法。这也是为什么本次同时放开了探针的会话级上报
上限 —— 判据要靠事件量,不能报满三条就变瞎。

⚠️ **代价要说清**:如果冻结从此不再出现,我们**分不开「真的修好了」和「只是把
时序推开了」**。这是产品知情后的取舍。

## 等价性

代码注释声称「跨两种书写方向和五种布局状态,计算几何(含 used track sizes)与
`elementFromPoint` 探针逐字段与 `minmax(0, 1fr)` 相同」—— 那是**实测得来的**,
不是假设。`chat-log-viewport-definite-tracks.test.ts` 守的是 CSS 文本契约
(轨道保持确定、不退回 `1fr`/`minmax()`、`0e8bbdaa69` 的 rtl 滚动条沟槽验收
不被破坏、百分比轨道依赖的「祖先必须是确定包含块」这个前提仍然成立)。

**它证明不了修复有效** —— jsdom 不做布局,没有 used track size、没有滚动边界、
没有合成器,假装能量的测试就是假绿。假设的证伪路径是遥测事件,不是这个套件。

* chore: 抬到 0.21.3

上一个包是 `0.21.2-beta.1`。因为特性分支必须 `publish=false`,R2 的 beta metadata
永远不推进,而 `prepare-beta.ts` 在「仓库基线 > metadata 基线」时把 beta 序号
固定为 1 —— 不抬 patch 位,这个包还会叫 `0.21.2-beta.1`,和刚发出去那个撞名。

**每要一个可区分的包就得抬一次**,这是 `publish=false` 路线下唯一不碰
`release_version` 覆盖(那条会锁死 beta 发布线)的办法。永久解是把已经躺在打包
job 环境里的 `RELEASE_COMMIT` 接进诊断包 —— 分支 `chore/pack-build-commit` 已备好,
未合并;⚠️ 合之前要先补 `release-stable.yml` 那四个 build job 看不到该变量的缺口。

* docs(specs): 滚动冻结——活现场实测,候选修法当场证伪,键盘同样走不动

包 0.21.2-beta.1,两次真实冻结现场,用 Electron 主进程 inspector +
`webContents.sendInputEvent` 注入真滚轮测量(JS 合成的 WheelEvent 不走这条路)。

**推翻了一条即将发布的「修复」**:`grid-template: 100%/100%` 加在活的冻结实例上,
滚轮纹丝不动。它在 `5a538991a0` 里,**建议撤**。

**把缺陷范围收紧了一格**:键盘 PageDown 同样死在 6px,而 JS `scrollTo` 能到 859.5,
`scrollTop` 写入拦截全程为空。所以既不是「我们的代码在拽回去」,也不只是滚轮 ——
scroll unification 之后滚轮/键盘都走 cc scroll tree,卡住的是那个 ScrollNode 的
bounds(停在 589),Blink 侧的 1442 是对的。

**它是彻底冻死不是滞后**:追加 1000px 内容,布局上限涨到 3114,滚轮仍只到 6。

**排除**:CSS 容器查询(第二次现场 DOM 里一个 artifact card 都没有)、祖先链合成边界
(全 none)、嵌套滚动盒吃滚轮(0 verdicts discarded)、残留 transform、overflow 切换、
will-change / translateZ 上下线、滚动盒自身高度微调、占位块钉 0。

**当前最可疑**:滚动盒用 ResizeObserver 观察自己、回调里又写占位块高度——一个长在会冻的
盒子上的自喂环,且 `origin/main` 没有这一条(它只观察子元素)。

**顺带记两条**:① 探针每个日志元素只报一次,同会话第二次冻结遥测拿不到;
② `display:none` 那一格不要再跑——答案早知道,唯一作用是终结现场,今天就这么浪费了一个。

* docs(specs): 滚动冻结——grid 包装盒证伪,平滑滚动可解冻而瞬时直写不能

**H1 证伪**:把 `.chat-log-viewport` 还原成 origin/main 的 flex 形状,照样冻
(wheelReached 31.5 不动,layoutMax 从 439 长到 1450)。8/31 那两次结构改动
(新造 viewport、flex→grid)全部洗清。天花板值不固定,它就是「助手消息刚开始长的
那一刻的内容高度」。

**★ 切出一条精确分界**:`scrollTop = N` 瞬时直写**不解冻**(自动探针每 12 秒做一次,
冻结纹丝不动),而 `scrollTo({behavior:'smooth'})` **解冻**(产品点「回到最新」后
量到 wheelReached 1449.5 = layoutMax)。和「JS 能到底但滚轮/键盘不行」自洽:
瞬时直写只走 Blink,动画滚动由 cc 驱动、会强制重新同步陈旧的 scroll tree。

由此得到一个**无闪屏**的止血手段:判定冻结 → 自动平滑滚动一次,先上报再自愈。

⚠️ 附一条量测纪律:常驻注入式探针会把用户画面每 12 秒「滚到底再滚回来」,产品当场
以为是 agent 在乱操作。只读采样可常驻,注入输入必须单次手动触发。

* Revert "fix(chat): 把 .chat-log-viewport 的 grid 轨道换成确定值(滚动冻结候选修法)"

This reverts commit 5a538991a0.

**在活的冻结现场上当场证伪了。** 2026-09-07,打包版 `0.21.2-beta.1`,通过 Electron
主进程 inspector 把 `grid-template: 100% / 100%` 加到一个正在冻结的实例上:

```
gtBefore 583px / 442px   →   gtAfter 583px / 442px   (几何一个字段没变)
before {top:6, sh:2685, ch:583, layoutMax:2102}
after  {top:6, sh:2685, ch:583, layoutMax:2102}
注入真滚轮 12 格 → 仍然只到 6
```

而且**对照是干净的**:同一次会话里,先给同一个元素写一条**与当前算出值完全相同**的
内联 `grid-template`(纯样式改动、语义零变化),滚轮同样不动 —— 所以"随便碰一下样式
就能解冻"这个替代解释也被排除了。

**同一轮还证伪了它所在的那个盒子**:把 `.chat-log-viewport` 用 `!important` 整个还原成
`origin/main` 的形状(`display:flex` + `.chat-log{flex:1 1 0%}`),照样冻 ——
`wheelReached` 全程 31.5,而 `layoutMax` 从 439 长到 1450。所以 8/31 那两次结构改动
(`77859f01f7` 新造 viewport、`0e8bbdaa69` flex→grid)都不是成因。

留着它有两个坏处,都不值当:
1. 提交消息把它写成"候选修法",发出去会被当成修复;
2. 它是后续观测里一个多余的变量 —— 而这个缺陷的判据本来就要靠
   `client_chat_scroll_frozen` 的事件量来读。

那条随它落地的 `chat-log-viewport-definite-tracks.test.ts` 一并删除:它守的是
"轨道必须保持确定值"这个契约,而该契约的前提(这么写能修好)已经不成立。

完整的实测记录(含九种干预的解法矩阵、键盘同样走不动、平滑滚动可解冻而瞬时直写不能)
见 `specs/current/chat-panel-issue-log-2026-08-28.md`。

* fix(diagnostics): 取证信封被全局 4mb parser 静默截断

`/api/diagnostics/chat-scroll-forensics` 的请求体被 `server.ts` 的全局
`express.json({ limit: '4mb' })` 截断了 —— 取证路由注册在它**之后**,所以路由链上
那个 24mb 的 `chatScrollForensicsBodyParser` **是死代码**。

**为什么这条致命**:信封里聊天区完整 `outerHTML` 单份上限就是 8MB,而它同时装
`live` 和 `retained` **两份**。也就是说 —— **越是长到会卡死的对话,越传不回来,
而且失败是静默的**。整条取证路就是为了拿这类现场,对它们正好是瞎的。

修法照仓库自己的先例:按路径**前置注册**到全局那行之前(`/api/library/ingest`
128mb 和 `/api/brands/:id/extract-from-html` 32mb 都是这么做的)。**全局 4mb 没动** ——
调大它等于放宽所有路由的攻击面。路由链上那个 parser 一并删掉,并写清它为什么不在
那儿:留着只会让人以为那行决定 limit,而这正是这个 bug 能潜伏下来的原因。

**红测走真实 express 挂载**(现有两条测试全绿但全瞎 —— 它们绕过挂载直接调 handler,
所以 parser 顺序错了照样绿):真 `startServer` + `node:http` 打真 socket,发 6mb。
修前**确定性 413**(`PayloadTooLargeError: request entity too large`),修后 200;
撤实现重跑**逐字复现同一条失败**。另加一条护栏钉住全局仍是 4mb,防止有人图省事调大它。

顺带在信封里补记滚轮接管开关的原始读值(`runtime.chatScrollTakeover`):读现场的人
此前无法排除「这台机器是不是把接管打开了」。刻意**不**复用 `chatScrollTakeoverFlagSet()`
—— 它把「键不存在」「值像开其实没开」「storage 读不到」压成同一个 false,而这三件事
对读现场的人是三个不同的事实;读不到就明说 UNKNOWN,不猜。

* fix(chat): 上游过载的报错卡不再把 JSON 原文摊给用户

用户在打包版上撞到上游 529,失败卡里出现整段原始 JSON:
`json-rpc id 4: opencode event stream: {"id":"evt_…","properties":{…},"sessionID":"ses_…"}`
—— 而真正该说的那句话就埋在里面(`Our servers are currently overloaded`)。

**四层叠加**:vela CLI 把 opencode 的 `session.error` 整段 JSON 拼成**诊断串**
→ daemon ACP RPC 前面加 `json-rpc id 4: ` → daemon ACP session 的 `fail()` 把这一整串
当 message 发出、`code` **写死 `AGENT_EXECUTION_FAILED`** → web 没有映射命中,描述位
**回落到原文**。

**分类器其实认出来了**(实测喂截图原文:`upstream_5xx` / 可重试),**但 UI 映射表里
没有这一行** —— `amr-guidance.ts` 自己的注释早就点名说这是已知缺口。本次补上
`upstream_5xx` / `provider_high_demand` 两行,**用的是仓库已有的 S10 文案**
(`fatal_rpc_error` 那档在用同一句,19 个 locale 现成)。**没有新文案、没有新 key。**

影响面参照 `docs/design/run-errors/error-ux-design.md` 的 S10:每月 11,200 次、
占失败 8.8%、2,056 台设备。

红测夹具是截图原文**逐字**(含 vela 拼的 `(event=…, session=…)` 尾巴),另有一条自检
断言那四段碎片确实在夹具里(证明量法非空转)。撤实现验红命中同一条。

**留下三个更大的口子,只列不改**(需要单独立项):
① `acp/session.ts` 那颗写死的 `AGENT_EXECUTION_FAILED` 服务**全部 9 个 ACP runtime**
(amr/vibe/devin/hermes/kilo/kimi/kiro/trae-cli/reasonix),它们的上游报错一律不问
`classifyAgentServiceFailure` —— 而 json-event-stream 路和 Claude 路都问;
② 另有 22 个 failure_detail 同样没人认领,命中就摊原文;
③ `ChatPane.tsx` 里 `runFailureUi` 为 null 时直接 `: rawError`,**跨全部错误类型**,
映射表加行救不了。

⚠️ 版本事实:打包版是从 `main` 出的,而 `main` 上**没有兜底句**,只要没命中映射就必然
摊原文;本分支有 08-27 的兜底,同样输入只会变成一句空话。**这个泄漏在 main 上更严重。**

* fix(chat): 零位移的上滚手势不该关掉跟随;并加两个冻结假设的 A/B 开关

两件事共用 `ChatPane.tsx` 顶部同一个 import 块,拆开会留下「导入了但没用」的中间
提交(`noUnusedLocals` 会红),所以合在一条里。下面分开说。

## 一、跟随被零位移手势误关(用户 2026-09-07 亲测)

复现:进一个内容很少的会话 → 往上滚几下(**滚不动**)→ 之后的流式输出**不再吸底**。

**根因是两把尺子**:wheel/touch 监听里用的是「可滚动余量**严格大于 0**」
(`scrollHeight > clientHeight`),而状态机其余部分用的是 `AT_BOTTOM_TOLERANCE_PX = 8`
的容差。手势那一侧更严,于是**它先松手** —— 而它是**事前**的(快速流式下浏览器会把
滚轮吃掉、连 scroll 事件都不发,所以必须在位移之前判定),一旦松了,后面
`nextFollowIntent` 再正确也回不来:`escaped` 是个 latch,只有「主动下滚并真的到底」
能清。

**真机数据坐实了这一档**:打包版 `0.21.2-beta.1` 上只读采样,「刚进会话」那个状态出现
**108 个样本**,几何恒定 `scrollHeight 589 / clientHeight 583` —— **余量 6px,正好落在
(0, 8] 这个出 bug 的区间**。另有 37 个样本是 `583/583`(余量 0),而余量 0 那一档**改前
就已经是好的**。所以用户说的「界面没什么内容滚不动」,真机上不是 0,是 6。

⚠️ 那 6px 是**真实的内容溢出**(盒模型里的 padding/margin),不是高 DPI 取整噪声 ——
取整误差是零点几像素而且会抖,恒定 108 次的 6px 不是。这条写进了 docblock,因为它决定
下一个人该去查盒模型还是去查 `devicePixelRatio`。
⚠️ 同时写明门槛的**来源**:8px 是从「挣脱 := `!isAtBottom`」推导来的,手势侧和判定侧
必须同一把尺子;6px 落在 8 以内是**验证**不是来源 —— 哪天量到 12px,该改的是别的东西,
不是把 8 调大。

修法:新增纯函数 `upwardGestureCanEscapeBottom()`,判据换成「**这一格手势在物理上有没有
可能产生一次离开底部**」。两处调用点(滚轮 `:2833`、触屏 `:2857`)换用它。

**撤实现验红两次**:换回旧判据 → 零位移那两条红;换成 `return false`(「永远不松手」的
假修法)→ **既有的**「滚轮往上拨一下就停手」「手指下拉也算停手」4 条红 —— 证明「用户
真的滑走仍要停止跟随」没有被牺牲,而且是被既有用例接住的。

**同类零位移场景走了一圈**:已在顶部继续上滚(顺手修了)、已在底部继续下滚(`deltaY>=0`
本来就 return)、`useThinkingFollow`(没挂 wheel/touch,纯位移驱动,无入口)。
**拖滚动条和键盘 PageUp/↑ 不经过这两个监听,没有零位移保护,也没人复现过 —— 留着没动。**

⚠️ **对取证面的副作用,先记一笔**:改前短会话里滚轮一响就 latch,后续流式增长**一次
`scrollTop` 写入都没有**;改后跟随保持,写入记录里会多出一串。而「是我们写回去的」vs
「合成器根本不动」正是靠这份记录区分的 —— 拿这个包复现冻结时,写入数量比旧包多**是本
提交造成的,不是新信号**。

## 二、两个滚动冻结假设的 A/B 开关(默认全关 = 现状)

冻结还剩两个嫌疑,都改不了 CSS 就能测、必须动代码。做成运行时开关,**一个包测两个**:

- `open-design:disable-chat-log-self-resize-observe` —— 摘掉「滚动盒观察自己」那条
  ResizeObserver(回调里会写尾部占位块高度,构成自喂环;`origin/main` 没有这条)
- `open-design:disable-msg-enter-animation` —— 停掉 `msg-enter` 入场动画(`fill: both`
  会把 transform 永久留在正在增高的元素上)

照 `chat-scroll-takeover.ts` 的形状(`open-design:` 前缀 + 刷新生效 + 读不动当关),
**没有另起机制**。只有字面量 `'1'` 算开。

**「默认行为不变」是证出来的,不是推的**:不设开关时滚动盒仍在观察者名单里且纯可视
高度变化仍能翻起状态;不设属性时 `.msg` 的 `animation` 仍解析成 `msg-enter … both`
(喂整张展开后的 index.css,jsdom 真层叠,所以「后面某张表又盖回来」也照得出);
`'true'`/`'on'`/`' 1'` 一律按关;两开关都关时 `install()` 在根节点上不留任何属性。

**打开各自会失去什么**(写在模块 docblock 里,真机 A/B 前必读):
H2 → 「只改可视高度、不改内容高度」这类变化失去唯一通知源(输入框长高/软键盘/窗口竖向
缩放时不再重新贴底);内容驱动的跟随不受影响 —— 这也正是 `origin/main` 的行为。
H3 → 消息少了 200ms 淡入;且动画留下的单位 transform 消失后,`.msg` 不再是绝对定位后代
的包含块、也不再新建层叠上下文,消息内部若有错位**先怀疑这条**。

⚠️ **滚动盒身上不止一个 ResizeObserver**:`QuoteBar.tsx` 也 observe 它。H2 只摘跟随那条,
所以开关打开后滚动盒**仍然被观察着** —— 若真机 A/B 显示冻结依旧,那条是下一个要看的。

* chore: 抬到 0.21.4

上一个包是 `0.21.2-beta.1`(0.21.3 那次在打包中途被我取消了 —— 当时刚发现取证信封会被
全局 4mb parser 静默截断,发一个取证传不回来的包没有意义)。

因为特性分支必须 `publish=false`,R2 的 beta metadata 永远不推进,而 `prepare-beta.ts`
在「仓库基线 > metadata 基线」时把 beta 序号固定为 1 —— 不抬 patch 位,这个包还会叫
`0.21.2-beta.1`,和已经发出去那个撞名。**每要一个可区分的包就得抬一次。**

永久解仍是把已经躺在打包 job 环境里的 `RELEASE_COMMIT` 接进诊断包 ——
分支 `chore/pack-build-commit` 已备好未合并;⚠️ 合之前要先补 `release-stable.yml`
那四个 build job 看不到该变量的缺口。

* docs(specs): 记 R-051 只止了血、未对齐设计稿 S10(产品拍板「先这样」)

`0.21.4-beta.1` 里补的 `upstream_5xx` / `provider_high_demand` 映射,解决的是
「把 JSON 原文摊给用户」这个 P0,**用的是仓库已有的邻近文案,不是设计稿 S10 的文案**。

差四处 + 一个中间态:供应商名插值、「不是你的问题」、「已自动重试过」、〔更换模型〕
按钮,以及「自动重试 2 次都失败后才出卡、期间显示正在重试 1/2」。

完整对齐要新增 key + 19 个 locale + 供应商名的数据通路 + 接模型切换器的按钮 + 重试
计数中间态,体量远超止血本身。产品 2026-09-07 拍板先止血、对齐单独立项。

**这是对设计稿的有意偏离,记录在案以免后来人以为是漏做。** 按仓库规矩不另开 issue。

* fix(daemon): ACP 这条路从不问错误分类器,9 个 runtime 的上游报错全是裸的

`acp/session.ts` 的 `fail()` 只产出两种形态:JSON-RPC 分支给 `AGENT_EXECUTION_FAILED`,
**其余所有 fail 点给裸 `{ message }` —— 连码都没有**。两条都从不调
`classifyAgentServiceFailure`,而 json-event-stream 路和 Claude 路都调。

影响面是全部 `streamFormat: 'acp-json-rpc'` 的 runtime:amr / devin / hermes / kilo /
kimi / kiro / reasonix / trae-cli / vibe。

## 先拍快照再改

44 条真实语料(现有测试 / `run-error-catalog.md` / 用户 `0.21.2-beta.1` 截图原文)冻结成
落点表,改前改后逐条 diff:

- **分析轴(`failure_category` / `failure_detail` / `failure_stage` / `retryable` /
  `user_action`)44 条一条没动。**
- 码轴动了 15 条:上游过载 → `UPSTREAM_UNAVAILABLE`;401/403 类 → `AGENT_AUTH_REQUIRED`;
  429 / session limit → `RATE_LIMITED`。其中 A3 那条改前**连码都没有**。
- 没动的 29 条里有关键守卫:握手判决先盖章的两条仍是 `AGENT_CLI_SESSION_REFUSED`;
  `exit code 401` / `line 502` 这类噪音仍被 `STATUS_CTX` 挡住;16 条分类器返回 null 的
  保持今天行为。

`evidence_level` 有 13 条从 `legacy_text` 变成 `structured_code` —— 不是巧合:
`run-failure-classification.ts` 里那三个 structured-code 分支**对 ACP 一直是死代码**
(码从来没送到),喂进码后才激活,而它们当初就是照着「和文本分支同判决、只差
evidenceLevel」写的。

⚠️ **唯一一处码/detail 分歧变大的**:`insufficient balance` 码变 `RATE_LIMITED`,
detail 仍是 `amr_insufficient_balance`。缓解:① AMR 自己走不到(账号分支在更前面拦截);
② 另外 8 个 runtime 的卡面会从「原文兜底」变成限流卡;③ **json-event-stream 和 Claude
两条路今天就是这个行为** —— 这是在向已评审的既有行为对齐,不是新增一类错判。
只放 `UPSTREAM_UNAVAILABLE` 一档等于自造分类器里没有的规则,故未采纳。

## 落点与取舍

改在 `server.ts` 的 ACP 专属 `send('error')` 桥接处,**不改 `session.ts`** ——
桥接处是唯一同时喂 SSE 和 `run.error` 的点(那段 docblock 自己写着这是「两个面能一起
纠正的最后一处」),也是唯一拿得到 runtime 身份的地方,还天然避开 AMR 账号分支和
resume 抑制分支。

**刻意不读 stdout/stderr tail**(json-event-stream 路读):ACP runtime 的 stderr 是它自己
的日志(vela 会打请求 URL、provider 名、重试),拿那个决定用户看哪张卡风险太大。只看
agent 写进 JSON-RPC error frame 的东西。

分类失败时**按引用原样返回**(测试用 `toBe` 而非 `toEqual` 钉住),完全退回今天的行为;
覆盖非对象 / null / 空白 message / 循环引用 details / 已有具体码五种情形。
`message` / `retryable` / `details` 一个字节没碰,`run.error` 前后完全一致。

## 顺带发现(未改)

**ACP 的协议违规被判成「去登录」**:`session.ts:1329` 拼的
`invalid session/new response: …` 含子串 `invalid session`,而
`integrations/vela-errors.ts:183` 把它当 AMR 鉴权失败。纯协议问题 → 用户被告知去登录。
实测最小触发就是字面量 `invalid session`(单空格)。本次改动对它无影响(冻结表 C5 钉住)。

* fix(chat): 找所有者充值弹窗整个是透明的,而且还压不住输入框(OPEND-2722)

两个独立缺陷叠在同一张弹窗上,合起来的效果就是工单说的「未正常弹出」。

## 一、缺主题接缝 —— 弹窗画不出来

`AmrOwnerTopUpDialog` 用 `createPortal` 挂到 `<body>`,但没带 `--chat-*` 接缝。
CSS 自定义属性按 **DOM 树**继承,而 `--chat-*` 只在 `[data-chat-root]` 子树里定义
(`ChatRoot.module.css` 的 `.vars`/`.root`)—— portal 出去就脱离了那棵子树,遮罩、卡片
底色、投影、圆角**全部解析失败**,文字裸浮在页面上。**它不报错**,现有按 testid 断言
存在性的测试照样绿。

同一个坑 2026-08-27 已在 `SupportDialog` 踩过并修好。修法**逐字照抄**它(`chatSeam()`,
同样三行、同样形状),没有另起机制。`inline` 仍跳过接缝,免得陈列页多出第二个
`data-chat-root`。两个调用点(`ProjectView` / `EntryShell`)都走 portal 形态,由组件层
一并覆盖,并有测试钉住。

## 二、`z-index: 40` —— 画出来了也压不住

同屏实测:输入框固定层 45(`chat.css:1395`)、同侪模态 `.staged-preview-modal` 1200
(`chat.css:2964`)、右上角那簇真机量到 150。`SupportDialog` 当初就为此抬到 **1500**,
**这张从没抬过**,而守它的测试只守 `SupportDialog` 一个文件。

抬到 1500,和同族对齐 —— 依据是 `SupportDialog` 自己的注释:1500 是为了压过同侪模态,
不是「刚好够用」的数。

守护测试一般化成 `chat-overlay-layer.test.ts`,覆盖整个 `components/chat/`,
**判据写死为三条同时满足**:`position: fixed` + 覆盖全视口 + **画了背景**。第三条是关键 ——
全屏但透明的层是点击捕获器,职责就是待在东西**下面**(如 `composer-toolbox-standalone-backdrop`
z=89);画了背景的才做出「我后面的东西都被压暗且失效」这个承诺,而被打破的正是它。
被排除的每一个(QuoteBar、QuotedRefs、PlanPill、AnchoredMenuShell、UpgradeCard 的
`::after`、两个 `overlayInline`)都单独钉了测试,**判据将来放宽会当场变红**。
一般化是**证明**过的:往目录里丢一个低 z-index 的假弹窗,扫描当场发现、红 5 条,再删掉验残留。

原 `support-dialog-layer.test.ts` 已被完全涵盖,删除。

## 量法自检(这两条都踩过同一个坑)

⚠️ **jsdom 不做 `var()` 替换** —— 量背景色是**假绿**判据:变量解析对了它照样报
`rgba(0,0,0,0)`。接缝那条因此改成量「每一枚被消费的 `--chat-*` 是否解析出非空值」+
排版基线 `font-weight`,并**显式断言背景色是不可用判据**;层级那条则只读 CSS 文本里的
字面整数,不碰任何 `var()` 依赖的计算值。两条都先证明尺子在**修复前**能读出错误值。

## 全仓 portal 普查(干净的否定结果)

48 个 `createPortal` 逐个对照 14 个消费 `--chat-*` 的 CSS Module 和 20 个全局类:
**只有 4 个 portal 带 `--chat-*` 内容,现在四个都有接缝**(composer 固定层、QuoteBar、
SupportDialog、本次)。其余用的是全局 `:root` token,portal 出去仍然有效。

## 关联工单的结论(未改其状态)

- **OPEND-2720「点击升级无法跳转 Pricing」不是缺陷**:非 owner 本就不外跳
  (`ProjectView.tsx:2991-3009` 注释逐字:「任何非 owner → 不外跳:账单动作 B 会拒」,
  与 §6.V 一致);owner 现在也不跳公开 Pricing 了(T54 改成 console 套餐页)。
  报告人看到的「点了没反应」**正是本条缺陷** —— 弹窗弹了,只是透明的。
- **OPEND-2719 一半是本条,一半是有意设计**:入队是故意的
  (`ProjectView.tsx:8175-8195` 停的是**完整载荷**,因为输入框已清空,只恢复文本会静默
  丢掉暂存附件 —— 注释写着),不能拿掉;「没弹窗」那一半随本条修复消失。

## 顺带发现(未改,需单独决定)

`packages/components/src/dialog.module.css:11` —— **共享 `Dialog` 原语的遮罩是
`z-index: 100`**,同样低于 150。它有 **14 个消费者**,其中一个是 `AmrBalanceDialog` ——
本家族里给有账单权限的人看的那张。同样的缺陷低一层,但抬共享原语是 14 个消费者的决定。
另有 5 个画了背景、全屏、低于 150 的遮罩(插件市场 60、首页确认 80、自动化 80、
插件导入 100、连接器抽屉 100)。

* fix(chat): 三处让聊天区「看起来坏了」的缺陷(原文泄漏 / 导轨死区 / 进会话钉顶)

三件事共用 `ChatPane.tsx` 顶部同一个 import 块,拆开会留下「导入了但没用」的中间提交
(`noUnusedLocals` 会红),所以合在一条。下面分开说。

## 一、用户可见面永远不再显示原始错误文本

报错卡的描述位原本在没有映射命中时**回落到 `rawError`** —— 也就是把 daemon 传来的原始
错误摊给用户。刚补过一行映射(`ca8e255550`),但**只要这条回落还在,映射表补得再全总有
漏网的**,而已知还有 22 个 failure_detail 没有映射行。

**判据从「落到哪条分支」换成「这段字是谁写的」**:任何来自一次 run 的字符串一律走兜底句,
新增 `resolveRunErrorCardDescription()`(`amr-guidance.ts`)。这样映射补不补都不影响,
结构上堵死。

摸底结论:`rawError` 共 5 个消费点,**只有 1 个会到用户可见面**(描述位);标题位永远是
key;逐条消息的红色 error 药丸和「错误详情」折叠 08-27 就已下线。

**文案一个字没新写**,复用 08-27 已有的 `chat.runError.fallbackMessage`,19 个 locale 现成。
**原文仍然可达**:`appendAssistantErrorEvent` 照落 `events.jsonl`,〔导出日志〕照打包
(`diagnostics-export.ts:95`),测试钉住每张卡都带导出与联系支持。

⚠️ **界线写进了注释**:藏的是 **JSON-RPC 传输信封**(事件 id、`sessionID`、`properties`、
本机端口与项目路径),**不是失败本身** —— 卡照出、标题照说、按钮照给。

⚠️ 一条既有断言被**故意翻面**并写了原因:旧判据是「来源是不是这一轮」,来源指向别的助手就
原样放行;而那个槽只有两种来源,`setRunError(err.message,…)` 装的**永远是原文**。判据收敛
成「有没有来源助手」后这条必然翻面。它原本要保护的东西(来源为空 = 我们自己写的人话)由
同文件上一条用例继续钉着。

## 二、右边缘 20px 的隐形死区吃掉滚轮

`ChatMessageRail` 是一条 **20px 宽、整个 log viewport 高**的绝对定位覆盖层。指针落在上面时
滚轮对聊天记录**完全无效,上下两个方向都死**。而 `.chat-log` **故意没有滚动条**
(`scrollbar-width: none` + `::-webkit-scrollbar{display:none}`,注释明说导轨就是替代品),
所以用户按肌肉记忆把指针停在右边缘,正好落进死区,屏幕上没有任何线索。出现条件只是
「≥2 条用户消息」。

⚠️ **根因不是 `overscroll-behavior: contain`** —— 反证过:改成 `auto`、track 滚到底再发
滚轮,日志仍然纹丝不动。真正原因是结构性的:**导轨是 `.chat-log` 的兄弟节点,不是祖先**,
而 Chromium 沿祖先链找滚动容器。那条 CSS 是红鲱鱼,未动。

修法:新增纯模块 `rail-wheel.ts`,track 在**这个方向**上还有余量就先吃,**剩下的转发给日志**。
短对话(track 不可滚)全额转发,长对话 track 先吃、到底后余量转发 —— 比今天更符合直觉
(今天到底后那部分滚轮凭空消失)。

⚠️ **React 18 把 `wheel` 注册成 passive**,`onWheel` 里 `preventDefault()` 是空操作,而取消
默认正是整个机制(track 是 `overflow-y:auto`,不取消就「原生滚 + 手动滚」双份位移)。
所以改用 ref 挂原生监听 + 显式 `{ passive: false }`。**这一条验成了行为而非断言**:jsdom 同样
强制 passive,把常量翻成 `true` 会让 `defaultPrevented` 变假、测试真的变红。
另需 `railWheelDeltaPx` 归一化 `deltaMode` —— 因为现在是我们自己写 `scrollTop`,
Firefox 某些平台一格是 `deltaY: 3`,不归一化就是每格 3px。

顺带修 `is-retracted`:原本只改 `opacity`,于是点完跳转后**看不见却照样吃悬停和点击**。
nav 和 track 都要关(`pointer-events` 虽可继承,但 track 自己声明了 `auto`,只关 nav 不够 ——
有专门的撤实现验红证明这条不冗余)。⚠️ **解除不靠 `mouseleave`**:元素刚被设成 `none` 会不会
补发一次,规范没要求、各引擎不一;赌错就是**导轨永久卡在收起态、再也不亮**,比原缺陷更糟。
改成收起期间用 `document` 的 `pointermove` 自己判指针是否离开 nav 矩形。

## 三、进会话有时停在顶部(用户 2026-09-07:「有时候在最底下有时候在最顶部」)

⚠️ **不是本分支引入的。** 用 144 种进会话时序的差分矩阵跑了三遍:撤掉缺陷三的守卫、
甚至撤掉本 range 内 ChatPane 的**全部四处**改动,**最终落点逐行相同**(只有写入次数不同)。
所以头号嫌疑 `isPinnedToLogBottom` 与 `upwardGestureCanEscapeBottom` **都不是真凶** ——
旧写法写的是 `el.scrollHeight`、当场被夹到 `maxTop`,新守卫跳过的正是那些**写了也不动**的帧。

**真凶**:`settledTailUserIdRef` 的三档语义里 `null` 是一句**结论** ——「这条会话我看过了,
没有用户消息」。而 `ProjectView` 在会话 id 一到手就挂 `ChatPane`(`:12914`),转录随后异步取,
**id 和转录永远不可能同一 commit 到达**。那几拍里空转录被落定成 `null`,下一拍历史一次到齐,
`isNewTailUserTurn(null, 尾条id)` 为真 → **一份刚读进来的旧历史被判成用户刚发的新一轮** →
`releaseFollow()` + 钉顶接管。

「有时底有时顶」由此解释:钉顶那一帧若排版已落定,停在最后一条用户消息上沿(最后一轮回复短时
看着就像贴底);未落定则矩形全挤在 0,量成 0 → 停在最顶。

修法:新增 `transcriptSpeaksForConversation()`(`anchor-to-top.ts`),用同一把闸同时管
「落定」和「表决」。**没有碰跟随的写入判据**,缺陷三的修复原样保留。

同一条规矩仓库里已写过一遍 —— `conversationMessageCount` 的注释:转录没落到这条会话头上时
`messages.length` 不作数(那边症状是列表里的幻影「0 msg」)。同一个错误的另一个出口。

⚠️ **「为什么 0.21.2 上没有」没有证出来** —— 它不在 `0.21.2 → 0.21.4` 的 diff 里。推测是本
range 内某处(观测限流放开、思考区滚动条改成占布局、问卷 `interactive` 判据放宽)动了排版
时机,把一个**本来就存在**的竞态从「多数落在底部」推到了「多数落在顶部」。**只有真机能确认。**

⚠️ **留下一个已知行为变化**:转录读取窗口内发出的那一轮,钉顶会被推迟;若那一轮恰好是该
mount 的第一次落定(首页发起 + 创建会话后立刻发送的竞态),这一轮会不钉顶、退回贴底跟随。
**请在真机上过一遍「首页发起第一轮」。**

## 时序夹具的共同要求

三条里有两条的红测都必须**能表达「判定发生在排版之前」**:几何分两拍喂,挂载时
`contentHeight === clientHeight`(对应真机 583/589)、矩形全在 0,`settleLayout()` 之后才给
终态。**一上来就给终态几何的夹具照不出这类缺陷。**

## 只有真机能确认

指针物理停在那 20px 上时 `wheel` 的 target 是不是导轨(jsdom 无命中测试,这正是症状本身);
`pointer-events: none` 之后点击/悬停是否落到底下的流水;双份位移是否真的消失;
滚轮接管替换掉浏览器平滑动画后的手感;以及上面点名的「首页发起第一轮」。

* chore: 抬到 0.21.6

跳过 0.21.5 —— 那个号被实验分支 `exp/disable-overscroll-features` 占了(滚动冻结 A/B 的
处理组包 `0.21.5-beta.1`),避免同名不同物。

本包相对 `0.21.4-beta.1` 新增:ACP 错误分类接线、找所有者充值弹窗的主题接缝与层级、
以及三处聊天区缺陷(原文泄漏堵死 / 导轨 20px 死区 / 进会话钉顶)。

⚠️ 「进会话钉顶」经差分矩阵证实**不是本分支引入的**(撤掉本 range 内 ChatPane 全部四处
改动,落点逐行相同),但它在 `0.21.4-beta.1` 上可见,本包修掉。

* docs(specs): 余额族归交付稿管,不跟新文案文档(T60);并更正 admin 角色那条错误事实

产品 2026-09-07 裁决:余额 / 额度 / 充值 / 升级这一族的权威源是**交付稿组件 18**,
不是 09-06 那份《报错文案|精简版》。原话「不能用以前那种白色报错卡……余额的一律对齐
这两个视觉稿卡片」。

 核实:我们当前就是**逐字一致**的(`chat.upgrade.balance` / `whyLow` / `whyOut`),
这一族无需改动。新文案文档给这一族的是通用话术并删掉了 `{balance}` 具体数字,
相对交付稿是倒退。

同时重新确认 2026-09-02「不存在第二张白色通用报错卡」继续有效 —— 新稿给这一格写了
标题+正文,而接棒的升级卡没有标题行(T59),没有地方放也不该放。

⚠️ **更正一条我先前给出的错误事实**:`admin` 角色**是存在的**(`CollabMemberRole`
三档,全仓在用),我先前说「代码里没有 admin」是把余额弹窗按账单权限分的两档误当成了
角色轴。

但 admin **确实不能充值**,且是后端强制:我们 `collab.ts:480` 与 vela
`packages/shared/src/workspace-context.ts:268` **逐字相同**(`readable && isOwner`),
vela 服务端还在 `billing/http/routes.ts:1765` 拦。而且是有意的 —— 同处
`canManageMembers` / `canInviteMembers` / `canManageSharedResources` 全含 admin,
**只有账单与自动充值排除它**。所以新稿「请联系团队管理员充值」会把用户指向一个
充不了值的人,保持 T56 的「所有者」。

* docs(specs): 升级卡改成按轮次锚定的存档件,不再是当前余额的实时读数(T61)

产品 2026-09-07 裁决:软档那张升级卡 ① 只在一轮结束后出现、运行中不出现;
② 出现后锚定在那一轮下面,第二轮运行期间不许挪走;③ 第二轮结束仍不足则另出一张;
④ 值是那一刻的存档,不随后续余额改写。

原话:「它就好像历史记录一样,存档在当时状态了,不能说我干个啥把当时的失败态搞丢了,
我往回看那一轮为啥失败了根本没有依据和想不起来啊」。

**这是语义变更**:卡片从「当前余额的实时读数」变成「这一轮为什么停下来的凭据」。
记在案是为了防后来人把「不随余额刷新」当成 bug 修回去。

⚠️ 与 2026-09-02「升级卡各只有一张」不矛盾 —— 那条管的是同一时刻同一档不要两块 UI,
本条管的是不同轮次各自一张,两者正交。

⚠️ 第 ④ 条体量未定:若卡片今天派生自实时钱包状态,刷新后要么消失要么显示当前余额,
两种都不满足「凭据」;真正存档可能要落进会话记录,跨 web/daemon/contracts。
①②③ 是纯渲染层先做,④ 视调研结论单独立项。

* docs(specs): 文案对齐的三处口径——S21 保三态、S17 放放、S29 插值槽不改(T62–T64)

产品 2026-09-07 逐条裁决:

- **T62 S21 保持三态**:文档一格 vs 我们三个可分辨状态(伪造角色标记 / 空输出 /
  工具死循环)。抄同一句等于悄悄合并三个状态,是产品设计不是文案活。已落地:
  只对最字面对应的 `outputInvalid` 采用新文案,另两个标题不动。
- **T63 S17 先放放**:我们没有这个状态,那两个 key 是死键、无任何映射引用;
  真实 401/403 走的是 S04 那张卡。S17 是否 ≡ S04 留给产品。连带「被移出团队」
  「客户端版本过旧」也没有分流(vela-errors 只有 3 个码,全仓不读 HTTP 状态码)。
- **T64 S29 插值槽不改**:标题已按新稿改,正文不采用——组件 22 是一行状态不是卡,
  没有正文槽,要新增两个插值槽 + 改结构。计数今天已贯通到 UI,只是形态是一行。

另:S01/S02/S04 标题判定为「无待拍板项,只缺接线」(产品文案已给全,我们缺的是
标题不传插值 + `title.signInRequired` 一键服务两格要拆),已派单实施,不在此表。

* feat(chat): 报错文案按产品新稿对齐第 1 批,并给标题接上插值

权威源:产品《Open Design 报错文案|精简版》(飞书 `docx/S1Ucd1frUo7opCxGLbRcj3XTnvh`,
最后修改 2026-09-06)。抬头自陈「原文中的按钮仅供核对」,**所以本次范围只有标题和正文**。

## 第 1 批:20 个 key × 19 语种 = 380 条

zh-CN 逐字对齐产品原文(全角标点已逐条核过),其余 18 个是它的忠实翻译。
覆盖 S09 限流、S10 上游不可用、S11 连接中断、S18 账号停用、S19 智能体退出、
S20 内容过长、S21 输出异常、S01/S02/S04 正文、S29 重连、S31 检查更新失败。

## 给标题接上插值(这一批唯一的代码改动)

`ChatPane.tsx` 原来是裸的 `t(runFailureUi.titleKey)`,**不传变量** —— 新稿 S01 标题要
「未检测到 {智能体}」,照抄会把**字面的 `{agent}` 摆到用户脸上**。

新增 `runFailureCopyVars`,标题(`:4150`)和正文(`:2242`)**同吃一份**。
刻意不给标题另造 `titleVars`:两处名的是同一条失败、同一个 agent,各取各的只会给
「标题说 Claude、正文说 Codex」留一道缝。`RunFailureUi` 一个字段没加。

## 拆 `title.signInRequired`

一个键原本同时服务 S02(本地 agent,新稿要「{智能体} 尚未登录」)和 S04(Cloud,
新稿要「Open Design 尚未登录」),装不下两句话。拆成 `.other` / `.amr`,
**旧键从 union、`types.ts`、19 个 locale 彻底删除** —— 不留「落到随便哪一边」的可能,
并有源码级守卫钉着。三个调用点各自落位:amr → `.amr`;antigravity(终端登录)→ `.other`;
通用兜底 → `.other`。

## ⚠️ 一条自己揪出来的假绿

原本用「S02 的标题 ≠ S04 的标题」证明拆键成功。撤实现验红时发现:**AMR 的 agent 标签
恰好就是 `OpenDesign`**,共用一个键时两句仍然不相等,那条断言照样绿。补了
`expect(s04).not.toContain('OpenDesign')` 才真能红。

## 按裁决跳过的(T60 / T62 / T63 / T64)

- **余额族整族不动**(T60):权威源是交付稿组件 18,`chat.upgrade.*` 已逐字一致;
  新稿给这一族的是通用话术且删掉了 `{balance}`,相对交付稿是倒退。
- **S21 保持三态**(T62):文档一格 vs 我们三个可分辨状态,抄同一句等于悄悄合并。
- **S17 先放放**(T63):我们没有这个状态,那两个键是死键,真实 401/403 走 S04 那条路。
- **S29 正文不改**(T64):组件 22 是一行状态不是卡,没有正文槽。
- **S12「已有 {N} 秒」不采纳**:与产品 2026-09-04「因重复而收掉思考区计时」冲突。
- **S07 `{retryAt}`** 保留:新稿删掉它会让 `formatModelWindowRetryAt` 整条解析链变成死码。

## 被更新的既有用例

7 个文件。其中 `run-error-ladder.test.ts` 那条「S19 每个语种都保留 `{agent}` 插值位」
是钉**旧稿**的,新稿没有插值位 → **反转成「都不带插值位」**,守的是「半退回的旧译文
偷偷把槽加回来」,手法与 `cliSessionRefusedMessage` 的 `{version}` 守卫一致。

`mirror-exec.html` 只含本次那 3 行(重生成会带进约 145 行无关 CSS 漂移,已剔除;
手工版与重生成版的文案字符串计数完全一致,已核)。

* fix(ui): 六个全屏遮罩压不住同屏 chrome,共享弹窗原语也在其中

产品裁决 2026-09-07:「那都修掉」。

## 缺陷

画了背景的全屏遮罩做出的承诺是「我后面的东西都被压暗且失效」。这六个都压不住:

| 文件 | 选择器 | 改前 | 改后 |
|---|---|---|---|
| `packages/components/src/dialog.module.css` | `.backdrop` | 100 | **1500** |
| `styles/home/plugin-marketplace-demo.css` | `.plugin-marketplace__modal-backdrop` | 60 | 1000 |
| `styles/home/home-hero.css` | `.home-hero-confirm__backdrop` | 80 | 1000 |
| `styles/home/tasks.css` | `.automation-modal-backdrop` | 80 | 1000 |
| `styles/home/plugins-view.css` | `.plugins-import-modal__backdrop` | 100 | 1000 |
| `styles/workspace/drawer.css` | `.connector-drawer-backdrop` | 100 | 1000 |

同屏参照:输入框固定层 45、`.workspace-tabs-chrome` 120、右上角那簇真机量到 150、
同侪模态 `.staged-preview-modal` 1200。

## 分档不是拍的

普查了全应用 36 个全屏遮罩,发现**应用里本来就有一档「应用模态」= 1000**
(`BrandPickerModal` / `FigmaImportModal` / `LibraryPreviewModal` / `LibraryUploadModal` /
`NewBrandModal` 五个 `.backdrop` 都在这一档)。五个全局遮罩放进**已有的档**,
它们各自独占一屏,没有谁需要压过另一张模态,1200/1500 是越权。

**共享原语是唯一的例外,1500 是推导出来的**:
- 它没有自己的屏 —— 从 `EntryShell` 和 `ProjectView` 两处渲染,而且**会嵌在别的模态里**:
  `NewProjectPanel` 的删除确认活在 `NewProjectModal` 的遮罩(920)里,100 时**那个确认框的
  遮罩画在了它自己的父弹窗底下**。
- 数值来自同一个闸门的另一条腿:`AmrOwnerTopUpDialog` 与 `AmrBalanceDialog` 是**同一个
  三元表达式的两条腿**(同一个余额闸门,只按账单权限分叉),owner 那条已在 1500。
  **一个闸门的两条腿不能差两档**,否则同一个时刻会因为你是谁而长得不一样。

`Dialog` 的消费者实测是 **17 个**(不是 14 —— 三个用多行 import,单行 grep 漏了):
13 个吃原语的遮罩、3 个自己往上escalate(1700/1700/1800,未动)、4 个用
`includeChromeClassName={false}` 完全不吃。**没有任何一个是故意压低的。**

## 守护测试

新增 `tests/styles/app-scrim-layer.test.ts`,按目录与 `components/chat/` 那条分工
(各自断言扫描到零个对方的文件,谁将来放宽范围都会红)。

判据四条同时满足:`position: fixed` + 覆盖全视口 + **画了背景** + **不是
`pointer-events: none`**。第三条把「点击捕获器」排除掉(全屏但透明的层职责就是待在
东西**下面**);第四条是本条新增 —— 我的范围够得着 `app-wash.css` 的 `body::before`,
它满足前三条却是 z −1 的装饰,没有它守护测试首轮会要求把整个应用背景抬到 150 以上。

尺子**只读 CSS 文本里的字面整数** —— jsdom 不做 `var()` 替换也不实现层叠,
`getComputedStyle` 在这里会空转假绿。夹具先证明它读得回 60/80/100 这些**改前的真值**。

改前 13 条红(6 个缺陷 × 地板 + 分档,加同闸门两腿同档),**其余 30 个遮罩全绿** ——
尺子会区分,不是见谁红谁。逐个还原逐个验红,每次只红它自己那几条。

## 未动,已记录

`.connectors-panel-embedded .connector-drawer-backdrop`(z 6,`position: absolute`)是
**故意压低**的,注释写着「so it does not stack above the settings modal」。它是**另一个
选择器**,抬基线够不着它;已在守护测试里钉成有意排除。

`.project-search-backdrop`(400)在地板之上,按判据不算缺陷,但它是 920 档以下唯一
剩下的遮罩,记在这里。

* fix(chat): 余额卡被压扁 / 卡改成按轮次存档 / 撤掉等待文案 / 两个白开销类名

⚠️ **前置说明**:这几件都要改 `ChatPane.tsx`,而那个文件的 hunk 已随上一条提交
`611ab085f7`(文案对齐)一起进去了 —— 那条消息只描述了文案。本条描述的是同一个文件
里其余几件的意图。**看 `ChatPane.tsx` 的改动请连着这两条一起读。**

## 一、余额卡只显示一半高度(QA 2026-09-07)

**根因不是底部让位不够,是 flex 把它当成了泄压阀。**

`.chat-log` 是定高的列向 flex 滚动容器。flex 项默认 `flex-shrink: 1`,内容超出时浏览器
**先压扁子项、压不动才溢出**。绝大多数行压不动 —— 它们 `overflow: visible`,
`min-height: auto` 解析成内容撑出的最小高度(flexbox §4.5 自动最小尺寸)。

但升级卡为了裁住右上角辉光和圆角写了 `overflow: hidden`,而**非 visible 的溢出会让自动
最小尺寸变成 0** —— 它成了整根流水上唯一压得动的那一项,一个人吃下全部负空间,
收缩到只剩自己的内距,再被自己那句 `overflow: hidden` 把下半截裁掉。

真实浏览器实测(已滚到最底):卡自然高 111.5px → 实际画出 **32px**,裁掉 79.5px;
加上修复后画出 111.5px,`scrollHeight` 918 → 998,**正好把被吃掉的 80px 还回来**。

三种可能都排掉了,有数为证:**不是 padding 不够**(20px 让位一分不少在那儿);
**不是被钉住**(它是流水的直接子元素,符合 T51);**不是滚动冻结**
(`scrollTop` 恰好等于 `maxScrollTop`,滚到头了还是半截)。

修法 `.chat-log > * { flex-shrink: 0 }` —— **归容器不归卡**:「会被压扁」不是余额卡的
毛病,是任何在这根流水里裁自己溢出的东西都会中的招,而圆角卡本来就该裁。补在卡上
只救这一张,下一个写 `overflow: hidden` 的还会中,而且照样长得像那张卡自己的渲染 bug。
`.chat-log-tail-spacer` 和 `.chat-virtual-spacer` 早就各自写了 `flex: 0 0 auto` ——
这条只是把它们各自的补丁提成整根流水的契约。

**52px 那笔账一个字没动**:底部让位只为**浮在流水上方**的东西算(药丸与 Jump 共用同一个
绝对定位浮层位),余额卡是流水里的一行,不需要让位。四种组合实测过,卡在不在**不改变
底部空间**;`w95-reserve-vs-anchor-spacer` 钉的「地板 + 顶补恒定」未受影响。

## 二、升级卡改成按轮次锚定的存档件(T61 ①②③)

产品原话:「它就好像历史记录一样,存档在当时状态了,不能说我干个啥把当时的失败态搞丢了」。

改前:读数写在一个与轮次无关的 state 上,渲染在 `<ChatRows>` **之后**的尾部子节点 ——
所以**运行中就会出现**,而且新一轮的消息追加在它上面、卡跟着往下跑,永远只有一张。

改后:读数带上锚点(`amrBalanceCard = { balanceUsd, anchorMessageId }` **合并成一条 state,
让「只写一半」在语法上不成立**);账本 `lowBalanceTurnCardsRef` **只增不删**,锚点那一轮
进终态才记账;卡画在那条助手消息**紧下面**,位置由 DOM 顺序保证,不做位置计算。

「一轮结束」= daemon 三个终态 `succeeded`/`failed`/`canceled`,**外加**「`runStatus` 缺席
但已落 `endedAt`」(非 daemon 模式建消息时 `runStatus` 本来就是 undefined),与
`runtime/todos.ts` 同一条。**失败和被停止都算结束** —— 那恰恰是最该留凭据的两种。

⚠️ **一条推翻既有设计的判断,已报备**:「余额恢复后旧卡还留不留」不在产品给的四条里。
按「不能把当时的失败态搞丢」做成**留着**,这推翻了原注释「上一轮缺钱、这一轮跑通了,
卡就该下去」。

⚠️ **T61 第 ④ 条(存档)未做**:刷新后「跑到一半死在钱上」那张卡的数字仍是**现查的当前
余额**(失败事件不带余额)。充值后回看会写成「$20.00 / 余额可能撑不完下一个任务」,
数字是今天的、句子是当时的。已单独立项。

⚠️ 删掉了一条自己写的假绿用例(「放行时锚点也一起撤掉」撤实现仍绿),测试文件里留了
说明。

## 三、撤掉「等待首批输出中」,继续显示「思考中」

产品口述 2026-09-07。**这是同一条设计原则被撤的第二次** —— 稿子
`error-ux-design.md:21` 第 3 条「等待要有回音」,它的第一次实现 S12「已等 411 秒」
在 2026-08-27 就被撤过(原话:「改回进行中…但背后的探测逻辑先保留,后续可能会用到,
只不过用别的展现形式」)。本次照 S12 的既有形状:**撤文案、留探测、留钉子**。

⚠️ **状态一行没删**。`waitingForFirstOutput` 才是那一行存在的原因:ACP 那一家
(vela/devin/hermes…)在首个 token 前**一条会落行的事件都不发**,删了它屏幕会退回
**完全空白**,而不是少一句话。只删了已无读者的显示层管线。

i18n key 留着退回死键:产品说的是换展现形式不是不再发生;而且 `locales.test.ts` 有一条
专钉它翻译质量的用例(土耳其语曾写成「等待首批**输入**」,正好写反)——**留着 key 才
留得住那条测试**。

## 四、两个零消费的类名

`is-scrollable` / `is-scrolling` 在**全仓 CSS 里没有任何规则选中**,却各自驱动一次
setState:后者由 650ms 空闲定时器管,**每次滚动手势开始一次、停手后再一次**,各触发
一次重渲染 + 一次属性改动,零渲染收益。

`is-scrolling` 整条删除;`is-scrollable` 按产品意见**改成常驻写死**(`.chat-log` 从创建
起就是滚动容器,「是不是滚动容器」不随内容变;原来那个类表达的其实是「此刻有没有超出
视口」,名字与语义对不上)。

⚠️ **第四个产地是 grep 找不到的**:ResizeObserver 回调里**内联**了同一段逻辑(没有调
`syncScrollable`)。那才是 H2 那条 A/B 测试真正的出口。顺带收益:那段内联逻辑**在每次
resize 通知里同步读 `scrollHeight`/`clientHeight`**,即在回调内部强制 layout;现在读全部
推迟进 `scheduleFollowSync` 的 rAF —— 对 H2 怀疑的那个「自喂环」本身也是减负。

⚠️ **换掉观测点是这件事的前提,不是附带**:那两个类是 H2 A/B 测试唯一的 DOM 出口,
直接删会把我们调查滚动冻结的一只眼睛弄瞎。新观测点改成直接量 `.chat-log` 的
`scrollTop`(RO 回调唯一且直接的产物),**零生产代码新增**,并先证明这只新眼睛看得见
缺陷(在未改动的代码上强制摘掉自观察 → 变红)才动的刀。开/关两边结论与改前逐条一致。

## 决策记录

T60(余额族归交付稿)、T61(按轮次存档)、T62–T64(S21 保三态 / S17 放放 / S29 不改),
以及 `run-error-catalog.md` §6.Z(R-051 只止血未对齐 S10)。

* fix(chat): 那一轮的余额存进它自己的失败事件,不再每次现查(T61 ④)

⚠️ **本条同时补齐 `f31c9b9b07`** —— 那条提交在这项工作在飞时把它的一半扫了进去
(带走了红测与 ProjectView 的新函数,没带走 contracts 字段声明与 effect 接线),
导致 HEAD 自己编译不过(5 条 `Property 'amrBalanceUsd' does not exist`)。
**补齐前不要拿 `f31c9b9b07` 单独出包。**

## 缺陷

「跑到一半死在钱上」那一轮,刷新之后卡还在,**但数字是「现在的余额」** ——
`ProjectView` 那个 effect 只要失败消息 id 有值就现查钱包,而刷新后这个 id 是从**已落库
的消息**重新算出来的。

充完值再回看那一轮:「剩余额度 $20.00 / 余额可能撑不完下一个任务」—— **数字是今天的、
句子是当时的**,两边对不上,比卡直接消失更误导。

产品要的是凭据(T61):「它就好像历史记录一样,存档在当时状态了」。

## 修法:写进那条已落库的失败事件(方案 a)

`PersistedAgentEvent` 的 `status` 成员加 `amrBalanceUsd?: number`。事件存的是自由 JSON
blob,daemon 对 status 事件**按引用原样 push、不做字段校验**,web 读回时裸 cast ——
不用数据库迁移。

**先例就在同一个函数里**:`chat-events.ts` 的 `appendErrorStatusEvent` 本来就会把后到的
事实(`code` / `failureCategory`)合并进同一条 error 事件。客户端补写派生数据不是新惯例。

## 为什么不是「让 daemon 判定时发出来」(方案 b)

我原本认为 b 更干净(数据在产生它的一侧被记录)。**这个判断在这个仓库里不成立**:

余额**不是 daemon 在失败时手上就有的东西** —— 它同样要去读一次,而且读的是 web 已经
钉好身份的那个钱包(团队 vs 个人,`amr-balance-gate.ts` 那段注释是拿真账号打出来的)。

代价对比:b 要改约 6 处、跨 2 个 app,因为 **daemon 自己的 SSE→落库映射是白名单**
(`chat-run-messages.ts:460` 只留 `kind`/`label`/`detail`),多发一个字段不会自动落库,
要在 daemon 和 web 两处各开一次口;还要在一条**已经在失败的路**上新增一次网络往返和
一个「读不到怎么办」的失败态。**换来的覆盖面完全一样。**

## 落地要点

- `amrInsufficientBalanceFailure` 一次返回 `{ messageId, archivedBalanceUsd }`,两者读**同一条
  事件** —— 不给「id 取这条、数字取那条」留缝。
- `stampAmrBalanceUsdOnFailure` **就地改那一条,长度不变,不碰 `runStatus`/`endedAt`,
  已有数字就原样返回同一引用** —— 三条都是为了活过
  `mergeMessageWriteForDaemonBacked`(按数组长度判缩短、按终态判回退)。
- effect 加「有存档就不问钱包」分支。顺带**减少**一次钱包读:重开历史失败会话不再查。

## 尺子自检 + 撤实现验红

修复前基线跑,尺子读出的正是缺陷本身:`expected '20' to be '0.35'`。
第三条红测的 `[undefined, undefined]` 额外说明:那条失败消息**本来就被 PUT 了两次**,
写路是活的,缺的只是字段。
另有一条**反向对照**修复前后都绿(「没存档过的那一轮照旧现查」)—— 有它才能说
「存档过就不查」是存档起的作用,而不是这一页根本发不出那次查询。

撤实现两处、逐条对应:撤「有存档就不查」→ 前两条红、第三条仍绿;撤「写回」那一行 →
只有第三条红。

## 两条边界

1. **「跑通了但余额低」盖不住**,刷新后仍会消失 —— 那一轮**没有 error 事件可挂**,
   要盖住得给 `messages` 加顶层字段(类型化列,要 `ALTER TABLE`)。**有意不做。**
2. **失败时客户端不在场**(run 在 daemon 里跑挂而窗口已关):第一次读数发生在重开时,
   存下来的就是那一刻的余额。**改前这一格每次都错,改后是错一次然后冻住** ——
   不是新引入的错,但值得知道。真要修只能走 b。

## ⚠️ 一个会骗人的验证坑,记在这里

**`pnpm --filter @open-design/web typecheck` 会假绿。** 改过 contracts dist 之后,
不带 `--force` 的 `tsc -b` 会用陈旧的 `.tsbuildinfo`,上面那 5 条错误**第一次跑一条都
没报**。判 contracts 相关改动一律用 `tsc -b --noEmit --force`。

* fix(daemon): 鉴权判定三处误判——平台凭据、协议违规、工具输出

产品裁决 2026-09-07:「跟 amr 相关的都要修」「工具输出肯定不算吧?」。

三条共同的病:**裸子串匹配**。而 `collectFailureText` 把 `error` / `agent` / `stderr`
事件全收进语料,于是任何地方冒出那几个词都能把用户送去登录页。

## 一、R-053:平台自己的凭据坏了,却让用户去登录

`run-error-catalog.md:215` 早就在册:「平台自己的 provider 凭据坏
`upstream_provider_unauthenticated` | **被误判成用户鉴权** | 「Sign-in required」(**错**)
| 用户可否自救:**不可**」。

上游原话是「Upstream provider credentials are missing or invalid」——说的是**平台的**凭据,
而裸的 `includes('unauthenticated')` 把它当成了用户的。用户重新登录一百次也没用。

改后落 `UPSTREAM_UNAVAILABLE` / `upstream_5xx`,**web 侧本来就认**
(`amr-guidance.ts:1643` 与 `:1267`,19 个 locale 现成)——不需要新码、不需要新文案。
顺带把同一个 vela switch 里的兄弟 `upstream_provider_forbidden` 一并归位(它原本落在
不透明的「Task failed」上)。

## 二、协议违规被判成「去登录」

`acp/session.ts` 报错时拼的 `invalid session/new response: …` 含子串 `invalid session`,
被当成 AMR 鉴权失败。**纯协议问题 → 告诉用户去登录。**

## 三、工具输出被判成「你要登录」(推翻冻结表 D1)

`npm ERR! you are not logged in` / `gh: not authenticated` / `curl 401 authentication
required` / Figma MCP token 过期 —— 用户看到「请登录」会去登 AMR,而坏的是他 shell 里
的 `gh`。**让用户做一件没用的事。**

⚠️ **D1 那行没有独立理由**,只有组标题 `adversarial: text whose service-signature may
not be the real cause` —— **作者自己的自认**。真正的理由在引入它那个 PR 的正文:那次的
主张是「只动码轴、不重新审判分类器语义」,这个错判是它**之前**就有的,只是被顺手对齐了码。
准确的码指向一张不准确的卡,卡还是不准确的。

## 判据:让它表达真正想认的东西,不用排除法

**7 个子串在 vela 仓里根本找不到出处** —— `not authenticated`、`login missing`、
`sign-in-again`、`sign-in required`、`signin required`、`expired token`、
`token has expired`(最后一个只存在于我们自己的测试夹具里)。和上一轮的 `invalid session`
是同一个物种:凭直觉写的同义词,从来没有任何真实报文长那样。**全部删除。**
`authentication required` 有出处,但那是 bifrost 的 per-user MCP 凭据库 ——
**另一个主体的凭据**,也删。

活下来的三条判据,都在说「这行报文的说话人是谁」:

1. **一个码是一个完整的码** —— `VELA_AUTH_CODE_PATTERN` 用边界前后瞻,
   `upstream_provider_unauthenticated` 和 `mcp_auth_required` 是**不同的码**,不是这些
   码加了前缀。
2. **`session` 是名词还是标识符的一截** —— `AUTH_SESSION_NOUN` 让 `session/new`、
   `sessionId`、`session_token_ttl` 是名字,`invalid session`、`Session expired.` 是名词。
3. **归属**:报文自己点名了凭据归属人就信它;谁都没点名,daemon 才可以补上「是 agent
   的」。点名有且只有两种形态:**工具调用信封**(词表复用已有的 `isToolErrorText`)和
   **程序自报名**(`gh:` / `curl:` / `npm ERR!` 这种 Unix 约定,是形态不是名单)。

⚠️ **「按来源分层」这条路走不通,查证后放弃**:`collectFailureText` 把所有来源拍成一个
扁平字符串;而且 D1 **根本不走 stderr** —— 它是 opencode 把工具失败塞进 JSON-RPC error
frame 送来的,ACP 桥接按设计只读 error frame。

## ⚠️ 反向用例逮住两次真的过度收紧(不是演习)

- `dsh: MISSING_CREDENTIAL: llm-deepseek: no API key…` —— **agent 自己穿着工具的外衣**,
  和 `gh: not authenticated` 位置、标点完全一样。**只有名字能分。**
  所以加了 `OWN_AGENT_COMMAND_NAMES`(36 个,每个 shipped adapter 的 id + bin)——
  **是我们自己人的白名单,不是工具黑名单**:方向反过来才成立,daemon 没起过的一律算外人。
  它没法在模块加载期从 `registry.ts` 派生(会成环),所以写成字面量 + **一条漂移守卫
  读真实的 `SHIPPED_AGENT_DEFS` 比对,漏一个就红**。
- `Unauthorized: OAuth token has expired` —— **状态短语被当成了说话人**。严重度标签
  (`Error:` / `warning:`)和状态短语一律拒绝:它们说的是「有多糟」「服务端答了什么」,
  不是「谁写的」。

## 落点与冻结表

工具鉴权失败落**既有的** `tool_error`,不需要新码 —— `mcp_auth_required` 今天就落在
那里,现在这一族归到同一个落点。web 走通用卡,**那颗「登录」按钮没了**,工具原文留在
折叠诊断区。⚠️ 放任它掉进 `process_exit/fatal_rpc_error` 会显示「模型服务商报错」——
那是另一种甩锅,`tool_error` 严格更好。要一张专属的「某个工具没登录」卡需要新 detail +
文案 ×19,**那要产品拍板,未动**。

新建第三张冻结表 `tool-vs-agent-auth-snapshot.test.ts`(33 条),每个 `*Before` 都是**实测**
值,并把「改前分类器」反向重建出来对拍 —— 基线不是谁的一句话。
`acp-service-failure.test.ts` **只有 D1 一行变**;`balance-vs-rate-limit-snapshot.test.ts`
**零变化**。

**余额分支未动**(产品说 BYOK 那边先不管)。

## 已知残留(写进表里,未硬造判据)

`Authentication required for Figma. Visit …/oauth to connect your account.` ——
**什么都没自报**:没信封、没前缀,"for Figma" 是散文。生产里它包在 `mcp_auth_required`
信封里发(已覆盖),裸着出现时任何判据都只能猜。

另记一处**测试里的假引用**:`vela-errors.test.ts` 曾声称 `apps/cli` 在 `run_test.go:180`
渲染 401 —— **那个文件不存在**。事实对但形状错,已更正为真实的 `ParseAPIError` 路径。

顺带发现 `details.kind: 'amr_account'` **全仓没有任何消费者**,web 完全按 `error.code` +
`failureDetail` 路由 —— 任何走 `details` 的方案都会是隐形的。

* fix(chat): 低余额那一档整档撤掉,余额 0 那张卡和弹窗都保留(T66)

产品裁决 2026-09-07。给出软档那张卡的截图后:

> 「**这个要不先不要了,跟产品说了一下,不要这个了**」

追问范围后:

> 「**余额为零的那个卡片要显示的,并且也要弹窗的**」

## 终态

| 档 | 终态 |
|---|---|
| 余额 `> 0` 且 `< $2` | **什么都不出**,直接放行 |
| 余额 `= $0` | 卡 + 弹窗都在,拦住发送(**既有行为,核实为保留而非新增**) |
| 跑到一半死在钱上 | 保留(T61 的凭据) |

## 从判定层摘掉,不是从渲染层挡掉

`AMR_LOW_BALANCE_WARN_USD` **删除**(核过没有其他消费者:不在埋点、不在契约、不在任何
闸门分档,唯一读取方就是它自己那两处比较)。余额 `> 0` 现在直接 `allow` ——
**没有第二条比较,这条路上一次套餐读数都不发。**

「什么都不出」因此是**判定层没有第二条线的结果**,而不是呈现层补写了一个 `null`。
把阈值调成 0 也能让卡不出现,但那会让「软档」这个概念以看不出来的方式活着。

## ⚠️ `soft` 混装了两件事,所以是换名不是改名

- ① 余额 `> 0` 但低 —— **这次撤掉**
- ② 余额 `= 0` 但硬拦让了位(**T55**:档次读不出来,由 Vela 入场兜底)—— **一个字没动**

②今天出 $0 那张卡、不拦、不弹窗。撤掉①之后 `soft` 就名不副实了,所以②改名
**`empty_not_blocked`**,并在类型和分支上写死「只在 `balance <= 0` 可达,正数永远到不了」。

## `whyLow` 保留,19 个 locale 一个没动

它还有一条活的来源:一轮跑到一半死在钱上、而**停下来时钱包还剩一点**(T61 的凭据,
如 `$0.35`),`UpgradeCard` 按 `balanceUsd > 0` 走的正是这句。有红测钉着
(`w62-mid-run-balance-wiring.test.tsx`「余额还剩一点的那一档,念的是真实读数,不是 0」)。

## 红测与验红

新增 `t66-low-balance-tier-retired.test.tsx`,**故意不 mock `checkAmrBalanceGate`** ——
喂钱包读数、判定用真的,否则「$1.20 该算哪一档」就掉在判据外面了。

- 先红:`$1.20:流水里不出现任何余额卡` → `expected '1.2' to be 'none'`
- 撤实现①(把低余额档接回去)→ **16 条红**(t66 六条 + w116 十条)
- 撤实现②(把硬拦档出卡那句 `if` 短路掉)→ **反向用例单条红**,证明它不是恒绿

**硬拦档是保留不是新增**,证据:那条反向用例「$0:卡在、弹窗也在、发送被拦」
**在改动之前就是绿的**;既有用例 `ProjectView.amr-balance-card.test.tsx:501` 也早就在。

删掉 `w116-amr-low-balance-card-tiers.test.tsx`(整份都在测「低余额卡对所有档位可见」,
那张卡没了)。它唯一还成立的红线 ——「这条路上不许多打一次套餐读数」—— 搬进了新红测,
档位扫描也作为回归护栏保留下来。

## 受影响的既有裁决

**T52 整条作废**;T51 适用范围缩小到硬拦档;T53 前半作废、后半(首页什么都不显示)
升为全局;**T61 ①②③④ 全部保留**,只是少了一条来源;2026-09-02「各只有一张」现在
只剩「额度耗尽」那一张;OPEND-2600 整条作废。四行都加了回指。

顺手把 `chat-panel-edge-audit.md` 两处指向已删常量的过期行标掉 —— 其中一行是
「2 → 5 待产品同意」的行动项,留着会有人去改一个不存在的常量。

## ⚠️ 一个改前就存在的自相矛盾,未动,值得产品看一眼

`empty_not_blocked` 那一档:余额 **$0**、档次读不出来、**run 照跑**,而卡上写着
「**现在无法开始新任务**」。原来的 `soft` 分支同样如此。按「保留既有行为、不自造规则」
处理,但它需要产品看真机截图后单独裁一条。

* fix(desktop): 关掉内层滚动容器的橡皮筋回弹(滚动冻结的缓解)

产品裁决 2026-09-07:「**直接关掉**」。

## 为什么

Electron 40 → 41 把 Chromium 从 144 跳到 **146,整个跳过了 145**,而
`kOverscrollEffectOnNonRootScrollers` 的默认值**正好在 145 从 DISABLED 翻成
ENABLED** —— 已拉 `branch-heads/7559`(M144)与 `7680`(M146)的
`cc/base/features.cc` 逐字核实。

它管的是「非根滚动容器撞到滚动边界时怎么表现」:145 之前只有整页会橡皮筋回弹,
之后聊天区这类内层容器也会。

我们在追的缺陷:聊天区的滚动范围被**永久冻**在某个早期内容高度上 —— 布局全对、
JS 程序性滚动能到底,**但滚轮和键盘都到不了**(scroll unification 之后两者都走
合成器)。位置(滚动边界)、平台(macOS 弹性 overscroll)、版本窗口三样都对得上。

## ⚠️ 这是缓解,不是根治

**因果链没有建立** —— 合成页面 89 个用例(精确复刻几何、顶部 overscroll、普通滚轮
与触控板 precise deltas、真实节奏、反复重建会话、RO 自喂环、`fill:both` 残留
transform、初始余量扫 0~100、向上格数扫 3~40)**一次都没撞出来**,而那个 harness
的量法是可信的(负对照:挂一个 `preventDefault` 的 wheel 监听,当场判成冻结)。

**判据仍然是 `client_chat_scroll_frozen` 的事件量**:带着这一行还在报,说明这条线
错了,该把这两个 feature 放回去、回到候选列表找别的。这也是为什么同一批把探针的
会话级上报上限整个删掉了 —— 判据要靠事件量,报满三条就变瞎的话这条判据立不住。

## 代价

macOS 上**所有内层滚动区失去橡皮筋回弹**(整页仍然弹)。产品知情并选择了它 ——
相对「滚不动」这个代价可以接受。

## 落点

`apps/desktop` 与 `apps/packaged` 两处都加,理由同旁边那条
`ignore-connections-limit` 的注释:打包外壳可能比它捆绑的 desktop 构建活得久。
必须在 `whenReady()` 之前 —— Chromium 在会话初始化时就消费这些开关。

实验分支 `exp/disable-overscroll-features`(处理组包 `0.21.5-beta.1`)随之失去意义,
其 A/B 对照不再需要跑。

* feat(chat): 下线意图澄清里的设计风格选择卡片 (OPEND-2760)

提示词侧不再让 agent 出「设计风格」这一类 question-form:
direction-picker / discovery-question-form 两个 atom 的 SKILL 与
daemon/contracts 的 prompts 同步删掉该问法。前端 visual-style-catalog
与 visual-style-deck 保留但不再被澄清轮引用,组件代码加注释说明可能找回。

用户裁决:「这些代码先讲提示词干掉, 组件代码注释, 后续可能要找回」。
od-next 策略分支核对过,其澄清轮本来就不枚举问题类型,无需改动。

* fix(chat): 报错卡片统一主 CTA 为切换到 OpenDesign Cloud (OPEND-2772, OPEND-2745)

2772:BYOK/自带 CLI 的各类报错原本各出各的卡片 —— 独立的 AMR 推荐卡叠在
错误卡下面,一屏两张卡两个主按钮。现在合并成一张:主 CTA 一律是「切换到
OpenDesign Cloud」,铺到所有会由用户自己的模型服务引发的报错码。
删掉 AmrGuidance 组件与 .amr-card__* 局部样式,推荐语并入统一卡片。

2745:宿主自己写的卡片(余额、报错)此前会被当成一次 run 渲染,于是跟着
出「进行中 / 已完成」这类运行状态。新增 assistantMessageNeverHadARun
把「这条消息从来没有过 run」判出来,该行整行不渲染。

用户裁决:「2772 的『统一』是『铺到所有报错』, 主 cta 都是切换至 cloud」。

* fix(chat): 团队成员充值弹窗按设计稿重做,并补回个人档的升级入口 (OPEND-2720)

弹窗侧:AmrOwnerTopUpDialog 原本没有 banner,标题 12px 左对齐、CTA 只有
58px 宽。按设计稿重做为 480 宽卡片 + 480x205.71 出血 banner(复用既有的
/upgrade/cloud-signin-aurora.jpg,与 AmrBalanceDialog 同一张,不新增资源)、
18px 居中标题、满宽 40px 主按钮,owner 名字加粗。新增的 4 个 --chat-* 变量
亮暗两个作用域都定义。CTA 的样式覆写写成 .modal .cta,靠特异性压过共享
Button 基类,不依赖打包顺序。

升级入口侧:workspaceUpgradeUrl 此前一律要求 canManageBilling,而该权限
是 readable && role==='owner' 的团队语义。个人 workspace 只有一个成员、
钱包就是本人的,这道闸把用户自己的付款路径删掉了,弹窗弹出来却没有落点。
新增 canReachWorkspaceBillingEntrance 放在 buildWorkspacePermissions 旁边
(团队仍由该权限位把守,B 端也会服务端拒绝),弹窗分支与链接解析共用它,
两边不会再各说各话。

说明:打包版走 vela directory 分支,个人档 role 解析为 owner,因此线上
不受影响;本地 tools-dev / 自建 daemon 走「请求头即权威」分支才会踩到。

* docs(chat-panel): 同步裁决表、边界审计与运行错误清单

记录本轮四条裁决:设计风格卡片下线、报错卡片统一 CTA、宿主自写卡片不报
运行状态、个人档升级入口的权限语义。

* chore(specs): 把 Plane 工单证据附件移出仓库

18 个附件(截图 + 一个诊断 zip)共 40MB,其中 13 个单文件超过 CI 的
1MB 变更文件上限,Static gate 因此常红。这些附件的权威源本来就是 Plane,
仓库里没有任何地方按文件名引用它们 —— 只有一句话指向 manifest。

manifest.md 保留:它记着每个附件对应哪个工单、当初取到没取到,
要看原图去 Plane 拿。本地副本在 ~/Documents/od-plane-evidence/。

* fix(prompts): 把「视觉方向不是一个可以问用户的问题」补回 discovery 首层 (OPEND-2760)

2760 下线设计风格卡片时,把 `direction-cards` 从问题类型清单里删掉了,
但同时也删掉了唯一那条无条件禁令,只剩「有活跃设计体系时不许发」那条带
前提的。于是类型没了、禁令也没了 —— 模型完全可能自己即兴发一个方向选择
表单,而那正是 2760 要消灭的东西。

放在 discovery 的 core-directives 首层(「read first — these override
anything later」),不放在 system.ts:那条带前提的句子在
ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE 里,只有存在设计体系时才
入 prompt,怎么改都救不了没有设计体系的那条路径。也不放「Structured
clarification」段:daemon 侧那段被 slim 变体跳过,而 slim 是生产默认值。

措辞只点行为、不点已下线的类型名 —— `question-form-visual-style-retired`
那条守卫连否定句都不许留(「一句『不要用 X』同时也在告诉模型『有个 X
可以用』」)。末句保留 `brand` 问题的豁免:它问的是「有没有品牌/参考来源」,
不是方向选择器,几条测试钉着它。

快照 15 处 totalChars 各 +429,恰好是走 discovery 层的 15 个场景;
不走该层的 5 个场景一字未变。

* fix(chat-mirror): 验收陈列页一直在拿回退字体量几何

mirror-exec.html 全文没有一条 @font-face,也不引外部样式,而 measure.mjs
用裸静态服务器托管它 —— 它声明的 "Albert Sans" 从来没被加载过,所有几何
读数都是在 PingFang SC 上量的。对稿子的 geom 差异 680,其中 61 条是这个
系统性偏差造出来的假差:22/90 格读数受影响,格 16/17/87/27/66/71 报的
「实现没对上」全部是假象(同一颗按钮被重复报了 3 次),而格 43 有一条真
差异一直被错字体正好抵消掉。

不把字体字节提交进去:同一份字节 apps/web/public/fonts/ 里已经有了,再复制
一份进 HTML 是 +423KB,还会撑破 CI 的单文件 1MB 上限。改成工具进仓库、
字节留本地:

- inline-fonts.mjs  从 base.css 现场解析 @font-face 整段搬过来,只把
  url() 换成同一份字节的 data: URI。描述符一个字不抄,「逐字一致」由构造
  保证 —— 包括 JiduMono Pro 那个只有 Regular 一份字节却要声明成 500 的
  描述符(两处只改一半就是坏的)。带 --strip 还原。
- check-fonts.mjs  量数前的前置闸。判据是差分(同一段文本带/不带该字族
  的宽度必须不等)加一个反向对照(不存在的字族差分必须为 0),不用
  document.fonts.check() —— 它在零 @font-face 时返回 true,真空成立,
  正好放过这次要抓的坏法。
- 生成器不注字体,改为在页顶留一条「还没上字体」的横幅,并让注入块把它
  藏掉 —— 「有字体」和「横幅不见了」因此是同一件事的两种表现。横幅走正常
  文档流,而 geom 是格内相对坐标,动不到任何一格的读数。

页面同时带上了内容跟进:提交版是很久以前生成的,这次重新生成把 tokens.css
后来落地的那几段带了进来。

* docs(collab): 写明项目页那个 role='member' 是只读态的承重实现

它看起来像「还没接上真实角色」的占位符,实际是「创建者可写 / 非创建者只读」
的全部实现:role 钉在 member ⟹ privileged 恒 false ⟹ canMutate 退化成
selfCreated。谁把它「修好」成真实角色,工作区 owner 就对工作区里每一个项目
都可写(包括不是他建的),只读态当场消失,而且没有任何测试会红。

同一条路径也不许改成去查目录或等 /api/workspace/context:只读态在首屏渲染
路径上,先渲染错的身份再纠正同样算回退。

两处一起写,免得只改一边的人看不到另一边:生产者(project-workspace-scope)
和消费者(workspaceResourceAccess)各一段,互相点名。

真正的缺陷不在这个硬编码,而在付款入口误用了它 —— 一个声明过「我不解析身份」
的上下文被拿去判身份,导致团队 owner 在项目页被降级。那条链另行修复。

* test: 修两条 CI 红,一条是误提交的红测,一条是编出来的夹具

**od-next coordinator**:那条红测是更早一次调查里「故意留红、明说不提交」的
(07bd6d9149),被我在提交 2760 时按文件名一起扫进了 501eb5640a —— 那次提交
对 od-next 只动了这一个测试文件,零源码改动,提交说明自己还写着「od-next…
无需改动」。

它断言的也不是仍然成立的不变量,而是被裁决推迟的产品行为:
chat-panel-issue-log-2026-08-28.md:58「不隐藏失败卡、不做关键词猜测。未来若要
支持 Design 模式纯问答,需显式 structured intent,作为独立产品设计」。实测复核
过:放开 deliverableValid 之后这条仍然 blocked(第二道 fail-closed 闸挡着),
而邻居那条「拒绝把 silent no-op 洗成 completed」立刻转红 —— 两条 fixture 除了
agent 那段散文输入完全相同,只有靠猜文字才能分开,正是裁决点名禁止的。
改成 it.skip,正文原样保留,docblock 记下裁决和实测结论。

**e2e AMR 认证收敛**:不是 2772 波及,真凶是 ff2b46fe7f(鉴权判定三处误判)把
子串匹配换成了归因判据。而假 vela 发的「Your authentication token has expired.
Please sign in again.」这句话在真实 vela 仓库里零命中 —— 是夹具编出来的。
换成实测验证过的真实报文(vela API 401 → CLI ParseAPIError →「API request
failed with status 401: unauthenticated」),断言一个字没动,守卫反而更强了。
改回旧夹具能复现 CI 那条失败,因果链是量出来的。

无产品代码改动。

* fix(chat): 项目页的付款入口改读权威工作区身份 (OPEND-2720)

真机六格矩阵挂了两格,根因是一个「声明过自己不解析身份」的上下文被拿去判
身份:项目页的 workspace-scope 走本地快路径,role 恒为 member(那是「创建者
可写 / 非创建者只读」的实现,见该处注释),而余额弹窗分支、卡片 URL、弹窗
URL 三处都从它读 role。

后果:
- 团队 owner 在项目页被判成 member,拿到一张没有升级按钮的「请联系团队所有者
  充值」—— 他自己就是所有者(矩阵 B 格)
- 个人档 max 用户,卡片跳 billing=plan、弹窗跳 billing=auto-recharge,两者
  各说各话(矩阵 C 格),源码注释早就把这称作缺陷

修法是 merge never swap,不是换掉上下文:新增纯函数
workspaceBillingAuthorityContext(scoped, authoritative),只采纳 role 一位,
并用 scope 自己的 lifecycleState/memberStatus 重算权限(冻结的项目不会因为
「你是 owner」被解冻);要求权威上下文的 workspaceId / workspaceType /
workspaceMemberId 三者全等且成员在职,否则原样返回 scope。属性测试遍历各种
权威候选,断言结果的工作区身份永远等于 scope 的 —— 「绝不掉回个人钱包」那道
防线的前提是身份能被换掉,而这里没有能换掉它的入口。

daemon 源码一行未改:scope 继续走零网络的本地快路径,只读态判定不受影响。
新增守卫钉住「非创建者进入别人建的项目 = 只读」这条此前零测试保护的不变量,
并断言该判定不发起任何网络请求(把 fetch 换成会抛的 spy 验的)。

顺带修好一格:个人项目在「环境里选中另一个工作区」时,弹窗以前会把人送去别的
工作区的账单页,现在钉在项目自己的工作区上。

* test(e2e): 三条 UI P0 判据跟上已裁决的行为

**余额那条不是 2772 弄红的** —— CI 时间线钉死:f8bd7d5dc0(09-04 10:44)绿,
bd5ddea74e(09-04 14:48,OPEND-2597 把升级卡的回填改指
/api/workspace/billing?scope=workspace)之后,a553a7fa31(09-05 02:02)就红了,
而 2772 是 09-07 才落地的。夹具原先没给账号级钱包接口打桩,回填读失败,测试
一直在跑「钱包读不出数字就把白卡还回来」那条 fallback;2597 改指的那个接口夹具
恰好有桩($20.00),fallback 就不再触发。

「Top up」入口本身没被删(ChatPane.tsx:4349-4388),变的是什么时候画那张卡:
余额不足时白色报错卡把回合让给升级卡(T60 产品裁决「额度不足和额度耗尽,升级卡
各只有一张,不存在第二张白色通用报错卡」),充值卡只在升级卡接不住时回来。
失败那一刻的 CI 无障碍快照就是这个形状:只有 Balance left $20.00 + 一颗
Upgrade,没有白卡也没有 Cloud CTA。判据改成钉住这个已发布的形状。

**另外两条确实是 2772 弄红的**::728 / :896 那两条「不推荐 AMR」期望 0 个
Cloud CTA。T68 裁决「铺到所有报错」正是要把它铺开,所以断言翻成 1。2772 更新了
8 个单测文件却一个 e2e 文件都没更,这是漏网。同族还有 :888 那条 zh-CN 页面却
断言英文按钮名(翻转前就恒真),一并改成 zh-CN 名。

**api-empty-response 不是 flaky**:8d0b542d0a 把共享弹窗遮罩的 z-index 从 100
提到 1500,盖住了 z-index 120 的标签栏,点击卡在可操作性检查上 113 秒才超时,
报出来的 page.goto 只是超时展开后的下一个动作。补 suppressWhatsNew。

无产品代码改动。

* fix(e2e): 给导轨开合的点击加超时上界;去掉账单身份里那条死判断

**导轨点击**:`openNewProjectModal` 把 `ensureRailOpen(page).catch(() => {})`
包起来,注释写着「绝不允许它让流程失败」。但里面那个 `toggle.click()` 是裸调用,
而 playwright.config.ts 全文没设 `actionTimeout`,于是吃 Playwright 的默认值 0
(无上限)—— 点击一旦被遮挡就不会 reject 给外层 catch,而是一直挂到整条测试
超时,失败还记在超时展开后的下一个动作上,完全指不到真凶。实际发生过一次:
共享弹窗遮罩的 z-index 从 100 提到 1500 之后盖住了 z-index 120 的 chrome,
一条 UI P0 因此耗掉 120 秒。

定成 T.short(本机 3s / CI 6s,现成常量,自带 CI 缩放)。不取 T.medium 是因为
test:ui:extended 那条 lane 预算就是 10s,10s 的上界不成其为上界。点击前一行刚
断言过可见,剩下只有 stability / hit-target 轮询,而本仓库 UI 过渡预算约 200ms,
3s 有一个数量级余量。仓库里已有同形先例(app.test.ts:1005)。

**账单身份**:workspaceBillingAuthorityContext 原先要求权威上下文的 memberStatus
是 active 才采纳角色。产品目前没有「移出成员」的入口,这个条件在现实中不可达;
即便将来可达,残留影响也只是被移出的人还看得见一个升级链接,而钱的动作后端另有
强制。去掉,注释写明理由而不是留个哑口。

顺带补了一条真正承重的守卫:采纳角色不许复活已关闭的成员 —— scope 自己报
removed 时,即便采纳了 owner 角色,权限位仍为 false。它和原有的「采纳角色不许
解冻」是同一道防线的两半。两条都撤实现验过会红。

身份三位全等那条一个字没动,属性测试照旧全绿。

* test(e2e): 只读判据跟上 OPEND-2624 的裁决;八条 spec 补上更新弹窗抑制

**只读那条不是回退,是判据过期。**project-management-flows:426 断言的是
「scope 已答 personal、/collab/status 还没答 → 仍须只读」,而 a17a22e32a
(2026-09-04,OPEND-2624)故意改掉了这个行为:daemon 一确认 personal 就立刻
给写权限,不再吊在 status 上等 —— 修的正是「用户自己建的私人草稿被显示成
共享只读,聊天/上传/编辑/导出全灰」。那次加了 jsdom 裁决测试,却没同步改这条
e2e。它此后一直靠抢跑维持绿:抢在 React 把 scope 响应上屏之前读一次旧 DOM。
trace 显示这次预览渲染花了 126ms,抢输了,首跑和重试都输。

两个 SHA 之间这条链的产品代码逐字节相同(collab/、ChatPane、ChatComposer、
FileWorkspace、FileViewer、App、composer 全部 diff 为空),所以不可能是产品
造成的行为差异。判据反转,416/417 那两条 fail-closed 原样保留 —— 它们是真的
(scope 未落定 → workspaceContextLoading → 只读)。新写法用 not.toHaveAttribute
会一直轮询到属性消失,顺带把竞态本身也消掉了,不是换个方向再赌一次。

**更新弹窗抑制**:CI 上 daemon 把自己认成打包版(app-version.ts 的 linux 分支
判 execPath 含 /opt/,而 runner 的 node 在 /opt/hostedtoolcache/),channel 因此
解析成 stable,真去 R2 拉了发版卡片;再叠上 8d0b542d0a 把共享弹窗遮罩 z-index
从 100 提到 1500、盖住 z-index 120 的 chrome,点击就永久挂在可操作性检查上。
两个条件缺一不可,失败还记在超时展开后的下一个动作上,完全指不到真凶。

逐个核过「这个 spec 的路由网能不能吃掉 /api/whats-new」,四条已被自己的兜底大网
挡住(design-systems-manager / settings-media-providers / visual-entry /
visual-navigation),给它们再加是噪音;真正暴露的八条已收掉。也确认了没有任何
spec 是故意要看这张卡片的。

mock-factory 里那句「fixture 也装了这条路由」原本只对 vela-status 成立,紧跟在
suppressWhatsNew 后面容易被读成两条都装 —— 点明 whats-new 不由 fixture 装,
否则下一个人会照着把这八处删掉。

daemon 那条 typecheck:ProjectWorkspaceScope 有 unbound 和 unavailable 两个
context 为 null 的变体,原代码只排除了一个。

* fix(prompts): 把设计风格选择整题下线,并修好那条永远不会红的守卫 (OPEND-2760)

前两轮只删掉了 `direction-cards` 这个类型。抓 daemon 真实写给 agent 的 stdin
字节发现,线上默认路径(slim)里还留着两处:一道 `tone` 心情多选题
(editorial / minimal / playful / tech / luxury / brutalist / human),以及
把「brand or visual style」列进澄清优先级。产品裁决原话是「把提示词里让 agent
感知到 question-form 能出设计风格的那些提示词下掉」,追加「tone 也下掉」。

**上一轮补的那句禁令从来没到过模型**:它落在 discovery 层,而
`server.ts` 默认 `promptCoreVariant: 'slim'`,`system.ts` 把整层 gated 在
`!isSlimCore` 后面。实测删掉它,默认路径抓包一个字节都没变。所以这次删掉它,
并退役 `system-prompt-api-mode.test.ts` 里那条要求提示词包含
'Do not emit a direction question-form' 的断言 —— 那条来自 #2579,当年方向
选择器还在,是搭便车的历史遗留。仓库自己的原则写着「连否定句都不许留:一句
『不要用 X』同时也在告诉模型『有个 X 可以用』」。

**守卫过去永远不可能变红。**它写的是 not.toMatch(/`tone`/),而 core-slim
是模板字符串,源码里的形态是 \`tone\`(反引号被转义),两者永不相交。修法不是
逐条补转义(那要求以后每个人都记得补),而是先把源码还原成模型真正读到的样子
再匹配,整份文件一次性摆脱这个盲区。同时发现第二类同形失效:按短语找的正则
会被行内反引号打断(`visual-style` catalog),另给一个去反引号的读法。

还有个比转义更致命的覆盖面问题:`tone` 那条以前只扫两份 discovery.ts,而那道题
真正住在 core-slim.ts —— 少扫的恰好是唯一有货的那份。现在按 PROMPT_PATHS 七条
全扫,并补上题 id / 散文点名 / 本地化文案三种形态。

证据(同一台 daemon、同一个项目、同一条请求上成对 before/after,所以别人在飞的
改动在两次抓包里都在、做差抵消):`tone` 2→0,`visual style` 1→0,字节
46,156→45,938,差 218 = 23+67+128,和三处删除逐字符对得上;完整文本 diff 只有
这三行。classic 路径另抓一对:`视觉调性` 1→0。快照 17 行 totalChars,零其它变化。

保留的两处是正面指令,不是选择器:core-slim「无设计体系时自己从 direction
library 里选,不要再问用户」、directions.ts「按 brief 的 tone 挑」。

* test(e2e): 让 e2e 的 daemon 自称 development,并把更新弹窗抑制挪进共享 fixture

CI 上的 daemon 会把自己认成打包版:app-version.ts 的 linux 分支判 execPath 含
/opt/,而 GitHub runner 的 node 就在 /opt/hostedtoolcache/node/…/bin/node。
channel 因此兜底成 stable,whats-new 那条真去 R2 拉了发版卡片;再叠上共享弹窗
遮罩 z-index 从 100 提到 1500 盖住 chrome,点击就永久挂在可操作性检查上。
这正好违背 app-version.ts 自己 33-37 行写的意图:「development/CI builds
resolve to no card so the card never intrudes on tests」。

不碰 app-version.ts 的 /opt/ 判定(那是产品代码、影响打包版身份识别),改成在
e2e 自己起 daemon 的环境里设 OD_RELEASE_CHANNEL=development。设在
runToolsDevJson 这个唯一咽喉处 —— Playwright worker 套件、Vitest smoke 套件、
collab-cluster、artifact-render-parity 全从这里过,而且它本来就管着同一类 env
(CODEX_HOME / OD_DATA_DIR / OD_MEDIA_CONFIG_DIR)。只在 e2e/lib/ 下,开发者的
pnpm tools-dev 和打包版都不受影响。常量排在 process.env 之后、extraEnv 之前:
外部 shell 值拖不进卡片,而想测发布通道的 spec 仍可自己传。

证据不是「我设了变量」:新测试直接 import harness 的 env 组装函数和 daemon 的
resolveAppVersionInfo/whatsNewSourceUrl,首条就是红基线(/opt/ 路径确实解析成
packaged 且真的拿到卡片文档);另外真起了两次 daemon —— 默认 → channel
development 且 /api/whats-new 空,对照组 OD_RELEASE_CHANNEL=beta → channel
beta 且拿回真实 R2 卡片。macOS 默认那次单独不能证明什么(macOS 两种情况都不算
打包版),beta 那个对照才是证明值真的穿过了整条链。

顺带查出 channel 共有六个消费者而不是两个:whats-new、installer-apply 观测
(e2e 下无状态,惰性)、langfuse 桥、诊断导出、任务观测导出,以及「关于」面板
的展示文本。前四个是遥测归因,方向一致(CI 不再自称 stable);「关于」面板是
唯一测试可见的,但读它的三条 spec 都自己打桩了 /api/version,没有任何断言依赖
daemon 解析出来的 channel。

弹窗抑制挪进 suite 的 auto fixture(auto fixture 先于 spec 的 hook 建立,想看
卡片的 spec 后注册可以盖掉)。八个 spec 里逐个加的调用保留:fixture 只绑内置
page,自己开新 page/context 的 spec 盖不到 —— 这正是 applyStandardMocks 也留着
自己那份的原因。两条防的是不同场景:设 channel 让 daemon 根本不去取卡片(CI
的失败),fixture 防的是 daemon 真给了卡片时页面还是看不见(本地恰好被认成打包
版,或机器上带着 OD_WHATS_NEW_URL —— 那个变量会直接盖过 channel 判断)。

* fix(collab): 给「这两个上下文是不是同一个人」一个名字,修掉它造成的五处缺陷

项目页的 workspace scope 走本地快路径,role 恒为 member(承重结构,是「创建者
可写/非创建者只读」的实现);壳层的 /api/workspace/context 给真实角色。两侧
context 的其余位都一样 —— 缓存 key 里的 canShareProjects/canWriteSyncedFiles
只看 memberStatus+lifecycleState 不看 role —— 所以两侧 key 有且仅有 role 一位
可能不同,真实角色是 owner/admin 才错位。这正是「为什么只有 owner 中招」。

这已经是同一根因的第 5、6、7 次,每次都是某人在某处顺手拿 role 比 key。所以
先给它一个能被测试钉住的名字:workspacePrincipalKey / isSameWorkspacePrincipal
放在 contracts,和 workspaceBillingAuthorityContext 并排(后者本来就在手写这个
比较,现在也改成走它,于是全仓只有一处定义)。docblock 列了全部七处已知实例,
「你为什么没走这个函数」在评审里答得出来。

刻意不比 memberStatus / lifecycleState:两侧描述的是**不同对象**的状态 ——
scope 的 lifecycleState 是从项目 resourceState 推的,壳层报的是工作区的。
一个活跃工作区里的冻结项目,两者本来就该不一致。那是身份的状态,不是身份。

修掉的五处:
- AvatarMenu:在项目页点一个被套餐锁住的模型,tooltip 写着「升级即可使用」,
  点下去什么都不发生。它既要合并权威身份(团队 owner),又要改用
  canReachWorkspaceBillingEntrance(个人档豁免)—— 只做一半就只修一半。
  :301-303 那句「个人工作区不受影响」的注释从 356c8c364f 落地第一天就是错的,
  一并改对。
- projects.ts:437 团队 owner 从目录卡片点开某些项目直接报「项目不存在」,
  硬失败无兜底;:591 首开团队项目的渐进快车道对 owner/admin 完全死掉。
- project-display-cache:owner 在项目页改了分享状态,首页网格的快照永远不被
  标脏,回首页看到旧状态 —— 唯一会给用户看错数据的一条。标脏和 patch 两个
  入口都改,只改具名那个会在另外两个入口留同样的 bug。
- useProjectWorkspaceScope:bootstrap 已经付过钱的 scope 种不进去,每次开项目
  多一次 GET 加一段 fail-closed 骨架。

两个夹具是生产中不可能出现的形状(scope 端点返回 role:'owner',而 daemon 那条
路由只有一个分支、永远写死 member),改成真实形状之后,两条一直在绿的既有用例
当场变红 —— 它们此前是靠假夹具过的。新的根不变量测试用 buildWorkspacePermissions
推导权限而不是手写,免得夹具再悄悄漂回去。

daemon 源码一行未改,只读态判定不受影响,不引入任何网络等待。

---------

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

5.6 KiB

First-party atom catalog

The atomic capabilities OpenDesign exposes to plugins. Spec: docs/plugins-spec.md §10. Source of truth: apps/daemon/src/plugins/atoms.ts. Live discovery: GET /api/atoms, od atoms list --json, and od atoms info <id> for the bundled SKILL.md body.

A plugin assembles atoms into ordered stages (od.pipeline.stages[].atoms[]). The OpenDesign daemon is responsible for resolving each atom into a system-prompt fragment, tool gating, and (when applicable) GenUI surface declarations. Plugins never own the atom implementations; they only reference them by id.

Reading this document

  • id — what you write inside od.pipeline.stages[*].atoms[] and od.context.atoms[]. Stable across daemon versions.
  • statusimplemented or planned. Every entry in the current catalog is implemented; the type remains available so a future reserved id can be discovered before promotion.
  • task kinds — which of the four product scenarios (new-generation, code-migration, figma-migration, tune-collab) the atom is intended for. Plugins may reference an atom outside its declared task kinds, but doctor flags this as suspicious.

Implemented atoms

id label task kinds
discovery-question-form Discovery question form — structured clarification protocol for unresolved material requirements on any turn. new-generation, tune-collab
direction-picker Direction picker — optional Host-owned visual catalog when the user explicitly requests alternatives. new-generation, tune-collab
todo-write Todo write — TodoWrite-driven plan. all
file-read / file-write / file-edit File ops on the project cwd. all
research-search Research search — Tavily-backed shallow research. new-generation
media-image / media-video / media-audio Media generation through configured providers. new-generation, tune-collab
live-artifact Create / refresh live artifacts. new-generation, tune-collab
connector Composio connector tool calls. new-generation, tune-collab
critique-theater 5-dimension panel critique; emits the critique.score signal that drives devloop convergence. all
code-import Clone / read existing repo. code-migration
design-extract Extract design tokens from source code / Figma / screenshots. code-migration, figma-migration
figma-extract Extract Figma node tree + tokens + assets. figma-migration
token-map Map extracted tokens onto the active design system. code-migration, figma-migration
rewrite-plan Long-running multi-file rewrite plan. code-migration, tune-collab
patch-edit Small-step file patches. code-migration, tune-collab
build-test Run build/typecheck/tests and produce build/test convergence signals. code-migration
diff-review Render rewrite as a reviewable diff. code-migration, tune-collab
handoff Push artifact to downstream surfaces (cli / cloud / desktop). tune-collab

How the daemon resolves an atom

  1. The plugin manifest's od.pipeline.stages[*].atoms[] is parsed into a PipelineStage[] by apps/daemon/src/plugins/pipeline.ts.
  2. Before the run starts, the daemon resolves bundled atom instruction bodies through apps/daemon/src/plugins/atom-bodies.ts and renders the available bodies into ## Active stage prompt blocks. Those bodies live under plugins/_official/atoms/<atom>/SKILL.md.
  3. At run time, apps/daemon/src/plugins/pipeline-runner.ts walks the stages. For each stage entry it:
    • emits a pipeline_stage_started SSE event,
    • asks the atom-worker registry in apps/daemon/src/plugins/atoms/registry.ts for daemon-observable signals (the built-in registry has a worker for every catalog entry and reads real Critique Theater scores when present),
    • persists one row into run_devloop_iterations for audit,
    • emits a pipeline_stage_completed event with the resulting signals.

Atoms whose work happens inside the selected agent CLI may use the registry's permissive compatibility signals because the daemon has no independent observation for that tool action. This is distinct from the old global stub; OD_PIPELINE_RUNNER=stub exists only as a diagnostic/replay escape hatch.

Atom signals + the until vocabulary

The current until vocabulary is:

  • critique.score — emitted by critique-theater.
  • iterations — built-in per-stage counter.
  • user.confirmed — emitted when a confirmation GenUI surface resolves.
  • preview.ok — emitted by the live-artifact preview pipeline.
  • build.passing — emitted by the build-test flow for the build/typecheck gate.
  • tests.passing — emitted by the build-test flow for the test gate.

The evaluator is deliberately closed and is not arbitrary JavaScript. Unknown signals fail parsing and od plugin doctor reports them.

Adding a new atom

  1. Author the atom out-of-tree as a plugin (per spec §22.5 promotion path).
  2. Once the SKILL.md / MCP tool / pipeline shape stabilises, add the bundled atom under plugins/_official/atoms/<id>/, append the matching row to FIRST_PARTY_ATOMS, and register a worker when the daemon has a real signal it can observe.
  3. Update this document and the spec §10 / §21 / §23 tables in the same PR.
  4. The atom is now reachable via:
    • od.pipeline.stages[*].atoms[] references in any plugin,
    • GET /api/atoms discovery,
    • od atoms list/show/info,
    • od plugin doctor validation.