Commit Graph

523 Commits

Author SHA1 Message Date
Ray Xi 6fd2f60802 fix(web): answer an empty wallet on Home before the project hand-off (OPEND-3300, OPEND-3309) (#8240)
* fix(web): answer an empty wallet on Home before the project hand-off

A Home send opened the optimistic project frame on the click tick and only
then ran the OpenDesign Cloud balance gate, so a $0 wallet produced a
"准备中" frame first and the balance dialog seconds later — on a Team
workspace up to ~6s, since the gate forces an authoritative upstream read
with retries (OPEND-3300, OPEND-3309).

The shell already holds the wallet reading it shows in the rail. When that
reading is a definitive $0 for the exact send scope, the dialog now opens on
Home immediately: no frame, no project, no request. One background
confirmation re-reads the wallet; only a fundable answer moves the send onto
the ordinary hand-off, and a dismiss in the meantime wins.

Red specs: EntryShell.amr-pending-handoff (OPEND-3300 cases, red on the
unpatched shell) and e2e/ui/home-amr-pending (blocked case now on Home, plus
the stale-$0-then-fundable case).

* fix(web): wait for the workspace identity read before gating a Home AMR send

A Team member with no billing permission and a $0 Team wallet was shown the
personal upgrade dialog on Home while the project page correctly asked them
to find the owner. Home's balance gate read the shell's exact workspace
identity at the click; while that read was still in flight (cold start, a
switch or a sign-in resolving) it had no scope, fell back to the ACCOUNT
wallet and, with no context to name an audience, chose the owner branch.

`awaitHomeAmrGateWorkspaceContext` now holds the send until the read the
shell already has in flight settles (bounded; a read that never settles
returns the draft with the existing "couldn't confirm balance" notice). Send
still never starts identity discovery, and a shell that has settled without a
workspace identity keeps the legacy account-scoped gate.

Pins: settled Team member → owner dialog + Team scope; identity in flight →
the gate waits and then scopes the Team wallet; never settles → notice, no
gate, no dialog.

* fixer: address PR #8240 follow-up items

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* Revert the Home gate identity wait and its test adaptations

Reverts 8ee3a105c1 ("wait for the workspace identity read before gating a Home
AMR send") and 0fdd577c1c (the fixer's test changes on top of it), restoring
the approved state of this PR.

The change rested on an unconfirmed reading of a QA report and altered a
deliberately pinned behavior without a product decision: the existing pin
"starts the team gate from in-memory directory identity when sessionStorage
fails" had to be rewritten into "waits for the team identity before gating"
to make CI pass. Both commits are kept on
fix/opend-3300-home-gate-identity-wait until the tester's runtime readout and
the decision are in.

* fix(web): preserve Team authority in local Home balance gate

Forward the selected workspace type and role to the local context endpoint so a Team member or admin with an empty wallet sees the owner top-up dialog on Home. Cover both roles with a daemon-style context response.

---------

Co-authored-by: Amy <1184569493@qq.com>
2026-09-18 10:27:25 +00:00
Ray Xi 3c7b16b442 fix(web): keep the project split still across the Home hand-off (OPEND-3207) (#8229)
* fix(web): keep the project split still across the Home hand-off

The optimistic creation frame left `.split` on its stylesheet default
(460px) while ProjectView resolved the saved width, or the equal split of
the container, only after it mounted — and that first write ran through
the 200ms width transition, so the chat column moved once the real view
arrived (OPEND-3207).

Move the split geometry into `project-split-layout.ts`, have both surfaces
resolve their first-paint width through `resolveProjectSplitLayout`, and
settle a surface's first measured write without the transition.

Red spec: e2e/ui/home-send-split-width.test.ts (red on main, green here).

* test(web): read the 4px handle constant from project-split-layout

The material spec pinned `SPLIT_RESIZE_HANDLE_WIDTH = 4` in ProjectView's
source; the constant now lives in project-split-layout.ts and ProjectView
imports it, so assert on the module and on the import.

* test(cms): wait for the Test modal impression before switching language

The locale case only passed while the impression frame never fired: on a
fast machine the next language switch unmounted the modal before its
requestAnimationFrame recorded the impression. CI is slow enough for the
frame to land, and the switch that follows then closes the modal for good.
Waiting for the recorded impression makes that order deterministic.

* fix(cms): keep the open Test modal across a same-deployment reload

A language switch (or lease renewal) reloads the selected Test deployment.
While the new decisions are in flight the runtime publishes a session
without decisions, and the modal treated that gap as the presentation
ending: it dropped its "open" marker, then the returning decision looked
like a new offer of an activity this account had already seen and stayed
closed.

Key the open presentation by the selected deployment and snapshot. A
reload of the same selection continues it; a redeploy, a different
account, a dismissal, or the session ending still releases it.

* test(e2e): enroll the Home hand-off specs in the ui_p0 entry-settings group

home-amr-pending, home-send-single-loading and home-send-split-width were
merged (or opened) without being enrolled in uiP0Groups, so no merge lane
had ever executed them; only the prerelease full pool would have. Enroll
them with the other Home-entry files.
2026-09-18 07:45:47 +00:00
lefarcen 792480fe21 fix(daemon): bound stored run events and keep crashed runs from acting on truncated prompts (#8170)
* test(daemon): red specs for unbounded persisted run events

Incident 2026-09-14: a cursor-agent conversation (~169 turns) stored every
unrecognised stdout line verbatim as a `raw` event. One `editToolCall`
completion repeats the edited file twice (~1.2 MB) and the `user` line echoes
the whole composed prompt, so each turn stored ~1.4 MB that nothing renders.
The renderer and daemon ran out of memory, and `GET .../messages` failed with
`RangeError: Invalid string length`.

These specs pin the invariants the fix must establish, against 0.22.x-shaped
rows (seeded straight into the daemon's tables) and the real parser +
persistence path:

- I1: every persisted run event fits a 64 KiB budget; meaningful events
  (status, done_key, text, usage) are stored unchanged; nothing is dropped;
  live `raw` lines are bounded too.
- I2: GET messages returns a bounded transcript; the PUT sibling-Run guard and
  the project run-status projection keep their verdicts without any database
  read handing a run's event log to JS.
- I3: a background, idempotent, one-row-per-transaction heal bounds existing
  rows, skips active runs, and is safe to interrupt.

* fix(daemon): bound persisted run events and heal oversized rows

A persisted run event never carries an unbounded payload (I1):
`runtimes/run-event-payload-budget.ts` bounds every stored event to 64 KiB of
JSON. An oversized event keeps its kind, identity fields and count; its
payload is shortened deterministically to a 16 KiB head+tail with an inline
`[open-design: ...]` marker (bytes cut, original size, digest) and a
`truncated: { originalBytes }` field. cursor-agent `tool_call` lines drop their
full-file copies (`beforeFullFileContent`, `afterFullFileContent`, read
`content`) first so the retained JSON stays valid and meaningful; Write/Edit
rows keep their `+N -M` via `od_diff_stat`; text/thinking are never cut.
Applied at the json-event-stream source (live SSE + run buffer), at the
SSE->persisted conversion, and at every events_json/batch write.

Loading a conversation never materializes unbounded data (I2): GET messages
streams rows and bounds legacy rows on read; the PUT sibling-Run guard reads
each sibling's first done_key in SQLite (`listSiblingRunDoneKeys`); the
project run-status projection picks each project's latest run in SQLite and
reads only the events `eventsEndedWithUnfinishedWork` can see.

Existing oversized rows are healed once (I3):
`storage/message-event-payload-heal.ts` runs after the daemon is listening,
one row per transaction, streaming each row's events via json_each, skipping
queued/running runs, and records completion in `daemon_maintenance_passes`.

Contracts gain the optional `AgentEventPayloadTruncation` field on raw,
tool_use and tool_result events (and the SSE raw payload).

* fix(daemon): keep listMessages working on all()-only DB adapters

Streaming rows with `iterate()` is what keeps a pre-heal conversation read
from holding every row's legacy event log at once, but some integration
boundaries hand listMessages a lightweight adapter that models only
`prepare().all()` (as `hasMessageEventBatchStorage` already tolerates). Fall
back to `all()` there. Also prepare the run-status completeness statement once
per listing instead of once per project.

* test(daemon): red spec for unbounded live raw lines from every parser

claude-stream, copilot-stream and qoder-stream hand unrecognised stdout lines
to the SSE fan-out verbatim; only json-event-stream bounded them at the
source. Pin that every parser's live raw event fits the 64 KiB per-event
budget on both the newline and flush() paths, and that a line that fits is
passed through unchanged.

* fix(daemon): bound raw lines at the source in every stream parser

Apply boundedRawAgentEvent to the raw emitters of claude-stream,
copilot-stream and qoder-stream, as json-event-stream already does, so the
live SSE stream and the in-memory run buffer carry the same bounded line the
transcript stores. Live tool_use/tool_result stay unbounded on purpose:
Langfuse tool spans and artifact detection read them from the live run.

* fix(daemon): deliver agent prompts intact and reap leftover agents after a daemon crash

Plain-text-stdin runtimes (cursor-agent, codex, opencode, copilot, qwen,
amp, ...) received the composed prompt through an async pipe write. When
the daemon died right after spawn, only the kernel pipe buffer (64 KiB)
reached an agent that reads stdin late, and the detached agent (running
with --force) kept going on the truncated prompt, whose last visible
request was the conversation's oldest one.

- Deliver the prompt as a file-backed stdin: a 0600 file under the run
  directory, passed as the child's stdin fd at spawn and deleted right
  after. If it cannot be written, the run fails before any agent exists.
  Claude's stream-json pipe is unchanged.
- Record each spawned agent (pid, process group, spawn window, daemon
  identity) in its run directory. On the next daemon start, terminate
  leftovers whose recording daemon is gone and whose identity still
  matches, and sweep stale prompt files. A failed record write fails the
  spawn instead of leaving an agent nothing can reap.
- Add process-identity helpers to @open-design/platform.

By design a leftover agent keeps running until the next daemon start;
the interrupted run reads back failed / DAEMON_RESTARTED.

Red first: with main's behaviour a late-reading agent received 65,536
bytes and its process group survived a restart. The agent-process specs
and e2e/tests/dialog/leftover-agent-reap.test.ts go red there and are
green on this change.
2026-09-18 05:58:46 +00:00
Ray Xi 2acb7f6f69 chore(pack): upgrade bundled Vela CLI to 0.0.37 (#8224)
* chore(pack): upgrade bundled Vela CLI to 0.0.37

* test(amr): align Vela continuation contract with 0.0.37
2026-09-17 11:38:54 +00:00
open-design-crew[bot] 46be6eacc7 fix(chat): preserve planning-only intent after clarification (#8172)
* fix(daemon): preserve plan-only intent across task clarification

* test(daemon): preserve production intent in strategy fixtures

* test(e2e): bound shared logo lookup during workspace restoration

---------

Co-authored-by: rebel-nexu-zero[bot] <rebel-nexu-zero[bot]@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
2026-09-17 06:33:33 +00:00
Caprika 8fb94d28eb feat(cms): 统一客户端活动点位接入与验收修复 (#7986)
* feat(cms): add verified desktop touchpoint hosts and Test acceptance

* fix(cms): keep Test modal scrim above app chrome

* fix(cms): load host styles and support hover packages

* fix(cms): report mounted host compatibility for restoration

* fix(cms): parse Vela actions in shared manifests without executing them

* fix(cms): preserve authorized actions and correct modal and hover behavior (#8003)

* fix(cms): harden touchpoint host interactions

* fix(cms): preserve modal actions and verify effective close controls

* test(cms): cover hover exit and above-anchor placement

* fix(cms): hide client test controls by default

* fix(cms): persist automatic campaign impressions per account and device

* fix(web): remove host campaign close button

* fix(cms): suppress campaign modals during onboarding

* fix(cms): unify test and production campaign lifecycle (#8118)

* fix(cms): enable safe campaign navigation in test and production

* fix(cms): enforce server-authorized touchpoint lifetimes

* fix(cms): refresh production touchpoints when client language changes

* fix(cms): stabilize hover refresh and synchronize Test locale

* fix(cms): preserve campaigns on focus and remember Test dismissal

* fix(cms): discover Test deployments without reloading

* fix(cms): keep granted touchpoint display authority through transport failures

A refresh that times out or fails in transport is not a withdrawal, but the
shared lifecycle treated every failure the same way: `status = "error"` then
`revoke()`, which tore down a lease the server had already granted and was
still inside its own deadline. One slow poll therefore blanked a running
campaign until the next 30s tick, and a second failure could not restore it
because the value had been dropped.

`abandonAttempt` now names the invariant: cancel the attempt, keep the lease,
and let `armExpiry` retire it at the deadline the server actually granted. The
lease length is the bound, so no retry budget is needed — Test's sixty-second
contract survives exactly one missed poll, and Production survives as many as
its authorization covers.

Three cases still clear, and one of them is new:

  - the failure carries the server's own withdrawal. A 410 is authoritative
    even when its receipt body is unreadable, so `ProductionTouchpointLoadError`
    now marks that one case and the lifecycle honours it. Without this, a
    malformed 410 would have been ridden out as if it were transport noise.
  - nothing is mounted, or the lease was already fenced by `wake`. Page
    recovery has no evidence the activity is still live, so it stays withdrawn.
  - the lease has run out on its own.

This does not fix OPEND-3237, which is a new activity failing its FIRST load:
there is no prior lease to keep and the display stays empty. It fixes the
adjacent class where a mounted activity is erased by a transient refresh.

* fix(cms): keep a failed refresh recoverable inside its own poll cycle

Three changes to how one refresh cycle behaves when it does not go cleanly.
None of them fixes OPEND-3237, whose first-load failure is still unexplained;
they remove the ways a working campaign is lost to an ordinary hiccup.

Bounded retries. A failed attempt used to get its next chance from the fixed
30s tick. For the sixty-second lease the Test runtime is granted, that chance
lands exactly when the lease expires, so a single failure put display on the
edge of going blank with nothing able to recover it. Retries at 1s and 3s give
a fast failure — transport error, 5xx, DNS — several chances while the lease is
still comfortably alive.

A retry must finish inside the cycle that spawned it. `refresh` declines to
start while a request is in flight, so a chain that ran past the next tick
would not merely be late: it would swallow that tick entirely. Worst case was
15+1+15+3+15 = 49s against a 30s interval. Scheduling now requires room for the
retry AND its full budget, which is also what makes "retries cover fast
failures" true in the code rather than only in a comment — an attempt that
burned the whole budget leaves no room by construction and is never retried.
The retry budget resets per cycle, not per success: otherwise the first cycle
to exhaust its retries would leave every later cycle with none.

Request budget 10s -> 15s. One refresh fetches a context and every enabled
placement, so the budget covers a round, not a request. A real round of 11.5s
whose placements all succeeded was being abandoned by the ten-second budget. It
stays well under the 30s interval on purpose.

Forward `accept-encoding` through the touchpoint runtime proxy. Decisions carry
base64 content and run to megabytes, and the proxy built its upstream headers
from scratch, dropping the caller's preference — every refresh pulled the
payload uncompressed, measured at 383KB against 214KB for the same decision.
Because the body is piped through verbatim, upstream's `content-encoding` has
to be handed back with it, or the caller decodes gzip as JSON; both halves land
together. Verified end to end through daemon and web: 2,149,685 -> 1,606,678
bytes on the wire, byte-identical after decode.

An authoritative withdrawal is never retried: a 410 that took a campaign off
the screen must not put it back.

* fix(cms): present each Test campaign modal once per account and device

Test modal presentations now share the production touchpoint-displayed:v1
impression: recorded once visible, read before any new automatic opening,
and persisted across reload, remount, and client restart.

* fix(cms): give the Test control readout an explicit ladder weight
2026-09-17 05:58:48 +00:00
Ray Xi 7245f543a7 feat(web): land the Home entry refresh (feat/home-entry-refresh → main) (#8208)
* feat(web): refresh the Home hero and composer (#7731)

* feat(web): add the SiriOrb run indicator

Ports the Siri Orb (SmoothUI, MIT) from #7635 as a standalone component: a rotating neon sphere with idle / thinking / streaming speeds, drawn entirely in its CSS Module. Unused by this PR; the rail run-status icon (PR-A of OPEND-2553) consumes it.

* feat(web): vendor the composer running orb

Adds the thinking-orbs 0.3.1 'solving' state at its 20px preset (MIT, attributed in the file) as a local component instead of a new runtime dependency: the composer needs one of the package's nine animations, pinned to the dark palette, and the transcription keeps the geometry, preset scaling, painter and frame loop of the published build so the canvas output is identical. Refs OPEND-2553 / #7635.

* feat(web): add the rotating Home headline i18n keys

homeHero.title now carries the {word} slot in every locale together with homeHero.titleWords (the comma-separated nouns the headline cycles), homeHero.subtitle and the attachment band's paging labels. Every locale owns its own sentence and noun list so the placeholder-parity check stays green (#7635 only rewrote en / zh-CN / zh-TW). Refs OPEND-2553.

* test(web): re-pin the Home hero and composer specs to the refreshed surfaces

Follows #7635's hero/composer contract: type row instead of the template dropdown, display+clear type pill, no Home mode picker, no resource submenus in the '+' menu, references in the working-directory row, lead-chip truncation, the 116px hero offset, the 220px execution-chip cap, the flat app wash and the stable send arrow. Carries over the Demo's new specs (dock collapse, Backspace-at-start, modelVersionLabel, icon-only design-system pill) and drops the ones for removed surfaces. Refs OPEND-2553.

* test(e2e): follow the Home type row and composer menu

Ports #7635's Playwright helper (homeTypeRow / clearHomeTemplate / pickHomeTemplate) and the home-hero-rail, entry-chrome-flows, project-management-flows, visual-navigation and entry-topbar changes that reach the composer through the type row, the working-directory group and the trimmed '+' menu. Refs OPEND-2553.

* feat(web): refresh the Home hero and composer

Replicates the Home hero + composer subset of #7635 on current main (OPEND-2553, PR-B): plain headline with the rotating noun and subtitle, a typeless cold Home whose type row (3 + 2 behind More) retires into the composer's display+clear pill, upload disc + design-system palette + type pill in the foot row, the supplied send mark and border beam, two-line staged-file chips in a paging band, the lead template chip with Backspace-at-start, references and linked checkouts in the working-directory row (one slot), the trimmed '+' menu without resource submenus or the mode picker, poster rows for examples, the model chip's brand mark and version-only label, the running orb + stop square in the project composer, and the flat app wash. HomeView gains the page/dock variant plumbing and drops the default type seed and the AppWashKineticGrid mount; the recent-projects grid stays for PR-C. Adds task_chip_clear to the composer click contract.

* test(e2e): follow the Home composer and type-row refresh

Migrates the Playwright specs that named surfaces #7635 removed or moved: the composer session-mode picker (every turn now carries the conversation's stored design mode, and design turns carry no message chip), the Home examples loading shell (a cold Home is typeless and shows only the type row while the catalogue loads), the fixed type row (three inline pills plus two behind More; every other create type reaches the hero through the cross-surface hand-off, which the shared pickHomeTemplate helper now drives), the working-directory row's single slot for references and linked checkouts, the '+' menu's working-dir group, the lead chip's eight-character title, the 32px send disc, and the flat app wash. The Home recent-projects specs keep main's shape (PR-C owns that move). Refs OPEND-2553.

* test(e2e): repair the visual captures the composer refresh broke

The projects captures clicked the rail's drafts item, which only renders
with a workspace context the visual fixture never has; Home already shows
the recent-projects strip, so the click is dropped. The integrations
capture reached its page by direct load, whose default tab is MCP and
whose cold mount outlasts a bare expect; it now clicks the connectors tab
like the MCP capture does. The BYOK switcher capture expected the link
glyph, but the chip leads with the selected model's brand mark and only
falls back to the glyph for a vendor without one. OPEND-2553 (PR-B).

* feat(web): add recent projects and run status to the entry rail (#7730)

* feat(contracts,daemon): report awaiting-input projects on the runs list

`ChatRunStatus` cannot express "waiting on the user": the run that asked a
`<question-form>` reports `succeeded` and exits while the project stays
blocked, so a client folding `GET /api/runs` into a per-project status shows
such a project as finished. Ship `awaitingInputProjectIds` beside the runs,
composed from the same `listProjectsAwaitingInput` read `GET /api/projects`
uses and intersected with the projects the returned runs already reveal, so
the field can never widen what a caller may see.

Ported 1:1 from upstream #7635 for OPEND-2553 (PR-A).

* feat(web): derive per-project run status from the runs feed

`Project.status` only arrives on the unscoped `GET /api/projects`, so a
workspace-scoped session cannot read it. Add the pure fold
(`foldRunsToProjectStatuses`) that mirrors the daemon's composition, the
per-project `listRunsForProject` read plus `useProjectRunStatuses` hook that
keeps it live off `RUNS_CHANGED_EVENT`, the one glyph per status
(`ProjectRunStatusIcon` over the SiriOrb), and the two conversation reducers
(`latestUserPromptText`, `runProgressSteps`) the Design Files empty state
will read.

No visual change on its own. Ported 1:1 from upstream #7635 for OPEND-2553
(PR-A).

* feat(web): list recent projects in the entry nav rail

Adds the 最近浏览过 disclosure under 插件 in the signed-in rail: the head of
the same recent catalog 全部项目 shows, newest first, capped at eight rows.
Each row leads with the project's live run status (running / needs input /
completed / failed) from the runs feed, opens through the shell's pull-first
opener, floats a cover preview beside the rail on hover, and carries a ⋮ menu
with rename, whole-project export and a two-step delete. An acknowledged ✓ is
spent by opening the project and re-raised by the next run.

The Home page's recent-projects grid stays; this only adds the rail entry.
Row geometry, easing and colours are pinned to the Demo (upstream #7635) by
`tests/styles/entry-rail-recent.test.ts`. OPEND-2553 (PR-A).

* fix(web): acknowledge the rail ✓ per finished run

The 最近浏览过 rows spent a project's ✓ by remembering the PROJECT id and only
forgot it after observing a live queued/running status. The section polls
only while it is open, so a run that started and finished while it was
collapsed was never observed: on re-expanding, the stale acknowledgement
still matched and the new completion's ✓ never showed. (The Demo, #7635,
carries the same defect; this deliberately diverges from it.)

Acknowledge by run identity instead. `foldRunsToProjectRunSummaries` reports
the newest terminal run's id beside each project's status (the status-only
fold is derived from it and unchanged for its callers), `useProjectRunSummaries`
exposes it live, and the rail stores `{ projectId: acknowledgedRunId }`: a ✓
is acknowledged for one specific finished run, and a newer finished run is a
new notice. The reset-on-running effect is gone. A store in the old array
shape reads as nothing acknowledged, so at worst one already-seen ✓ shows
once more.

Red spec: tests/components/EntryNavRail.recent-section.test.tsx
"re-raises the ✓ for a newer finished run even when its running phase was
never seen". OPEND-2553 (PR-A).

* fix(web): close the Home hero/composer acceptance issues against #7731 (OPEND-2553 PR-B2) (#7769)

* fix(web): keep the staged attachment band in sight as the prompt scrolls

The scroller sat on .home-hero__prompt-flow, which is also the attachment
band's parent, so scrolling a long prompt carried the band away with the
text: an attachment the user had just staged left the screen while they
were still writing about it.

Move the scroll down to a new .home-hero__prompt-surface holding the
prompt and its lead chip and nothing else. That element keeps the
position: relative the out-of-flow chip needs as a containing block, so
the chip still scrolls with the text it leads rather than pinning itself
to the top of the box.

Ported from nexu-io/open-design#7635 (2981a2025).

* feat(web): lead staged videos and vectors with their own thumbnail

Only rasters got a thumbnail in the composer; a staged .mp4 or .svg fell
back to a generic file chip, and a.kind could not tell them apart because
the daemon splits attachments into image/file only, so a video arrives as
'file'. FileTypeIcon decides from the name and MIME type instead, and
both the home hero and the chat composer now draw a first frame for video
and a rendered preview for vectors, opening into the same preview card.
Every other type leads with its own mark from the supplied artwork.

The card's close control was a 36px-tall pill with 0 16px of padding
around a 14px glyph; pinning its width to its height and dropping the
padding makes it the circle it was meant to be, and flex: 0 0 auto stops
a long filename from squashing it back into an oval.

Adds a Vitest spec for the FileTypeIcon resolution table (fig, html, svg,
mp4, png and the MIME fallbacks) and for unique gradient ids.

Ported from nexu-io/open-design#7635 (d3b251de0).

* fix(web): make the Figma import modal's close control visible and round its chrome

The global `button` rule in styles/primitives.css sets `padding: 0 16px`,
which on the modal's 34px border-box close button left 2px of content
width; the 18px close glyph is a shrinkable flex item, so it collapsed to
0px and the button rendered as an empty box. Drop the padding so the
glyph has room, and round the close control, the mode tabs and the
footer actions into pills per product.

The notes field had the same global-rule problem from the other side:
`textarea { width: 100% }` plus the field's own 16px side margins made it
32px wider than the modal, whose overflow clipping hid the right edge.
It is a stretched flex item, so `width: auto` hands the width back to
the column.

The backdrop now reads the shared scrim tint token instead of a hard
coded dark tint, matching the other overlays.

Adds a style spec pinning the close control's padding and size, the pill
radii, the notes width and the scrim token.

Ported from nexu-io/open-design#7635 (FigmaImportModal.module.css).

* fix(web): carry the entry typography ladder into the composer "+" menu popup

The Home hero and the project composer each scope the interim 600 text
weight to their own root until the app-wide ladder lands. The "+" menu
popup renders through a portal on document.body, outside both scopes,
so its rows fell back to the app's current 400/500 defaults and read
visibly lighter than the trigger that opened them and than the Demo.

Extend the same `:where()` stand-in to `.plus-menu__popup`, with the
note that it goes away with the global ladder, and pin it with a style
spec so the rows keep inheriting the weight rather than naming a lighter
one.

* fix(web): show the Home design-system trigger's tooltip through the app bubble

The palette trigger and the "+" trigger share one row on Home. The "+"
names itself through the app's `od-tooltip` bubble; the palette carried
only a native `title`, so hovering the two neighbours showed two
different tooltip styles.

Give the unselected palette trigger the same `od-tooltip` class and
`data-tooltip` text and drop the native title (the `aria-label` keeps
the accessible name). Once a system is picked its name is on the pill,
so the bubble is dropped there, mirroring the "+" trigger's rule that a
labelled control does not repeat its label in a tooltip.

The composer variant keeps both `title` and `data-tooltip`: TooltipLayer
suppresses the native title while its bubble is shown, so the title is
the fallback for hosts without the layer, not a second tooltip.

* fix(web): hide the recent-folders submenu when there are no recent folders

The working-directory panel always rendered its "Recent folders" row,
and with nothing to list the flyout opened onto a faint "No recent
folders" line: a menu whose only content was that it was empty, which
read as a broken row rather than a submenu.

Render the row only when there is at least one recent directory. The
empty-state line and its `homeWorkingDir.recentEmpty` key had no other
consumer, so both go, from the module CSS, the typed Dict and all 19
locales.

Adds a component spec that opens the panel with and without recent
folders.

* fix(web): preserve labeled attachment previews in chat

Use filename, path, then image kind to select media consistently in the staged chip and preview card. Cover labeled images, extensionless images, vectors, and videos through the staging event.

Generated-By: looper 0.12.0 (runner=fixer, agent=codex)

* fix(web): even out home template rows, deck posters, and the placeholder caret (#7830)

Four home-composer fixes from the OPEND-2553 acceptance round.

- OPEND-2687: the template row's hover fill carried 8px above and 16px below
  its content, so the poster and text sat low in the block. Vertical padding is
  now 12px each side; the 80px row pitch and the four-row viewport are
  unchanged.
- OPEND-2705: a white or near-white document poster merged into the page. A
  1px hairline ring (border token mixed down to 70%) is drawn over every
  poster by a pseudo element. Default treatment pending design sign-off.
- OPEND-2698: the typewriter placeholder's caret was 1.5px wide and 14px tall
  (line box minus a 3px margin pair) while the native caret on the 14px
  composer text is 1px by 17px, so focusing the field made the caret jump.
  The placeholder caret is now sized to the native one.
- OPEND-2702: deck posters showed the agenda page instead of the cover. The
  bake script probes the advancing input before capture, which moves the deck
  to slide 2, and decks that persist their position in the URL hash reopened
  there after the reload. The deck's persisted position is now reset before
  the reload, and BAKE_VERSION is bumped to 7 so every deck re-bakes.

* feat(web): show the page taking shape while a run builds it (#7772)

* feat(contracts): add the preview-build-focus bridge contract

Cross-runtime protocol a sandboxed artifact preview uses to tell the host
where in the rendered page the agent is writing right now, either by a
literal string it just wrote or by one of the page's own top-level
sections. The frame has no allow-same-origin, so it posts to '*' and the
host identifies it by event.source. The browser side is serialized as a
string so web and daemon inject the identical script; the module stays
free of browser APIs. Shipped as a subpath export like the sibling
preview-observability bridge.

Ported from nexu-io/open-design#7635 (f7c39b9d2) for OPEND-2553.

* feat(daemon): inject the build-focus bridge into opt-in raw previews

A raw project HTML preview requested with odPreviewBridge=buildfocus (or
build-focus) now carries the preview-build-focus bridge script before
</body>, next to the scroll/selection/snapshot bridges. It stays opt-in,
injects once, never rides along with the other bridges, and leaves
non-HTML responses untouched. No new endpoint and no path change: the
route keeps serving files from the resolved project root.

Ported from nexu-io/open-design#7635 (f7c39b9d2) for OPEND-2553.

* feat(web): carry a text anchor on run steps and pick the page being built

runProgressSteps now attaches an anchor to write/edit steps that touched
an HTML file: the last run of visible text taken from the tool's own
input, so a live preview can be scrolled to the part just written without
diffing the file. selectBuildPreviewHtmlEntry picks the page to watch
while a run works — the shallowest index.html, else the newest HTML file —
and deliberately ignores markdown and media, unlike the auto-open ranking.

latest-user-prompt goes away: nothing on main consumed it once the Home
composer landed, and the Design Files pane names what the run is doing
instead of echoing the prompt.

Ported from nexu-io/open-design#7635 (f7c39b9d2) for OPEND-2553.

* feat(web): show the page taking shape while a run builds it

The Design Files pane used to sit on its starter CTAs, then jump straight
to a grid of file cards once the first artifact dropped in. Everything the
run was doing in between happened somewhere the user could not see.

While a run is in flight and has produced a page, the pane now renders
that page live (a sandboxed, click-through URL-load preview reloaded on
every settled write) with a feed of the run's steps docked in the corner
and a cursor parked on the part being written. After each load the
frame's build-focus bridge broadcasts the page's top-level sections; the
host walks the cursor over the ones that just appeared, then falls back
to the current step's text anchor. A topbar switch flips between the
building preview and the file grid in both directions and resets when the
next run starts. Runs that have written no page keep the pane as it was.

Ported from nexu-io/open-design#7635 (f7c39b9d2) for OPEND-2553.

* test(web): cover the design files building preview

Component tests for DesignFilesBuildingState (cache-busting src, opt-in
bridge, sandbox, section walk, anchor fallback), DesignFilesPanel's
building/grid/toggle gating, and the module CSS invariants the drop target
and reduced-motion rely on.

Ported from nexu-io/open-design#7635 (f7c39b9d2) for OPEND-2553.

* fix(web): key the building preview off a run in flight, not disabled actions

FileWorkspace's streaming prop is the composer's "actions disabled" state:
it is also true for a read-only viewer of a shared project, a conversation
still loading, or a run with no billable principal. The building preview
used it as "a run is writing this page", so a viewer with nothing running
saw their page under a cursor captioned "thinking" instead of the grid
(the Demo wires it the same way; CI's visual-settings account-balance
capture caught it on a read-only launchpad project).

FileWorkspace now takes an explicit runInFlight flag, fed by ProjectView
from the conversation's streaming/attached-run state, and the Design Files
building preview keys off that alone. Red spec: the new FileWorkspace test
fails on the previous commit and passes here.

* fix(web): limit building preview to pages written during the active turn

Generated-By: looper 0.12.0 (runner=fixer, agent=codex)

* fix(daemon): complete project-scoped run lists from durable history after restart (#7771)

* fix(daemon): complete project-scoped run lists from durable history

`GET /api/runs?projectId` listed only the runs that happened to be in the
in-memory map. After a full daemon restart the startup scan rebuilt the
clientRequestId and pluginWorkflowId indexes but never put persisted runs
back into the map, and terminal runs also leave the map after the TTL, so a
project's newer succeeded run could be missing while an older failed one
was still present. Consumers that fold the list into one status per project
then showed the wrong outcome and lost the awaiting-input flag, because the
route intersects the awaiting set with the projects the returned runs reveal.

Index every persisted and created run id by project at startup and creation,
and make `list({ projectId })` hydrate the not-yet-loaded ids of that one
project through `hydrateDurableRun` before filtering. The unscoped list is
unchanged and hydration never loads other projects.

Refs OPEND-2629, follow-up to #7730.

* test(e2e): cover project run list across a daemon restart

Drive a failed then succeeded run on one project and a succeeded run plus an
unanswered question-form on another through the production HTTP APIs, then
restart the daemon over the same data directory and assert that
`GET /api/runs?projectId` still returns every persisted run and the
awaiting-input project id, so the per-project fold gives the same answer
before and after the restart.

Refs OPEND-2629.

* feat(web): entry materials and rail layout from #7635 (OPEND-2553 PR-C1) (#7832)

* feat(web): port the entry materials and rail layout from #7635 (OPEND-2553 PR-C1)

Materials: one app scrim (60% white tint under a 50px blur) read by the
shared <Dialog> backdrop and every app-owned overlay; the rail column sits
flat on the entry ground while the content column becomes the one base card
(85% white frost, 16px corner, inset ring, 12px inset); --material-dim /
--vibrancy-label-on-dim join material.css. Image lightboxes stay dark on
purpose.

Entry layout: the search + rail toggle move out of the rail into the chrome
row (the pinned Home pill is hidden there); 设置 sits under 插件 on the
signed-in rail too; every rail destination is a 38px / 12px row on the
shared quiet ink with one hover for the whole column (OPEND-2700); the
workspace switcher row matches; the recent list drops its 8-row cap and
scrolls past ~11 rows, shrinking with the window (OPEND-2757); the section
is titled 最近项目 (OPEND-2703); the chat project switcher leads rows with
the run status and previews the hovered row with the rail's own card
(OPEND-2694); the community view docks Home's composer at its foot; the
Home recent-projects grid is gone wherever the rail carries the list
(OPEND-2683). RecentProjectsStrip takes the Demo's card / header restyle.

* test(e2e): follow the entry grid to 草稿 and the viewport-wide switcher backdrop

A workspace-bound Home carries no recent-projects grid any more, so the
visual project wait accepts the rail's 最近项目 row and the workspace-switch
specs read the 草稿 grid; the switcher backdrop spans the viewport now that
the rail column paints nothing, so its dismiss click lands in the content
column instead of under the tabs chrome.

* test(e2e): dismiss the What's New popup before driving the entry chrome

The shared <Dialog> scrim now mounts on <body> above the tabs chrome, so a
release announcement blocks the rail toggle and the settings triggers until
it is closed. Specs on a real daemon (no standard mocks) hit it whenever the
build ships highlights; the rail and settings helpers close it first.

* fix(web): keep the rail's menu click-catchers scoped to the rail column

The rail panel's backdrop-filter used to make it the containing block for
the fixed workspace-switcher / account-menu backdrops; with the frost gone
they spread over the viewport and swallowed clicks on the content beside
the rail while a menu was open. A no-op transform restores that scope
without painting anything.

* fix(web): bound rail status polling and restore local Home controls

Poll only recent rows intersecting the rail scrollport, preserve the page grid callbacks, and keep the Community dock free of the local project grid.

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* feat(web): land the 600 default weight app-wide and retire the Home stand-in rules (#7840)

Set the product UI's default text weight to 600 on body and button (base.css,
primitives.css, @open-design/components), fold the remaining stylesheets onto
the consolidated 500 / 600 / 700 ladder, and delete the scoped stand-in rules
that home-hero.css, plus-menu.css and chat.css carried while the app-wide
default was pending. font-weight-normalization.test.ts pins the default, the
ladder and the absence of stand-ins; entry-layout.css, EntryNavRail.module.css
and design-files.css stay on an explicit pending list because concurrent PRs
own them.

* feat(web): sync #7635 run-status glyphs and zh-CN headline, fix template preview chrome and reference rows (OPEND-2553 PR-F3) (#7881)

* feat(web): one badge for stopped runs, a warm orb for a pending question

Every status that ended without delivering — failed, canceled, and a run
that finished with declared work undone — draws one static badge now,
instead of three unrelated things (an orange orb, nothing at all, and an
amber orb). The row only has to say "this did not finish"; the reason
belongs to the status text beside it. `canceled` drew no glyph before, so
`hasRunStatusGlyph` had to stop excluding it or the badge would never
reach the two surfaces that reserve the column.

That moves the split from "did it go well" to "is it still going". A
stopped run is never spun, because the rotation is what says "working".

Awaiting a reply keeps the orb and turns at running's speed — a pending
question is a live run, not a stalled one, and only `queued` may turn
slowly. It is recoloured to a warm three-step ramp: orange base, amber
second accent and bloom, gold glint.

Recolouring at all needed a new `literal` mode on SiriOrb. The stock
build is tuned around #00FF08 in six places — three slots hardcode green
where `colors` cannot reach, and the white highlights, the saturate/
contrast pair and the overlay texture pass each edit whatever hue they
are handed. None of them disturb the green, whose R sits at 0.00 and G at
1.00, pinned at the channel bounds. #FF8D02 has a mid green channel and
came out green, then yellow, then red as each was found. `literal`
collapses the unreachable slots onto c1 and stops the build editing the
colour; `colors` now accepts c5 so a caller can name the glint that white
is no longer allowed to draw.

* fix(web): reword the Chinese home headline

「我们来设计点原型」→「让我们创建原型」(per product).

The `{word}` placeholder stays. HomeHero splits the string on it to
decide whether a locale opts into the rotating noun at all, so dropping
it would leave Chinese with a frozen headline while en and zh-TW kept
cycling. Two comments quoting the old sentence are updated with it;
zh-TW keeps its own wording.

* fix(web): drop the Remix footer and clear the macOS traffic lights in the template preview

The lightweight community template preview (the one the Home active-template
chip opens) carried a footer bar with the category and a Remix / 使用 action.
Per product that bar goes (OPEND-2692); the header already names the category,
so the panel is two rows — head and stage — and the `__foot` / `__actions`
rules leave the stylesheet with it. `onUse` / `busy` stay on the props, made
optional, so the HomeView call site type-checks unchanged. Ported from #7635.

On the macOS desktop host the window is `hiddenInset` and the OS draws the
traffic lights over the top ~32px of the web content. The overlay covers the
whole window with an even 28px inset, so on any window short enough to hit the
fit-to-window clamp the panel's top edge cut straight through the buttons
(OPEND-2691). The top inset is now a variable the darwin host raises to 56px —
the same band the shared modal drag strip reserves — and the panel's height
clamp subtracts that inset so it shrinks instead of sliding back under the
lights. The overlay also joins the drag-strip backdrop list, so the uncovered
band above the panel drags the window like every other full-screen backdrop.
Keyed off the `data-host-platform` mark App.tsx stamps; browsers and Windows
keep 28px.

The two mapping specs that asserted the footer's Remix now assert the header
carries the category and no footer exists.

* fix(web): size project reference rows to their content

Each row in the 引用其他项目 dialog is a <button>, so the global primitive
(styles/primitives.css) hands it `height: 36px; line-height: 1;
white-space: nowrap`. The module never overrode the height, and a row with a
28px icon beside two lines of text needs ~54px: the box was shorter than its
content, the content overflowed evenly above and below, the selected border
framed only the title band, and the overflow drew over the neighbouring rows'
meta text (OPEND-2787).

The row now sizes to its content (`height: auto`, `line-height: normal`, 8px
vertical padding) with the two text lines and the 已选 tag on fixed line
heights, so default, hover and selected share one box — the border is always
drawn, transparent at rest, and the states only recolour it. A measurement
spec pins the row box and forbids the state rules from touching it.

---------

Co-authored-by: wangchenglong <honam884844@gmail.com>

* fix(web): rail recent list scrolls within the sidebar, row menu gains duplicate/move, delete confirms in a dialog (OPEND-2553 F1) (#7878)

OPEND-2757: the entry shell's grid had no row track, so the implicit auto row
grew to the rail's content height and pushed the footer's Discord / X / mail
links under the window. Pin the row to minmax(0, 1fr) and let the rail opt out
of its content minimum so the existing flex chain down to the recent list can
shrink; the list scrolls inside the sidebar at any window height.

OPEND-2794 / OPEND-2686: the rail row menu is now 重命名 / 复制项目 / 转入团队空间
(team workspaces only) / 删除, dropping 导出. Duplicate and move go through the
same handlers and endpoints the project cards use; move reuses the shared
MoveToTeamConfirmDialog and reports progress / failure in the row menu.

OPEND-2797: delete confirms in the shared project delete dialog again instead
of arming the menu item in place. The dialog, the delete flow, the move flow
and the duplicate flow are extracted into components/project-actions/ and
consumed by both RecentProjectsStrip and the rail.

* fix(web): read project run status from one shared feed for the rail and the tab switcher (#7877)

OPEND-2795: the rail's recent rows and the project switcher held separate
copies of the run-status feed with different display rules, so a finished
project read as done in one and default in the other. OPEND-2762: the rail
asked for statuses only after a scroll-observer hop and reset its request
set on every catalog re-render, so rows painted a round trip ahead of their
glyphs and flashed on each hand-over.

Both surfaces now subscribe to one module-level store (one request per
project, last known answer on remount, shared per-run acknowledgement), and
the rail asks for the head of the list in the commit that paints it.

* feat(web): seat the rail toggle first in the window chrome, before the search (OPEND-2553 F5) (#7889)

The entry chrome's rail toggle now takes the first slot after the
traffic-light space, with the search beside it, so the sidebar switch sits
where a macOS sidebar toggle is expected and stays put across open and
collapsed states. Both glyphs stay mounted and cross-fade on the ease-out
curve (enter 200ms, exit 140ms) instead of popping.

Refs OPEND-2685.

* fix(web): quiet the template preview eye, colour the lead type chips, name awaiting replies, drop the community dock (OPEND-2553 F4) (#7891)

* fix(web): quiet the template preview eye, colour the lead type chips, name awaiting replies, drop the community dock (OPEND-2553 F4)

Four home-entry polish items from the second acceptance round, each pinned
by a test that went red before the source change:

- OPEND-2697: the preview eye on a template row's poster is a secondary
  affordance. It stays hidden at rest, fades in with the row's hover or
  keyboard focus (200ms in / 140ms out on the shared ease-out curve), sits
  on a translucent light ground with dark ink instead of the dark scrim
  disc, and drops one size step (20 -> 16px, glyph 13 -> 11px).
- OPEND-2684: the type chips under the composer wear a visible 1px ring
  mixed from the text ink (20%, 40% on hover) instead of the #EDEDED that
  vanished on both grounds. 原型 / 幻灯片 / 文档 each carry their own icon hue
  through new --type-prototype / --type-deck / --type-document tokens (with
  dark values), the label stays ink, and the composer pill that names the
  picked type takes a 10% tint of that hue with a 36% ring. 更多 and every
  other type stay neutral.
- OPEND-2744: a run that ended on an unanswered <question-form> is titled
  "Awaiting your reply" (assistant.awaitingReplyLabel, 19 locales) in the
  footer and on the task activity card until the immediate user reply
  submits or skips the form; older, locked forms and failed/canceled runs
  keep their wording.
- OPEND-2793: the community view no longer mounts the docked HomeView
  composer at its foot (product decision B; phase three brings it back in
  a template-bound shape). The dock variant itself is kept. A card's Use
  now hands the prompt and plugin driver to Home, as the standalone
  /community route already did.

* fix(web): only an answerable form holds the awaiting-reply title

An unterminated <question-form> (the run hit its output limit mid-form)
renders neither a form nor a skip once the run is terminal, so the
"Awaiting your reply" title would point at nothing and never clear. Derive
the title from parsed, unanswered forms only; the unterminated case keeps
holding back the next-step card as before. Regression test added.

* feat(web,daemon): enter the pending project frame on send and bound create preparation (#7890)

* feat(web,daemon): enter the pending project frame on send and bound create preparation

OPEND-2617 (OPEND-2553 PR-F6). Pressing Send on Home now switches to the
project frame on the same tick the create request leaves, built from the
creation record alone (name, prompt, staged attachments) so it no longer
depends on the optimistic row surviving a project-list refresh. The frame
ports the Demo's inert ChatComposer so ProjectView takes over in place, and
a failed create hands the staged File objects back to the remounted Home
through a one-shot stash.

The daemon ports Demo #7635's request-wide 15s deadline for every read
POST /api/projects performs before its transaction (design-system and skill
validation, plugin and location lookups, registry loads, template seeding,
which now runs before the commit with compensation). Overruns answer 504
with the new PROJECT_CREATE_PREPARATION_TIMEOUT contract code, retryable,
and commit nothing; OD_PROJECT_CREATE_PREPARATION_TIMEOUT_MS is a test
seam. The Web maps the code to a localized toast (19 locales) and
`od project create --json` prints the structured error envelope.

* test(daemon): keep the create deadline out of the smoke test's accelerated 15s window

run-failure-telemetry-smoke stubs every setTimeout(…, 15_000) to fast-forward
the Langfuse terminal fallback. POST /api/projects now races its own 15s
preparation deadline, so the stub fired that deadline immediately and the
headerless case saw 504 instead of 200. Create the project first, then install
the stub for the run window only.

* fix(web): hand attachments back to a mounted Home and keep pending image previews StrictMode-safe

Review follow-ups on #7890. Backing out of the pending frame before the
create fails leaves Home already mounted, so the stash's mount initializer
never ran and the files were lost; stashing now also dispatches
HOME_COMPOSER_ATTACHMENTS_EVENT and the page HomeView takes the slot on it.
The pending frame's image chips minted blob URLs in a useMemo and revoked
them from a separate cleanup, which StrictMode's double mount turned into
dead links; creation and revocation are now paired in one attachments-keyed
effect like DesignSystemAssetDropzone.

* fix(web,daemon): snapshot the attachment stash and bound the example-card reads

Review round 2 on #7890. The Home attachment stash consumed on read inside a
useState initializer; React StrictMode double-invokes that initializer and
keeps only one result, so the stay-on-pending-until-timeout path handed the
files to the discarded call and Home mounted empty. The slot is now a
snapshot: HomeView peeks it in the initializer and the event listener, clears
it explicitly once the files are in state, and App clears it when a create
succeeds or a new optimistic create starts.

POST /api/projects also left the example-card lookup and manifest digest
outside the preparation deadline; both now go through the same
request-wide race, with their own stage strings.

* fix(home): curate template presets and clarify starter prompts (#7808)

* fix(home): curate template presets and clarify starter prompts

* fix(i18n): translate home presets for all supported locales

---------

Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>

* feat(web): community cards, Home-aligned type tabs, empty state and message center archive (OPEND-2553 S3) (#7971)

* feat(web): port the Demo community gallery, type tabs, empty state and message center archive (OPEND-2553 S3)

Community (CommunityView / CommunityTemplatePreview / plugin-marketplace-demo.css)
- Card is surfaceless: the 16:9 plate carries the hairline stroke and 16px
  corners; the caption is the template title over a publisher byline
  (manifest author, else the source); Remix / 做同款 are 30px frosted pills
  overlaid on the plate and revealed on hover.
- Type tabs are the Home type row's pills (home-hero.css shared contract),
  fixed to 原型 / 幻灯片 / 文档 / 图片 with a 更多 popover for every other
  kind the catalogue carries; the sub-facet row and header search are gone.
- Document joins TemplateType; an empty tab shows the blueprint empty state
  (community-empty-mark.svg) once the catalogue has answered.
- 图片 / 视频 tabs lay out as an uncropped masonry; MediaSurface publishes
  the poster ratio for it.
- Preview modal: Esc closes it, panel scaled 8% down, 16px corners.

Message center
- Archive as a local third state (archived-ids store survives the sign-in
  hand-off), header shelf toggle, card restyle (title + chevron, media on
  expand, type · date), no hover states, no subtitle or settings footer.

i18n: community.typeDocument / emptyTitle / emptyBody, messageCenter.archive
/ unarchive / archivedTitle / emptyArchivedTitle across 19 locales;
community.usePrompt now reads 做同款 / Try it now.

* fix(web): close template previews on iframe Escape

Handle Escape in host and accessible preview documents, rebind on frame load, and focus the dialog on open. Cover keyboard capture, reload cleanup, and inaccessible frames.

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* feat(web): dock the account module at the foot of the entry rail, keep only credits top-right (OPEND-2553 阶段二) (#7976)

* feat(web): dock the account module at the foot of the entry rail, keep only credits top-right (OPEND-2553 阶段二)

Port the Demo (#7635, 997a0a08f3) account increments onto feat/home-entry-refresh:

- EntryTopRightCluster keeps the GitHub chip and the credits / 升级 pill in
  the chrome host; the account module (avatar + name, message-centre bell,
  update rocket, hover/pinned menu) portals into a new `accountHost` at the
  foot of the rail's nav column. The project route (no rail) drops it.
- Free plan: the pill becomes the green 「✦ 升级」 CTA; paid plans lead with
  the plan wordmark and the bare amount (subscribed $0 keeps the wordmark
  only). The billing card hangs under the pill as a hover panel.
- Account menu: centred identity card, 账单 (console dashboard, workspace-id
  fallback), 获取帮助, 提交建议, 退出登录. 设置 and 消息中心 leave the menu
  (rail item / bell). Menu height is bounded to the rail card and scrolls.
- Social links move into the dock above the identity row with labels
  (labels drop under a 190px container query); signed-out keeps the footer
  row. Update rocket falls back to the top-right home while a narrow window
  (≤1080px) auto-collapses the rail.
- entry-layout.css: dock / bell / head / social / pill / panel rules per
  Demo; rail panel is a size container again.
- AvatarMenu uses modelVersionLabel; UserActionCard gains the
  chat-panel-next alignment layer; contracts add the `billing` account-menu
  analytics element; i18n adds entry.socialMail / entry.accountBilling and
  shortens the help / feedback labels.
- Tests re-pinned to the new form (account-billing, updater placement,
  message-centre bell, credits hover panel, zero-balance pill, project
  route); e2e updater and team-interaction specs follow the new homes.

* fix(web): restore keyboard access to account dock actions

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* fix(web): use workspace fallback for billing card actions

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* feat(web): restore the project chat pane material and shell per Demo (#8120)

Port the OPEND-3090 shell deltas from the #8113 Demo (877980fb17) onto
feat/home-entry-refresh without cherry-picking:

- The chat card paints nothing of its own (transparent, no backdrop
  blur) so the app wash / window vibrancy shows through; the 85% white
  frost is gone per product decision. Reduced-transparency and
  no-backdrop-filter fallbacks still flatten it to a solid surface.
- The resize gutter is a 4px track: `--project-chat-handle-width`
  seeds and registers at 4px, the handle sizes from the variable with
  no min-width, and `SPLIT_RESIZE_HANDLE_WIDTH` matches so the layout
  math and the CSS agree. No strip is painted at rest, hover, or drag.
- The composer's design-system trigger uses the Home variant; the Home
  foot-row rules gain a `.composer-row` twin so it renders as the same
  36px palette disc beside the upload trigger.
- AvatarMenu pins its popover between the composer shell's edges,
  right-aligned to the trigger, and re-measures through a
  ResizeObserver on the shell; the popover is a flat `--bg` surface
  with no glass backdrop.

A new style spec pins each value to the Demo's; FileWorkspace's split
style expectation and the project e2e flows follow the new handle
width and trigger test id.

* chore(web): sync main into feat/home-entry-refresh (#8124)

* fix(web): stop asserting unknown project facts while entering a project (#7428)

* fix(web): stop asserting unknown project facts while entering a project

Entering a personal project cold showed two statements that were not true yet.

The read-only banner rendered on viewerOnly, which is fail-closed and also
covers the window where ownership is simply unproven. So your own project told
you it was someone else's shared project for as long as the workspace context
took to resolve. The gate stays -- controls should be disabled until proven --
but the banner asserts a reason, so it now waits for isSharedNonOwner, whose
docblock already promises positive evidence and never mere !isOwner during the
status-unknown window.

The design-files empty state has the same shape: zero files before the first
authoritative read is indistinguishable from a genuinely empty project, and its
CTAs create NEW content. The panel already draws this distinction for a team
mirror that is still downloading; a local list that has not returned yet now
gets it too, keyed on the files snapshot generation, which advances only on an
accepted list (a transport failure deliberately does not advance it).

Measured in Chrome against a personal project with one artifact: the read-only
banner appeared at 4.8s and the empty state was rendered then removed; both are
now absent for the whole entry. Neither change touches what is disabled -- only
what the UI claims while it does not know.

* fix(daemon): keep one preview scope per project so a refetch is byte-identical

The web client builds its srcDoc preview from GET /raw/<file>?odPreviewBridge=…,
and that route minted a fresh preview scope on every request, injecting it as
<base href="…/preview/<uuid>/">. So the same artifact served different bytes on
every read: any refetch produced a different srcDoc string, React assigned it,
and the iframe reloaded -- the artifact visibly vanished and came back.

Measured on a team-workspace entry before this change: the srcDoc was rebuilt
1-4 times per entry depending on timing (one run wrote an empty document in
between), each rebuild carrying a different base href.

Preview reads now acquire the live scope for that exact (project, workspace),
renewing its TTL, and only mint when none exists. The grant is identical -- same
project, same member, same TTL bound -- only its name is now stable.

Deliberately not folded into mint(): export flows mint a scope and revoke it
when the render finishes, and sharing an id with a live preview would revoke the
preview out from under it. acquire() is a separate entry point used only by the
preview read.

Verified two ways. Unit: two reads of one artifact return the same base href;
swapping acquire back to mint turns it red. Browser: recording a team-workspace
entry and measuring frames where the preview goes blank after content had
appeared -- 2 such episodes before (one lasting 74ms), 0 after.

* fix(web): say the file list is loading instead of showing nothing

Suppressing the empty-state CTAs removed a false claim but left the panel blank
until content arrived, which reads as stuck rather than busy -- a worse first
impression than the wrong copy it replaced.

The unknown window now renders the same pill the team-mirror sync uses, with the
existing common.loading copy, so the slot always says which of the three states
it is in: loading, syncing, or genuinely empty. No new copy, no new i18n keys.

This is a consolation, not a cure: the real cost is how long the list takes to
arrive. That is tracked separately -- the project-files read is keyed on the
workspace identity, and workspaceType inside that key is echoed straight back
from the request's own x-od-workspace-type header, so a stale header during
entry changes the key and defeats the request sharing that exists to prevent
exactly these duplicate reads.

* perf(web): single-flight the design-system catalog read

Entering a project mounts several independent catalog readers in the same
tick. `fetchDesignSystemsResult` fetched `/api/design-systems` directly, so
three concurrent readers meant three requests — while the team-index step in
the very same function was already coalesced. The catalog read was the half
that got missed.

Identical concurrent reads are not merely redundant here. The daemon answers
them serially, so each duplicate holds one of the browser's ~6 per-host
connections for seconds. Measured entering a real project, three catalog
reads took 1.8s / 3.1s / 3.7s while this project's own `/files` read — 17ms
when uncontended — waited 1.2s for a connection.

Route it through the same `coalescedGet` the team index uses, partitioned by
workspace identity and by the caller-supplied team witness. Two invariants
are preserved deliberately:

- A forced read (`forceTeamMaterialization`, fired BY a mutation) evicts any
  in-flight snapshot instead of joining it, exactly as the team index does
  one function above. A mutation must observe its own effect.
- `coalescedGet` declines to cache a rejection, but this reader reports
  failure as a resolved `{ ok: false }`, so that is evicted explicitly rather
  than shared for the rest of the window.

Measured entering the same project, three runs before and three after:

  design-systems requests   4 / 4 / 4      ->  2 / 2 / 2
  slowest of those          3678/5056/5318 ->  1501/745/1769 ms
  requests over 1s          37 / 53 / 27   ->  16 / 7 / 9

Total request count moved too but its ranges overlap across runs, so no
claim is made there. The remaining 2 reads are the two identity generations
of entry (provisional, then resolved); collapsing those is a separate change.

Red spec first: the burst assertion failed with "expected 3 to be 1" before
the fix. The forced-read guard was green before and after — it locks in the
invariant rather than reporting a defect.

* fix(daemon): never let a live preview adopt a one-shot preview scope

Review caught a real defect in this branch's own `acquire`. It searched every
registry entry by (project, workspace), and entries carried no record of who
minted them or why. A screenshot/PDF export mints a scope for the same tuple
and `revoke`s it in its `finally`, so a preview that acquired the export's
entry would lose its `<base href>` the moment that export finished -- blanking
the artifact, which is the exact symptom this area exists to prevent.

The comment above `acquire` claimed the mint/acquire split already prevented
this. It did not: the split only stopped `mint` from handing out a shared id,
while `acquire` remained free to find an export's entry.

Mark the lifetime on the entry instead. `mint` creates one-shot scopes whose
owner revokes them; `acquire` creates and matches only reusable scopes, which
are released by expiry alone. The public surface is unchanged -- both go
through one internal `create`.

Red spec first, at the HTTP boundary: `preview-url` mints exactly the way an
export does, so it stands in for one without needing a renderer. Before the
fix the bridged raw read handed back the *same UUID* that mint had just
issued:

  expected 'e60f3014-…-642f00aeadc2' not to be 'e60f3014-…-642f00aeadc2'

* fix(web): stop the remaining surfaces claiming an unproven share

Browser verification of the earlier banner fix showed the claim was shortened,
not removed. Measured entering an OWNED personal project, three runs each:

  main            "这是共享项目…" shown ~4.1s
  banner fix only shown ~1.0s, 3 of 3 runs
  this commit     not shown, 3 of 3 runs

The banner was only one of three surfaces deriving the claim from
`projectMutationReadOnly` (= `viewerOnly || materializationPending`), which is
fail-closed and therefore also true while ownership is merely unknown. The
chat composer placeholder and the project title tooltip each re-derived it,
so gating the banner alone left the same false sentence on screen.

Give the reason one name. `projectReadOnlyClaim` returns undefined while the
share is unproven, and all three surfaces read that one value. Disabling still
comes from the fail-closed flag — only the CLAIM waits for evidence.

This is the same defect class as the hook-level fixes in
use-project-collab.context-seed and use-project-collab.created-by-viewer; it
survived them because these three sites bypass the hook's guard and read the
flag directly.

* fix: restore the fixes d61be13806 reverted by accident

d61be13806 was meant to carry one web change. It also silently reverted the
daemon preview-scope fix, the design-files loading placeholder, and the
file-workspace banner gate — every fix this PR had landed so far.

Cause: verifying the branch in a browser meant swapping files to their
origin/main content and back. `git checkout origin/main -- <paths>` does not
only change the working tree, it STAGES those paths. Restoring the working
tree afterwards left the index still holding main's content, and `git commit`
commits the index, not the working tree. The check I ran before committing
(`git diff HEAD -- apps/daemon`) compares the working tree, so it reported
clean while the index was not. `git status` did say `MM`; I read the second M
and not the first.

This commit restores all five files to their state at the merge commit
0f923028c4, verified marker by marker rather than by eye:

  daemon acquire / reusable scope entries        present
  server-context acquire on the deps interface   present
  design-files loading placeholder + gate        present
  file-workspace banner requires a reason        present

No behavior is intended to change relative to 0f923028c4 plus d61be13806's
ProjectView work; this only undoes collateral damage.

* fix(daemon): keep the whole bridged document stable, not just the scope id

Review caught that this branch's preview-scope reuse does not actually stop
the flicker it was written for. `acquire` renewed `entry.expiresAt` on every
call, and that expiry is serialized into the bridge script of the very
document the scope is injected into (`buildPreviewBaseHrefBridge` JSON-encodes
`{href, expiresAt}`). So two repeated reads returned the same scope id and the
same `<base href>` while the full body still differed — React reassigns
`srcDoc`, the iframe reloads, and the artifact blinks exactly as before.

Stop renewing on acquire. Lifetime extension already has its own path: the
client renews explicitly via `x-od-preview-scope-renewal`, and that response
is not a document, so extending there changes no served bytes.

The existing spec could not see this: it compared only the `<base href>`. It
now compares the complete returned document, which is what `srcDoc` actually
receives — any byte that moves per request reloads the iframe just as surely
as a fresh scope id would.

Verified end to end against a running daemon, three repeated reads of one
artifact:

  before  6ccf51baf7238d5c / 3a7c49a353b5efdd / 0e02c96c1da882f4
  after   dfd91bb06572dd65 / dfd91bb06572dd65 / dfd91bb06572dd65

Not verified: a browser-level before/after on the srcDoc transport. Every
"before" attempt resolved to the URL-load path instead, where changing bytes
at a stable `src` does not reload the iframe at all, so the two runs were not
comparable. The HTTP-level evidence above measures the defect itself.

* docs(daemon): say that acquire reuses a scope without renewing it

Both docblocks still described the behaviour the previous commit deliberately
removed. The dependency contract in server-context.ts promised a renewed TTL,
and the summary above the implementation said the same, so a future consumer
could reasonably assume repeated acquisition keeps a scope alive -- or make
the implementation match the comment and reintroduce the byte instability
that reloads the iframe.

Both now state that acquire reuses without renewing, that ttlMs applies only
when it has to create a new reusable scope, and that keeping an in-use scope
alive is renew's job.

* fix(daemon): freeze the expiry a preview document reports

The previous commit stopped `acquire` from renewing, but left the other half:
the explicit `renew` endpoint still moves `entry.expiresAt`, and the next
bridged read serializes that new value. Review measured it — `read -> renew ->
read` kept the same scope and the same length, changed the embedded expiry,
and produced one additional iframe `load` in Electron. The client renews on a
timer for as long as a preview is open, so this is the steady state, not an
edge case.

Split the two meanings the field was carrying. `expiresAt` stays the live
lifetime that renewal extends, and JSON responses keep reading it.
`documentExpiresAt` is frozen when the scope is created and is what a served
body embeds, so nothing a renewal does can change the bytes of a document.

The host does not lose anything: it uses the embedded value only to seed its
first renewal, and every renewal after that is scheduled from the renew
response (FileViewer keeps the returned expiry, it does not re-read the
document for it).

Red spec first: `keeps the bridged document identical across an explicit
renewal` failed on two byte-different bodies. Mutation-checked by pointing the
route back at the live `expiresAt`. Verified against a running daemon —
read, renew, read, renew, read returned 7a2f2c060559759e three times.

* 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 …

* feat(web): align the home and project composer Add menus with the Demo (#8128)

Port the Demo (877980fb17) Add-menu shape onto main's shared ComposerPlusMenu:
one 36px plus disc on both composers, a paperclip attach row, the context
actions flat under it, optional plugin / connector / MCP submenus, no design
toolbox flyout and no working-directory group, and a 4px inset on every
composer tag. Both callers pass the three resource submenus with their add
handlers and analytics; the Home add-plugin row lands on the plugins page.

Refs OPEND-3085

* feat(web): dock the conversation history in the project toolbar (#8129)

Port the OPEND-2553 chat S3 slice from the Demo (#8113 @ 877980fb17):

- ProjectView hosts a 32px `chat-history-dock` seat right after the docked
  project dropdown; ChatPane portals its history control there when given
  `historyPortalTarget` and renders in place otherwise (side chat, flows).
- The history trigger uses the Demo's filled discuss-line glyph; the single
  "new session" button moves from the card header into the dropdown's
  search row (new `ConversationHistoryDock.module.css`).
- The dropdown drops its heading, conversation count, per-row message-count
  prefix and delete button; rows show recency only (`runtime/chat/
  conversation-time.ts`: now / Nm / Nh Mm / Nd), replacing the run-duration
  labels and the ChatPane-local relTime helpers.
- Toolbar row grows to 48px, centred, `position: relative`; the collapse
  button follows the dock (`order: 2`) with a transparent seat.
- QueuedSendStrip remounts per conversation.
- Tests: Demo's history-dock and conversation-time specs (red before the
  change), timestamps spec updated, w124/w129 follow the moved entry,
  msg-count spec removed with the feature; e2e locators updated and the
  delete-recovery flows parked since the UI no longer has a delete entry.

Plane: OPEND-3087

* fix(web): show the chat message rail from the first user message and drop the retract state (#8123)

Port the message-rail deltas from the chat module demo (#8113) onto the
current ChatPane:

- the rail mounts as soon as a conversation has one user message
  (`CHAT_RAIL_MIN_USER_MESSAGES` 2 -> 1) and unmounts when the active
  conversation has none;
- every dash rests at 6px instead of 8px (`CHAT_RAIL_DASH_BASE_PX`);
- clicking a marker only closes the preview card. The `retracted` state,
  its document `pointermove` release listener and the `.is-retracted`
  rules are removed, so the markers stay hoverable right away;
- `.split:has(.chat-message-rail) > .split-resize-handle::before` keeps
  the resize hitbox flush against the rail.

Tests: port `message-rail.test.tsx` (red before, green after), drop the
retract-state specs and refresh the threshold comments in the remaining
rail specs.

Refs OPEND-3088.

* feat(web): stack queued chat messages as layered cards (#8125)

Replace the flat queued-send strip under the chat composer with the Demo
QueuedSendStack: the newest queued message sits in front while the two
behind it peek out shifted 12px up, scaled down 6% and faded (0.6 / 0.36);
anything deeper is hidden until the region is hovered, focused or dragged,
which spreads every card into a 2px-gapped column of at most six visible
cards that scrolls for longer queues. Cards slide in from below and animate
out before their node is removed.

Per-message send-now / edit / remove / drag-reorder controls keep their
callbacks; the region is labelled "queued · N" and the cards behind the
front one are inert. The legacy `.chat-queued-send-strip` /
`.chat-queued-send-list` rules and DOM are removed together with the two
specs that pinned them; the ported Demo specs cover the stack behaviour.

Refs OPEND-3086.

* feat(web): blur the chat transcript progressively under the project toolbar (#8127)

Port the Demo ChatScrollEdge: a 40px four-layer backdrop-filter overlay at
the top of .chat-log-viewport that activates once the transcript scrolls
past the first pixel, plus a viewport mask that lets the real surface show
through instead of a painted cap. The project header becomes transparent
and loses its ::after glass gradient.

Refs OPEND-3089.

* fix(web): use one idle glyph for projects in the rail and the tab switcher (#8150)

A project with no run to report led with two different marks depending on
where the user found it: the rail's 最近项目 row drew a chat mark, the
project switcher above the chat drew the tab's folder icon. The two already
share ProjectRunStatusIcon for a project that has a status (OPEND-2795), so
the resting state is now shared the same way.

- ProjectRunStatusIcon.tsx exports ProjectIdleGlyph, the chat mark that used
  to live inline in RailRecentRow, sized by the caller and inked by
  currentColor so each slot keeps its own box and colour.
- RailRecentRow draws it at 16 in its 18px slot, as before.
- WorkspaceTabsBar.leadGlyphFor draws it at 14 for a project tab with nothing
  to report; non-project tabs keep their own icon. hasRunStatusGlyph is
  unchanged.
- New spec ProjectIdleGlyph.test.tsx renders both surfaces with an idle
  project and asserts they hold the same glyph (red before, green after).

OPEND-3129

* fix(web): keep context actions in the Add menu and drop the template eye badge (#8151)

OPEND-3126: the Home working-directory menu offered its own copies of
"Reference another project" and "Link local code" next to the Add menu's
entries. Product wants the two reachable from the Add menu only, so the
working-directory row is back to folder rows (choose / recent) and the
WorkingDirPicker props, rows, and divider that carried the copies are gone.
The plus_pick analytics are emitted from the Add menu alone.

OPEND-3100 (supersedes OPEND-2697): the template poster no longer carries an
eye badge or a "Preview" tooltip, at rest or on hover. The whole poster still
opens the preview; only the badge DOM, its tooltip, and its CSS are removed.

Tests: HomeView.context-picker asserts the working-directory menu no longer
lists the two entries and routes the existing reference flows through the Add
menu; HomeHero.rail asserts the poster renders no badge or tooltip at rest and
on hover while the poster click still opens the preview; the home-hero style
spec asserts no eye-badge rule survives. The Home e2e flows use the Add menu.

* fix(web): project list filter, title spacing and naming (OPEND-3107/3110/3142) (#8153)

OPEND-3107: the project list's type filter now offers exactly the six
product types in order — Prototype, Slides, Document, Image, Website
clone, Design System. Live Artifact and Media are no longer offered.
Projects resolve to a bucket through `projectKindFilterCategory`, built
on the card-chip vocabulary: Document (`intent: 'document'`) and Image
(`kind: 'image'`) get their own buckets, live-artifact projects fold into
Prototype, video/audio projects match only "Any type". New i18n keys
`recentProjects.kindDocument` / `recentProjects.kindImage` in all 19
locales; Slides reuses the existing `recentProjects.kindSlides`.

OPEND-3110: the page title sat 29px below the content card's top edge
because the head row centred the 28.8px heading inside the 39px-tall
filter row. The title block now pins to the row's top (`align-self:
flex-start`), so the title box starts on the 24px gutter like its left
edge.

OPEND-3142: the sidebar entry and the project list page title read
"Projects" instead of "Personal projects" (`entry.navDrafts`,
`workspaceSwitcher.draftsTooltip`), using each locale's existing
"Projects" noun. Copy only; route and data are unchanged.

* fix(web): entry rail account and workspace polish (OPEND-3119/3112/3113/3153/3116) (#8152)

* fix(web): entry rail account and workspace polish (OPEND-3119/3112/3113/3153/3116)

QA acceptance batch G1 on feat/home-entry-refresh, five fixes in the rail's
account and workspace area:

- OPEND-3119: the billing card's plan label names the personal tier itself
  (Plus / Pro / Max) instead of folding plus and max into the Pro label, so
  a Max account no longer reads "Pro Max" beside the max wordmark. Adds
  entry.billingTierPlus / entry.billingTierMax to every locale.
- OPEND-3112: the workspace switcher row and the switcher menu rows render
  their names through a new MarqueeLabel, which slides an overflowing name
  leftwards once on hover (CSS `.od-marquee` primitive; trigger, direction,
  speed and leave behaviour match the Demo).
- OPEND-3113: the switcher's hover fill is the same ink-10% veil the
  selected nav row wears, not --bg-subtle.
- OPEND-3153: the account menu's hover bridge moves off the overflow-y:auto
  menu (where an absolutely positioned child below its box added 8px of
  scrollable overflow and a permanent scrollbar) onto the account container,
  gated on the open state; the identity card's vertical padding is
  tightened 16/18 -> 12/14.
- OPEND-3116: the rail panel's bottom gutter is 8px instead of 12px.

Red specs for each ticket live in apps/web/tests (components + styles).

* fix(web): respect RTL direction in workspace name marquee

Derive marquee travel from the clipping slot computed direction and cover inherited LTR and RTL row layouts.

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* fix(i18n): translate the home-visible working-directory and type-chip strings (#8154)

* fix(i18n): translate the home-visible working-directory and type-chip strings

Fill the home-visible subset of OPEND-2841 / OPEND-2843: homeWorkingDir.*,
homeHero.chip.* names, descriptions and next-step copy, and the six
account-menu entry.* labels, in the 15 locales that still carried the
English source (plus the last 8 keys in ru and 4 in zh-TW). Brand and
technical names (HyperFrames, WebGL, Design Files, iOS, Android, PDF)
stay as they are.

* test(web): lock the home-visible i18n strings for OPEND-2841 / OPEND-2843

Red on feat/home-entry-refresh (39 of 46 cases), green with the
translations: no target key may equal the English source, non-Latin
locales must use their own script, and the ko / ja strings the tickets
name are pinned.

* feat(web): bring the local CLI/BYOK home onto the entry rail layout (#8156)

* test(web): red specs for the local shell on the entry rail layout (OPEND-3140)

The signed-out CLI / BYOK shell still keeps Home's recent-projects grid and
has no 最近项目 section, no 项目 destination and a bare message-centre rail
item. These specs describe the target layout at the unit and browser
boundaries and go red on the current feat head.

* feat(web): list local projects in the entry rail's recent section (OPEND-3140)

Without a cloud identity the catalog EntryShell hands the rail is the local
project list, so the signed-out branch now renders the same 最近项目 section
the signed-in branch has. The row menu keeps only what a local project can
do (rename / duplicate / delete): 转入团队空间 already gates itself off a null
workspace context.

* docs(web): state the local-project contract of the shared run-status feed (OPEND-3140)

The store the rail and the tab switcher share already reads per project
without Workspace headers when there is no cloud identity, and the daemon's
headerless branch answers for unbound local projects. Record that this is
the contract the local shell's 最近项目 rows rely on, so nobody adds a
local-only copy of the feed.

* feat(web): reach the 项目 page from the local entry rail (OPEND-3140)

The signed-in rail's 项目 item opens /drafts; the local shell had no such
destination and EntryShell bounced /drafts back to Home without a
workspace. Without a workspace every local project is a draft, so the view
is now allowed on both branches and the local rail carries the same item.

* feat(web): dock the local shell's message centre beside a local identity row (OPEND-3140)

Signed in, the message-centre bell rides the account dock at the rail foot
(阶段二 S2); signed out it was still a bare rail destination under 设置. The
signed-out footer now renders the local twin of that dock under the sign-in
card: the social links, a 本地模式 identity row that opens Settings, and the
bell in the same trailing slot with the same unread dot. Adds the
entry.localAccountName key to every locale.

* feat(web): drop Home's recent-projects grid from the local shell (OPEND-3140)

With the rail's 最近项目 section, the 项目 destination and the foot dock now
live on the local branch too, Home no longer needs the grid it kept as the
migration constraint's other half (OPEND-2683): the strip mount and its
filter / sort / view controls leave Home on both branches, along with the
card handlers HomeView only carried for that grid. Specs that reached the
catalogue through the Home grid now go through the rail rows or the 项目
page.

* fix(web): duplicate from project cards and icon-only social links (OPEND-2686/3180) (#8164)

* fix(web): offer Duplicate project from the project list cards (OPEND-2686)

The Personal projects (/drafts) and All projects card "more" menus only
showed Rename / Delete. RecentProjectsStrip already renders a Duplicate
item between the two whenever it is handed `onDuplicate` (the rail row and
the DesignsTab both wire the shell's `onDuplicateProject` through), but the
two strip mounts in EntryShell never passed it, so the item silently stayed
out of the card menu. Pass the same handler so both card grids duplicate
through `POST /api/projects/:id/duplicate` like the rail row does.

* fix(web): icon-only Discord / X / mail links at the rail foot (OPEND-3180)

Drop the visible names from the social row above the account dock (and its
signed-out local twin) so only the three glyphs remain: 28px square targets
at one 4px gap, left-aligned so the first glyph sits on the avatar's own
left edge. Each link keeps its aria-label and now surfaces the same copy
through the shared od-tooltip layer on hover; hrefs are unchanged. The
label-only CSS (and its narrow-rail container query) goes with the labels,
as does the orphaned `entry.socialMail` key.

* fix(web): folder glyphs everywhere and an unread dot for completed projects (OPEND-3129/3133) (#8168)

The rail's recent-project rows and the project switcher now both lead an idle
project with the folder, and a finished run no longer replaces that glyph with
a checkmark: the folder stays and a 6px #1F68FE dot at the row's end says
"done, unread" until the project is opened. Both surfaces read the dot from the
one shared run-status store, so opening the project from either spends it in
both.

This supersedes the first OPEND-3129 pass (#8150), which had unified the
resting glyph on a chat-bubble mark; product and design settled on the folder
instead (design reference: 4acbb899f5).

- ProjectRunStatusIcon: drop ProjectIdleGlyph and the succeeded badge; add
  ProjectFolderGlyph, ProjectCompletionDot and hasCompletionNotice;
  hasRunStatusGlyph answers no for succeeded.
- RailRecentRow / WorkspaceTabsBar: folder in the lead slot, dot after the
  name (entry-nav-recent-unread / workspace-tabs-dropdown-unread).
- entry-layout.css / routines.css: the dot's shared measurements.
- Tests: ProjectGlyphs spec (rail + switcher, all four states, spend from
  either side), CSS measurement spec, e2e run-status spec for the dot.

* fix(web): give chat containers a material under the transparent pane (#8166)

* fix(web): give chat containers a material under the transparent pane

After OPEND-3090 made the project chat pane transparent, the question form,
its Confirmed answer block, the thoughts window, the nested terminal block,
the composer shell, the project dropdown and the conversation history menu
kept the opaque white / panel fills they used to borrow from the pane and
showed up as flat slabs over the app wash (OPEND-3177, OPEND-3175,
OPEND-3178; composer shell for OPEND-3173).

Content-layer cards now read the Regular material and the material
separator from styles/material.css; the two floating menus read the liquid
glass pair. The record primitives get the material through new --chat-
material-* seam tokens on ChatRoot. Every token flattens back to a solid
surface under reduced transparency or without backdrop-filter, and dark
carries its own values. The tint level stays on the tokens for design to
tune; the left/right pane grounds are untouched.

* test(web): re-pin the thoughts window surface to the Regular material

The record surface spec pinned the thinking window to the draft's opaque
#fafafa; under the transparent pane the window now reads the Regular
material tint from styles/material.css. The fact the section guards (one
defined tint, not the context-dependent fill-tertiary) is unchanged, so the
pin follows the paint and the resolver learns the material tokens.

* fix(web): enter the pending view immediately for AMR sends from Home (#8169)

A Home send with OpenDesign Cloud (AMR) selected awaited the pre-run balance
gate (wallet + authoritative workspace billing read, 1-2s upstream) before
creating the project, so the optimistic pending frame F6 introduced never
showed for that path and the user sat on a frozen Home. Local agents entered
the frame on the click tick.

EntryShell now hands off to the project frame first (App-owned
beginOptimisticProjectCreation) and runs the balance gate behind it. The
gate's hard-block dialog moves to App (HomeAmrBalanceGateDialogs) because
EntryShell is unmounted on the project route; a dismiss rolls the hand-off
back to Home with the draft and staged files intact, an unreadable wallet
rolls back with an explicit notice, and an allow creates the project under
the same optimistic id.

Refs OPEND-2614.

* fix(web): keep one loading state from the Home send to the first turn (#8174)

* fix(web): enter the pending view immediately for AMR sends from Home

A Home send with OpenDesign Cloud (AMR) selected awaited the pre-run balance
gate (wallet + authoritative workspace billing read, 1-2s upstream) before
creating the project, so the optimistic pending frame F6 introduced never
showed for that path and the user sat on a frozen Home. Local agents entered
the frame on the click tick.

EntryShell now hands off to the project frame first (App-owned
beginOptimisticProjectCreation) and runs the balance gate behind it. The
gate's hard-block dialog moves to App (HomeAmrBalanceGateDialogs) because
EntryShell is unmounted on the project route; a dismiss rolls the hand-off
back to Home with the draft and staged files intact, an unreadable wallet
rolls back with an explicit notice, and an allow creates the project under
the same optimistic id.

Refs OPEND-2614.

* fix(web): keep one loading state from the Home send to the first turn

After the optimistic hand-off, the pending frame dropped the moment
POST /api/projects answered and ProjectView then walked through its own
loaders in the chat column: a whole-column spinner while the conversation
id resolved, the transcript skeleton while messages loaded, then an empty
log until the auto-send painted the user turn. Three forms in ~300 ms
before the same prompt bubble and assistant row came back.

The creation record now outlives the request: App marks it persisted when
the project frame is allowed to open, ProjectView keeps drawing the
hand-off's chat card (ProjectCreationPendingChat, extracted from the
pending view) on top of its chat column with the real ChatPane laid out
hidden underneath, and releases it once the first transcript settles (the
auto-sent turn is painted, or there is nothing to send, or the load
failed). App retires a card that never settles after 8 s.

Refs OPEND-2170.

* fix(web): restore composer portal containment outside handoff

Keep layout/style containment and translateZ(0) on the base composer layer. Limit the creation-handoff hidden selector to visibility.

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* fix(web): collapse the project list toolbar to the Demo's three controls (OPEND-3107) (#8186)

The project list's control row carried five controls on feat: 多选, the
type filter, a clear-filters chip (once a filter narrowed the grid), a
sort button, and a grid / list toggle pair. The Demo (877980fb17) keeps
that row at three: 多选, the type filter, and one ⋯ button whose menu
folds sort and view into two labelled groups with the current item
checked. G4 (#8153) already aligned the type filter's six options; this
lands the row itself.

- Merge the sort button and the view toggle pair into a ⋯ trigger
  (RemixIcon more-2-line, aria-label "Sort projects · View mode",
  aria-haspopup="menu") whose menu is two role="group" blocks — Sort
  (Newest / Oldest / Name) and View (Grid / List) — of menuitemradio
  buttons carrying aria-checked and a check glyph on the current item.
  Escape closes the menu and returns focus to the trigger.
- Drop the standalone clear-filters chip: the type trigger already prints
  the picked value, and picking "Any type" back is the reset. Its CSS and
  the unused view-toggle group rule go with it; the i18n key stays, as on
  the Demo.
- Port the Demo's menu styles: viewport-capped min/max width, the 196px
  display variant, group separators, group labels, and the 13px check
  slot; anchor label-pill menus on the left through
  `:has(> .recent-projects__filter)` so the icon-only ⋯ keeps its right
  anchor regardless of how many pills precede it.
- Red spec: tests/components/RecentProjectsStrip.toolbar.test.tsx (seven
  cases, all red on the parent commit). The clear-chip test and the
  list-view Playwright step follow the new structure.

* fix(web): opaque composer card, compact queue, and one container material under the transparent pane (#8185)

After OPEND-3090 made the project chat pane transparent, the containers on
it went two different ways: H1 (#8166) gave the question form, its Confirmed
block, the thoughts window, the nested terminal block, the composer shell and
the two floating menus a frosted Regular / glass material, while the
direction settled on since (#8165 commit 1, the Demo reference) is the
opposite — one opaque floating card for the content layer and one plain
elevated menu for the popovers. This lands that direction on the feat branch
by hand and withdraws H1's frost (OPEND-3177 / OPEND-3175 / OPEND-3173), and
compacts the queued-send stack the same way (OPEND-3086).

Composer shell: the fixed-layer shell reads the same floating-card family
the queued-send cards stacked above it already use — `--chat-floating-card-bg`
ground, `--chat-border-soft` edge, `--chat-radius-xl` corners — with no
backdrop blur and no shadow; the chat.css glass block for it and the
per-appearance dark overrides are gone, dark follows the token.

Queue: rows are compact (`gap: 4px; padding: 6px 4px`) and lose the index
column; the expanded viewport shows five complete cards plus half of the
sixth as the scroll cue (`expandedHeightLimit`), still keeps every queued
item and still fits short windows; the "jump to latest" button yields while
the stack is open (`QueuedSendStack.onExpandedChange`) and returns when it
folds. Send / edit / remove / reorder / quote / draft restore are untouched.

Containers: the question form (shell, confirm variant; head / body / foot /
pill stay transparent), the Confirmed answer block, the thoughts window and
the nested command block read `--chat-floating-card-bg` + `--chat-border-soft`
with no blur; the project switcher menu and the conversation history menu
use the action-menu recipe (`--bg`, `--border-soft`, `--shadow-md`), the
history search field goes back to its solid mix. The H1 `--chat-material-*`
seam tokens have no consumer left and are removed.

Specs: `chat-transparent-pane-materials.test.ts` is re-pinned to the new
values (red on the H1 state, green here); `queued-send-viewport.test.tsx`
follows the reference numbers (502→471, 670→684, 236/322→196/218) and pins
the expansion callback; the answered-surface and record-surface specs pin the
floating card; the four queue specs and the manual-edit e2e stop expecting
the index column.

* fix(web): show real HTML and deck covers in the recent-project hover preview (#8187)

The rail's recent-project hover preview (shared with the chat project
switcher) resolved covers with only the cheap half of the grid's pipeline
and treated every html cover as "not a picture", so prototypes, decks,
documents and web clones showed nothing but the tinted glyph
(OPEND-2766).

- Extract the grid's cover decision (files read, cover pick, HEAD probe,
  deck cover-slide preload, design-system special cases) from
  RecentProjectsStrip into lib/project-cover-pipeline so both surfaces
  resolve one decision and write it back through the same snapshot key.
- Render html covers in the preview plate the way the grid does: a plain
  page in a sandboxed 1280px frame scaled to the plate, a deck as its
  cover-slide srcDoc; the frame is pointer-inert and stays hidden behind
  the glyph until its document has loaded, so a first hover swaps once.
- Join concurrent resolves of the same project so repeated hovers do not
  repeat the files read and probe; the glyph is the fallback only when
  the project really has nothing to show.

* feat(web): rename, duplicate, move-to-team and delete from the project switcher (#8183)

* feat(web): rename, duplicate, move-to-team and delete from the project switcher

The docked project switcher (the chat header's tab dropdown) gains a ⋮ menu
per project row with the four actions the rail's 最近项目 rows already offer:
重命名 (in place), 复制项目, 转入团队空间 and 删除 (OPEND-2686 / OPEND-3128).

All four run through the shared project-actions flows so the switcher, the
rail rows and the project cards cannot drift: `useProjectDeleteFlow` +
`ProjectDeleteConfirmDialog`, `useProjectDuplicateFlow` (which now reports
its outcome so a menu can stay open on failure), and `useWorkspaceProjectMove`
+ `MoveToTeamConfirmDialog`. 转入团队空间 is a real move into the team space,
shown under the rail row menu's conditions: only in a team workspace with
`canShareProjects`, inert once shared, and disabled with the ownership hint on
a project someone else shared. The move response doubles as the row's
ownership witness until the team catalog lists it, through the same optimistic
ownership helpers EntryShell uses for the cards.

Opening a row's menu suppresses that row's hover preview; the menu takes the
preview's slot beside the dropdown. New locale key
`recentProjects.duplicateInProgress` across all 19 locales.

* fix(web): tolerate a workspace context without permissions in the switcher menu gate

A context carrying no permissions block must read as "cannot share", not
throw during render; some App-level fixtures build contexts that way.

* fix(web): one artifact type list for Home and Community, centred overflow, neutral type chips, document and WebGL community tabs (#8198)

OPEND-3146: the Home type row keeps 原型 / 幻灯片 / 文档 inline and folds every
other create type into 更多 in product order (图片, HyperFrames, 网站克隆, 视频,
音频, 实时产物, WebGL). The duplicate web-clone catalog entry left by the S0
merge is removed.

OPEND-3098: the Community filter row's 更多 is the fixed list HyperFrames /
视频 / 音频 / 实时产物 / WebGL, rendered whether or not the catalogue has a
template of that kind, with a WebGL template type mapped to the Home webgl chip.
Both popovers open centred on their row (one line, Home may wrap when narrow)
instead of hanging off the 更多 trigger.

OPEND-3103: type chips and Community tabs carry no per-type colour — icons rest
at currentColor and the picked composer pill is the brand pair, per the #7635
Demo. This withdraws the colour half of OPEND-2684 (F4 #7891); the ring
contrast half stays. The --type-* tokens are removed.

OPEND-3118: facets.ts gains document (prototype mode + the docs/reports tag
group) and webgl (webgl / webgl2 / shader / gpu) primary categories ahead of
prototype, so the bundled document and webgl-* examples land in their own
Community tabs. The docs-reports prototype scene is retired with it.

* fix(desktop): pixel-scan startup splash, 44px top chrome, and a mailto fallback when no mail handler exists (#8197)

* feat(desktop): boot on the inlined pixel-scan wordmark splash (OPEND-3202)

Port the Home hero's pixel-scan wordmark into the startup splash as plain
WebGL, inlined with the logo art because the splash is up before any HTTP
origin exists; the sweep loops for as long as the boot takes instead of
playing a one-shot clip and freezing. The Home kinetic grid also plays a
scripted startup sweep once per page load so the effect introduces itself.

Base: Demo #7635 head 877980fb17 (splash-pixel-scan.ts and its spec taken
as-is; runtime.ts and AppWashKineticGrid.tsx merged by hand against feat).

* fix(web,desktop): 44px top chrome with the traffic lights re-centred (OPEND-3111)

The shared top chrome (Home / community / all-projects entry pages and the
project page's chrome row) is 44px per the design update, down from the
52px tab-bar rhythm. The workspace-shell grid row, the header height and
the --workspace-tabs-chrome-height token move together; the generic
.app-chrome-header base rule follows. macOS trafficLightPosition.y goes
20 -> 16 so the 12px circles sit on the new 22px midline.

Design update beyond the Demo (Demo and feat both measured 52).

* fix(desktop): copy the support address when no mail handler can open a mailto (OPEND-2799)

On Windows the rail's "email us" button did nothing when no mail client is
registered: shell.openExternal(mailto:) resolves and opens nothing. Check
app.getApplicationNameForProtocol('mailto:') first on non-mac platforms and,
when nothing is registered or the registered handler refuses the launch,
copy support@open-design.ai to the clipboard and say so in a main-process
dialog (zh/en by OS locale). The macOS LaunchServices / Apple Mail path is
unchanged.

* fix(web): let thoughts, confirmed answers, terminal blocks and question forms sit directly on the transparent pane (#8199)

* fix(web): let thoughts, confirmed answers, terminal blocks and question forms sit directly on the transparent pane

Refs OPEND-3177 / OPEND-3175 / OPEND-3173 (OPEND-3178 is the design evidence).
Corrects the content-layer half of K1 (#8185): after the design settled on
2026-09-16 (OPEND-3178 recording + acceptance-3178-0.png, OPEND-3177 comment
"去掉底部的白色底"), the thoughts window, the Confirmed answer block, the nested
command + output block and the question form (shell / confirm variant /
Confirmed) paint no ground, edge or shadow of their own; the thoughts body
reads `--chat-text` (#494949 light) instead of the muted stream ink so it stays
legible straight on the pane. The opaque floating card stays with the composer
shell and the queued-send cards only. The user bubble already matches the
recording (#121212 light / --text-strong dark) and is pinned, not changed.

Re-pins the K1 measurement specs (chat-transparent-pane-materials,
question-form-answered-surface, record-surface-and-terminal-ink): 13 red on
e14622a644, green here.

* test(web): restore the K1 floating-card and stream-ink reverse anchors in the record-ink spec

The BEFORE table lost the two keys the re-pinned assertions read
(floatingCard / streamInk), which broke the web typecheck in CI.

* test(web): re-pin the thoughts body ink to --chat-text in record-ink-layers

The "thoughts stay on the muted stream ink" case encoded the pre-2026-09-16
design; the OPEND-3178 recording moves the thoughts body to the chat body
ink (#494949) now that the window paints no ground, so the case pins that
value and still guards both boundaries (not --text-strong, not #a3a3a3).

* fix(web): all-projects entry with recent / personal / team tabs, twelve-type filter, and a Demo-clean project header (#8202)

* fix(web): all-projects entry with recent / personal / team tabs, twelve-type filter, and a Demo-clean project header

OPEND-2553 home-entry QA, P2 card (OPEND-3107 / 3108 / 3128 / 3258 / 3201,
plus the OPEND-2787 second pass).

- OPEND-3107: the project list's 任何类型 filter lists every creation
  type in the product order (原型 / 幻灯片 / 文档 / 图片 / HyperFrames /
  网站克隆 / 视频 / 音频 / 实时产物 / WebGL / 设计体系), labelled with the
  Home chip keys; `projectKindFilterCategory` buckets by creation
  metadata (intent outranks kind) and every project resolves to one bucket.
- OPEND-3108: the rail's project entry is 全部项目 (19 locales, withdrawing
  G4's 项目); the page splits one catalog into 最近浏览过 / 个人项目 /
  团队项目 tabs on the Demo head grid; the team workspace's separate
  团队项目 rail entry is gone and `/all-projects` opens the 团队项目 tab.
- OPEND-3128 / OPEND-3258: the chat card renders no project-title row on
  the project page (S3's kept header withdrawn; renaming lives in the
  switcher row menu), the pending-creation card drops the same row, and
  the switcher trigger sits on the pane with no white pill or shadow.
- OPEND-3201: the card footer keeps the relative time whole - the
  creator ellipsises first, then the kind chip wraps under the row.
- OPEND-2787: the reference-project rows were shrinking again as flex
  items of the capped list; pinned with `flex: 0 0 auto`.

e2e flows that clicked `entry-nav-all-projects` or edited `project-title`
now go through the 团队项目 tab and the switcher row menu.

* fix(web): switcher carries the catalog title authority; team-tab e2e follow-ups

CI follow-up for #8202 (UI P0 entry-settings, run 35087499546):

- The switcher is now the only place the open project is named, so it
  reads the same catalog-title authority ProjectView reconciles with
  (`projectsForWorkspaceChrome`): a shared project opened by another
  member shows the catalog name, not the local mirror's stale one. The
  chat card's inline title used to carry that and was removed with it.
- Entering 全部项目 from the rail opens 最近浏览过 again (the legacy
  `/all-projects` deep link still lands on 团队项目).
- e2e: the move-in / move-out flow asserts on the 个人项目 tab, where a
  move must take the card away from (最近浏览过 spans both sides); the
  failed first-open case asserts `/drafts` + the 团队项目 tab instead of
  the retired `/all-projects` URL.

* chore(web): sync main into feat/home-entry-refresh (second pass) (#8204)

* fix(web): stop asserting unknown project facts while entering a project (#7428)

* fix(web): stop asserting unknown project facts while entering a project

Entering a personal project cold showed two statements that were not true yet.

The read-only banner rendered on viewerOnly, which is fail-closed and also
covers the window where ownership is simply unproven. So your own project told
you it was someone else's shared project for as long as the workspace context
took to resolve. The gate stays -- controls should be disabled until proven --
but the banner asserts a reason, so it now waits for isSharedNonOwner, whose
docblock already promises positive evidence and never mere !isOwner during the
status-unknown window.

The design-files empty state has the same shape: zero files before the first
authoritative read is indistinguishable from a genuinely empty project, and its
CTAs create NEW content. The panel already draws this distinction for a team
mirror that is still downloading; a local list that has not returned yet now
gets it too, keyed on the files snapshot generation, which advances only on an
accepted list (a transport failure deliberately does not advance it).

Measured in Chrome against a personal project with one artifact: the read-only
banner appeared at 4.8s and the empty state was rendered then removed; both are
now absent for the whole entry. Neither change touches what is disabled -- only
what the UI claims while it does not know.

* fix(daemon): keep one preview scope per project so a refetch is byte-identical

The web client builds its srcDoc preview from GET /raw/<file>?odPreviewBridge=…,
and that route minted a fresh preview scope on every request, injecting it as
<base href="…/preview/<uuid>/">. So the same artifact served different bytes on
every read: any refetch produced a different srcDoc string, React assigned it,
and the iframe reloaded -- the artifact visibly vanished and came back.

Measured on a team-workspace entry before this change: the srcDoc was rebuilt
1-4 times per entry depending on timing (one run wrote an empty document in
between), each rebuild carrying a different base href.

Preview reads now acquire the live scope for that exact (project, workspace),
renewing its TTL, and only mint when none exists. The grant is identical -- same
project, same member, same TTL bound -- only its name is now stable.

Deliberately not folded into mint(): export flows mint a scope and revoke it
when the render finishes, and sharing an id with a live preview would revoke the
preview out from under it. acquire() is a separate entry point used only by the
preview read.

Verified two ways. Unit: two reads of one artifact return the same base href;
swapping acquire back to mint turns it red. Browser: recording a team-workspace
entry and measuring frames where the preview goes blank after content had
appeared -- 2 such episodes before (one lasting 74ms), 0 after.

* fix(web): say the file list is loading instead of showing nothing

Suppressing the empty-state CTAs removed a false claim but left the panel blank
until content arrived, which reads as stuck rather than busy -- a worse first
impression than the wrong copy it replaced.

The unknown window now renders the same pill the team-mirror sync uses, with the
existing common.loading copy, so the slot always says which of the three states
it is in: loading, syncing, or genuinely empty. No new copy, no new i18n keys.

This is a consolation, not a cure: the real cost is how long the list takes to
arrive. That is tracked separately -- the project-files read is keyed on the
workspace identity, and workspaceType inside that key is echoed straight back
from the request's own x-od-workspace-type header, so a stale header during
entry changes the key and defeats the request sharing that exists to prevent
exactly these duplicate reads.

* perf(web): single-flight the design-system catalog read

Entering a project mounts several independent catalog readers in the same
tick. `fetchDesignSystemsResult` fetched `/api/design-systems` directly, so
three concurrent readers meant three requests — while the team-index step in
the very same function was already coalesced. The catalog read was the half
that got missed.

Identical concurrent reads are not merely redundant here. The daemon answers
them serially, so each duplicate holds one of the browser's ~6 per-host
connections for seconds. Measured entering a real project, three catalog
reads took 1.8s / 3.1s / 3.7s while this project's own `/files` read — 17ms
when uncontended — waited 1.2s for a connection.

Route it through the same `coalescedGet` the team index uses, partitioned by
workspace identity and by the caller-supplied team witness. Two invariants
are preserved deliberately:

- A forced read (`forceTeamMaterialization`, fired BY a mutation) evicts any
  in-flight snapshot instead of joining it, exactly as the team index does
  one function above. A mutation must observe its own effect.
- `coalescedGet` declines to cache a rejection, but this reader reports
  failure as a resolved `{ ok: false }`, so that is evicted explicitly rather
  than shared for the rest of the window.

Measured entering the same project, three runs before and three after:

  design-systems requests   4 / 4 / 4      ->  2 / 2 / 2
  slowest of those          3678/5056/5318 ->  1501/745/1769 ms
  requests over 1s          37 / 53 / 27   ->  16 / 7 / 9

Total request count moved too but its ranges overlap across runs, so no
claim is made there. The remaining 2 reads are the two identity generations
of entry (provisional, then resolved); collapsing those is a separate change.

Red spec first: the burst assertion failed with "expected 3 to be 1" before
the fix. The forced-read guard was green before and after — it locks in the
invariant rather than reporting a defect.

* fix(daemon): never let a live preview adopt a one-shot preview scope

Review caught a real defect in this branch's own `acquire`. It searched every
registry entry by (project, workspace), and entries carried no record of who
minted them or why. A screenshot/PDF export mints a scope for the same tuple
and `revoke`s it in its `finally`, so a preview that acquired the export's
entry would lose its `<base href>` the moment that export finished -- blanking
the artifact, which is the exact symptom this area exists to prevent.

The comment above `acquire` claimed the mint/acquire split already prevented
this. It did not: the split only stopped `mint` from handing out a shared id,
while `acquire` remained free to find an export's entry.

Mark the lifetime on the entry instead. `mint` creates one-shot scopes whose
owner revokes them; `acquire` creates and matches only reusable scopes, which
are released by expiry alone. The public surface is unchanged -- both go
through one internal `create`.

Red spec first, at the HTTP boundary: `preview-url` mints exactly the way an
export does, so it stands in for one without needing a renderer. Before the
fix the bridged raw read handed back the *same UUID* that mint had just
issued:

  expected 'e60f3014-…-642f00aeadc2' not to be 'e60f3014-…-642f00aeadc2'

* fix(web): stop the remaining surfaces claiming an unproven share

Browser verification of the earlier banner fix showed the claim was shortened,
not removed. Measured entering an OWNED personal project, three runs each:

  main            "这是共享项目…" shown ~4.1s
  banner fix only shown ~1.0s, 3 of 3 runs
  this commit     not shown, 3 of 3 runs

The banner was only one of three surfaces deriving the claim from
`projectMutationReadOnly` (= `viewerOnly || materializationPending`), which is
fail-closed and therefore also true while ownership is merely unknown. The
chat composer placeholder and the project title tooltip each re-derived it,
so gating the banner alone left the same false sentence on screen.

Give the reason one name. `projectReadOnlyClaim` returns undefined while the
share is unproven, and all three surfaces read that one value. Disabling still
comes from the fail-closed flag — only the CLAIM waits for evidence.

This is the same defect class as the hook-level fixes in
use-project-collab.context-seed and use-project-collab.created-by-viewer; it
survived them because these three sites bypass the hook's guard and read the
flag directly.

* fix: restore the fixes d61be13806 reverted by accident

d61be13806 was meant to carry one web change. It also silently reverted the
daemon preview-scope fix, the design-files loading placeholder, and the
file-workspace banner gate — every fix this PR had landed so far.

Cause: verifying the branch in a browser meant swapping files to their
origin/main content and back. `git checkout origin/main -- <paths>` does not
only change the working tree, it STAGES those paths. Restoring the working
tree afterwards left the index still holding main's content, and `git commit`
commits the index, not the working tree. The check I ran before committing
(`git diff HEAD -- apps/daemon`) compares the working tree, so it reported
clean while the index was not. `git status` did say `MM`; I read the second M
and not the first.

This commit restores all five files to their state at the merge commit
0f923028c4, verified marker by marker rather than by eye:

  daemon acquire / reusable scope entries        present
  server-context acquire on the deps interface   present
  design-files loading placeholder + gate        present
  file-workspace banner requires a reason        present

No behavior is intended to change relative to 0f923028c4 plus d61be13806's
ProjectView work; this only undoes collateral damage.

* fix(daemon): keep the whole bridged document stable, not just the scope id

Review caught that this branch's preview-scope reuse does not actually stop
the flicker it was written for. `acquire` renewed `entry.expiresAt` on every
call, and that expiry is serialized into the bridge script of the very
document the scope is injected into (`buildPreviewBaseHrefBridge` JSON-encodes
`{href, expiresAt}`). So two repeated reads returned the same scope id and the
same `<base href>` while the full body still differed — React reassigns
`srcDoc`, the iframe reloads, and the artifact blinks exactly as before.

Stop renewing on acquire. Lifetime extension already has its own path: the
client renews explicitly via `x-od-preview-scope-renewal`, and that response
is not a document, so extending there changes no served bytes.

The existing spec could not see this: it compared only the `<base href>`. It
now compares the complete returned document, which is what `srcDoc` actually
receives — any byte that moves per request reloads the iframe just as surely
as a fresh scope id would.

Verified end to end against a running daemon, three repeated reads of one
artifact:

  before  6ccf51baf7238d5c / 3a7c49a353b5efdd / 0e02c96c1da882f4
  after   dfd91bb06572dd65 / dfd91bb06572dd65 / dfd91bb06572dd65

Not verified: a browser-level before/after on the srcDoc transport. Every
"before" attempt resolved to the URL-load path instead, where changing bytes
at a stable `src` does not reload the iframe at all, so the two runs were not
comparable. The HTTP-level evidence above measures the defect itself.

* docs(daemon): say that acquire reuses a scope without renewing it

Both docblocks still described the behaviour the previous commit deliberately
removed. The dependency contract in server-context.ts promised a renewed TTL,
and the summary above the implementation said the same, so a future consumer
could reasonably assume repeated acquisition keeps a scope alive -- or make
the implementation match the comment and reintroduce the byte instability
that reloads the iframe.

Both now state that acquire reuses without renewing, that ttlMs applies only
when it has to create a new reusable scope, and that keeping an in-use scope
alive is renew's job.

* fix(daemon): freeze the expiry a preview document reports

The previous commit stopped `acquire` from renewing, but left the other half:
the explicit `renew` endpoint still moves `entry.expiresAt`, and the next
bridged read serializes that new value. Review measured it — `read -> renew ->
read` kept the same scope and the same length, changed the embedded expiry,
and produced one additional iframe `load` in Electron. The client renews on a
timer for as long as a preview is open, so this is the steady state, not an
edge case.

Split the two meanings the field was carrying. `expiresAt` stays the live
lifetime that renewal extends, and JSON responses keep reading it.
`documentExpiresAt` is frozen when the scope is created and is what a served
body embeds, so nothing a renewal does can change the bytes of a document.

The host does not lose anything: it uses the embedded value only to seed its
first renewal, and every renewal after that is scheduled from the renew
response (FileViewer keeps the returned expiry, it does not re-read the
document for it).

Red spec first: `keeps the bridged document identical across an explicit
renewal` failed on two byte-different bodies. Mutation-checked by pointing the
route back at the live `expiresAt`. Verified against a running daemon —
read, renew, read, renew, read returned 7a2f2c060559759e three times.

* 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 sub…

* fix(web): white card for question forms, opaque tab launcher, no rail workspace-settings entry, team tab only in a team workspace (#8207)

OPEND-3282: the question form (input / choice region) keeps the floating
white card on the transparent pane; the read-only Confirmed block, thoughts
and terminal blocks stay bare. Narrows the OPEND-3177 reading P3 applied.
OPEND-3257: the rail no longer renders the Workspace settings entry under
recent projects in either workspace (2026-07-20 decision withdrawn).
OPEND-3285: the 团队项目 collection tab renders only in a team workspace and a
personal workspace that lands on it falls back to 最近浏览过 once the context
is known. The tab strip's + launcher is an opaque panel (no glass).

---------

Co-authored-by: wangchenglong <honam884844@gmail.com>
Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: 陈志谦 <89645338+simpleqt@users.noreply.github.com>
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
Co-authored-by: David Z <18444050+davezfr@users.noreply.github.com>
Co-authored-by: Chanhyo Jung <roian6@naver.com>
Co-authored-by: Eric Lemesre <eric.lemesre@gmail.com>
Co-authored-by: lorenzozane <wyz0707@proton.me>
Co-authored-by: lbjzz-hash <52551822+lbjzz-hash@users.noreply.github.com>
Co-authored-by: Tal <zey8840@naver.com>
Co-authored-by: taljeon <169621860+taljeon@users.noreply.github.com>
Co-authored-by: lefarcen <ontf116@gmail.com>
Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
Co-authored-by: zhouzheng <zhouzheng@refly.ai>
Co-authored-by: open-design-release-bot[bot] <295937643+open-design-release-bot[bot]@users.noreply.github.com>
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>
Co-authored-by: nettee <nettee.liu@gmail.com>
Co-authored-by: PerishFire <39043006+PerishCode@users.noreply.github.com>
Co-authored-by: QiuQiuNB666 <gentergumina@gmail.com>
Co-authored-by: QiuQiuNB666 <318713393+QiuQiuNB666@users.noreply.github.com>
Co-authored-by: Austin W <133685420+readwrightexecute@users.noreply.github.com>
Co-authored-by: airano <244096521+airano-ir@users.noreply.github.com>
Co-authored-by: jax-novita <jax.zhang@novita.ai>
Co-authored-by: jax-novita <jax-novita@users.noreply.github.com>
Co-authored-by: jax-novita <jax@novita.ai>
Co-authored-by: Tony Box <tonybox@gmail.com>
Co-authored-by: maoxin1234 <875408344@qq.com>
Co-authored-by: John Kattenhorn <john.kattenhorn.personal@gmail.com>
2026-09-17 02:48:00 +00:00
open-design-crew[bot] eca7c7ab98 fix(web): align ChatPanel failures with approved product copy (#8140)
* fix(web): align runtime error messages with approved product copy

* test(chat): give retry deduplication scenario a Cloud owner

* test(chat): use the supported Cloud model preference shape

* test(chat): align CLI recovery scenarios with fixed Cloud actions

* test(e2e): align Cloud error layout with fixed recovery actions

* test(e2e): use ordinary Cloud failure for error-card layout

* test(web): preserve ChatPane helpers and align recovery fixtures

* test(e2e): resize error-card layout through the native separator

* test(e2e): reject missing error-card action geometry

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-16 03:28:29 +00:00
open-design-crew[bot] dc61db8e03 fix(daemon): show Codex and OpenCode tools before arguments finish (#8117)
* fix(daemon): stream early Codex and OpenCode tool previews

* fix(daemon): retry missing runtime versions for tool previews

* test(amr): verify early tool previews through real Vela runs

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-15 04:21:09 +00:00
nettee d465086b2c fix(amr): align client links with Cloud dashboard routes (#8126)
* fix(amr): use canonical cloud dashboard URLs

* test(e2e): align AMR recharge link assertion

Generated-By: looper 0.13.1 (runner=fixer, agent=codex)

* fix(amr): honor selected dashboard profile

Generated-By: looper 0.13.1 (runner=fixer, agent=codex)
2026-09-14 11:23:17 +00:00
PerishFire 1ff25e5b2b fix(packaged): preserve MCP runtime integrity and sidecar delivery (#8027)
* fix(daemon): keep packaged MCP discovery out of package managers

* fix(packaged): preserve MCP payload identity and require sidecar supervisor

* fix(pack): finalize runtime manifests and report entry recovery

* ci: allow isolated full Windows smoke on dispatched revisions

* test(packaged): derive recovery versions from the release channel

* test(win): read portable installer logs and retain rollback diagnostics

* test(win): distinguish portable startup reconciliation from installer sync
2026-09-14 08:13:45 +00:00
open-design-crew[bot] 8921ccac20 fix(daemon): exclude URLs from ACP title file inference (#8063)
Co-authored-by: lefarcen <935902669@qq.com>
2026-09-14 03:53:01 +00:00
Ray Xi d06eb1efeb fix(daemon): fail blocked OD Next runs before finalization (#7942)
* fix(daemon): fail blocked OD Next runs before finalization

* test: align run fixtures with OD Next terminal status

Keep generic session and smoke fixtures on ordinary runs, and assert the blocked verdict in the strategy timing fixture.

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* test(daemon): isolate Codex resume and retry fixtures from OD Next

Select ordinary-run mode for plain-reply session and recovery fixtures, restoring the rollout override after each test.

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
2026-09-14 01:37:47 +00:00
Ray Xi c333324da0 test(amr): verify prerelease runtime identity and Vela contracts (#7957)
* test(amr): verify prerelease runtime identity and Vela contracts

* docs(amr): bind continuation evidence to final upstream commit

* docs(amr): track host cancellation integration dependency

* docs(amr): record final Write test-only dependency head
2026-09-11 03:08:47 +00:00
open-design-crew[bot] 98f2fd1a46 fix(chat): hide undesigned cards and auxiliary message panels (#7956)
* fix(chat): render cards inside execution records

Render supported od-card payloads inside execution records with the existing
card components, instance scopes, and browser-assistance callback. Preserve
Markdown code examples and isolate card payloads from later Markdown parsing.

* test(web): await writable scope before deleting a conversation

* fix(web): buffer streamed card opener prefixes

* fix(web): remove undesigned ChatPanel cards (OPEND-2971)

Remove task-brief and rule-proposal presentation and their exclusive UI,
styles, and translations following the explicit product decision.
Preserve quoted card examples, neighboring prose, and the three retained
card kinds, including browser-assist callbacks.

* fix(chat): stop generating undesigned brief and rule cards

* test(web): use supported role query options for removed cards

* fix(chat): hide retired brand browser assistance cards

* fix(web): hide undesigned assistant message panels

* test(web): specify the brand project kind in hidden UI fixture

* style(web): remove trailing blank line after panel styles

* fix(web): hide verification scorecards in ChatPanel

* test(chat): align retired UI expectations with approved hiding

* fix(chat): keep card payloads opaque to question form parsing

* fix(web): preserve card parsing across replay metadata

Keep marker masking aligned with physical run boundaries, and replace repeated
Markdown tail scans with a forward indexed walk. Preserve actual forms and raw
code examples with replay and paragraph-boundary regression coverage.

* fix(web): keep form field syntax inside its payload

Recognize complete forms through the existing parser before scanning their
fields for card or code markers. Preserve sibling form actions and keep outer
card/code ownership with callback-based parsing that avoids an import cycle.

* fix(chat): share question form protocol context with host detection

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-10 10:24:07 +00:00
lefarcen 34ac405bfe fix(release): gate the Thursday patch cut on the previous patch, not the minor base (#7977)
* refactor(release): extract the patch-cut pre-flight out of inline shell

Faithful port, no behavior change: resolve-patch-cut.ts computes the same
version and the same X.Y.0 gate target the shell did, and runs the same
gh release lookup. Moving it out of YAML is what makes the gate decision
testable.

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

* test(release): pin the patch cut on the previous patch, not the minor base

Red against the current guard: with release/v0.22.2 cut and unshipped, the
pre-flight gates open-design-v0.22.0, finds it published, and cuts v0.22.3.

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

* fix(release): gate the Thursday patch cut on the previous patch

The guard checked the minor base X.Y.0, so it only ever guarded a line's
first patch. Once X.Y.0 shipped, X.Y.2, X.Y.3, ... all passed no matter how
many unshipped releases had stacked up beneath them.

Gate on the release the cut actually stacks on — the highest release/vX.Y.Z
below it — which is the same target cut-release.yml uses for the Tuesday
minor, and still gives the answer the old minor-base comment wanted (cutting
0.15.1 checks open-design-v0.15.0, not the older 0.14.0).

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

* fix(release): surface patch gate lookup failures

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 08:03:53 +00:00
open-design-crew[bot] 365313443d fix(e2e): make packaged Home readiness timeout deterministic (#7952)
Use a fake clock to verify the existing 10ms deadline and the final readiness
snapshot after multiple inspections. Cover a first inspection that consumes
the whole budget, where a single observation is valid.

Keep the readiness helper and application behavior unchanged.

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-09 11:16:58 +00:00
open-design-crew[bot] 21bba485b6 fix(whats-new): support localized Arena CTA labels (#7943)
Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-09-09 09:36:40 +00:00
open-design-crew[bot] 8be9b0fcaf fix(daemon): deliver artifacts with syntax warning telemetry (#7925)
* fix(daemon): deliver artifacts with syntax warning telemetry

* docs: record main-based syntax warning acceptance

* fix(daemon): surface internal syntax failures at delivery boundary

---------

Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-09-09 07:27:32 +00:00
open-design-crew[bot] 35fa33b3c7 fix(e2e): align packaged Home smoke inputs and evidence (#7918)
* fix(e2e): use daemon-owned request input in Codex plans

* fix(e2e): scope Codex smoke receipts to app-server processes

---------

Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-09-09 06:11:25 +00:00
lefarcen d07102a3b9 feat(release): post the prerelease card on trigger and time every platform (#7916)
Reverses the "withhold the card until the first artifact publishes" rule.
The card is now posted on the watcher's first poll, while every platform is
still queued, and every later state edits that same message.

The reason the card was withheld — a "构建中…" message left standing forever
when a build dies — has since been answered by the machinery that grew around
the card: a dead lane renders its own terminal state, a release where nothing
shipped turns the card red, the watcher's 140-minute budget stamps the card
instead of abandoning it, and a watcher that never delivered draws the
fallback bot. Silence for the whole build window bought nothing.

`shouldPostFirstCard` conflated two predicates. One half was already
`anyPackagePublished`; the other is now `allPlatformsFailed`, and it moves
from gating the post to being the card's red verdict — the header's old
"some platform reported and none shipped" proxy is only equivalent while the
card does not exist before total failure, and with the card posted early it
reads 全部平台构建失败 the moment one lane fails, or red at t=0 because
`build_linux` is skipped on every run.

Each platform row now carries its build time (`用时 11m42s` once finished,
`已用 18m` while running) and the footer carries the whole round's clock, both
read from the `started_at` / `completed_at` already in the polled job list
plus the origin run's `created_at`. Live numbers are quantized to the minute
so a 30-second poll does not PATCH the card on every cycle.
2026-09-09 04:55:40 +00:00
open-design-crew[bot] d232620c16 fix(e2e): align packaged Home smoke with Codex app-server (#7914)
Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-09-09 03:56:35 +00:00
open-design-release-bot[bot] 70665d2b66 chore(release): bump main to 0.22.1 ahead of stable 0.22.0 (#7903)
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>
2026-09-08 15:10:47 +00:00
open-design-crew[bot] dd278cea65 fix(release): allow publishing whats new from release branches (#7902)
Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-09-08 13:25:44 +00:00
lefarcen 25475defab fix(e2e): stop the packaged mac first-run gate failing on budget alone (#7899)
The `[P0] cold first Home run` packaged mac case has been red on almost
every beta build for eleven days (1 pass in 12 attempts) and nobody saw
it, because release-beta's smoke step is `continue-on-error: true`.

Four changes, in the order they matter:

1. Capture evidence before cleanup. A failing case uninstalled the app
   and the next case deleted the runtime namespace, so the only surviving
   trace was `logs: {skipped: true}`. The failure path now writes the
   desktop log, a raw `/api/runs` diagnostics snapshot, and the error into
   the release report before anything is torn down.

2. Give the wait a budget that matches the chain, split in two. 15s had to
   cover cold project creation through rendered output; it is now 60s for
   "the daemon owns a finished run" plus 30s for "the output surfaced", so
   a timeout says which half broke instead of leaving three observations
   that cannot falsify each other. Polling moved inside the page: every
   sample used to cost a `tools-pack mac inspect` process, which ate about
   a third of the old budget on its own.

3. Drop the injected 503. `POST /api/runs` does not answer
   WORKSPACE_AUTHORITY_UNAVAILABLE — local run creation is deliberately
   independent of Workspace membership availability — so the case spent
   an extra create round trip plus a 500ms backoff on a branch production
   cannot reach. Run-create retry semantics stay covered by
   apps/web/tests/providers/sse.test.ts.

4. Make the exempted red audible. The exemption stays (at a 1/12 pass rate
   a blocking gate would stall the daily beta), but `tools-release
   write-report` now turns a failed smoke into a run annotation and a
   caution banner at the top of the step summary.
2026-09-08 11:38:41 +00:00
lefarcen 3ddff71205 feat(ci): publish the What's New card from the repository (#7892)
* feat(ci): publish the What's New card from the repository

Move the post-update highlight document into the repo and publish it from
GitHub Actions, so changing the card is a pull request instead of a task
that needs personal Cloudflare credentials.

- docs/whats-new.json is the source of truth, next to its spec doc.
- pnpm guard validates it through the daemon's own parser on every PR, so a
  document that would silently show no card cannot merge.
- whats-new-publish.yml uploads on merge to main (and on manual dispatch,
  with a read-only dry-run mode) and verifies the object by reading it back
  from the origin the daemon fetches, past the edge cache.

* docs: name the CI job that actually runs the guard check

* fix(ci): gate the What's New publish behind a main-only environment

A workflow_dispatch runs the workflow file from the ref it is dispatched
against, so the in-workflow main-only assertion was editable by whoever
triggered it: anyone with write access could publish unreviewed content to
every installed client from their own branch.

Split the workflow so credentials live in one place and that place is gated by
GitHub rather than by YAML. `validate` holds no secrets and runs on any ref;
`publish` declares the `whats-new-publish` environment, whose deployment-branch
policy allows main only and which owns the R2 secrets. Dropping the environment
declaration to escape the branch policy also drops the secrets.

Also points the card at the new Arena URLs supplied by product.

* fix(ci): pin the What's New publish trust boundary in a check that runs

The missing-credentials error still told operators the R2 keys are
repository secrets. Following that string is the exact setup the previous
revision closed: repository secrets are readable from any job on any
branch, so a modified workflow dispatched from an unreviewed ref could
publish to every installed client — or just take the keys. Point the
error at the `whats-new-publish` environment and refuse the repository
path in the same breath.

The boundary itself had no coverage: nothing read the workflow, so moving
`environment:` or a `secrets.*` expression back onto the arbitrary-ref
job would stay green while still publishing correctly from main.

That coverage cannot be lane-routed. Running the planner over
`.github/workflows/whats-new-publish.yml` selects neither
`web_tests_required` nor `ui_p0_validation_required`, so the `e2e_vitest`
workload does not run for a workflow-only edit — an assertion living only
there would be skipped for exactly the change class it guards. The check
therefore lives in `pnpm guard`, which CI invokes from the unconditional
preflight job, same reasoning as the document guard next to it.

It asserts that the credential-bearing job declares the environment by
its exact name, needs `validate` and is gated on its output with no
`always()`/`cancelled()` escape, reads all four bucket bindings, and that
no other job attaches an environment or reads any secret at all.

The e2e suite drives it over the real workflow mutated into each of those
regressions in turn, so every assertion is proven able to fail; gutting
the checker turns all eleven red.

* chore(whats-new): seed the document from the live card

Bringing the workflow in with new copy attached ties the infrastructure to
a content decision. The Arena CTA this document pointed at is not live —
`/llm-arena-for-design/` and `/zh/llm-arena/llm-arena-for-design/` both
404 — and the card renders regardless, so merging would have put a dead
button on every client's Home.

Seed the repository copy from the object that is already serving instead:
byte-identical to `https://whatsnew.open-design.ai/whats-new.json`, id
`0.21.0`. That puts the current state under version control and makes the
first automatic publish idempotent — it uploads exactly what is already
there, so the merge exercises the whole chain (validate, upload, read-back
against the origin the daemon fetches) without changing anything a user
sees.

The Arena copy is not lost; it moves to the PR body as a follow-up for the
release owner, who can now change the card through this workflow without
holding personal Cloudflare credentials.

* fix(ci): detect secrets by context, not by access shape

The guard added last round matched `${{ secrets.NAME }}` with a regex over
the raw workflow text. A guard whose whole job is to stop a secret reaching
the arbitrary-ref job had a blind spot for most of the ways to read one.

Verified against the previous implementation, each returning zero
violations while a secret reached `validate`: `secrets['NAME']`,
`secrets["NAME"]`, `secrets [ 'NAME' ]`, `secrets . NAME`,
`toJSON(secrets)`, a bare `secrets`, and `fromJSON(toJSON(secrets)).NAME`.

Extending the regex would have repeated the mistake that caused it. One
bypass proves why: a double-quoted YAML scalar may carry a backslash line
continuation, so

    STOLEN: "${{ sec\
      rets.NAME }}"

parses to a live secret read while the source text contains no such
substring. No text scanner can see that, however many shapes it lists.

So the workflow is read through a YAML parser, and detection is a single
property instead of an enumeration: GitHub's expression language has no
string-to-context evaluation, so a secret cannot be reached without naming
the `secrets` context as an identifier. Finding that identifier inside any
`${{ … }}` expression covers every access form, including ones nobody has
thought of.

Parsing also retires the hand-rolled job splitter and its own failure
modes, and lets the check distinguish "no violations" from "nothing was
examined" — an unparseable document, a flow-style jobs mapping, or a
renamed publish job now fail closed rather than passing quietly.

Two holes the shape list never covered are closed as a side effect: a
job-level `secrets:` key passes credentials to a called workflow with no
expression at all, and workflow-level `env:` is inherited by every job.

`yaml@2.9.0` becomes a root devDependency. It was already in the lockfile
via `tools/release` and pinned repository-wide by the existing pnpm
override, so the lockfile gains only the root importer entry.

Test coverage moves with it: 26 of 46 cases now go red when the checker is
gutted, up from 11.

---------

Co-authored-by: lefarcen <ontf116@gmail.com>
2026-09-08 11:14:16 +00:00
lefarcen 819c10d49f fix(chat): make the failure card, the queued action and the fork note stop disappearing (#7871)
* fix(web): ellipsize the fork divider note instead of hard-cutting it

The fork divider footnote lost its ellipsis when #7863 merged the
two-block layout into one line. `.fork-note` became `display: flex` so
the branch icon and the label could sit side by side, but the three
truncation declarations (`overflow: hidden`, `text-overflow: ellipsis`,
`white-space: nowrap`) stayed on `.fork-sep span` — the very same
element. Bare text inside a flex container lives in an anonymous flex
item, and `text-overflow` is not inherited, so the anonymous box falls
back to `clip`: the 62% cap still clipped, just without the three dots.

Move the label into its own `.fork-note-label` span and hang the
truncation on that layer, with `min-width: 0` to release the flex item's
default `min-width: auto`. `.fork-sep span` becomes `.fork-sep > span`
so the inner label is not pinned to `flex: none` — a non-shrinkable flex
item is always exactly as wide as its content and can never overflow.

Also finishes OPEND-2714's copy: the 16 locales other than en / zh-CN /
zh-TW still carried translations of the retired English sentence. They
now say what `Continued from chat` says, each within 1.5x the English
length so they stay inside the 62% cap.

* test(web): witness the fork note ellipsis in a browser, not in chat.css

apps/web/src/components/chat/AGENTS.md §5 forbids asserting CSS class
names or declarations in chat tests, and the spec added in the previous
commit did exactly that: it located the label by className, parsed
chat.css, and pinned the resolved values of text-overflow, overflow,
white-space, min-width and flex. Delete it.

Replace it with a narrow browser witness. e2e/ui/fork-note-ellipsis.test.ts
forks a conversation through the real daemon endpoint, renders it in the
longest shipped locale (German), squeezes the chat panel until the label
overflows, then checks two observable things: the label really is
overflowing its box, and its paint differs from the same element forced to
`text-overflow: clip`. Comparing the element against itself keeps the
oracle baseline-free, and a build that still hard-cuts paints identically
under both, so the check cannot pass vacuously.

Asserting the declarations was the wrong instrument anyway: the broken
build declared `text-overflow: ellipsis` too. What was wrong was the box
it sat on, and only a laid-out browser can see that.

The one structural fact worth keeping - the label is carried by its own
element rather than as bare text beside the icon - moves into the existing
fork-divider spec and keys off a data-testid instead of a class name.

Enrolls the new file in the project-workspace UI P0 group. An unenrolled
P1 would run only in the prerelease full pool, which means shipping a
witness that nothing on the merge path ever executes.

* test(e2e): wait for the fork divider before squeezing the chat panel

The width squeeze is a raw custom-property write onto the split, which
React's own width effect would overwrite if a late fetch re-rendered
between the write and the measurement. Wait for the divider to paint at
the default width first, then squeeze with nothing awaited in between.

Also corrects a stale doc comment that named a helper this file never had.

* fix(e2e): squeeze the chat panel without racing its width transition

CI caught the guard doing exactly what it was written to catch: it failed
with "content 161px vs box 161px", meaning the note never overflowed and
the ellipsis check would have been measuring nothing.

The cause was the squeeze, not the copy. `--project-chat-panel-width` is a
registered `@property` with a 200ms transition, so writing it tweens from
the default 460px and the measurement taken right after read the panel
mid-flight. Suspend the transition the way the product's own resize handle
does (`.is-resizing-chat`), and do the write and the read inside a single
evaluate so no re-render can land between them.

Also measures the label before and after the squeeze, so the next failure
names its own cause: a panel that never narrowed is a fixture problem, a
narrowed panel that still fits the text is a copy problem. The previous
message could not tell those apart.

* chore(web): drop the now-unused splitList export from the cascade helper

It was exported only to feed the CSS-parsing spec that the previous commit
deleted. Nothing outside the helper calls it any more, so put it back to a
module-private function and leave the shared test infrastructure exactly as
it was on main.

* fix(web): collapse the queued row's leading action into one 「引导对话」 button

The queued-send row's leading action had two faces: a 74px labelled
「引导对话」 while `canSteerCurrentTurn` held, and an unlabelled 22px
"send now" icon otherwise. Both were wired to the same
`sendQueuedChatSendNow` — the gate only changed the name, the tooltip and
the analytics `element` value.

Product ruled on 2026-09-08: 「引导对话就是原本的立即发送啊,只不过我们
换了个名字跟 codex 客户端对齐了下」. So the gate goes, the fallback face
goes, and the row keeps one button that is always 「引导对话」.

The delivered spec agrees. `729fa43ce7:docs/design/chat-panel-next.html`
component 17 "Queue" draws exactly one action there —
`aria-label="引导对话" data-tip="引导对话"` with `<svg/><span>引导对话</span>`
— and never the icon-only send-now key. `data-tip` is the plain name, so
`title` / `data-tooltip` / `aria-label` collapse from
`chat.queuedSteerInterrupts` back to `chat.queuedSteer`; visible text and
accessible name are now identical (WCAG 2.5.3 Label in Name).

Order is untouched: steer → edit → delete (OPEND-2715).

Analytics: this surface no longer emits `ui_click` with
`element: 'send_now'`. The surviving button reports `'steer'`, matching
the name it now carries. The `send_now` member stays in the contract union
because PostHog still holds the historical events.

Props: `onSteerQueuedSend` existed only to carry the gate, so it is
removed from ChatPane, SideChatTab and both ProjectView call sites, along
with `canSteerCurrentTurn`. `onSendQueuedNow` survives as the single
handler — it has the second pass-through face in `SideChatTab`'s
`controlledChat`, and it names what the handler actually does.
`steerBlockedReason` is left alone: it had no producer before this change
either, and retiring a deliberately parked placeholder is its own call.

* fix(prompts): stop asking the model for a per-question help string

OPEND-2707. The clarification card stopped rendering the per-question
subtitle (`q.help`) in #7863, but the prompt layer kept asking the model to
write one — so the model spent tokens on copy nobody would ever see, and
believed it had already told the user something it had not. The user ruled on
2026-09-08 to go all the way and retire the prompt half too.

Three prompt surfaces carried it:

- `core-slim.ts` routed leftover context into "the individual question
  labels/help"; context now has one destination, the question label.
- The UI locale override (daemon + contracts mirror) listed "helper text"
  among the question-form controls that must be localized — naming the field
  without naming it. The other four entries in that list all still render.
- The ElevenLabs voice picker is the only form the repo itself authors, and
  it shipped the only `help` string in the tree. Its content is real
  information (the visible labels are voice descriptions; the submitted value
  is the voice_id), so it moves into the question label rather than being
  dropped. `FormQuestion` has no per-question `description`, and
  `placeholder` is already taken by "Choose a voice".

That form is `JSON.stringify`d whole into the system prompt, so it doubles as
the model's worked example of what a question may look like — leaving a
`help` key there would have quietly undone the rest of the change.

`e2e/tests/question-form-help-retired.test.ts` guards all seven authorized
prompt paths, mirroring `question-form-visual-style-retired.test.ts`. It
reads source bytes, so it also asserts the parser keeps tolerating `help` —
the dormant field must not be removed along with the prompt guidance.

Snapshot: only `totalChars` moves, on the three scenarios that compose the
touched text; no section is added or removed.

* docs(web): record FormQuestion.help as dormant, not dead

OPEND-2707 follow-up. With the renderer, the CSS rule, the prompt guidance
and the last host-authored producer all gone, `help` now has no producer and
no consumer anywhere in the tree. It still stays.

Removing `help?: string` would break
`QuestionForm.no-question-subtitle.test.tsx` and `QuestionForm.test.tsx` —
and the first of those is the only positive evidence that the subtitle is not
rendered. Deleting a zero-cost field at the price of the test that guards the
invariant is a bad trade. Cached prompts, older clients and the model's own
memory of the old format can also still send a question carrying the key, and
the parser should keep tolerating it rather than change parse shape as a side
effect of this retirement. Same reasoning as the six `qf.visual*` i18n keys in
`specs/current/chat-panel-decisions-sheet.md`.

The field now documents when and why it went dormant, and how to bring the
rendering half back (the pre-#7863 revision of both files).

Also corrects `DesignDirection.mood` in both prompt mirrors: its docblock said
the text is "shown to the user as `help`", which was already inaccurate — it
is the `mood` blurb on a `direction-cards` option, and that renderer has had
no call site since the visual-style question was retired.

* fix(web): keep the disconnect failure card when no reconnect row is there

A turn that failed with `DAEMON_STREAM_DISCONNECTED` left nothing at all on
screen once the user stepped out of the project and came back: no failure card,
no reconnect line, no way to retry.

`anotherSurfaceOwnsFailure` in `ChatPane` handed the story to the reconnect
line unconditionally, on the premise (stated in the comment right above it)
that the reconnect line "is always there". It is not: `ProjectView` has a
dedicated unmount effect that clears `reconnectView` on project change / screen
exit, so on re-entry the card yields to a surface that has already left.

The neighbouring balance rung already encodes the right invariant —
`balanceCardCannotTakeTheHandoff` refuses the handoff when the upgrade card
cannot be drawn. The same invariant was only written on one of the two rungs.
Both rungs now name their handoff target first, then ask one shared question:
is that surface actually present.

Red spec: `ChatPane.error-card-ladder.test.tsx` — a disconnected run with no
reconnect row must render the card, its own `chat.connectionDropped` copy, and
a retry action. Reverse anchor (row present -> no card) is green on both sides
of the fix.

The two older cases in that file asserted "no card" while rendering *without* a
reconnect row: they pinned this defect rather than the invariant. Both now put
the handoff target on screen, so they pin what they were written for — that
either signal (structured code, or a legacy detail-only row) is recognized as
the disconnect failure.

* fix(web): shorten the Cloud switch CTA to match the delivery spec

The error card's primary button read 「切换到 OpenDesign Cloud 并重试」
while the delivery spec (docs/design/chat-panel-next.html, cell 79
"特殊错误 · 运行环境为 CLI / BYOK") writes 「切换到 Cloud」.

Product ruled in person on 2026-09-08: "切换到 cloud 就行了,你怎么写那么
长的文案『切换到 Cloud 并重试』" and "具体的报错文案不一定跟设计稿对齐,
按钮文案对齐先". So only `chat.amrCard.switchCta` is aligned here — the
card's title and body keep their own product copy and are deliberately
left untouched, including the separate `chat.amrCard.switchBody` key.

All 19 locales are shortened to the same "Switch to Cloud" register with
the trailing "and retry" clause dropped. Comments in ChatPane.tsx and
amr-guidance.ts that quoted the old label are updated so they stop
describing copy that no longer exists; the reasoning around them is
unchanged. Playwright assertions in e2e/ui that pinned the old label
follow the new string.

* docs(specs): record the three rulings taken on 2026-09-08

* fix(web): keep the failure card when a failed turn has no words for it

A turn that ends in a terminal failure loses its entire recovery surface
whenever the card cannot compute a description. The whole card — title,
message, and all four actions (contact support, export logs, retry,
switch to Cloud) — hangs off one `displayError` ternary, so a null
description takes the explanation and every way out with it. What is left
on screen is the execution shell's bare "run failed" line, written on the
assumption that the card below it says why.

The three description sources all go empty together more often than the
shape suggests: the mapping table's own last-resort row carries no
`messageKey`, the pane-level `error` state is reset when ProjectView
unmounts on navigation, and the run's raw detail never reached the message
for the `AGENT_UNAVAILABLE` / `BYOK_PROVIDER_REQUIRED` /
`AGENT_RUNTIME_DEF_INVALID` family (daemon `fail()` emits without going
through `persistRunEventToAssistantMessage`) or for the `runStatus:'failed'`
rows ProjectView writes itself when self-healing a phantom running turn.

`resolveRunErrorCardDescription` now takes `turnEndedInTerminalFailure` and
falls back to `RUN_FAILURE_FALLBACK_MESSAGE_KEY` instead of rendering
nothing. It is the last branch, so every handoff and every more precise
source still wins ahead of it. The signal is `retryableAssistantMessage`,
which already gates the recovery entries and already treats a delivery
failure (`no_result` / `delivery_failed`) as terminal, minus the empty
API response — that one has a surface of its own ("No output") and must
not get a second one.

Also stops an empty pane-error string from shadowing the run's own detail.
That branch carried a comment saying the silence was a separate question;
this is the same question, answered the same way.
2026-09-08 08:50:41 +00:00
lorenzozane 00fb311455 test(e2e): harden onboarding status mock against reload race (#7699)
* test(e2e): harden onboarding status mock against reload race

wireOnboardingMocks wrote window.__amrOnboardingStatusCalls via
page.evaluate inside the vela/status route handler. When the
reload tests intentionally reloaded the page, an in-flight status
poll could evaluate against a destroyed execution context and fail
the test before its reload assertions ran.

Wrap the instrumentation writes in a narrow safeEvaluate helper
that only swallows navigation-destroyed errors and re-throws all
other failures, preserving strict reload/cancel assertions.

* test(e2e): harden onboarding status mock against reload race

wireOnboardingMocks wrote window.__amrOnboardingStatusCalls via
page.evaluate inside the vela/status route handler. When the
reload tests intentionally reloaded the page, an in-flight status
poll could evaluate against a destroyed execution context and fail
the test before its reload assertions ran.

Wrap the instrumentation writes in a narrow safeEvaluate helper
that only swallows navigation-destroyed errors and re-throws all
other failures, preserving strict reload/cancel assertions.
2026-09-08 08:27:02 +00:00
lefarcen faba302437 feat(release): alert through the other Feishu bot when the prerelease card goes silent (#7873)
* feat(release): alert through the other Feishu bot when the prerelease card goes silent

The progressive card is now the only prerelease notification, and every way it
breaks is silent: the pipeline stays green, the packages ship, and the channel
hears nothing. The watcher catches Feishu failures on purpose — one hiccup must
not end a two-hour watch — so expired application-bot credentials look exactly
like a healthy run.

Adds a last-resort notice delivered through the custom-bot webhook, which is a
different bot with a different secret, so a credential failure on the card side
cannot take the alert down with it. It fires only when the card did not reach
the group: the watcher now reports `card_delivered` for a card whose FINAL state
is in the chat, and the gate reads that rather than the job result.

Two callers, mutually exclusive so a release can never draw both:
release-prerelease-card.yml's `fallback_notice` job covers a watcher that died
or never delivered; steps at the end of release-prerelease.yml's
`dispatch_validation` cover a card that was never dispatched. The latter are
steps, not a job, because nothing may appear in a `needs:` on the dispatchers.

* fix(release): keep the fallback notice reachable past the dispatch it reports

The two steps that actually deliver the notice carried no status function, so
GitHub's implicit success() skipped them whenever anything earlier in the job
had failed. `dispatch-validation.sh` exits non-zero when it cannot dispatch the
card on any ref — which marks the job failed — so on the one path the
dispatch-side fallback exists for, it was silent. This PR was written to close
the implicit-success() trap at the job level and walked into its step-level twin.

Both consumers now carry `always() && steps.notice.outcome == 'success'`: the
status function so a failed dispatch still reaches them, and the outcome check
so neither a crashed nor a skipped composer can hand feishu-notice.ts an empty
body. The composer writes `alert` last, after every field a poster reads, so
`alert=true` in an append-only $GITHUB_OUTPUT means the whole notice is on disk.

Adds the step-level reachability rule as a spec beside the existing job-level
one, and puts the dispatched prerelease lanes into that job-level sweep —
release-prerelease-card.yml was outside it, so `fallback_notice`, whose `if`
hand-checks `needs.card.result`, was uncovered by the rule it has to obey.
2026-09-08 08:24:00 +00:00
lefarcen 5a7bbe5b10 chore(release): stop blocking stable releases on missing release notes (#7886)
A stable release with no docs/CHANGELOG/v<version>/ directory hard-failed at
the very first job. Fixing it mid-release is not "write two markdown files":
the stable promotion gate pins github.commit to the promoted prerelease, so the
changelog commit invalidates that artifact and forces a fresh ~27-minute
prerelease round trip.

Missing notes are now a loud warning instead of a failure. reviewReleaseNotePlanPolicy
returns warnings for a stable release with no notes, or one missing zh-CN, and
prepare-release-note surfaces them as a ::warning:: annotation plus a job
summary entry. Notes that exist but cannot be consumed still throw: a channel
mismatch, or a plan that omits the default locale every consumer falls back to.

publish-metadata carried a second stable gate on the same condition. Removing
only the first would have moved the hard failure past the GitHub draft release
and asset upload, which is strictly worse, so both are gone. The rest of the
chain already treated "absent" as a complete outcome — it is the beta and
prerelease path — so publication, verification, and metadata assembly are
unchanged.

Non-stable channels behave exactly as before.
2026-09-08 08:23:56 +00:00
lbjzz-hash 92f27d1fc1 fix(daemon): finalize deliverable syntax without agent turns (#7801)
* fix(daemon): add bounded deliverable syntax repair loop

* test(daemon): enable OD Next for branch evals

* fix(daemon): keep syntax gate narrowly scoped

* feat(daemon): report deliverable syntax timing

* fix(daemon): remove evaluation rollout override

* feat(daemon): measure syntax repair windows

* fix(daemon): finalize deliverable syntax without agent turns

* fix(daemon): validate committed syntax recovery and quote repairs

* docs: clarify syntax gate applicability without touched paths

* fix(telemetry): reject partial syntax summaries without version
2026-09-08 07:03:28 +00:00
lefarcen d82385309f chore(release): retire the transitional prerelease webhook card (#7867)
The progressive Feishu card (release-prerelease-card.yml) has carried four
real prereleases: correct rendering, per-platform refresh, correct changelog,
smoke wired through. The one-shot custom-bot webhook card it superseded was
kept alongside it on purpose — a gap in release notification is worse than a
duplicate — and its own TRANSITIONAL comment named the retirement scope.

Deletes notify-release-feishu.yml's `notify` job, leaving that workflow as
what its remaining job already is: the push-to-release/** prerelease build
entry point.

tools/release/src/notifications/feishu.ts stays. notify-daily-feishu.yml
renders the beta download card through it and `tools-release notify feishu`
exposes it, so only the prerelease call path is removed. The
FEISHU_RELEASE_WEBHOOK / FEISHU_RELEASE_SIGN_SECRET secrets stay too — six
other workflows post through them, and keeping them leaves a rollback open.
2026-09-08 01:26:40 +00:00
lefarcen 4c5873c7cc fix(chat): ChatPanel post-merge follow-up batch — failed turns, codex tool rows, Home mode chip, and six ticket fixes (#7863)
* fix(chat): 失败的那一轮不再自称「已完成」

一轮跑挂了,壳头写着「运行失败」。用户接着再发一条消息之后,这一轮的页脚上
多出一枚「✓ 已完成」,还戴着成功那一档的绿勾 —— 同一屏上,壳头说失败、页脚
说成功。

为什么发了下一条才出现:页脚让位的第一条例外是「这一轮拥有那张报错卡」,而
报错卡的归属(`errorCardOwnerId`)要求这条失败助手消息正好是转录的最后一条,
追加任何一条消息就变 null。而页脚那条文案阶梯里只认识 `canceled` 一个终态,
`failed` 一个字都没有,于是直落最后那个 `doneLabel`。

裁决是形态 A —— 失败轮不出这一行的状态词:
  · `chat-panel-next.md` B18「整轮失败:…不出回合状态行」
  · `chat-panel-dev-design.md` 状态机 + 场景表「壳头「运行失败」收起 + 报错卡,
    无回合状态行」;壳只有三态,「运行失败」是壳的词
  · `chat-panel-next-review.md` B18 同条
  · 交付稿「两边不重复:这一行只负责宣布『这轮到此为止』」

所以给 `hideRunStatus` 加第四条具名例外,按终态本身判而不是靠报错卡在不在。
判据绕开空流那一档:API 空回复也是 `runStatus:'failed'`,但它的状态词是
「没有输出」,由 `e2e/ui/api-empty-response.test.ts` 那条 P0 钉死。

顺带清掉两处已死的注释(纯注释,零行为):`.status-pill.is-error` 在
2026-08-27(`812e550ebe`)被无条件下线了,描述它「其他失败轮次保留 pill」的
那段英文注释和 `ChatPane` 里同一段话的拷贝都已不成立;后者改写成
`errorCardOwnerId` 今天唯一的活消费者是 `hideRunStatus`。

* fix(web): drop the session-mode chip from the Home composer

The Home composer footer carried a 「设计 ×」 mode chip (icon + mode name +
a clear ×) immediately left of the model switcher. The project composer
dropped the same chooser first (2026-08-19); Home was the last surface
still showing it, and every Home request defaulted past it to Design
anyway.

Removes the entry only. `ComposerModePicker` stays in the tree as a
dormant part with a header note saying when and why it was unhooked and
how to hook it back up; its i18n keys, `.composer-mode*` styles, its own
unit test and its `trackComposerSessionModeClick` analytics helper are
all left in place so restoring is a one-step change.

Behaviour is unchanged. `HomeView` still owns the `sessionMode` state
that feeds `conversationMode`, the automatic task profile and plugin
provenance; with nothing calling `setSessionMode` it simply stays on the
app default, `design`.

Pinned by a new `HomeView.mode-picker-removed.test.tsx` (absence at the
Home mount point, plus the payload invariant that Home still submits
`conversationMode: 'design'`).

* test(e2e): retarget the two Home specs that asserted the mode chip

These are the only two Playwright specs that positively asserted the
Home mode chip's presence; both follow the removal in the previous
commit.

`entry-chrome-flows` was a one-line presence check inside the default
prototype-scenario spec — flipped to `toHaveCount(0)`. That spec already
asserts `conversationMode === 'design'` from the request body, so the
routing it cared about is still covered.

`home-hero-rail`'s "home session mode toggle switches Ask planning
prompts away from design routing" drove the chip to pick Ask and checked
the request switched to `conversationMode: 'chat'` with no plugin. Home
has no surface that can select Ask any more, so that half is unreachable
rather than broken; the spec now pins the surviving truth — no picker on
screen, and every Home submission routes design with a plugin. Ask/Plan
routing itself is untouched and still covered on the project side, where
a conversation's stored session mode is what runs.

* fix(daemon): fill the codex command row in while the command runs

A codex `Bash` row stayed empty for the whole run and only showed its
output once the command exited. On the app-server transport codex does
send the child's output as it is produced
(`item/commandExecution/outputDelta`, confirmed against codex-cli 0.153.4
on 2026-09-08: one frame per write), but the normalizer had no case for
it, so every frame was counted as an unknown notification and dropped.

Routing the delta alone would not have been enough. The client retires an
early `tool_in_flight` row into the settled `tool_use` that shares its id,
and it does that by dropping every early row whose id already has a
settled one. Because `item/started` was forwarded to the `exec --json`
branch — which emits the SETTLED row — every later update would have been
discarded by the client without rendering, while every daemon-side test
stayed green. So the started frame now opens the row in its early form and
the settled pair comes from `item/completed`, where the output is final
anyway.

`startedAt` is the item's own `startedAtMs`, never the moment a delta
arrived, so the row's stopwatch does not restart on every chunk.

Output is bounded by the same two numbers the ACP bridge already uses
(2000 chars per event, 250ms between publications for one call): the cap
bounds one event, the interval bounds how many, and neither alone is
enough.

`exec --json` is untouched — that wire has no output-delta frame — so the
two transports now diverge by one event type. The parity test drops the
early form for the same reason it already drops `raw`, and the early rows
are asserted on their own instead.

* fix(web): resolve the composer plan tier through the approved workspace projection

The AMR model row in the composer popover read the exact billing SNAPSHOT to
decide a team workspace's plan tier. `workspaceBillingSummaryForContext` already
consults that snapshot first, but it also carries an approved, load-bearing
fallback: a TEAM-namespaced account tier may stand in when a team workspace has
no authorized snapshot. Reading the snapshot raw walked around it.

The daemon omits the `workspaceSnapshot` key whenever it cannot authorize one —
A answering 409 `billing_workspace_snapshot_unsupported` while `/wallet/balance`
still answers 200, a rolling deploy leaving an old API pod 404/405-ing the
route, or a local vela CLI predating `--workspace-id`. The tier then resolved to
null, `canUpgradeVelaPlan(null)` vetoed, and that veto landed BEFORE
`canReachWorkspaceBillingEntrance` was ever asked — so a team owner clicked a
plan-gated model and nothing happened at all.

Same for a team nobody has subscribed: B reports it as `billingState: 'free'`
with a null `planId`, and reading `planId` raw turned that positive "free" into
an unknown tier. The projection normalizes it to `'free'`, which is upgradeable.

The projection is a pure function of the response this component already holds,
so the corrected tier lands on the same render frame — no second async hop that
would paint the wrong identity first.

* fix(chat): keep next steps on the turn that earned them

OPEND-2764. A deck finished, the daemon emitted its three `next_steps`
suggestions, and the chat panel showed no next-step guidance at all — the user
had no way to keep refining what had just been delivered.

The memory card is a host-authored assistant message (`useMemoryWrittenCard`),
and extraction is queued after the child closes, so the card almost always
lands *after* the turn that produced the artifact. Next-step guidance was gated
on `isLast` — the last assistant message in the transcript, host cards
included — so the card displaced the delivering turn and the whole block, plus
the `suggestions` / `onSuggestion` props, was withheld.

"Last assistant message" answers three different questions in this panel and
none of them can stand in for the others: a question form asks whether anything
follows it (OPEND-2644), the brand assist card asks whether it is itself the
tail, and next-step guidance asks which turn is the conversation's current
landing point. Merging them turns the first two red. So this adds a criterion
rather than redefining the existing one: guidance belongs to the tail, and a
host-authored card is transparent to it — the turn keeps its suggestions, and
the next real turn still takes them away.

* fix(chat): move the question-form step progress next to the question it counts

OPEND-2641. In a stepped question form the `1/4` progress sat in the card
header, where `.qf-step-progress` and `.qf-picked` ("2 picked") each declared
`margin-inline-start: auto`. Two autos on one flex line split the free space in
half, so the progress parked in the middle of the header — detached from the
question it describes and competing with the selected-count label.

It now renders at the end of the current question row (`.qf-label`), after the
question text and after the required badge. The header keeps the card's own
name and card-level status only.

Layout rules that come with the move, both measured in headless Chrome rather
than assumed:

- `margin-inline-start: 6px` on the progress. The required badge and the
  progress are adjacent siblings with no whitespace text node between them, so
  at 0 the `1/4` sits flush against the badge's border (measured gap 0.0px).
- `overflow-wrap: break-word` on `.qf-label`. With a long unbreakable localized
  compound at a 300px panel width the row overflowed its box (scrollWidth 412
  vs clientWidth 298), pushing the question text out of the card. With it, 298.

Deliberately not added: `white-space: nowrap` on the progress. Measured in an
8px-wide box, `1/4` and `10/24` stay on one line even at `display: inline` +
`white-space: normal` — there is no break opportunity around the slash, so the
declaration would do nothing. `display: inline-block` is the actual guarantee.

`chat-mirror-cascade`'s property whitelist gains `margin-inline-*` and the two
physical longhands so the badge-separation assertion measures a real cascade
result instead of reading back `<unset>`.

* fix(web): drop the per-question subtitle from clarification cards

A clarification card question is its title, its required badge, and the
control the user answers with. The model-authored `help` subtitle sat
between the title and the control, pushing them apart and reading as the
card's own aside rather than as part of the question.

Stop rendering it. Dropping the node rather than hiding it also drops the
line box it held in the card body's vertical flow, so the title now sits
directly above its control with no leftover band of whitespace. `help`
stays on the parsed form shape, so existing and streamed forms keep
round-tripping unchanged.

Refs OPEND-2707

* fix(web): lead the queued row with steer and end it with delete

The three actions on a queued message read left to right as edit, delete,
steer — which puts the irreversible one in the middle of the sweep and
buries the action that acts on the run you are watching.

Reorder them into an escalation order: act-on-this-now first, edit second,
delete last. The leading slot keeps a stable landing point across both
faces of that button — "steer" while a run is interruptible, plain "send
now" when there is none — so the position does not move when the label
does. Nothing about what the three buttons do changes.

Refs OPEND-2715

* fix(web): collapse the fork origin hint into one divider line

Forking left two stacked blocks behind: a hairline divider labelled with
the source conversation's title, and a centred footnote under it. Two
rows, two ideas, for one fact.

Collapse them into the Codex shape — a branch glyph and a single line of
copy sitting in the middle of the divider. The source title stops being
rendered: a line that says "the context above came with you" is closer to
what the divider is for than one that reports an old title, and the title
is a click away in the conversation list. `forkedInto.title` stays on the
contract and in the database; only the rendering changes.

The label inherits the footnote's voice, not the title's: it is an
annotation of the line, the same class of chrome as the timestamp and the
copy button beside a message, so it takes the muted ink rather than brand
text. With one block instead of two there is no read order left to stagger,
so the footnote's 60ms entrance delay goes with it.

English copy is the reference image's, verbatim. The other locales keep
the strings they already had.

Refs OPEND-2714

* fix(web): move the notes chip's remove control onto its leading glyph

The notes chip carried a hidden remove button after the count. Hidden with
opacity, so it still held its place: measured in Chrome, the gap from the
count's right edge to the chip's inner edge was 31px against 9px on the
left — 5px of gap, 1px of margin and a 16px button that nobody could see.
The lopsided chip and the invisible button were the same fact.

Put the remove control where the speech bubble already is: one slot at the
head of the chip holding both marks, the bubble at rest and the remove
button when the pointer is over the chip or the keyboard has reached it.
The right side then has nothing left to reserve and closes to the padding,
so the chip is 9px on both sides and sized to what it actually shows.

Nothing about the action changes: same handler clearing every note at
once, same accessible name, no confirmation, and the chip still unmounts
when the last note goes. Only the affordance moved.

Measured in Chrome on the repo's own mirror page, before → after:
chip 104.9 → 85.9px wide, unchanged 28px tall, trailing gap 31 → 9px, and
the swap costs no reflow (identical box hovered and at rest).

Refs OPEND-2713

* fix(chat): stop a late host card from stealing the turn's streaming and retry

Two separate regressions with one physical cause. The host appends assistant
messages of its own — the memory card (`useMemoryWrittenCard`) and the brand
browser assist card — through `ProjectView.appendConversationMessage`. They
carry no runId / runStatus / startedAt / endedAt, and memory extraction is only
queued once the child process closes, so the card almost always lands *behind*
the turn it belongs to and displaces the physical tail. Two surfaces read
"the last assistant message" as their anchor and both were fooled.

1. API / BYOK streaming. `isAssistantMessageStreaming`'s final fallback
   (`message.id !== lastAssistantId`) is the ONLY streaming source for a real
   API-mode placeholder: that tier's optimistic message has no runId and no
   runStatus (`runStatus: config.mode === 'daemon' ? 'running' : undefined`), so
   it never reaches the `isActiveRunStatus` early return. When last turn's
   memory card lands behind the streaming placeholder, `lastAssistantId` points
   at the card, the fallback stops matching, and the running turn shows no
   streaming indication at all — the panel just sits there.

   OPEND-2745's reverse anchor could not see this: its placeholder is the tail,
   with nothing behind it.

2. Retry after a failed turn. `retryableAssistantMessage` anchored on
   `messages[messages.length - 1]`. A host card in that slot is not a terminal
   failure, so `retryAssistant` went null and the whole recovery chain collapsed
   with it — `runFailureUi`, [Retry] / [Resume], `errorCardOwnerId`. The turn
   failed and the user had no way to push it forward.

Both fixes add a criterion instead of rewriting the existing one, following
OPEND-2764: "last assistant message" answers several different questions in this
panel and none can stand in for the others. Streaming keeps asking whether the
message is the tail, and now treats a host-authored card as transparent to that
question (`lastAssistantTurnId`). Retry keeps anchoring on the tail, and now
reads it through `trailingMessageIgnoringHostCards`. Neither makes the entry
sticky: a real next turn, or the user's own next message, still takes it away.

`ChatPane.error-card-raw-text-never-rendered.test.tsx` had pinned the defect
("retry is absent when the failed turn isn't last — and still is after this
change"); that PR scoped the vanishing entry out. It is now the invariant, plus
the genuine no-retry case (the user moved on).

* fix(web): keep the fork footnote on the message muted ink

Collapsing the fork hint into one line moved the footnote inside the
divider, and its colour moved with it — onto `.fork-sep span`, an
ancestor-scoped rule. `.fork-note` is still the class on that very
element, so anything rendering it on its own (the mirror gallery, the
muted-ink spec's bare fixture) was left with plain black text.

Give the label its own styling back. `.fork-note` owns what the footnote
is — icon beside text, 12px, message muted ink — and `.fork-sep span` is
left with only how that cell sits in the line: no stretching, 62% at most,
truncated rather than wrapped. The muted-ink custom property goes back on
`.fork-note` too, which is the point of "whoever uses it declares it": the
footnote has to stand up without an ancestor.

The colour also had to leave the ancestor rule to stay effective at all:
`.fork-sep span` outweighs `.fork-note`, so any declaration there silently
wins and the footnote's own can never apply.

Adds a companion assertion on the rendered node. The existing spec asks a
detached `.fork-note` what it is; that cannot see an ancestor rule
out-specifying it. Re-adding `color` to `.fork-sep span` proves the split:
the new assertion goes red, the detached one stays green.

Refs OPEND-2714

* fix(web): settle the fork footnote's layout against the one-line divider

The reverse-control spec pins five values on the footnote's rule. It was
written for the two-block divider, so collapsing that into one line put
two of them in question.

`display` goes back to the draft's `flex`. The footnote is an item of the
`.fork-sep` flex row, so its outer display is blockified either way and
`inline-flex` computed identically — a difference that bought nothing was
not worth diverging from the draft for.

`padding-bottom: 2px` goes, and stays gone. It was the breathing room
under a footnote that had its own row below the line. The footnote is now
a cell inside the line, and the line centres its items: two extra pixels
underneath grow the border box, so the text rides a pixel above the centre
the hairlines sit on. Keeping it would be the defect. The draft only ever
drew the two-block form, so this follows the precedent already set for the
one-line divider rather than any draft text.

The control keeps its teeth in both directions: the fifth value is
replaced by `align-items`, and a companion assertion pins the two-block
leftovers — `padding-bottom` and `justify-content` — as forbidden. Taking
a required value away reddens the first; back-filling a leftover reddens
the second.

Also corrects a stale premise in that spec's preamble: the mirror page's
missing `.fork-*` inlining, which the note describes in the present tense,
has since been fixed.

Refs OPEND-2714

* i18n(chat): take the product ruling on the fork footnote's Chinese copy

#2714 换掉了英文(`Context above came along — just keep going.` →
`Continued from chat`),但 18 个非英语语言包一字未动 —— 工单只给了英文,而
交付稿给的中文恰好就是原来那句,两边都有出处,不该由 agent 挑一边。

用户 2026-09-08 当面给定中文:「从上一个会话继续」。zh-TW 取同句繁体。
其余 17 个 locale **仍未动** —— 翻译属于产品文案,不自拟,需要逐条给。

顺带把跟着旧文案走的三处一起对上,否则它们会各自说一句产品里不存在的话:
· 陈列页第 38 格的样例正文与 `state` 描述;
· `AssistantMessage.tsx` / `ProjectView.tsx` 里论证「分界线为什么落在新会话」
  时引用的那句脚注,以及两条测试的同款文件头注 —— 论证本身不变,新句子
  「从上一个会话继续」同样只有站在新会话里回看才说得通。

裁决与四条遗留(其余 17 语、`.fork-sep span` 的省略号永不生效、生成产物
`mirror-exec.html` 仍停在两块式、无标题源会话不盖 `forkedInto`)记进
`specs/current/chat-panel-decisions-sheet.md`。`chat-panel-outro-audit.md`
里那三处旧文案是审计当时的记录,作为历史保留不改。
2026-09-08 01:23:04 +00:00
lefarcen a07dc736fd perf(ci): take prerelease validation off the release pipeline, and report it on one live card (#7842)
* perf(ci): make prerelease mac Intel opt-in

build_mac_intel is the prerelease critical path: ~33 min on the
macos-15-intel fleet against ~21 min for mac arm64 and Windows, which
stretches every release-branch push to ~56 min end to end.

Gate it behind a new `enable_mac_x64` input that defaults to false, the
same shape build_linux already uses, and let publish tolerate a skipped
Intel job. Push-triggered prereleases now finish in ~21 min; a manual
dispatch can still turn Intel back on.

The stable promotion gate is unchanged and still hard-requires
platforms.macIntel in the promoted prerelease metadata, so an Intel-less
prerelease fails promotion loudly rather than shipping unvalidated.

* perf(ci): stop the prerelease pipeline waiting on work it does not need

Run 34102146769 took 56m, 42m of which survived turning macOS Intel off.
Four things in that 42m were wall clock nobody was buying anything with.

Tests no longer sit in front of packaging. functional_e2e, e2e_vitest,
daemon_unit_tests and verify take ~11 min; packaging takes ~21 min, and the
build jobs needed all four before they could start. They now need only
metadata and run alongside the tests. Nothing about the safety of a release
changes: `publish` is what writes metadata.json, and it already required all
four gates to be `success` — a red suite still cannot ship. What it costs is
CI minutes, since a failing gate now stops builds that have already run.

The Feishu card stops cloning the repository to read one `git log`. That
checkout was `fetch-depth: 0` against a 2.2 GiB pack and took 4m09s to
produce a `PREV..CUR` commit list. The list now comes from the compare API,
reproducing `git log --no-merges --pretty=format:'%s (%h)'` line for line —
verified against 35 commits of real history, where the only difference was a
leading space that feishu.ts trims anyway. Every fallback the old script had
(no baseline, unreachable commit) still produces an empty changelog rather
than a failed notification.

Packaged smoke and Apple notarization become opt-in, like Intel already is.
Smoke is advisory — `continue-on-error`, so it has never blocked publication
— and costs ~2.5 min on the Windows critical path. Notarization is a
round-trip to Apple that no prerelease consumer needs. Signing is NOT
optional and stays on: prepare-stable.ts requires platforms.mac.signed ==
true, so an unsigned prerelease could never be promoted. `mac_sign_mode: no`
is offered for parity with beta and now correctly reports signed=false
rather than lying in the metadata.

Windows drops setup-node's pnpm store cache and every job checks out
shallow. Measured across four prerelease runs the store cache missed 3 times
out of 4, costing 100s/66s/43s to save with no install speedup, and on its
one hit traded a 32s restore for a 36s faster install — while competing for
the 10 GB cache budget against tools-pack caches worth ~11 min each.
Nothing in this workflow reads git history.

* perf(ci): let a prerelease ship on the build's schedule, not the tests'

prerelease is the internal channel. Its job is to put a package in a
colleague's hands so a human can test it; automated CI is a second, parallel
opinion about the same commit. Wiring the two in series meant a single flaky
suite cost the team a whole day's package — the expensive failure, and the
one this pipeline kept producing.

The four test jobs (functional_e2e, e2e_vitest, daemon_unit_tests, verify)
now gate nothing. They are absent from `build_*` and from `publish`. They
still run on every prerelease and their results leave the workflow as
outputs — functional_e2e_result and friends, plus a tests_all_green /
failed_test_jobs rollup — so the notification layer can raise the alarm
without holding the package hostage. Stable is untouched: its promotion gate
reads the prerelease metadata and runs its own checks.

The packaged smoke suites move for the same reason. They were already
`continue-on-error`, so a smoke failure never failed the job — but they sat
between the build and the R2 upload, which meant the package waited on them
and a hung smoke could still eat the job. Every smoke now runs AFTER
"Publish <platform> platform" and "Upload <platform> publish manifest", and
every step after that manifest upload is continue-on-error, so nothing
post-delivery can fail a build job and retroactively block publish.

The results travel through a `test_signals` job rather than the
`jobs.<job_id>.result` expression GitHub documents for workflow_call outputs,
because actionlint has rejected that property since 2023
(rhysd/actionlint#343) and ci.yml runs actionlint at a pinned version.

One consequence to hand to the notification work: a failed advisory job now
makes the overall workflow conclusion red even though the package shipped, so
`needs.build.result` is no longer the right thing to colour a card by.
`version_metadata_url` is the authoritative "did a package ship?" signal and
is documented as such.

* perf(ci): move prerelease validation out of the pipeline it validates

release-prerelease.yml holds one repository-wide concurrency group with
cancel-in-progress: false, so any job that outlives `publish` keeps that
group held and stops the NEXT prerelease from starting. Making the test
jobs advisory did not fix that: they still held the group for 45 minutes
after the package had shipped.

The pipeline is now metadata -> build_* -> publish plus two dispatcher
jobs that nothing depends on. The four code suites, the packaged
mac/Windows smoke, and the release card each run in a workflow of their
own, keyed on the origin run.

The packaged smoke no longer installs electron-builder's output
directory on the machine that produced it. It reads the published
version metadata, downloads the DMG / setup.exe out of R2, verifies its
sha256 sidecar, and writes it to the single path
`tools-pack <platform> install` reads - which is why the specs need no
changes. That is both a better test and what lets the smoke live on a
different runner at all.

The release card becomes progressive. A Feishu application bot can PATCH
a message it sent, so one card is created when the first platform lands
in R2 and edited in place as the rest of the platforms, the code tests,
and the smoke report in. A PATCH replaces the whole card, so exactly one
job may write it: the watcher owns the message and polls the three runs.
The card is withheld until a package exists, because a "building..."
card posted at dispatch time becomes a permanent lie when a build dies -
with total build failure as the one exception, where silence would hide
an incident.

macOS Intel goes back to on-by-default: stable ships Intel and stable's
gate is a validated prerelease artifact, so an Intel-less prerelease
cannot be promoted at all. The switch stays, forwarded as
`github.event_name != 'workflow_dispatch' || inputs.enable_mac_x64` -
`inputs.enable_mac_x64 || true` would be unturnoffable, because a
dispatch with the box unchecked also yields true.

Also fixes the signal batch 2 left wrong: a reusable workflow reports
failure if any job inside it failed, including work that runs after
publication, so `needs.build.result` painted the download card red while
its own buttons worked. `version_metadata_url` is non-empty only when
publish wrote metadata, and now drives both the header colour and the
smoke annotations.

Two topology tests were already red on this branch - batch 2 changed the
workflow without updating them. Both now assert the new invariant.

The existing webhook card stays until the new one has carried a couple
of real releases; a gap in release notification is worse than a
duplicate.
2026-09-07 17:38:33 +00:00
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
Ray Xi b5c9723aca revert(web): take the Home entry refresh (#7730, #7731) off main pending the feature branch (#7843)
* Revert "feat(web): add recent projects and run status to the entry rail (#7730)"

This reverts commit d4138ea818.

* Revert "feat(web): refresh the Home hero and composer (#7731)"

This reverts commit 2e4c1a753b.
2026-09-07 11:34:23 +00:00
lefarcen 6788a8667b fix(dsh): support DeepSeek Harness 0.1.2-rc.1, now npm's latest (#7825)
`@deepseek-ai/dsh@0.1.2-rc.1` was published on 2026-09-03 and holds npm's
`latest` dist-tag, so it is what a user who installs without naming a version
receives. Both of our authorities rejected that line:

  - the agent def did not accept `0.1.2-rc.1`, so Settings labelled the user's
    CLI untested;
  - `packages/dsh-runtime`'s peer ranges evaluated to false for it, because
    semver admits a prerelease only against a comparator sharing its exact
    major.minor.patch and carrying a prerelease of its own — so the companion
    component could not resolve at all. That half is silent, which makes it the
    worse one.

Move the installers to `0.1.2-rc.1` with a resolution cutoff between its publish
time and any successor, add the `0.1.2-rc.N` alternative to the accepted line,
and add the matching comparator to all five dsh peers. All five, because a
single updated peer would otherwise vouch for the other four.

The Alpine fallback in install-dsh.sh named `0.1.0-rc.6` — two patch lines stale,
because nothing pointed at it when the pin moved. It now interpolates
$DSH_VERSION.

Both test files previously pinned the broken state, asserting `0.1.2-rc.1` was
rejected; they now assert the invariant and went red before any source change.
`0.2.0-rc.1` stays refused by both authorities so the widening cannot decay into
accepting everything.

`packages/dsh-runtime`'s dependencies and devDependencies stay at `0.1.1-rc.2`
on purpose: bumping them resolves a mixed-generation tree (dsh-scope and
dsh-settings stay on 0.1.0-rc.6) and wants a cordis bump too. See the PR body.

Claude-Session: https://claude.ai/code/session_01B8MaA1ABkvbnfaYUKw3UH3
2026-09-07 07:33:19 +00:00
lefarcen d18ada9e35 fix(ci): make the DSH upstream drift card readable (#7824)
The maintainer who received this alert could not tell what it was. The
headline jumped straight to a verdict, so nothing said an upstream release
had been detected or where the message came from; the body opened with
semver theory; the blocking branch never mentioned the version we pin,
making the most urgent card the least informative one; and nothing said
when upstream shipped, so there was no way to tell a release from this
morning apart from one that had been sitting for a fortnight.

The headline now names the check and only the check — severity travels in
the header template, which Feishu renders before any text. The first
sentence is three facts with no reasoning attached: what upstream
released, when, and what we still pin, on both severities. The
consequence follows, and the semver rationale is compressed into a
parenthetical behind it. The three-file checklist is untouched.

Reading the publish date means asking npm for the full packument: the
compact one (`application/vnd.npm.install-v1+json`) carries no `time` map
at all. `readPublishedAt` degrades to null for every shape short of that,
because the date is one sentence of context on an alert whose entire
reason to exist is being sent.

Also replaces a self-check assertion that could never fail:
`String(card.header).length === 0` is "[object Object]".length whatever
the card says. It now reads the fields a human reads.

Claude-Session: https://claude.ai/code/session_01B8MaA1ABkvbnfaYUKw3UH3
2026-09-07 07:01:15 +00:00
Ray Xi 2e4c1a753b feat(web): refresh the Home hero and composer (#7731)
* feat(web): add the SiriOrb run indicator

Ports the Siri Orb (SmoothUI, MIT) from #7635 as a standalone component: a rotating neon sphere with idle / thinking / streaming speeds, drawn entirely in its CSS Module. Unused by this PR; the rail run-status icon (PR-A of OPEND-2553) consumes it.

* feat(web): vendor the composer running orb

Adds the thinking-orbs 0.3.1 'solving' state at its 20px preset (MIT, attributed in the file) as a local component instead of a new runtime dependency: the composer needs one of the package's nine animations, pinned to the dark palette, and the transcription keeps the geometry, preset scaling, painter and frame loop of the published build so the canvas output is identical. Refs OPEND-2553 / #7635.

* feat(web): add the rotating Home headline i18n keys

homeHero.title now carries the {word} slot in every locale together with homeHero.titleWords (the comma-separated nouns the headline cycles), homeHero.subtitle and the attachment band's paging labels. Every locale owns its own sentence and noun list so the placeholder-parity check stays green (#7635 only rewrote en / zh-CN / zh-TW). Refs OPEND-2553.

* test(web): re-pin the Home hero and composer specs to the refreshed surfaces

Follows #7635's hero/composer contract: type row instead of the template dropdown, display+clear type pill, no Home mode picker, no resource submenus in the '+' menu, references in the working-directory row, lead-chip truncation, the 116px hero offset, the 220px execution-chip cap, the flat app wash and the stable send arrow. Carries over the Demo's new specs (dock collapse, Backspace-at-start, modelVersionLabel, icon-only design-system pill) and drops the ones for removed surfaces. Refs OPEND-2553.

* test(e2e): follow the Home type row and composer menu

Ports #7635's Playwright helper (homeTypeRow / clearHomeTemplate / pickHomeTemplate) and the home-hero-rail, entry-chrome-flows, project-management-flows, visual-navigation and entry-topbar changes that reach the composer through the type row, the working-directory group and the trimmed '+' menu. Refs OPEND-2553.

* feat(web): refresh the Home hero and composer

Replicates the Home hero + composer subset of #7635 on current main (OPEND-2553, PR-B): plain headline with the rotating noun and subtitle, a typeless cold Home whose type row (3 + 2 behind More) retires into the composer's display+clear pill, upload disc + design-system palette + type pill in the foot row, the supplied send mark and border beam, two-line staged-file chips in a paging band, the lead template chip with Backspace-at-start, references and linked checkouts in the working-directory row (one slot), the trimmed '+' menu without resource submenus or the mode picker, poster rows for examples, the model chip's brand mark and version-only label, the running orb + stop square in the project composer, and the flat app wash. HomeView gains the page/dock variant plumbing and drops the default type seed and the AppWashKineticGrid mount; the recent-projects grid stays for PR-C. Adds task_chip_clear to the composer click contract.

* test(e2e): follow the Home composer and type-row refresh

Migrates the Playwright specs that named surfaces #7635 removed or moved: the composer session-mode picker (every turn now carries the conversation's stored design mode, and design turns carry no message chip), the Home examples loading shell (a cold Home is typeless and shows only the type row while the catalogue loads), the fixed type row (three inline pills plus two behind More; every other create type reaches the hero through the cross-surface hand-off, which the shared pickHomeTemplate helper now drives), the working-directory row's single slot for references and linked checkouts, the '+' menu's working-dir group, the lead chip's eight-character title, the 32px send disc, and the flat app wash. The Home recent-projects specs keep main's shape (PR-C owns that move). Refs OPEND-2553.

* test(e2e): repair the visual captures the composer refresh broke

The projects captures clicked the rail's drafts item, which only renders
with a workspace context the visual fixture never has; Home already shows
the recent-projects strip, so the click is dropped. The integrations
capture reached its page by direct load, whose default tab is MCP and
whose cold mount outlasts a bare expect; it now clicks the connectors tab
like the MCP capture does. The BYOK switcher capture expected the link
glyph, but the chip leads with the selected model's brand mark and only
falls back to the glyph for a vendor without one. OPEND-2553 (PR-B).
2026-09-04 07:23:41 +00:00
open-design-crew[bot] 1b6d9a59b1 fix(web): validate the onboarding runtime before Continue is pressed (#7440)
* fix(web): validate the onboarding runtime before Continue is pressed

Continue on the Local Agent step ran the runtime smoke test inside the click
handler, so every user paid a full agent spawn plus a real model round trip
between pressing it and reaching the next screen: 7.1s for Claude Code and
12.1s for Codex CLI measured against a local daemon, against a 45s budget.
The button was only greyed out through that wait and still read "Continue",
so it looked like the click had done nothing. Before #7164 the click itself
was instant, because the validation came from the separate Test button.

Start the validation as soon as the selection settles instead, the way BYOK
already validates a settled provider, so it overlaps with the time the user
spends reading the panel and picking a model. Continue then takes the
already-validated result with no round trip at all. #7164's guarantee is
unchanged: onboarding still cannot complete on an unproven runtime.

`startOrJoinInlineTest` keeps that to one round trip per selection. A click
landing mid-flight joins the pass already validating those inputs rather than
being swallowed or spawning the agent a second time, and an attempt whose
inputs the user has moved past is aborted — the daemon kills the agent child
along with the request. A background pass no longer disables Continue; only a
click that is itself waiting marks the button busy, with a spinner and
"Testing connection…" in place of the label.

Internal report: OPEND-2281.

* chore(web): drop an internal tracker id from the onboarding spec comment

The surrounding sentences already carry the reason the validation has to be
under way before Continue is pressed, so the id added nothing a reader of
this tree could act on.

* fix(web): release the runtime validation when its setup step is left

Review caught two lifecycle gaps in the background validation this branch
introduces.

The pass spawns a real agent CLI, so abandoning one is not free, and nothing
aborted `agentTestRunRef` / `providerTestRunRef` when the user left the setup
step. Back, a runtime switch, or leaving onboarding all left that child
occupying the daemon until its own timeout — a cost that did not exist while
validation only ever started from an explicit click, and one a few abandoned
visits multiply. Abort both runs whenever the setup step is left; React runs
the same cleanup on unmount, so that path is covered by construction.

The BYOK auto-validation effect also still bailed while a run was in flight,
so editing the key, base URL, or model mid-flight scheduled nothing: the
inputs the user was now looking at stayed unproven until the request they
replaced resolved or timed out. That is the exact bail the local effect had
already dropped, and keeping it defeated the cancellation path
`startOrJoinInlineTest` was built for. Both effects now schedule through the
helper and let it abort whatever it supersedes.

* fix(web): make an aborted runtime validation undo its own bookkeeping

Review found three ways the cancellation added in the previous commit and the
attempt bookkeeping around it had drifted apart.

Aborting released the request but kept `*AutoTestKeyRef` claiming those inputs
were validated, while the aborted run deliberately writes no result. Coming
back to the same selection therefore hit the key guard, scheduled nothing, and
left the panel rendering a "testing" line no run would ever finish — the
automatic path was dead until Continue was pressed by hand. Aborting now clears
the key and returns a `running` state to idle. A run that already finished is
left alone: its verdict still describes the current selection, and re-proving
it would cost another spawn.

Releasing was also keyed on leaving the step, but inputs can stop being
testable while the user stays: clearing a BYOK key, or a daemon that goes away
under a local agent, leaves a request nothing can consume — the same situation
as walking out, and now the same cleanup. The condition is what the step can
still act on, not where the user is.

Finally, the debounce outlived a manual Test press, because pressing Test
disturbs none of the effect's inputs. A manual check answering inside that
window let the armed timer spend a second request on inputs that were just
proven, which also let a transient second failure overwrite the first success.
Both timers re-read the bookkeeping at fire time instead of trusting the check
made when they were armed.

* fix(web): do not validate a selection the setup step is still asking for

Being selectable is not the same as being provable, and the background
validation only checked the first. `canTestAgent` asks whether a selection
exists and the daemon is up; the picker deliberately keeps a setup-required
DeepSeek Harness entry in that list so the user has somewhere to start setup
from, and `selectDefaultCliAgent` only reassigns the saved selection when some
other agent is actually available. With that entry saved and nothing else
installed, opening the Local Agent step spent a `/api/test/connection` on it.

The entry carries a resolvable `path` — that is part of what identifies it as
setup-required — so the daemon does not short-circuit on `agent_not_installed`
and genuinely tries to start the runtime. The panel then reports a failure on
the same screen that is telling the user to finish installing the companion,
which reads as the setup being broken rather than unfinished. Continue was
already gated on `selectedAgent?.available === true`; the unprompted spawn was
not, and the two disagreed.

Start the background pass only on a selection the step would actually let the
user continue on. Pressing Test stays their call. Releasing keeps the wider
condition on purpose: a run started by hand on a setup-required entry still
has to be aborted when they leave, so what may start on its own is a stricter
question than what must be cleaned up.

---------

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
2026-09-03 04:03:09 +00:00
PerishFire 8ab7c3d5f1 feat(standalone): establish the exact lifecycle and Terminal carrier (#7716)
* feat(standalone): add shell-neutral exact lifecycle

* feat(terminal): add native exact cold-start carrier

* test(terminal): exercise mutable exact channel heads

* feat(standalone): model synced blobs and Shell updates

* feat(terminal): exercise the complete Shell lifecycle

* feat(release): add the independent exact Terminal line

* test(exact): run the full local Terminal release line

* feat(terminal): harden exact Sidecar lifecycle

* feat(standalone): prove exact update transactions

* refactor(standalone): separate shell lifecycle dimensions

* refactor(e2e): decouple terminal exact drivers

* refactor(terminal): isolate exact sidecar fixture host

* feat(terminal): gate handoff on physical retirement

* refactor(release): gate exact activation on public acceptance

* test(terminal): formalize guarded lifecycle algebra

* fix(terminal): guard shell installer handoff

* feat(terminal): add exact bootloader handoff

* feat(terminal): integrate exact sidecar lifecycle

* refactor(exact): rename validation channels

* test(terminal): harden exact release acceptance

* fix(postinstall): build standalone dependencies first

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(ci): build the native Terminal scene

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* test(ci): cover exact convergence handoff routing

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(exact): isolate terminal validation from main CI

* fix(terminal): complete shell update confirmation

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(exact): bind acceptance to installed manifest

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(standalone): block occupied shell installs

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

---------

Co-authored-by: Looper <looper@noreply.github.com>
2026-09-03 03:50:34 +00:00
Marc Chan 1d2150007b chore: remove extracted landing-page from the monorepo (#7727)
* chore: remove extracted landing-page from the monorepo

The marketing site now lives in a separate repository. Drop the
Astro app, its GitHub workflows/scripts, and the remaining
monorepo coupling so this workspace no longer builds or deploys it.

* fix: drop leftover landing-page coupling from Teams and skills

Point development enterprise URLs at the deployed marketing origin,
remove nextjs-app skill outputs that no longer emit a page, and stop
advertising apps/landing-page in plugin marketplace metadata.

Generated-By: looper 0.12.0 (runner=fixer, agent=omp)

* fix(ci): restore DSH installer E2E Vitest routing

Removing dsh-bootstrap-identity dropped the only planner route from
tools/release/resources/dsh-bootstrap/ to web_tests_required, so
installer-only PRs skipped e2e/tests/dsh-installer-version-policy.test.ts.

Generated-By: looper 0.12.0 (runner=fixer, agent=omp)

* docs: drop leftover landing-page paths from template assets

The skill contract no longer advertises the extracted Astro app, but
styles.css, compose.ts, and example.html still named deleted
apps/landing-page sources. Describe the standalone template behavior
instead and keep the bundled plugin example in sync.

Generated-By: looper 0.12.0 (runner=fixer, agent=omp)

* docs: keep landing-page off review and test surfaces

Add apps/landing-page to the forbidden-surface review list and stop naming the extracted path in plugin registry acceptance tables.

Generated-By: looper 0.12.0 (runner=fixer, agent=omp)

* fix: drop leftover landing-page refs and bump plugin version

Stop naming the deleted apps/landing-page in the landing-deck demo and
plugin-registry plan. Bump example-open-design-landing to 0.1.1 and keep
the official catalog version aligned with the bundled manifest.

Generated-By: looper 0.12.0 (runner=fixer, agent=omp)
2026-09-03 03:21:08 +00:00
nettee ff2cc80f33 chore(amr): use default Vela agent runtime (#7726) 2026-09-02 09:32:03 +00:00
lefarcen 09bd500d43 fix(plugins): enforce HTML preview contracts (#7707)
* fix(plugins): enforce HTML preview contracts

* fix: fail closed on preview contract I/O errors
2026-09-01 14:17:59 +00:00
PerishFire 0688faaec3 reland(sidecar): converge product clients on package boundary (#7636)
* reland(sidecar): converge product clients after weekly release

Restore the sidecar convergence from #7244 after the release-scheduling revert in #7634. This tree matches the previously reviewed and cross-platform QA-accepted implementation at d83d4e032f.

* fix(sidecar): clarify identity and cache witnesses

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(packaged): probe cross-source desktop owners

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(sidecar): preserve cross-source launch ownership

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* test(pack): assert cross-source Windows launch ownership

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(sidecar): align HyperFrames with client boundary

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(e2e): keep HyperFrames fixture in helper lane

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* docs(sidecar): restore review and handoff contracts

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(sidecar): restore cache and daemon boundaries

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(pack): restore sourcemaps and Linux smoke discovery

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(sidecar): make payload handoff generation-atomic

* fix(sidecar): preserve packaged action and mode parity

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* test(pack): align Linux cleanup resource count

Generated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix(packaged): preserve headless sidecar mode

* test: normalize source line endings

* fix(daemon): launch Codex cmd shims on Windows

---------

Co-authored-by: Looper <looper@noreply.github.com>
2026-09-01 13:22:04 +00:00
Caprika 05a01a0048 fix packaged mac Home readiness race (#7688)
* fix e2e packaged Home readiness race

* fix e2e poll Home readiness outside inspect

* fix e2e wait for Lexical attachment

* fix e2e publish setup after prompt insertion
2026-09-01 11:59:14 +00:00
chejiulong 743ae80d71 fix(deploy): ship data/ so Docker images keep their baked plugin previews (#7028)
`deploy/Dockerfile` copies skills, design-systems, craft, prompt-templates,
assets and plugins/_official, but not data/ — so
`data/plugin-previews/manifest.json`, which CI commits precisely so deployments
can find the bakes, is absent at runtime. `resolvePluginPreviewsDir()` defaults
to <projectRoot>/data/plugin-previews, `loadManifest()` returns {},
`applyBakedPreviews()` returns the records untouched, and
`inferPluginPreview(record, { preferBaked: true })` falls through to the live
iframe for every plugin.

Measured on 0.19.2 with 461 registered plugins: the checked-in manifest has 176
entries and 0 records carried od.bakedPreview. With data/ present the same
daemon reports 176 — deck 79, prototype 79, video 10, scenario 4, image 1,
audio 1, template 2. The packaged app reaches the manifest through its own
packaging path, so only Docker/self-hosted deployments are affected.

Also warn once per path when the manifest is absent. `loadManifest()` already
carries the reasoning for the malformed case — «would otherwise silently
disable every baked preview with no trace; surface it so it's diagnosable» —
but the absent branch returned {} with no message, which is the same failure
with the same lack of trace.

Fixes #7018

Co-authored-by: Jason <>
2026-09-01 09:03:36 +00:00
Marc Chan a36ebb0df3 fix(release): make catalog pack git-safe in the Playwright container (#7680)
* fix(release): make catalog pack git-safe in the Playwright container

Checkout's set-safe-directory did not cover later git calls, and Meta
swallowed a failed rev-parse under sh -e, so export ran with an empty
source commit. Mark GITHUB_WORKSPACE safe and pin CATALOG_SOURCE_COMMIT
to github.sha.

* fix(release): temporarily allow catalog-publish on the git-safe branch

Let pack and publish run on fix/catalog-publish-container-git so the
container git fix can be verified end-to-end before reverting the guard.

* fix(release): restore catalog-publish main-only job guards

Drop the temporary fix-branch allowlist now that the container git
workaround is verified end-to-end.
2026-09-01 09:02:58 +00:00
Ray Xi 1919ddc8be fix(amr): retire unlimited model badges (#7544)
* fix(amr): retire unlimited model badges

* test(web): remove obsolete campaign badge contract

Generated-By: looper 0.12.0 (runner=fixer, agent=codex)

* test(web): drop retired campaign restriction contract

The model-picker badge removal intentionally deleted campaignRestricted, but the campaign source-contract suite still required the dead branch and failed Web workspace tests (2/2). Keep the surviving campaignNeedsUpgrade URL-isolation contract only.\n\nGenerated-By: looper 0.12.0 (runner=fixer, agent=codex)

* test(e2e): remove duplicate model badge witness

Generated-By: looper 0.12.0 (runner=fixer, agent=codex)
2026-09-01 07:50:53 +00:00
Max Hsu c25c31aa65 test(e2e): cover grid card menu overflow near the viewport bottom (#7587)
#6971 fixed the placement for both layouts through one shared render site,
but its regression coverage only exercised the list row. Grid is the default
view, and that is the layout #6916 reports, so the reported scenario had no
witness.

Verified red/green against the placement rule: with
.recent-projects__card-menu[data-placement='up'] removed both this case and
the existing list-row case fail on the menu-above-trigger assertion; with it
restored both pass.
2026-09-01 06:14:33 +00:00