185 Commits
Author SHA1 Message Date
PerishFire 00c2d8ae57 feat(plan): establish reusable workload and postinstall contracts (#8383)
* feat(plan): add verified atomic workload product restoration

* perf(plan): probe product availability without downloading payloads

* refactor(ci): formalize workspace initialization plans

* feat(plan): add versioned workload execution contracts

* refactor(plan): colocate postinstall defaults and docs

* fix(convergence): enforce cache result integrity
2026-09-23 07:57:35 +00:00
+24
Ray Xiwangchenglongopen-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>elifive555555lefarcenClaude Opus 5陈志谦CheemsCheemsDavid ZChanhyo JungEric Lemesrelorenzozanelbjzz-hashTaltaljeonlefarcenbone3deep1962-collabNJUHuazhouzhengopen-design-release-bot[bot] <295937643+open-design-release-bot[bot]@users.noreply.github.com>open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>netteePerishFireQiuQiuNB666QiuQiuNB666Austin Wairanojax-novitajax-novitajax-novitaTony Boxmaoxin1234John Kattenhorn
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
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]andzhouzheng 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]andzhouzheng 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
lefarcenandlefarcen 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
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
lefarcenandClaude Opus 5 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
PerishFireandLooper 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
nettee 49cc51058f chore(nix): retire official Nix distribution (#7644) 2026-08-31 08:08:33 +00:00
lefarcen 1c1ecae470 fix(web): preserve HTML preview state across edit handoffs (#7592)
* fix(web): preserve HTML preview state across edit handoffs

* test(web): align reload race coverage with URL handoff

* test(e2e): target canonical active preview frame

* fix(preview): complete URL edit handoff parity

* fix(e2e): classify preview round-trip parity

* fix(preview): close edit handoff readiness gaps

* fix(preview): close remaining handoff regressions

* refactor(preview): centralize runtime state contract

* test(preview): isolate parity audit runtime
2026-08-31 01:32:49 +00:00
nettee df84ae5b9e refactor(web): remove unused component islands (#7591) 2026-08-29 14:18:01 +00:00
nettee 60be43c132 chore(repo): remove accidental Bun lockfile (#7589) 2026-08-29 14:17:38 +00:00
lefarcen 1e34ca4059 fix(daemon,web): report analytics for daemon-started Runs and mid-write Labs toggles (#7514)
* fix(daemon,web): report analytics for daemon-started Runs and mid-write Labs toggles

An OD Next task runs as several physical Runs — the user's request, then the
repair and production stages the daemon starts itself. The Run analytics
lifecycle was installed inline in POST /api/runs, so only the stage a client
asked for ever reported: volume, success, failure, cancellation, duration and
artifact counts were all computed on the request stage alone.

Walking every Run-creation site rather than only the one the report named found
three more callers in the same position — scheduled Automations, live-artifact
refreshes, and POST /api/chat.

Extract the lifecycle into routes/run-analytics-lifecycle.ts and install it from
internalRunCreation.start, which is now the only way a physical Run starts. Its
analytics argument is required, so a new caller has to state its identity —
including stating that it has none — instead of silently dropping the Run, and
a guard check keeps the bypass from coming back.

A daemon-created Run inherits its caller's identity and its task lineage from
the Run that caused it, resolved through the same deterministic fallback the
lifecycle itself applies. Reading the source Run's recovery record instead would
race: the lifecycle re-reads host facts before capturing, so a short Run hands
off before its own record exists.

Also split the Labs mounted guard. It sat in front of the toggle event and the
dialog's save pill as well as this component's state, so leaving the section
while the write was in flight lost the event and left the pill on "Saving" for a
preference the machine already held.

* refactor(daemon): move the run analytics lifecycle to its owning domains

Review follow-up. `createRunAnalyticsLifecycle` takes no Express request or
response, so by `apps/daemon/AGENTS.md` it is a reusable daemon service and
belongs under `src/services/`, not `src/routes/`. The run/project record shapes
it shares with the run routes describe a Run, not an HTTP exchange, so they move
to the runs domain.

The seam also carried a second copy of the analytics facts, with its context,
snapshot and rollout fields widened to `unknown` so the two definitions could
drift. `RunAnalyticsFacts` is now declared once by the lifecycle and reused by
`InternalRunCreationService.start`. Consolidating immediately surfaced what the
weak copy had been hiding: the route was passing a snapshot shape the lifecycle
did not actually accept, and the test was passing an analytics context missing
four required fields. Both are now typed at the choke point.

* fix(web): keep a late Labs save from taking the shared indicator

Review follow-up. Letting the Labs write settle after unmount fixed the stuck
"Saving" pill but handed an older request the ability to relabel a newer one:
hold the Labs PUT, switch section, start another save, release. The old
completion wrote `saved` over the newer save and its timer then forced `idle`,
which is worse than the pill it was fixing.

`SettingsDialog` owns one indicator for every section and already refuses to let
a superseded save win — its own path checks `autosaveLatestRef` and its idle
timer is a compare-and-set. Labs bypassed both, and its timer wrote `idle`
unconditionally.

The dialog now hands out a claim when a writer takes the indicator, and only the
current claim may settle it. Labs captures its claim when the write starts, not
when it finishes. Comparing the displayed value would not do: a newer section
can legitimately be showing the same `saving` the older write left behind.

The analytics event still fires after unmount — that half was never about the
indicator.

* fix(daemon): make the run analytics seam impossible to opt out of by omission

Review follow-up. The choke point documented an invariant it did not enforce:
`analyticsLifecycle` was optional and invoked with `?.`, so a future factory
could build the service, satisfy the new `.runs.start` guard, and still emit
neither `run_created` nor `run_finished` — the exact failure this PR exists to
close. `RunAnalyticsFacts.requestAnalyticsContext` had the same gap: the
contract says a caller with no attributable identity passes `null` explicitly,
while the type let it drop the key instead.

Both are required now. A harness that wants silence injects a no-op lifecycle
and says so. Making the context required immediately caught a service spec that
had been omitting it.

* fix(web): preserve autosave ownership across async saves
2026-08-28 01:45:59 +00:00
lefarcen 7f65c795d9 refactor: share one structural HTML injection-point locator (#7422)
* refactor: share one structural HTML injection-point locator

#7410's root cause was two copies of the same lookup drifting apart: the srcDoc
path anchored on the real `</body>` while the daemon copy still matched the
first one in the text. This puts the locator in `packages/contracts` and points
every injector at it — daemon preview bridges and containment base, web srcDoc
and export paths, desktop PDF/artifact/deck capture, and the brand build.

Rebuilt on `main` rather than rebased onto it. This branch was cut before #7421
and carried its 28 commits, so a rebase replayed them against a main that
already had them and conflicted on the first one; replaying 17 commits through
that would have been the easy way to silently drop one of the fixes. The net
change is the same 26 files, and only four of them had also moved on main.

The shared module is the daemon implementation as merged, not a reconstruction.
Verified as equivalence before the daemon's copy was deleted: against `main`'s
`apps/daemon/src/routes/project/index.ts` on identical inputs, 2,544 repository
HTML files × 7 lookups and 15,000 generated adversarial documents × 6 lookups
produce byte-identical answers — 0 divergences. The generated corpus is built
from every hazard #7421's review rounds turned up, which is the comparison that
would have caught the drift this PR exists to prevent.

Three call sites finished their migration on the way: `daemonSanitizeTitleInDoc`,
`assembleExample` and the collab-sync title read all located `<title>`
structurally and then searched for its close as text, so `</title >` and
`</title\n>` were missed and a `</title` inside the open tag's own attributes
could match first. All three go through `findRealElementRange` now.
`findRealElementRange` itself built its shadow with `toLowerCase()`, which is
not length-preserving; it uses `asciiLower` like the rest of the module.

`pnpm guard` gains an "HTML structural boundaries" check that fails any new
hand-rolled boundary lookup outside the shared module — the safeguard #7421's
rationale comment once claimed and did not have.

Validation: 100 daemon route/containment specs (#7421's full set, now running
against the shared locator), 45 contracts specs, 817 web runtime specs including
the jsdom differential oracle, guard exit 0 with the new check reporting, daemon
and contracts typecheck clean.

* fix: finish the title migration in srcdoc and the prefetch reader

Two from review, both the same leftover: the open tag was located structurally
and then the close was searched for as text. I had claimed this migration
finished in three call sites and it was five — a sweep of the repository for
`indexOf('</…')` found exactly these two remaining, and nothing else (the other
hits are `<artifact>` / `<PANELIST>` marker streams, which are not HTML).

`sanitizeTitleInDoc` used `indexOf('</title>')`, which accepts one spelling. A
document titled with `</title >` fell through to the next `</title>` in the
source — here one inside an authored script string — and the rewrite spanned
everything between, splicing into author content exactly as #7410 did.
`readRealTitleText` used `indexOf('</title')` with no boundary check, so
`</title-page>` ended the title. Both go through `findRealElementRange` now, and
with it the `toLowerCase()` shadow both were building, which is not
length-preserving.

Two specs. The `</title >` one is red on the previous commit. The
`</title-page>` one is green on both — `indexOf('</title>')` happened to skip it
too, since it demands the full spelling — so it is regression cover rather than
a red spec, and it is worth having because the new code reaches that case
through a different rule.

Writing them cost three wrong assertions before one held, all mine rather than
the code's: the title text is rewritten by `sanitizePreviewTitle`, so the raw
authored bytes are not observable in the output, and `b</title>` — my second
attempt at something that would be — is a substring of the sanitized
`a-title-page-b</title>`. The spec asserts the sanitized title itself now.

Validation: 819 web runtime specs, 100 daemon route/containment specs, `pnpm
guard` exit 0 with the structural-boundary check reporting, web and daemon
typecheck clean.
2026-08-27 04:53:21 +00:00
lefarcen 204ee7a06d fix(daemon): explain an ACP handshake rejection instead of retrying it (#7303)
* fix(daemon): explain an ACP handshake rejection instead of retrying it

An agent CLI that answers `initialize` and then refuses `session/new` /
`session/load` surfaced as a bare `json-rpc id 2: Internal error`, and the
retry policy treated it as transient — so the run failed, retried, and failed
identically while the user read a JSON-RPC frame.

Classify a handshake-numbered protocol error as `session_init` /
`install_cli`, keep every later protocol error on `child_close`, and lead the
user-facing message with the CLI and its detected version. The raw line stays
appended because `run.error` is also the classifier's input.

* fix(daemon): route the ACP handshake guidance onto the path users hit

The guidance copy was only reachable through `rewriteKnownAgentStreamError`,
which the ACP failure path never calls. An ACP agent's error goes
`attachAcpSession` -> `fail()` -> the bridge's `send('error', payload)`, and
that callback forwarded the payload verbatim; the close handler then returned
on `acpFatalErrorObservedBeforeCancellation && hasFatalError()` before the
stderr-tail rewrite further down. So a Kimi user still read
`json-rpc id 2: Internal error`.

Rewrite the payload at the bridge instead. That one object is the whole
user-visible surface of an ACP failure: `send` streams it to SSE clients and
`design.runs.emit` reads `run.error` out of it, so correcting it there fixes
both surfaces at once. `withAcpHandshakeFailureGuidance` rewrites only the
message fields, and only for a handshake-numbered JSON-RPC error, so
`AMR_MODEL_UNAVAILABLE`, promoted opencode errors, and post-session protocol
errors keep the shape their own handling depends on. The raw line stays
embedded because `run.error` is also the classifier's input.

Cover it through the full server run cycle with a fake ACP CLI that answers
`initialize` and rejects `session/new`, asserting on what a client and the
telemetry pipeline actually observe. Against the pre-fix tree the run error is
the bare `json-rpc id 2: Internal error`; against the pre-PR tree the daemon
also opened `session/new` twice.

* fix(daemon): read the handshake failure's cause, not just its id

Running a real Kimi CLI while signed out produces `json-rpc id 2:
Authentication required`. The handshake reading keyed on the JSON-RPC id
alone, so that user was told their CLI version was incompatible and they
should update or reinstall it — a prescription for a problem they did not
have, in place of the one sentence that pointed at the fix.

The id answers *when* a failure happened; it can never answer *why*. Split
the two questions: `isAcpHandshakeRpcErrorText` stays structural, and
`isAcpCliSessionRefusalText` is what the copy and the `install_cli` verdict
now hang off — handshake numbering AND no cause with a remedy of its own.
The "has its own remedy" half reuses `classifyAgentServiceFailure` rather
than growing a second signature list: it is agent-agnostic, already covers
auth / quota / upstream, and carries its own suite. It is deliberately
unrelated to, and narrower than, `isCliNotInstalledText` — that predicate
answers "was the binary even there", which a CLI that already answered
`initialize` has plainly settled.

A handshake failure that names its cause now keeps its own text and falls
through to the branch that owns that cause, so telemetry and copy agree: an
unauthenticated handshake is `auth` / `login`, a throttled one is
`rate_limit`. A bare refusal (`Internal error`, `Method not found`) is
unchanged and still gets the CLI guidance and the retry suppression.

No pure-function test could have caught this: the helpers were consistent
with themselves. The wiring test drives a fake ACP CLI that rejects
`session/new` with `Authentication required` and asserts on the text a
signed-out user actually reads.

* test(daemon): pin the category each named handshake cause is filed under

Verifies the routing claimed in the PR body rather than asserting it. Caught
one: `insufficient balance` has its own `insufficient_balance` category, not
`rate_limit`.

* fix(chat): name the ACP session refusal instead of writing its sentence

The guidance this PR added reached the user as a paragraph the daemon
composed: "The Kimi CLI (0.38.0) accepted the connection but refused to start
a session. … Details: json-rpc id 2: Internal error". That is the wrong layer,
and it showed:

  - The daemon has no locale. The string went straight into `run.error` and
    the chat rendered it verbatim, so a Chinese UI put a Chinese title over an
    English body. No amount of rewording fixes that; only moving the sentence
    to the client does.
  - The paragraph restated the raw agent line, which the card's details block
    already prints — the same text twice in one card.

So the daemon now NAMES the failure and stops there: `AGENT_CLI_SESSION_REFUSED`
plus `details: { kind: 'agent_cli', action: 'update_cli', agent, agentCliVersion }`,
with `retryable: false` because a build that refuses `session/new` refuses the
identical request identically. `withAcpHandshakeFailureGuidance` stays the single
invariant point (the ACP bridge payload feeds SSE and `run.error` at once) and now
stamps only the structured half; the stderr-tail fallbacks wrap their payloads in
it too, so no path is left composing prose.

`run.error` keeps the agent's `json-rpc id N: …` line byte for byte. It is the
classifier's input — rewriting it degrades this failure class to `unknown` in
telemetry — and it is what the details block shows, so leaving it alone is also
what removes the duplicate.

The web maps the code to localized copy in `resolveRunFailureUi`, resolved before
every other branch because the copy is not a fixed sentence: it names the CLI
build the daemon detected, and falls back to a version-less wording when there
was none (the same rule the rolling model-window copy follows). The version rides
to the client as data, on the status event next to `code` / `failureDetail`.
Three keys in all 19 locales.

Red first, on the path users hit: the wiring suite (real `startServer` + a fake
ACP CLI) went red on `run.error` being the English paragraph, and a new ChatPane
render spec went red on the raw line appearing twice in one card. Both green here.
Verified in a real browser against a local runtime: the same failed run renders in
zh-CN and in en from one persisted event.

* test(daemon): stop the wiring suite pinning a racy CLI-version probe

CI run 32683047377 caught this on the previous head: the wiring test demanded
`0.38.0` inside `run.error` and got a version-less sentence, because
`getDetectedRuntimeVersions` is a process-wide cache that `probe()` clears
before each probe and refills after — a concurrent `/api/agents` refresh can
leave it empty exactly while a run is failing. Locally it always won the race;
on CI it did not.

The new assertions inherited that: `details.agentCliVersion` was pinned to an
exact string on both wiring cases. Assert the property that actually holds
instead — the version is either the one the daemon detected or absent, never
some other value — and say in the helper why presence is not asserted here.
Both copy paths (version present, version absent) stay covered
deterministically at the layers that own them: the payload unit suite and the
two web suites.

Making detection itself deterministic is a separate finding, still open on
this PR, and deliberately not fixed here.

* fix(daemon): let the classifier, not the wording, name a handshake refusal

Three review findings, one shape: the ACP handshake verdict was being decided
in more than one place, so the places disagreed.

1. The handshake reading sat inside `isAgentProtocolErrorText`, whose JSON-RPC
   arm matches only `json-rpc id N: Internal error`. A CLI that refuses
   `session/new` with `Method not found` or `Invalid params` skipped it
   entirely and was promoted to a retryable `fatal_rpc_error` on `child_close`
   — the no-auto-retry behaviour this PR claims never applied to it — or, once
   the payload rewrite had stamped its code, fell all the way through to
   `unknown` / `finalize`, which is exactly the untriageable bucket the module
   docblock says the rewrite must never produce.

   The wording an agent picks for its own rejection is not a signal. The
   handshake branch now sits AFTER every cause branch instead of inside the
   protocol one, so precedence is a fact of the control flow rather than a
   promise: whatever auth, rate limit, balance, upstream, prompt size, or CPU
   already claimed keeps it, and only an unexplained refusal reaches the last
   line. Ids 3 and up never enter it and keep the transient treatment they
   have always had.

2. `handshakeFailureNamesItsOwnRemedy` knew only the three
   `classifyAgentServiceFailure` classes, so every other cause the run
   classifier can already advise on was rewritten into the CLI-upgrade copy.
   A user whose content was too long read "your CLI version is incompatible"
   while the same run was filed as `prompt_too_large` / `reduce_context`.

   `isAcpCliSessionRefusalText` now IS the classifier — it runs
   `classifyRunFailure` over the text and reports whether that reading is the
   handshake refusal — so the card the user gets and the bucket the run lands
   in cannot prescribe two different fixes. The id-only helpers moved to
   `runtimes/acp-handshake-id.ts` so the classifier can read the id without the
   id-reader having to reach back into the classifier.

3. The guidance read the process-wide detected-version cache at FAILURE time,
   while `probe()` cleared each entry before re-reading it. Any `/api/agents`
   refresh overlapping a failing run decided whether the user was told which
   build refused them; CI caught it (run 32683047377) and the assertion was
   loosened instead of the race closed.

   Both halves are closed. A probe now publishes its reading once, at the end,
   and only when no later probe has already published, so the cache holds the
   newest COMPLETED reading rather than the state of one in flight. And the run
   freezes the version it is spawning with, so failure-time guidance reads the
   run and never the live cache.

Coverage is at the wiring layer, not just the predicates: the fake ACP CLI
gained named gates so a run's handshake and a concurrent `/api/agents` refresh
can be held open together and the failure built at the exact moment the cache
is mid-probe — deterministic, no sleeping on wall-clock timing. The wiring
suite's CLI-version assertion goes back to strict.

One deferral found by the existing suite and now pinned: a handshake-numbered
frame carrying an OS-level crash banner (Windows STATUS_ILLEGAL_INSTRUCTION
from the bundled opencode) reports a child that died, not one that refused, and
stays with the crash reading.

* test(daemon): describe the overlap the gate test actually constructs

The comment said the failure is built "at the precise moment the cache is
blank". That was the pre-fix window; the cache no longer goes blank mid-probe,
which is half of what the test proves. Say what it now holds open — a probe in
flight — and name both halves it exercises.

* fix(daemon): drop the duplicate detection import that blocks startup

`getDetectedRuntimeVersions` was imported twice — once standalone and
once inside the `./runtimes/detection.js` group — so the module has two
declarations of the same lexical binding. Node's ESM loader rejects that
outright with `Identifier 'getDetectedRuntimeVersions' has already been
declared`, i.e. the daemon could not boot.

The suite never caught it: vitest transforms through esbuild, which
tolerates the duplicate, so 179 tests passed against a module real Node
refuses to load. Verified by importing server.ts under Node directly —
SyntaxError before, gone after.

* fix(daemon): report the CLI build this run actually started, on both surfaces

A version-specific refusal (`AGENT_CLI_SESSION_REFUSED`) names the CLI build
that refused, so the user knows which one to change. Two things could make
that name wrong.

**The persisted event dropped it.** `send()` stores every run event on the
assistant message before emitting it, and `runSseEventToPersistedAgentEvent`
serialized only `detail` and `code`. The live card named the build; a reloaded
one fell back to the version-less sentence — exactly when the user comes back
to act on it. The mapper now lifts `error.details.agentCliVersion` out of the
frame, validated as a non-blank string, because `details` is a pass-through bag
that also carries the agent's own JSON-RPC `error.data`.

**The spawn-time capture could name a different executable.**
`getDetectedRuntimeVersions` answers per agent id and says nothing about which
binary the reading came from. A user who repointed the CLI after Settings last
listed agents (Settings writes `KIMI_BIN`; PATH changes) ran one build and was
told to change another. The capture now resolves through
`ensureDetectedRuntimeVersions` with the same `configuredAgentEnv` that
produced this run's `agentLaunch` — cached when the launch identity is
unchanged, re-probed through the same bounded `--version` read when it is not,
and taken before the spawn marks so preflight work is accounted to preflight.

Both are pinned end to end in `acp-handshake-failure-wiring.test.ts`: one test
reads the conversation back through the messages route a reloading client
calls, the other repoints `KIMI_BIN` between detection and the run and reads
the two fake CLIs' invocation logs to establish which child actually ran before
asserting what the failure reports.

* fix(daemon): keep a runtime that never started out of the CLI-refusal verdict

An ACP handshake rejection was decided on the JSON-RPC request id alone, so any
failure numbered 1 or 2 became "this CLI build cannot open a session; change
it". AMR is the population that reaches this path, and what it runs underneath
is OpenCode: when vela's bundled OpenCode child fails to come up it reports
`start opencode server: opencode exited before readiness: exit status 3` from
inside `session/new`, which arrives handshake-numbered while saying nothing
about the agent CLI's own build.

Two things went wrong at once. The user was told to replace a perfectly healthy
CLI, and the automatic retry that actually recovers a startup race was
withdrawn — `process_exit / agent_protocol_error / session_init`,
`retryable: false`, `install_cli`, with the ACP wrapper stamping
`AGENT_CLI_SESSION_REFUSED` on top. A startup race clears on the second
attempt; a CLI-build verdict never does, so the two must not collapse.

`isManagedRuntimeStartupFailureText` names the distinction: a CLI reporting
that a runtime IT manages never came up has named the moving part itself, so
its build is not the only variable left. The markers are vela's own wrapper
text, already trusted by `isCpuUnsupportedCrashText` for the crash-banner
sibling of this shape and now shared with it. Both deferrals now read the same
way: the handshake frame is the envelope, not the evidence — a child that DIED
(crash banner) or one that never STARTED (readiness) is not a refusal.

Also stops this suite's runtime from being a property of the host. The wiring
cases drive `GET /api/agents`, which probes every shipped runtime and resolves
binaries from PATH plus the machine's toolchain directories, so a host with
real agent CLIs installed turns each refresh into a dozen real spawns: ~2.8s
here, ~28s on a reviewer's machine — past the 20s test timeout for the
mid-refresh case, which waits on two. `isolateAgentDetection` points
`OD_AGENT_HOME` at an empty per-test directory, detection's own mechanism for
scoping the search, so the only CLI anything can find is the fixture's. Same
route, same probe path, same deterministic gates and overlap assertions; the
file drops from 109s to 14s and the mid-refresh case from 30s (timing out) to
1.2s.

* revert(daemon): drop the CLI-version half of the handshake-refusal card

The copy wanted to name the build that refused, and the chain that
followed ended in the launch path: the version had to be frozen before
the spawn, freezing meant awaiting a `--version` probe there, and an
await before `spawn()` sits after the run's only pre-spawn cancellation
guard. Stop clicked during that window finishes the run as canceled,
the probe then resolves, and the child is spawned onto an already
terminal run that nothing will signal.

The whole sub-branch goes rather than a second guard. What it bought
was `(0.38.0)` in a sentence that already read correctly without it;
what it cost was a run the user asked to stop starting anyway.

Removed: the pre-spawn `ensureDetectedRuntimeVersions` await,
`run.spawnedAgentCliVersion`, and the version half of
`agentFailureIdentity`; detection's probe generation guard;
`agentCliVersion` persistence, its `PersistedAgentEvent` field, its web
pass-through, and the `{version}` copy variant in all 19 locales.
`server.ts` no longer touches the run lifecycle at all — its diff is
four `send('error', ...)` call sites and one helper.

Detection's generation guard fixes a real, independent daemon bug (for
the length of every `/api/agents` refresh the daemon cannot report the
version it detected). It ships as its own PR, and naming the build in
the copy follows it.

Kept: the localized `AGENT_CLI_SESSION_REFUSED` card in 19 languages,
no auto-retry for a handshake-stage refusal, `run.error` exactly equal
to the agent's own line, auth/quota/upstream/managed-runtime-startup
each keeping their own verdict, and ids 3+ staying out of the branch.
The cases that asserted a version number now assert the same behaviour
without one, plus `not.toHaveProperty('agentCliVersion')` and a locale
guard against a stray `{version}` placeholder, so a partial re-land
cannot slip half of this back in.
2026-08-25 06:04:55 +00:00
PerishFireandLooper d03bf69d58 feat(standalone): complete Terminal exact lifecycle (#7319)
* feat(standalone): bootstrap terminal exact lifecycle

* feat(standalone): complete terminal exact lifecycle

* fix(ci): build standalone during postinstall

* fix(standalone): repair exact lifecycle recovery

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

* fix(standalone): make exact updates replay-safe

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

* fix(terminal): enforce replay-safe exact carrier

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

* fix(standalone): align active update replay state

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

* fix(standalone): preserve exact channel lifecycle

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

---------

Co-authored-by: Looper <looper@noreply.github.com>
2026-08-25 03:56:31 +00:00
lefarcenandlefarcen fe94564a71 fix(daemon): stop sending Kimi 0.37+ an MCP transport it rejects (#7313)
Kimi Code CLI 0.37.0 replaced the stdio branch of its ACP `session/new`
MCP handler with a throw:

  {"code":-32603,"message":"Internal error","data":{"details":
   "ACP stdio MCP server open-design-live-artifacts does not declare
    a runtime identity"}}

OD attaches a stdio MCP server (`open-design-live-artifacts`) to every
`mature-acp` runtime, so every Kimi run on 0.37+ failed at the handshake
— which is why the observed failure rate is 84–92% rather than flaky.

The message names a "runtime identity" but no accepted value exists.
Kimi validates `session/new` against a zod union whose stdio member
declares no `type` key, so an explicit `type: "stdio"` is stripped by
validation and the handler then sees no `type` and throws. Verified
against the published tarballs by driving a real ACP handshake: 0.35.0
and 0.36.1 accept a stdio entry with or without `type`; 0.37.0, 0.37.1,
0.37.2 and 0.38.0 reject both spellings. Only http/sse survive.

So the session now withholds stdio MCP servers from builds that reject
them, judged against the version the agent reports in its own
`initialize` result — the build that is actually about to parse
`session/new`, and therefore more current than any earlier `--version`
probe. Which builds reject is runtime-def data
(`acpStdioMcpRemovedInVersion`), following the `acpMcpEnvFormat`
precedent, so hermes / trae-cli / reasonix declare nothing and keep a
byte-identical payload.

Kimi 0.37+ runs lose live-artifact tooling, which is unreachable on
those builds either way; a session that opens without MCP beats one that
cannot open at all. 0.36.x keeps the capability.

Co-authored-by: lefarcen <ontf116@gmail.com>
2026-08-24 13:33:10 +00:00
lefarcen d85e78ab7d fix(runtimes): reach a working agent CLI past a broken shim (#7153)
* fix(runtimes): reach a working agent CLI past a broken shim

Resolving an agent's name on PATH only proves a file exists there, never
that it runs. Detection stopped at the first hit, so a wrapper orphaned by
a half-finished `npm i -g` shadowed a perfectly good CLI of the same name
in a later search directory — and because the not-invocable branch dropped
the resolved path, the picker (which renders an unavailable agent only when
it carries one) hid the agent entirely. The user saw "scan found nothing"
with no way to act.

Detection now walks past candidates that cannot be executed, and reports
the path it tried when every candidate fails, so the row stays actionable.

Also on this chain:

- `@open-design/dsh-runtime` pinned its DeepSeek Harness peers to one exact
  release candidate, so it became uninstallable the moment upstream shipped
  the next one. Peers now take ranges. `scripts/guard.ts` gains a narrow,
  logged exemption for external-host plugin manifests — their peers describe
  a host this repo neither installs nor controls, which is the one case
  where an exact pin is wrong rather than reproducible.
- The DSH installers pinned only the entry package while its ~190 siblings
  used caret ranges over prerelease floors. Once a newer release candidate
  existed, the transitive tree floated onto it and its mutually exclusive
  peer sets sent npm into an ERESOLVE backtrack that never converged. Both
  installers now freeze the resolution window alongside the pin.
- The rescan notice counted agents the list never renders, so it announced
  "3 available" above two rows.

* fix(runtimes): make detection's chosen executable the one spawn sites use

The candidate walk taught detection to skip a binary it cannot execute, but
every other resolution — chat, the connection test, the memory summariser,
companion install — called `resolveAgentLaunch` with no skip list and got
back the first file that merely exists on PATH: the broken shim detection
had just rejected. Settings advertised the agent as installed while each
turn exec'd the wrapper, so the fix only ever reached the picker.

Detection is the only stage that spawns anything, so it is the only one that
learns which candidate works. It now publishes that winner, and resolution
prefers it over a fresh first-hit walk. An explicit `*_BIN` override and a
packaged built-in still outrank it, a winner whose file has since vanished
is ignored, and detection clears the entry before each pass so a rescan
after a repair never resolves against a stale one.

This restores the invariant the file already documented — detection probes
the exact path the runtime will spawn — which the walk had broken.

Reported by @mrcfps in review.

* fix(runtimes): keep the remembered executable inside the live candidate set

The winner map made resolution stop being a pure function of the current
environment. It only checked that the remembered file still existed, so a
path learned in a richer environment survived an emptied PATH, a sandboxed
OD_AGENT_HOME, and any other narrowing of the search — resurrecting a binary
the caller could no longer see.

That is not just a test artifact: `GET /api/integrations/vela/status` must
answer `amr-runtime-unavailable` when the vela binary cannot be resolved, and
it started answering "ready" instead, because an earlier detection in the
same process had remembered a path.

The memory now reorders candidates rather than introducing them: it applies
only when the live search already offers the remembered path. An emptied
PATH, a sandbox, and an uninstalled CLI all keep meaning "not found", while
a broken shim shadowing a working binary is still resolved to the winner.

This also settles the scope question raised in review — no separate cache key
is needed, because the candidate set already carries the resolution scope.

* fix(runtimes): treat a launcher whose target is gone as not invocable

A stale global npm wrapper on Windows is a `.CMD`, which OpenDesign runs
through `cmd.exe /d /s /c`. When the package behind it has been
uninstalled, cmd.exe starts, node starts, and only then does node fail to
load the script the wrapper names — a plain exit 1 with MODULE_NOT_FOUND
on stderr. None of the spawn-level signals the classifier looked for
(ENOENT / EACCES / ENOTDIR, exits 126 and 127) can occur along that
chain, so detection read the dead wrapper as a healthy CLI that merely
disliked `--version`, stopped the candidate walk on it, and never reached
the working `dsh.CMD` later on PATH.

Classify invocability from the launcher's own vocabulary as well: cmd.exe's
9009 (the Windows analogue of POSIX 127), node's missing-module report, and
cmd.exe / PowerShell's missing-command and missing-path reports. Exit status
alone still means nothing — a CLI that runs and exits non-zero is a real
answer from the right binary and keeps its place in the walk.

Drop the win32 early return from every case in the fallback suite and write
fixtures per platform, so `dsh.CMD` resolution through PATHEXT and the
cmd.exe wrapping are exercised natively. The orphaned-wrapper regression is
reproduced on every platform — the defect is a classification bug, not an OS
API one, and the daemon suite only runs on Linux in CI.

* fix(runtimes): carry forward the dead paths, not detection's winner

Detection published the candidate it settled on and resolution preferred
it over the first live PATH hit. That pinned the daemon to one binary for
its whole lifetime and let it silently outrank the caller's own PATH: a
CLI that became visible earlier afterwards — a fresh install, a version
manager swapping shims, a caller resolving under its own environment —
could no longer be reached.

The daemon suite caught it on a machine that has a real `vela` installed:
`chat-route` and `connection-test` inject a fake agent CLI ahead of PATH
per case, and the remembered winner outranked it, so 15 cases spawned the
machine's real CLI instead of the fixture. CI never saw it because its
runners have no such CLI to remember.

Carry the proven-dead paths forward instead. Skipping them keeps every
candidate still standing in plain PATH order, while chat, the connection
test, the memory summariser, and companion setup still avoid the shim
detection rejected — which is the whole point of publishing anything.

* docs(runtimes): say why detection keeps its own launcher-error list

`isCliNotInstalledText` in run-failure-classification.ts reads similar
output, and the next reader will reasonably ask why the two are not one.
They answer different questions: that one buckets a finished run for
telemetry, where over-matching costs a mislabelled event, while this one
decides whether to abandon a binary mid-resolution, where over-matching
sends a working CLI's user to a different install.

* fix(runtimes): accept the dsh release line, not one release candidate

This branch moved the installers to 0.1.0-rc.8 and left the agent def
naming 0.1.0-rc.6 as the only supported version, so following our own
one-line install produced "0.1.0-rc.8 has not been tested with this
OpenDesign build" in Settings on a clean machine. That is the same
pinning defect the installers had, one layer up.

`dsh` publishes a stream of release candidates whose sibling packages
peer-require each other, so the version our installer hands the user
moves with upstream. Version policies can now declare the release line
they accept alongside the exact versions exercised, and DeepSeek Harness
declares its own. A version off that line still warns — the point is to
stop pinning, not to stop checking.

Add a cross-boundary guard for the drift itself: the installers and the
agent def cannot import each other, so `e2e/tests` reads both and fails
when an installer pins a version the daemon would not accept.

* docs(contracts): correct what shim-broken now means

The reason's docstring said "exit 126/127", which stopped being the whole
story once detection learned to read a Windows wrapper's stderr: a `.cmd`
starts an interpreter successfully and reports the missing target there,
so no POSIX exit code appears at all.

* test(runtimes): cover the launcher signatures no fixture can produce

`launcherTargetMissing` recognises six ways a launcher says it never
reached its target, and until now two of them had any coverage — the two
a POSIX shell can print. cmd.exe's wording, PowerShell's, and exit 9009
were load-bearing for the Windows fix and untested anywhere.

A fixture cannot close that gap. POSIX masks exit statuses to eight bits,
so a shell asked for 9009 reports 49, and /bin/sh does not speak cmd.exe.
The daemon suite runs on Linux in CI, so driving the classifier directly
is the only way the merge gate can catch a typo in a signature the fix
depends on for the platform the bug came from.

Extract the catch arm verbatim as `classifyVersionProbeFailure` — same
branches, same order, no behaviour change — and feed it the rejection
shape `promisify(execFile)` actually produces. Verified by mutation:
deleting the 9009 branch reds one case, deleting the stderr branch reds
six.

The suite also pins the constraint from the review by name: exit 1 with an
ordinary argument complaint, with no stderr, or with wording that belongs
to run-failure telemetry ("not installed") all stay `spawned`. That last
one is why detection keeps a narrower list than `isCliNotInstalledText`.
2026-08-24 13:04:31 +00:00
PerishFireandLooper 84381efab9 feat(ci): add global hash skip planning (#7185)
* feat(ci): add global hash skip planning

* refactor(ci): remove superseded hash skip cache

* refactor(ci): keep scope trust in planner

* fix(ci): run scope goldens for planner changes

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

* fix(ci): publish hash state after successful validation

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

---------

Co-authored-by: Looper <looper@noreply.github.com>
2026-08-21 00:12:33 +08:00
lefarcen 16fad1c4d8 revert(design-systems): remove structured runtime workflow (#7110)
* revert: remove structured design system runtime

* fix: preserve authority boundaries during revert
2026-08-19 05:37:48 +00:00
Joey-nexuandJoey f1a0b60c6c chore: unify product name display to OpenDesign repo-wide (#6998)
* chore: unify product name display to OpenDesign repo-wide

Rename the user-facing product name from "Open Design" to "OpenDesign"
across documentation, web/desktop UI strings, i18n locales, prompts,
plugin/skill/design-system content, changelogs, and specs, matching the
landing-page rename already in flight.

Deliberately excluded so installed-app identity stays untouched:
- packages/release PRODUCT_NAME and everything derived from it
  (app bundle names, installer/registry identity, updater feeds)
- tools/pack, tools/release, apps/packaged, landing page (PR #6983)
- mocks/ recorded traces and LICENSE
- test fixtures and comments that encode real on-disk install paths
  (Open Design.app, Program Files\Open Design, AppData paths)

Validated with pnpm guard, pnpm typecheck, and targeted web/desktop/host
test runs over the renamed strings.

* fix(web): keep two-letter author monogram for CamelCase brand names

authorInitials split words on whitespace only, so the renamed single-token
author name "OpenDesign" produced a one-letter monogram. Treat lower-to-upper
CamelCase boundaries as word breaks so the avatar fallback stays "OD".

* fix(release): restore spaced Windows source artifact names in win.ps1

tools/pack still derives builder outputs from the unchanged PRODUCT_NAME
("Open Design"), so the asset script must look for the spaced filenames.
Matches the mac/mac-intel/linux asset scripts, which were already kept on
the original names.

---------

Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
2026-08-18 07:09:45 +00:00
lefarcen 1a7a23d4cc fix(community): make deck remix previews reliable (#6996)
* fix(community): make deck remix previews reliable

* fix(community): support nested deck scrollers

* fix(web): isolate nested deck fallback scrolling

* fix(web): infer deck scroll axis from slide geometry
2026-08-18 05:17:00 +00:00
f9fe73c836 feat: add DeepSeek Harness support (#6874)
* feat(daemon): scaffold DeepSeek Harness profile protocol

* feat(daemon): integrate DeepSeek Harness profile runtime

* fix(daemon): preserve headless prompts on native resume

* refactor(daemon): model Harness profile resume explicitly

* fix(daemon): resume Harness sessions after cancel

* test(daemon): verify DeepSeek Harness Windows carrier

* test(daemon): reap DeepSeek Harness Windows process tree

* feat(agents): surface DeepSeek Harness models and branding

* fix(daemon): surface Harness credential guidance

* feat: add bundled DeepSeek Harness setup

* fix: link DeepSeek Harness installer

* fix: link official Harness docs

* fix(daemon): distinguish Harness companion install

* test(pack): verify bundled Harness resources on Windows

* fix: harden Harness profile turn lifecycle

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

* fix(dsh-runtime): stop cancelled restore follow-ups

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

* fix(daemon): cancel Harness startup before execute

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

* fix(pack): build DeepSeek runtime on cold cache

---------

Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: Looper <looper@noreply.github.com>
2026-08-14 02:07:55 +00:00
open-design-crew[bot]xiaoche-hubmrcfpsopen-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
4908baa4f4 feat(design-systems): ship structured runtime workflow (#6805)
* feat(design-systems): add structured runtime contract (#6456)

* feat(design-systems): add structured runtime contract

* chore: retrigger CI for PR 6456

---------

Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: mrcfps <mrc@powerformer.com>

* feat(design-systems): resolve structured runtime intents

* fix(design-systems): preserve workspace runtime scope

* test(design-systems): cover scoped prompt runtime

* fix(design-systems): use one component authority

* fix(design-systems): preserve run workspace authority

* fix(design-systems): honor runtime rollback gate

* fix(design-systems): pin run binding authority

* fix(runs): reject client-supplied scopes

* fix(design-systems): keep user package assets scoped

* feat(design-systems): validate generated UI adherence

* test(e2e): assert durable reload authority state

* fix(design-systems): reject non-executable adherence evidence

* fix: harden design system adherence validation

* fix(design-systems): validate JSX selectors structurally

* fix(design-systems): close adherence input gaps

* chore(nix): refresh pnpm dependency hashes

* chore(ci): retrigger design system validation

* fix(daemon): bound design system artifact reads

* fix(design-systems): bridge Codex runtime tools

* docs(codex): clarify shell environment policy

---------

Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-08-13 09:02:20 +00:00
Amy a338a13aab test(ci): harden Functional E2E and daemon release gates (#6708)
* test(e2e): harden coverage and runtime isolation

* test(e2e): gate prerelease on full functional suite

* test(daemon): enforce prerelease coverage

* test(daemon): stabilize full-suite fixtures

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

* test(e2e): preserve interaction regression coverage

Keep deck navigation subject to Playwright actionability checks and restore the current rail updater/composer stacking witness as an explicit expected failure.

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

* fix(ci): close conformance validation gaps

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

* test(e2e): restore MCP snippet style guard

Restore the browser-computed style witness that was over-deleted when the OAuth flow moved to Integrations, and navigate through the current Settings MCP server entry.\n\nGenerated-By: looper 0.11.8 (runner=fixer, agent=codex)

* fix(ci): close directive and job-boundary gaps

Compare normalized MUST_FIX directives as sets so identical multi-item rounds remain weak debates, and bound workflow topology assertions to their immediate sibling jobs.\n\nGenerated-By: looper 0.11.8 (runner=fixer, agent=codex)
2026-08-11 08:31:14 +00:00
Ray XiandAmy 886bcc4db0 feat(web): streamline onboarding model source flow (#6475)
* feat(web): streamline onboarding model source flow

* test(e2e): align onboarding coverage with cloud-first entry

* test(e2e): isolate cloud status in non-auth specs

* test(e2e): keep restoration focused on tab state

* test(e2e): close cloud-first onboarding gaps

* test(e2e): align runtime checks with cloud-first gate

* ci(e2e): retire automations from P0 gate

---------

Co-authored-by: Amy <1184569493@qq.com>
2026-08-06 18:48:33 +08:00
open-design-crew[bot]andxiaoche-hub 99fc8a45bc Revert "feat(design-systems): add structured runtime contract (#6456)" (#6520)
This reverts commit 2b8aa8c457.

Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
2026-08-06 18:48:11 +08:00
2b8aa8c457 feat(design-systems): add structured runtime contract (#6456)
* feat(design-systems): add structured runtime contract

* chore: retrigger CI for PR 6456

---------

Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-08-06 06:08:21 +00:00
Amy 5a2e56101e test(e2e): restore workspace-aware P0 coverage (#6445)
* test(e2e): restore workspace-aware P0 coverage

* test(e2e): await collaboration unload and event readiness

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

* test(e2e): slim multi-client collaboration P0

* ci(e2e): split workspace editor P0

* test(e2e): avoid ambiguous upload assertion
2026-08-06 03:00:27 +00:00
Marc Chan fef0ddf88e ci: auto-rerun once on runner/spot cancel (#6453)
* ci: auto-rerun once when runner/spot cancel kills ci jobs

Add rerun.atom.yml as a trusted workflow_run consumer that requests one
`gh run rerun --failed` when completed pull_request/merge_group ci runs
lose leaf jobs to infra cancel (shutdown signal / cancelled runner),
while skipping ordinary assertion failures, stale heads, and ejected
merge-queue entries.

* fix(ci): grant rerun atom read scopes and refuse mixed failures

Add checks/pull-requests read permissions so the helper can fingerprint
infra cancels, and require every non-success leaf to be infra-classified
before authorizing gh run rerun --failed.

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)

* fix(ci): require open PR for live-head stale gate

Stop treating a same-repo branch tip as live-PR evidence. Closed PRs can
leave the source branch at the failed run SHA with no open association;
query open pulls by repository-qualified head ref instead, and cover the
closed-PR + unchanged tip shape in self-check.

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)

* fix(ci): require infra marker for cancel rerun

Bare cancelled jobs without shutdown/spot annotations are ordinary
(manual cancel) and must not authorize gh run rerun --failed.

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)

* fix(ci): ignore aggregate gate jobs in infra-cancel classify

Validate workspace and Runtime summary fail as always() mirrors of leaf
outcomes and never carry runner/spot markers. Counting them as ordinary
blocked pure infra-cancel reruns whenever the workflow still reached the
gate. Exclude those names from classification and cover the shape in
self-check.

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)

* fixer: address PR #6453 follow-up items

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)

* fix(ci): refuse mixed ordinary+infra job annotations

Jobs that record both a real failure annotation and a later runner
shutdown marker must classify as ordinary, not pure infra-cancel, so
gh run rerun --failed cannot auto-retry assertion failures.

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)

* fix(ci): paginate check-run annotations before infra classify

GitHub defaults annotations to 30 results; a later ordinary failure must still
block gh run rerun --failed. Walk pages like paginate_jobs and cover the split
with a multi-page request fixture.

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)

* fix(ci): drop tracked pycache from infra-cancel helper

PerishCode BLOCKING on #6453: remove the accidental CPython 3.14
bytecode under .github/scripts/__pycache__/ and ignore __pycache__/
plus *.py[cod] so local self-check/compile output stays out of git.

* fix(ci): open-PR merge_group freshness; ignore warning annotations

Merge-group infra-cancel retries no longer require the synthetic head to
still sit in the merge queue — workflow_run completed fires after required
checks already ejected it. Freshness is an open PR still associated with
the run (commit→pulls or gh-readonly-queue pr-N ref).

Warning-level annotations (e.g. scopes.ts full-plan fallback) no longer
count as ordinary failures that suppress pure infra-cancel retry.

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)

* fix(ci): match merge_group PR heads and arm rerun e2e Vitest

Compare the live open PR head with the PR head SHA encoded in the
merge-queue branch before authorizing infra-cancel retry, and enroll the
rerun atom surfaces in scopes so PR CI runs the topology self-check.

Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)
2026-08-06 01:38:59 +00:00
+12
lefarcenRay XiCHENGLONG WANGlefarcenPerishFireLooperJoey-nexuJoeybestthanaponopen-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>xiaoche-hubCheemsAmyMarc Chan_HDCoderbone3deep1962-collabNicholas-Xiongmehmet turacopen-design-release-bot[bot] <295937643+open-design-release-bot[bot]@users.noreply.github.com>open-design-botshangxinyu1elifive555555
356c8c364f feat(workspace-team): team workspaces, shared resources, workspace-scoped billing, and the #5517 redesign (#6142)
* fix(web): scope plugin URL imports to workspace

* test(web): cover billing interest remount lifecycle

* feat(packaged): enable the workspace-team transport for test-profile builds

The vela test backend now serves the workspace-team API, so a packaged build
baked with the test AMR profile should drive team projects / collab / resource
sharing there instead of leaving them dormant. Replace the single feature-test
equality check with a profile -> vela web origin map so adding a backend is one
entry, and keep every unlisted profile (prod above all) off.

* test(daemon): cover billing catch-up after SSE reconnect

* fix(collab): surface and start first materialization when opening a shared project

QA P0: a brand-new member on a fresh install joins someone's workspace, opens a
project from it, and sees no loading state and no files — content only appears
on a SECOND open, much later.

Two defects, both on the first `/collab/status` request that opening a shared
project makes.

1. No pull is started. The daemon's self-materialization block was gated on
   `callerIsOwner`, so a non-owner member got the placeholder record registered
   and nothing else. The web's auto-pull is gated on `publishedVersion`
   advancing past its cursor, and a fresh daemon's first status response cannot
   carry a published head: `collab.publishedVersion()` reads an in-process map
   that has never been written, and the real hub head is fetched
   fire-and-forget into `headEnrichmentCache` for a LATER poll to consume.
   Materialization therefore arrived only whenever a proactive lane (hub push /
   reconnect catch-up / the 30s recovery floor) next fired — the "second open"
   the report describes.

2. No download state is shown. `useProjectCollab.downloadPending` — which picks
   DesignFilesPanel's "Syncing files from the team…" over its empty state and
   create-a-file CTAs — reads only `publishedVersion > cursor`,
   `contentTransferState`, and an in-flight pull. All three are blank on that
   first response, so the member was shown an empty project inviting them to
   start creating over content that was still on its way.

The fix makes the placeholder stamp the load-bearing signal on both sides:

- `materializePlaceholderOnOpen` (routes/collab-sync.ts) replaces the
  owner-only block: any viewer opening a shared project whose only local record
  is an unmaterialized placeholder starts its pull on that same request,
  through the same coalesced flow. The retracted-share heal in its catch stays
  owner-only — a member who hits the hub tombstone has merely lost access and
  must not unshare anyone's project.
- `/collab/status` reports `awaitingFirstMaterialization`, a purely local,
  synchronous fact that needs no hub round-trip and is therefore available on
  the very first response. `od collab status` prints it too.
- `localFilesAreNotTheContentYet` (useProjectCollab.ts) makes it always
  download-pending, bypassing the member-only `shouldAutoPull` gate: whether the
  local files are the content is a fact about the record, not about who may
  pull it, so the owner of an unmaterialized placeholder (the reinstall case,
  recvqzaDvUU6B3) stops seeing the same empty state too.

Red evidence: all six new specs fail on origin/feat/workspace-team @ 793d7b92c
— the first status response reported `awaitingFirstMaterialization: undefined`
with publishedVersion/materializedVersion/contentTransferState all null,
`beginContentTransfer` was never called for a member's first open, and
`downloadPending` was false for both the member and the owner.

* chore(packaged): inject the vela web origin at build time, not source

This repository is public. Commit 319cb42b3 added a profile -> vela web
origin table to apps/packaged/src/sidecars.ts, which put an internal
environment's hostname into product source and would have published it on
merge. The same hostname had also reached apps/web/src/runtime/amr-guidance.ts
as a new profile row.

Internal AMR environments are not public (one of them fronts a Stripe
sandbox), so their origins now travel the same build-time path POSTHOG_KEY
already uses:

  release-beta.yml env (per-profile secret)
    -> tools-pack config (OD_VELA_WEB_URL)
    -> open-design-config.json (velaWebUrl)
    -> packaged config -> daemon spawn env (OD_VELA_WEB_URL)
    -> GET /api/integrations/vela/status (consoleOrigin)
    -> web runtime console links

The workspace-team gate keeps its meaning and gets stricter: it now needs an
allowlisted AMR profile AND an injected origin. `prod` can never satisfy the
profile half, so a stable build stays dormant however it is configured, and a
feature-test/test build whose secret is unset stays dormant too rather than
pointing the transports at an unknown backend.

A build with no secret configured therefore ships with workspace-team silently
off — the same failure mode as a fork build without POSTHOG_KEY, and the safe
direction for an unreleased feature.

apps/packaged/tests/source-origins.test.ts is a standing guard: every absolute
URL literal under apps/packaged/src must resolve to a publishable host, so the
next backend origin cannot be hardcoded there either.

Secrets required for a workspace-team dogfood build:
VELA_WEB_URL_FEATURE_TEST, VELA_WEB_URL_TEST.

* test(e2e): follow the #5517 plugin-details route in the visual capture

`captures the home plugin use staged surface` has been failing on this
branch since before the main merge. It was written against the pre-#5517
UI: clicking a marketplace card opened a role="dialog" details overlay
with a per-slug `plugin-details-use-<id>` action.

#5517 made plugin details a full-page route — `openCardDetail` calls
navigate({ kind: 'marketplace-detail' }) for plugin records, rendering
`PluginDetailView` at /marketplace/<id> whose Use control is a single
`plugin-detail-use` button. Per the product rule that the accepted
feature-branch UI is the source of truth, the test follows the route
instead of the UI being changed back.

Asserting the route alone was not enough: the detail surface refetches
the record from /api/plugins/<id>, which the visual fixture never mocked
(it mocks the list, */preview and */apply), so the route rendered its
"Failed to load plugin: HTTP 404" branch. configureVisualPage now serves
the single-plugin GET from the same VISUAL_PLUGINS fixture.

The capture asserts the Use button rather than the `plugin-detail` shell
because the shell also renders for the loading and load-failed states.

* fix(packaged): restore masked CLI icons (#6154)

* Sync local workspace-team batch: UI polish, popover simplification, update-reminder progress (#6156)

* UI polish batch: import-dialog cleanup, settings toast placement, tab chrome fixes

- Plugin/skill import dialog: drop per-card icons, left-align card content
- Settings page: center the autosave pill under the top nav
- Avatar model menu: soften the pinned footer divider
- Workspace tabs: pinned entry tab always reads as Home; remove tab-search
  button and its popover
- Model picker: wider row gap in the two-pane browse popover
- Community header: unpin (scrolls with content)
- New update-reminder dialog assets and strings
- Keep entry-settings-button as the signed-out settings entry (e2e contract);
  align NextStepActions with the navPlugins key rename

* Update reminder: simulate in-place download progress instead of linking out

The strip's confirm now runs a staged progress state ('updating') with an
eased fake download bar, then marks the version updated — replacing the
jump to the GitHub releases page.

* test(web): retire AvatarMenu account-row suites after the popover simplification

The composer popover was reduced to a model picker (2026-07-24): the Open
Design account row — plan badge, balance, wallet fallback, upgrade/console
links — no longer renders there, so the nine suites asserting that surface
(including the workspace-balance and billing-permission gates that landed
upstream meanwhile) can no longer pass. Drop them and recast the remaining
account-row test as the guard for the new invariant: a signed-in AMR status
must render no account UI in the popover.

Also drop the stale onClose prop from the new upstream sign-out-confirm
test; the rail no longer takes one.

* Design-systems toast: anchor to the pane's top edge, pill radius

* Update reminder strip: progress ring on the button, percent face

While updating, the collapsed strip no longer stacks a label + bar above
the rocket button. Progress draws as a brand-green ring hugging the round
button's edge (5px band reserved so the button never shifts), and the
button face swaps the rocket for the bare percent — 'N%' while
downloading, 完成 (updater.done) at 100. The full 正在下载更新 N% label
stays reachable via the hover bubble and the ring's aria-label. Demo
payload version bumps to 1.4.6 so the once-per-version card re-arms.

* fix(web): restore design kit section spacing (#6155)

* fix(web): carry the plugin back to Home when Use runs from the detail route

Product call: using a plugin from the full-page detail route should land on
Home with that plugin already selected and its brief pre-filled, exactly
like the marketplace card's "Try it".

It did not. `PluginDetailView.onUse` applied the plugin, kept the result in
local state, and navigated home. `App` renders that route outside
`EntryShell`, so the navigation unmounted the holder and dropped both the
plugin and the brief — Home came up empty. The "redirected to Home with the
brief pre-filled" note under the button could never be seen either, since
the navigation fired immediately; it is removed rather than left lying.

Reuses the existing channel instead of adding a parallel one: onUse now
publishes the same `createPluginUseHandoff` payload "Try it" produces, and
Home's existing pendingPluginUseHandoff path applies it unchanged. The
handoff is parked in module scope — not on `window` — so it survives the
unmount, and `EntryShell` claims it in a lazy state initializer so it
arrives in the same commit as the mount. Reads are destructive, so a
handoff applies once and does not re-fire on the next visit to Home.

Red first: PluginDetailView.use-handoff.test.tsx fails on the unfixed code
at the handoff assertion (navigation and the failure path already passed),
and goes green with this change. The e2e visual capture that asserts
home-hero-active-plugin should follow without weakening its assertion.

test(e2e): match the settings surface in both #5517 presentations

openSettingsDetailsFromHeader still waited on `.modal-settings[role="dialog"]`.
#5517 routes the entry's settings to /settings, where SettingsDialog renders
in presentation="page" mode with `role="region"`, so that selector never
matched the page presentation: an already-open settings surface looked
absent and the helper fell through to hunting for entry triggers that
surface does not carry, failing 16 captures on one line.

Reuses `settingsSurface` from amr.ts — the existing bare `.modal-settings`
matcher both presentations share — rather than adding a third local
variant. That helper also documents why the `role="dialog"` fallback was
actively wrong: AvatarMenu is a dialog too, so it could resolve to the
account menu.

* test(e2e): open the nav rail before reaching for entry settings

The bare-`.modal-settings` change alone did not move the settings-workspace
lane: all 16 captures still died on `.settings-icon-btn`, because the
failure happens before the surface selector matters. None of the four
triggers was ever visible.

amr.ts already documents why. #5517 moved the entry settings chip into the
nav rail footer, and a collapsed rail is `inert` + `aria-hidden`: the chip
is present but invisible to `getByRole`, and even a programmatic
`element.click()` is a no-op. `openSettingsDialog` expands the rail first
(`ensureEntryRailOpenIfPresent`), clears loading, dismisses the privacy
dialog, matches the surface with the shared bare class, and ends its
trigger chain on the settings aria-label instead of `.settings-icon-btn`,
which the entry surface does not carry. It also clicks
`entry-settings-open-details`, so it is a superset of the local copy.

So `openSettingsDetailsFromHeader` now delegates to it and keeps only its
name, leaving exactly one settings opener instead of a third variant.

* test: realign the suites #6156 outpaced, and reach Settings from a project

#6156 (4e3161751) landed a UI batch whose test fallout was only partly
handled. Four Vitest files and four e2e call sites still describe the surface
as it looked before it, and the Playwright settings opener never had a trigger
that exists once a project is open.

Vitest — the assertions now read the surface that shipped:

  - community-view: the type tabs traded their `<small>` count badges for type
    icons, so `readFacets().badge` was `NaN`. Counts move to a new
    `readFacetCardCounts()` that drives each tab and counts the grid — the same
    array the badges were derived from, so the catalogue-fidelity guard
    (including "never grids more than the whole catalogue") survives the
    badge's removal rather than being dropped.
  - home-logo-assets: the rail's signed-out brand header is gone (with no cloud
    identity the rail starts at the search box), so it can no longer carry
    `od-brand-glyph`. Keeps the guard that still means something: it must never
    fall back to the retired raster app icon.
  - acceptance-visual-fixes: recvq4iEq1Esno's fix is intact — page mode still
    hides only `.settings-chrome-btn`, leaving the autosave pill visible. #6156
    re-added the bare `.settings-page-shell .settings-chrome` selector purely to
    centre that pill under the top nav, so assert the invariant the row is
    about (no page-mode `display: none`) instead of the selector's absence.
  - FileViewer: the new `artifact-preview-first-load` cover is a `role="status"`
    of its own, so `findByRole('status')` had become ambiguous. Scope the
    success assertion to the toast.

e2e — `entry-nav-settings` no longer exists anywhere in `apps/web/src`; the
rail item carries `entry-settings-button`. #6156 renamed it and updated
entry-chrome-flows' two `toHaveCount(0)` assertions but not the four places
that click or await it (critical-smoke, entry-topbar x2, entry-chrome-flows x2).

`openSettingsDialog` also only ever knew entry-shell triggers, all of which
live on Home. From a project every one of them is absent — and
`EntrySettingsMenu` and `AppChromeHeader`'s `SettingsIconButton` are both
unrendered — so the surface never opened and the failure surfaced as a missing
`.modal-settings`. Add the project surface's real entry: the composer model
popover's pinned `avatar-open-execution-settings`, with the topbar switcher's
`inline-model-switcher-open-settings` as a second route.

Not addressed here, deliberately: #6156 also deleted the `settings-general-field`
block that rendered `AppearanceSection`, orphaning the component. That removes
the product's only System/Light/Dark control (the rail's account menu dropped its
theme row *because* Settings owned it), so the 11 `SettingsDialog.execution`
appearance failures are reporting a capability loss, not a stale expectation.
Restoring it is a product call, so those tests are left red rather than weakened.

* test(e2e): finish the popover the settings chain opens on a project surface

`openSettingsDialog`'s trigger chain ends on OPEN_SETTINGS_LABEL, which matches
`Account & settings` — and that is `avatar.title`, the label on AvatarMenu's
own trigger. So on a project surface the chain does find something and clicks
it, but what opens is the composer's model popover, not Settings. The
follow-through step then looked only for `entry-settings-open-details`, which
#5517 left unrendered, so the run gave up with a missing `.modal-settings`
after three identical attempts.

Widen the follow-through to the two rows that actually route to Settings from
a project — AvatarMenu's pinned `avatar-open-execution-settings` and the topbar
switcher's `inline-model-switcher-open-settings` — so whichever popover the
first click opened gets finished.

* test(e2e): pin the AMR selection assertion to the agent card

With Settings actually opening from a project surface again, the surface-wide
`/Open Design/i` in amr-logout-requires-relogin resolves to the sidebar's
"Open Design MCP" `settings-nav-item` — which carries no `aria-pressed` — not
to the AMR agent card's select button. The looseness was invisible while the
surface never opened.

Read the toggle through `settings-agent-card-amr` instead, so the assertion
names the thing the test is about: AMR stays the selected agent.

* test(e2e): prove Settings opened via its section nav, not a heading probe

`prepareVisualSettingsDialog` gated on a heading matching
/Settings|General|Execution mode/. Now that the surface opens from a project it
lands on the execution section, whose heading reads "Models & providers" — and
the surface's own <h2> is consumed as its accessible name via aria-labelledby
anyway, so the probe could not match in either presentation.

Assert `settings-nav-execution` instead, which is what critical-smoke already
uses for the same purpose and holds for both the modal and the routed page.

* test(web): retire the theme/appearance cases the product cut

Product decision (2026-07-28), verbatim: 「主题设置不要了,因为 workspace 功能不
支持暗色主题,要干掉,并且之前用户如果设置了暗色主体,需要强制改成明亮色主题」

So #6156 deleting the `AppearanceSection` render site was the right direction,
and the NON-ALIGNMENT #9 note that argued for keeping the segmented control as
the product's last "follow system" entry point is formally overturned. These
cases are retired because the capability is gone by decision — NOT to turn CI
green. The source-side removal (orphaned `AppearanceSection`, onboarding
light/dark toggle, `settings.appearance*` keys, `trackSettingsAppearanceClick`,
plus forcing stored dark/system back to light for existing users) lands
separately on `fix/remove-theme-force-light`.

Deleted (8) — their whole subject was theme or the accent it carried:
  - offers the theme segmented control, and System leaves the document theme unset
  - applies the stored default accent color even though the picker is gone
  - writes the picked theme to the document and autosaves it
  - live previews the configured theme on open, and System leaves no explicit
    document theme
  - reverts an unsaved appearance preview back to the saved appearance when the
    dialog closes
  - persists System mode explicitly and preserves accent variables without an
    explicit document theme
  - keeps a stored non-default accent applied and carries it through an autosave
  - localizes the theme controls in Chinese

Nothing was left behind as a loose "still renders something" assertion, and no
live coverage went with them: the close-button/`onClose` path the revert case
also touched is already covered at five other call sites in this file.

Kept (3) — theme was only the vehicle, the subject is still shipping. Each now
rides the notifications completion-sound toggle, seeded on so the single click
is a real state change (the pills no-op when clicked in their current state):
  - reconciles the open settings draft when the parent agent CLI env changes —
    the only integration proof that `reconcileAmrModelChoice` /
    `reconcileAmrProfileEnv` actually run through the real draft on autosave;
    its two siblings are pure unit tests and do not cover that path.
  - drops a pending autosave when explicit onboarding reset unmounts Settings —
    subject is the pending-autosave drop and the draft the reset carries.
  - still autosaves an unrelated edit that lands during a silent-update save —
    subject is autosave bookkeeping (success must only advance
    autosaveLastSavedRef for allowSilentUpdates).

The enclosing block is renamed 'SettingsDialog draft reconciliation' since it no
longer describes appearance, and its document theme/accent teardown is dropped —
nothing in it writes those any more. 144/144 green.

* test(e2e): realign the visual workspace captures with the narrowed surfaces

Three deliberate narrowings shipped without this Playwright lane following, so
eight captures were driving UI that no longer renders. All eight are stale
fixtures; none was a regression.

  - `ef9c8cd8b` made the home top-bar `InlineModelSwitcher` `compact`, and
    `EntryShell` (its only call site) always passes the flag. In compact mode the
    popover is just the active agent's model radio list plus the route to
    設定 → 執行; the mode segmented control, agent grid, account block, and both
    searchable dropdowns live in the non-compact branches. `68cecac1c` records
    that product confirmed this narrower shape, so acceptance #40 ("home 页不能
    切换 cli 了") is working as designed.
  - `4e3161751` (#6156) removed the Open Design account row from `AvatarMenu`
    (plan badge, balance, wallet fallback, upgrade/console links) and retired the
    nine equivalent Vitest suites — but not these.
  - `56b538aa4` moved the design-system picker out of the staged-context bar, so
    `staged-contexts` is no longer unconditionally mounted.

Where a surface still ships, the capture follows it and the assertion got
sharper rather than looser: staged contexts now stages a real attachment and
names the chip; the topbar/avatar captures assert the surviving model picker
plus the *inverse* invariant (no account block, no upgrade button, no balance),
gated on a counted `vela/status` request so a negative cannot pass by racing the
fetch. `:57` additionally pins `inline-model-switcher-mode-daemon` at count 0, so
re-mounting the CLI console on Home fails here again.

`:167` (topbar BYOK model dropdown) is deleted: in compact mode it would
re-capture the identical popover as `:141`, which is a duplicate screenshot
rather than coverage. `:141` absorbed its guard.

`:244` (avatar reasoning readout) turned out never to have passed — not a #6156
casualty. `VISUAL_CLI_AGENTS`' codex entry declares `models` only, while
`AvatarMenu` draws the readout solely for agents reporting `reasoningOptions`, so
it has been red since `68cecac1c` authored it. Fixed by declaring
`reasoningOptions` on a test-local codex fixture, matching what the real daemon
reports (`apps/daemon/src/runtimes/defs/codex.ts:164`).

Nothing was dropped without a verified live home elsewhere in the same CI lane
or in Vitest: the AMR plan/balance/upgrade card at `visual-settings.test.ts:30`,
the searchable dropdowns at `visual-settings.test.ts:93`/`:156`, the upgrade
attribution URLs in `InlineModelSwitcher.test.tsx:585` and
`AvatarMenu.test.tsx:283`, the "no account row" invariant at
`AvatarMenu.test.tsx:528`, and real staged-context chips in
`project-management-flows.test.ts`. The one assertion with no surviving home is
the Open-Design-first agent ordering, whose grid no longer renders anywhere;
that is called out in the test comment rather than silently lost.

* test(e2e): give the AMR upgrade capture a billing-capable workspace

`settings-agent-card-amr-upgrade` stopped rendering for this capture because
`amrCardCanUpgrade` (SettingsDialog.tsx:4651) now also requires
`workspaceContext?.permissions?.canManageBilling` — this branch's
workspace-scoped billing work, i.e. the feature #6142 exists to ship. The gate
is correct; the fixture was not representing anyone entitled to pass it, since
`mockSignedInVelaAccount` establishes a Vela session but no workspace context.

Stub `/api/workspace/context` with a personal-owner context inline in this one
test rather than widening `mockSignedInVelaAccount`, which visual-workspace
shares. The assertion is unchanged — the upgrade entry is a real capability for
billing-capable members, so the fixture represents one instead of the assertion
being relaxed.

Verified the stub does not quietly satisfy the three assertions that already
passed: `planId: null` keeps `resolvePlanTier` falling through to `plus`, and
billing still 404s so the balance stays `$247.51`.

Expect one legitimate baseline diff on `visual-settings-open-design-account`:
the rail now renders signed-in workspace chrome, which is the only state in
which the upgrade entry exists at all.

* test(e2e): leave projects through shipping chrome, and reach /projects reliably

Two dead locators and one under-budgeted wait, all test-side.

`openNewProjectModal` (rail.ts) probed `entry-nav-new-project` then
`entry-nav-projects`, both deleted from EntryNavRail by the #5517 redesign
(`b55f17169`, `f16075f7e`) — `onNewProject` is still destructured there but has
zero call sites. Its body is byte-identical to main, where both testids exist, so
main takes the rail path and never reaches the fallback this branch always hits.
The live affordance is `designs-new-project` / `designs-empty-new-project` in
DesignsTab, inside `entry-view-projects`; `/projects` has no UI entry at all
(entry-chrome-flows:373-382 already documents that and routes directly).

The fallback then failed for a second reason: `entry-view-projects` does ship
(EntryShell.tsx:1579) and `/projects` does route to it, but it was asserted on
the 10s default budget right after `waitUntil: 'domcontentloaded'`. `apps/web`
mounts `src/App` via `dynamic(..., { ssr: false })`, so domcontentloaded fires
while only the boot shell exists — every suite that does this correctly waits out
`Loading Open Design…` with `T.long` first. Now routed via
`history.pushState` + `popstate` (the client router listens) instead of a full
reload, boot shell waited out, and both the view and the create button given
`T.long`.

`ensureRailOpen` was also called unconditionally, but it ends in a hard
`expect(workspace-home-rail-toggle).toBeVisible()` and that testid only renders
for `isPinned && active` (WorkspaceTabsBar.tsx:1474-1510) — from inside a project
it does not exist, so the helper hard-failed on a control this flow never needed.
Now best-effort and gated on `.entry`, kept only so the
`visual-new-project-modal` baseline does not churn.

`real-daemon-run.test.ts:547` hung on `/back to projects/i`, which matches
nothing on a project surface: `AppChromeHeader` owns that aria-label but is no
longer mounted anywhere, and ChatPane's top-left slot resolves
`onCollapse ?? onBack` while ProjectView passes both, so it is always
`chat-collapse-toggle` (deliberate, `884ed1085`). Replaced with a local
`leaveProjectForEntry` that clicks the pinned entry tab — the shipping way out —
so the isolation journey still leaves through real chrome instead of a URL jump.

Dropped `getActionablePoint` with its only consumer. Removing the reload from
~25 call sites across 15 suites was audited against the stub-order-sensitive ones
(settings-media-providers, new-project-ds-picker-*, api-empty-response,
visual-entry); all install routes before their first navigation.

Correcting my own earlier misattribution: `.ws-tab` / `.ws-tab-label` are
FileWorkspace's in-project file strip and are fully intact, `role="tab"` and
file-name labels included. #6156 rewrote WorkspaceTabsBar, which renders
`.workspace-tab` / `.workspace-tab__label` — a different component. Upload→tab is
also intact (`FileWorkspace.uploadFiles` still calls `openFile`). Five of those
six failures merely routed through the broken helper above; the sixth was the
dead back-button. No `.ws-tab` assertion was rewritten.

* fix manual edit across workspace pages (#6160)

* fix(collab): keep process-local presence alive when the collab transport is off (#6170)

`createVelaCliCollabClientFromEnv` returns `null` unless the run opted into
the vela-cli collab transport, but `server.ts` wired the presence routes with
an object literal of arrow functions closing over that client. A literal is
unconditionally truthy, so `registerCollabPresenceRoutes` always saw a `cloud`
dependency: its `deps.cloud ?? null` could never resolve to `null`, the
process-local `presence.present()` fallback became dead code, and every
presence request that reached the cloud branch dereferenced `null` and was
answered `502 collab_presence_unavailable`.

`POST /api/projects/:id/presence/leave` carries no shared-project
precondition, so it failed on any project id in any run without the
transport — every stable/prod packaged build (the packaged workspace-team env
is gated to the `feature-test`/`test` AMR profiles) and every plain
`tools-dev` run. `server.ts` is `@ts-nocheck`, so the null dereference was
invisible to `pnpm typecheck`.

Route the dependency through `createCollabPresenceCloudClient`, which lives in
the checked route module and states the invariant: a `cloud` dependency exists
if and only if a transport exists. Removing its guard is now a typecheck
error, so the bug class cannot come back at the construction site even while
`server.ts` stays unchecked.

* fix(web): scope desktop vibrancy to macOS (#6171)

* fix(web): remove the theme setting and force every install back to light (#6168)

Product removed theme selection: the workspace surfaces shipped for team
workspaces have no dark tokens, so dark mode renders a broken app.

Deleting the picker is not sufficient on its own. Every install that ever
opened it still has `theme: 'dark'` — or `'system'`, which resolves dark on a
dark OS — persisted in localStorage, and a stored value does not move when the
default does. So the theme is now coerced on READ, at all three points a
persisted value can reach the document:

- `loadConfig()` funnels `parsed.theme` through `resolveAppTheme()` and marks
  the config migrated so the coerced value is written back once.
- `applyAppearanceToDocument()` stamps `data-theme="light"` unconditionally.
  The attribute must be PRESENT, not merely non-dark: every dark CSS rule is
  gated on `html:not([data-theme])` / `html:not([data-theme="light"])`, and
  every JS theme reader (shiki, ConnectorLogo, SketchEditor, TerminalViewer,
  connectorBrandColor, MentionNode) falls back to `prefers-color-scheme` only
  when the attribute is absent.
- The pre-hydration inline script in `app/layout.tsx` stamps light before React
  mounts, outside its try/catch so a throwing storage read still leaves the
  attribute set.

Electron's `themeSource` defaults to `system`, which colours everything the web
layer does not own (macOS vibrancy glass, native menus/dialogs) on a dark-mode
Mac — including the splash, before the renderer's appearance IPC lands. It is
now pinned to light before the first window exists.

Removed surfaces:

- `AppearanceSection` in SettingsDialog, orphaned by #6156 (zero call sites),
  together with its `settings-general-block--appearance` styles. Its docblock
  claimed the control was "deliberately kept … NON-ALIGNMENT #9"; that decision
  is superseded, so the comment goes with the code.
- The onboarding welcome page's sun/moon toggle — the last reachable theme
  writer — and the `onThemeChange` prop chain behind it
  (App → EntryView/ProjectView → EntryShell → OnboardingView).
- The orphaned theme row in EntrySettingsMenu and its styles.
- i18n keys `settings.appearance`, `settings.appearanceHint`,
  `settings.themeSystem`, `settings.themeLight`, `settings.themeDark` across
  `types.ts` and all 19 locales.
- Analytics `trackSettingsAppearanceClick`, `SettingsAppearanceClickProps`, and
  the `settings_popover` `appearance` element.

`'appearance'` survives only as a legacy settings deep-link token that
`normalizeSettingsSection` folds into General, so an old link is not a type
error.

Red-first: tests/state/force-light-theme.test.ts and
tests/components/theme-settings-removed.test.tsx were written against the
unmodified branch and went red (11 failing) before any source change.

* fix(web): make the update surfaces read real data (#6163)

* fix(web): make the update surfaces read real data

#6156 (4e3161751) shipped two update surfaces whose every field was
invented. `EntryShell` held a private `updateReminderStage` machine keyed on
a literal `'1.4.6'` (the app ships 0.16.1), the rocket's confirm ran a 200ms
`setInterval` easing a fake bar toward 100% and then wrote "updated" to
localStorage, and the cover dialog listed three hardcoded English notes past
i18n over a committed 432 KB JPEG. Nothing in that path made a single
network, IPC or daemon call — a user who clicked "立即更新" watched a
progress ring finish and got no update.

Both surfaces now sit on the real machinery that already existed:

The cover dialog becomes the post-update highlights surface and REPLACES the
bottom-right `WhatsNewPopup` card rather than coexisting with it. Cover art,
release headline, bullets and link all come from the hosted highlights
document via `/api/whats-new`; the title states the running version from
`useAppVersion()` (`/api/version`). It keeps that card's show gate verbatim —
once per highlight `id`, only with content, only on the home surface after
the app returns on a new version — and its existing analytics
(`whats_new_popup` surface-view / click). Its footer is close + open-release;
there is no "cancel / update now", because reporting what already shipped is
all this surface does. Bullets come from a new `whatsNewNotesFromBody`, which
splits the document's `body` per line and strips operator list markers.

The rocket keeps its look and becomes the real updater's ready indicator in
the bottom-left rail footer, replacing that indicator's arrow glyph. It is
gated on the updater's own `shouldShowControl` (installer downloaded and
never opened, desktop only) and still drives `openUpdaterInstaller` →
`quitAfterUpdaterInstallerOpen` through the untouched panel, watchdog and
restart-safety preflight. The progress ring and percent face are gone on
purpose: the indicator only exists once the download has finished, so any
percentage it drew would be invented.

Deleted: `lib/update-reminder.ts`, `UpdateReminderDialog.tsx` and its module
CSS, `public/update-reminder-cover.jpg`, the now-dead
`entry-updater-menu__button/__glyph/__progress` rules, and the unused
`accountNotice` rail slot. The three `updateReminder.*` i18n keys and the
orphaned `whatsNew.dismissAria` are replaced by one `whatsNew.updatedTitle`
across all 19 locales.

A source-level guard (`tests/update-surface-real-data.test.ts`) pins the
invariant that outlives the deletion: no quoted `x.y.z` and no `setInterval`
in the update surfaces, and the placeholder module and cover art stay gone.

* fix(web): name the release-notes destination on the what's-new CTA

Product review of the five open calls closed all five; only the CTA copy
changes. The button opens the release notes, so it now says so —
「查看更新说明」 in Chinese (product's wording, verbatim), with the other 18
locales aligned on "view the release notes" instead of inheriting the old
"see what's new" tease. `whatsNew.cta` had exactly one consumer left (the
dialog) once it replaced the bottom-right card, so the key is repurposed in
place rather than forked.

The other four calls were approved as implemented, and two of them overturn
conventions the old comments still asserted, so those comments are synced:

  - `lib/whats-new.ts` documented the toast's deliberate Escape exemption
    (Escape hid the card WITHOUT spending the highlight). As a focus-trapping
    modal that exemption is retired — every close path marks seen — and the
    docblock now says which paths and why.
  - `contracts/api/whats-new.ts` described `title` as generic copy, `imageUrl`
    as an image "beside the copy", `body` as prose, and `linkUrl` as the "See
    what's new" link. The dialog reads them as headline / top-inset cover /
    one-bullet-per-line / release-notes link, so each field now documents the
    role it actually plays. Comment-only; no shape change.

* fix(web): never state the placeholder version on the what's-new dialog

Review catch (nettee, #6163): `useAppVersion()` reads /api/version at runtime,
so it necessarily boots on `APP_VERSION_PLACEHOLDER` and resolves a round-trip
later. This dialog rendered as soon as /api/whats-new resolved and printed the
hook unconditionally — so a highlights fetch that won that race painted
"Open Design 0.0.0 is here" on first frame. An invented version string, on the
one surface this PR exists to make truthful. The new suite could not see it
because it mocked the hook to an already-resolved value.

Fixed by falling back rather than gating the whole render: gating would delay
the dialog behind a second round-trip on slow networks, while the highlights
document already carries the running version in its `version` field — the
daemon stamps it for display (see contracts/api/whats-new.ts), which is what
fed the old bottom-right card's eyebrow. So `statedAppVersion()` prefers the
resolved hook, falls back to the document, and returns null when neither can
name one; on null the dialog waits, because highlights are worth nothing under
a headline that lies. The same derived value feeds the title AND every
`app_version` prop, so analytics cannot ship the placeholder either.

`APP_VERSION_PLACEHOLDER` moves out of ./provider into a new leaf
analytics/app-version.ts alongside an `isResolvedAppVersion()` predicate, so a
surface that must not PRINT a placeholder can test for it without importing the
analytics client. The provider keeps its own use of the constant.

Regression tests, red before this commit (`.red-evidence-version-race.txt`
reproduces nettee's exact string, `expected 'Open Design 0.0.0 is here…' not to
contain '0.0.0'`): the placeholder never paints while /api/version is in
flight, it never reaches the surface-view analytics, the title switches to the
running version once the hook resolves, and a document with no usable version
waits instead of inventing one. The suite's provider mock is now a mutable
holder so both sides of the race are reachable.

Also caught by this PR's own source guard: quoting the literal in a docblock
tripped it, so the comment names the constant instead.

* test(e2e): unmask the AMR runtime lane and repair the visual settings/workspace captures (#6174)

Three fixture defects, each verified by running the lane rather than reading it.

- amr-run-failure-recovery.test.ts was still `mode: 'serial'`. A second
  `describe.configure({ timeout })` cannot clear it — configure only
  overwrites the keys it is given — so one failure skipped the eight cases
  behind it and the lane reported them as "did not run". e2e/AGENTS.md
  forbids serial groups outright.
- configureVisualPage's `**/api/**` catch-all (added by #6126, which is on
  main) answered `/conversations` with 404. ProjectView mounts ChatPane only
  once a conversation resolves, and both listConversations and
  createConversation swallow a non-ok response, so the project opened with no
  conversation and no error, ChatPane never mounted, and every capture that
  enters the workspace died on `chat-composer` not existing. Supplies the
  conversation boundary the catch-all closed without replacing.
- The BYOK tab is named "API providers" since #5971 renamed
  `settings.modeApiMeta`; `getByRole('tab', { name: 'BYOK' })` matched
  nothing and three captures hung until the test timed out. The topbar BYOK
  capture also left `[MOCK_AGENT]` installed, contradicting its own "a BYOK
  config has no local agent" premise: an installed agent wins the popover and
  renders its model radiogroup instead of the BYOK rows.

Co-authored-by: lefarcen <ontf116@gmail.com>

* Home shell productization batch: message center, What's-new on release data, update-reminder & updater popup restyle (#6162)

* UI polish batch: import-dialog cleanup, settings toast placement, tab chrome fixes

- Plugin/skill import dialog: drop per-card icons, left-align card content
- Settings page: center the autosave pill under the top nav
- Avatar model menu: soften the pinned footer divider
- Workspace tabs: pinned entry tab always reads as Home; remove tab-search
  button and its popover
- Model picker: wider row gap in the two-pane browse popover
- Community header: unpin (scrolls with content)
- New update-reminder dialog assets and strings
- Keep entry-settings-button as the signed-out settings entry (e2e contract);
  align NextStepActions with the navPlugins key rename

* Update reminder: simulate in-place download progress instead of linking out

The strip's confirm now runs a staged progress state ('updating') with an
eased fake download bar, then marks the version updated — replacing the
jump to the GitHub releases page.

* test(web): retire AvatarMenu account-row suites after the popover simplification

The composer popover was reduced to a model picker (2026-07-24): the Open
Design account row — plan badge, balance, wallet fallback, upgrade/console
links — no longer renders there, so the nine suites asserting that surface
(including the workspace-balance and billing-permission gates that landed
upstream meanwhile) can no longer pass. Drop them and recast the remaining
account-row test as the guard for the new invariant: a signed-in AMR status
must render no account UI in the popover.

Also drop the stale onClose prop from the new upstream sign-out-confirm
test; the rail no longer takes one.

* Design-systems toast: anchor to the pane's top edge, pill radius

* Update reminder strip: progress ring on the button, percent face

While updating, the collapsed strip no longer stacks a label + bar above
the rocket button. Progress draws as a brand-green ring hugging the round
button's edge (5px band reserved so the button never shifts), and the
button face swaps the rocket for the bare percent — 'N%' while
downloading, 完成 (updater.done) at 100. The full 正在下载更新 N% label
stays reachable via the hover bubble and the ring's aria-label. Demo
payload version bumps to 1.4.6 so the once-per-version card re-arms.

* Home shell productization batch: message center in the account menu, What's-new on real release data

- Message center: entry moves into the account menu under 设置 (bell row);
  MessageCenter becomes controllable (hideTrigger + open/onOpenChange +
  onUnreadCountChange) and mounts persistently in EntryNavRail so unread
  polling survives the hover menu; unread shows as a red dot on the account
  avatar (and on the menu row); avatars go from squircle clip to 8px rounded
  squares; panel filter segmented control and mark-all-read go pill-radius.
- What's-new: the post-update highlights card now wears the shared
  release-dialog shell (cover / title / dotted notes / pill footer) while
  keeping its REAL /api/whats-new data source, show-once-per-id timing,
  soft-hide Escape/backdrop semantics, and analytics. The demo-payload
  update-reminder dialog and its simulated download no longer trigger; the
  stage machine and progress strip stay for the real updater-feed wiring.
- Rail chrome: brand logo and the in-rail collapse control are gone — the
  rail starts at the search box and folding lives in the pinned Home tab's
  toggle; the signed-out cloud tip bottom inset now matches its sides.
- Brand picker (modal variant): side-nav split — search + vertical category
  nav left, a roomy 2-up borderless gallery right, content flush to the
  column line with hover fill/ring bleeding past it; logo tiles lose their
  boxed edge; selected template chip and the composer mode pill share the
  same selected ring, with the mode glyph optically matched at 13px.
- Tab launcher: rows are icon + label only (descriptions dropped) with a
  rotating four-hue icon palette; the 新建空白页面 entry is paused behind
  ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT and its PageCreator suites skipIf
  on the same switch.
- Move-to-team confirmations: extracted MoveToTeamConfirmDialog, no backdrop
  blur (dim only), and the confirm keeps its brand-green label through the
  mention-home ink-pill restyle in every interaction state.
- Publish CTA: hover inverts to ink fill with brand-green icon + label.
- Plus assorted in-progress workspace polish (composer, viewer, settings,
  model icons) riding the same branch iteration.

* Restyle the updater ready popup to the update-reminder dialog language

Cover art on top (compressed 1296x555 jpg, natural aspect), the orange
accent icon tile removed, secondary action as a soft white pill and the
install CTA as a black pill with brand-green ink, and a stacked footer so
the silent-update checkbox keeps a full-width single line instead of
wrapping beside the widened pills.

* Signed-out rail: message-center entry under 设置

The signed-out rail has no account menu (where the 消息中心 row lives when
signed in), which left the message panel with no opener at all. The rail
item carries the unread dot.

* Composer row: pin chip glyphs against flex shrink

The row-wide min-width: 0 that lets chip labels ellipsize also let SVG
glyphs absorb the squeeze — a hard-narrowed pane rendered the design-system
palette icon tiny. Chips still shrink; their icons keep natural size.

* Bind Community Remix projects to the current workspace

Addresses mrcfps' review thread on apps/web/src/App.tsx:3303.

Product reproduction: 「通过 remix 后的方案, 不出现在我当前 workspace 里的
草稿了」 — stable repro in a team workspace.

`duplicatePluginAsProject` only sends the workspace/member identity headers
when its third `workspaceContext` argument is supplied, and the daemon's
`authorizeCreatedProjectWorkspace` deliberately reads a COMPLETELY headerless
create as a legal legacy/anonymous caller — `bindCreatedProjectToWorkspace` is
a no-op for a null context. Both remix call sites this PR adds (App.tsx's
standalone /community branch and EntryShell's community tab) omitted the
argument, so a remix performed inside a team workspace produced a project
bound to no workspace at all and therefore invisible to every workspace view.

Same enforceWorkspaceProjectMutation / created-project bypass class as
c0bce3b8f, fixed the same way: thread the already-resolved workspace context
that both files already hold, through the repo's existing
`resolvedWorkspaceContextForWrite` seam so an unresolved or unavailable
authority fails closed instead of silently creating an unbound orphan.

The second half of the review note — the follow-up `patchProject` whose `null`
was ignored — was a consequence of the same root cause: with the project
unbound, `enforceWorkspaceResourceMutation` finds no workspace row for the
caller's workspace and 403s the pendingPrompt seed, so the template prompt was
dropped too. Both requests now share one resolved authority, so they can never
disagree about which workspace they are acting in, and a still-null result is
reported instead of swallowed (without creating a second project, which is what
routing it through the existing catch would do).

Red spec first: apps/web/tests/components/community-remix-workspace-binding.test.tsx
drives both call sites in a team workspace against a fetch stub that reproduces
the daemon's create-then-bind contract. Red before this change (the create
carried no workspace headers, the project stayed unbound, and the seed PATCH
403'd), green after.

* Cover the refused-prompt-seed branch of the Community Remix fix

Second half of mrcfps' review ask on the same thread: prove what the flow does
when the follow-up `patchProject` still returns null after the binding fix.

The remaining null shapes are genuine transient refusals (daemon down,
membership revoked mid-flight, workspace locked between the two requests). By
then the copied project is real and bound, so the flow deliberately does NOT
fall into its own catch — that path creates a second, prompt-only project and
would strand the copy. It keeps the user on the remix and reports the dropped
seed. This case pins that: user lands on the remixed project, no second project
is created, and the failure is surfaced rather than swallowed.

* Re-anchor three style specs on the dogfooded UI

`Web workspace tests` went red on this branch because three CSS specs still
described the pre-dogfood layout the restyle replaced. Each one is re-pointed at
what shipped, keeping the invariant it was written to protect:

- `acceptance-visual-fixes` (recvpYDfW12NBu): the preset-thumbnail fix is the
  resting `scale(1.15)` framing transform, which is intact. Its hover partner
  went away with the family-wide cover-zoom removal (2026-07-27), so the spec
  now asserts the framing zoom on both media surfaces and separately locks the
  hover rules out — re-adding one would resample the covers soft again.
- `mention-popover`: the filter strip scrolls on one line instead of wrapping to
  a second row. The "no clipped labels" half of the contract is unchanged and
  still asserted (pills hold natural width, labels stay `nowrap`).
- `settings-polish`: the updater popup footer stacks the silent-update checkbox
  above a 50/50 action row. Long en labels must still wrap inside the checkbox
  column rather than overflow the panel, which the spec keeps checking.

* Return message-center focus to the rail opener that owns it

`hideTrigger` handed the message-center entry point to the rail but left the
component's internal `triggerRef` unattached, so `closePanel()` restored focus
to nothing. Opening focuses the portaled dialog, so every close — button,
backdrop, Escape, notification-settings — unmounted the focused node and dropped
keyboard focus to the document. Both external openers also failed to advertise
the dialog they own.

`MessageCenter` now takes a `returnFocusRef`: the host that hides the built-in
bell owns the duty that button used to serve. `EntryNavRail` points it at a
control that is still mounted after the close — the account trigger on the
signed-in branch, because the hover menu unmounts the 消息中心 row before the
panel opens, and the rail item itself when signed out. Both openers carry
`aria-haspopup="dialog"` and the live `aria-expanded`.

Red first: `EntryNavRail.message-center-entry.test.tsx` opens from each entry,
closes with Escape and with the close button, and asserts focus lands back on
the host control. All four cases failed before this change.

* Carry the menu popup contract onto the composer quick pills

The 插件 / 设计百宝箱 pills open the standalone `role="menu"` popovers in
ChatComposer, but they arrived without the contract of the ComposerPlusMenu rows
they replaced: no `aria-haspopup` / `aria-expanded`, no Escape handler, and no
focus return. Keyboard and screen-reader users could not tell a popup was open,
and Escape pressed while focus sat in the plugin search did nothing.

The pills live in ChatPane while the popovers live in ChatComposer, so the state
is plumbed both ways rather than duplicated:

- `onStandalonePanelChange` reports which popover is open, and each pill carries
  `aria-haspopup="menu"` plus its own `aria-expanded` — the same pair
  `ComposerPlusMenu` already puts on its trigger. No `aria-controls`: that
  surface does not use one, and this is not the place to invent a second
  convention.
- `openDesignToolbox` / `openPluginsPanel` take the opening pill as the
  return-focus target. Both popovers move focus inside themselves, so a
  dismissal has to hand it back, and the pill is the control the user came from.
- Escape closes through a document-level handler, mirroring ComposerPlusMenu's.

`dismissStandalonePanels` is deliberately only wired to the dismissal paths
(Escape, backdrop). Picking a plugin or an action keeps the plain setters,
because those hand focus to the composer input and pulling it back to the pill
would fight that.

No interaction changes: hover-open, the shared close timer, pill-to-popup
pointer travel and every selection path behave exactly as before.

Red first: `tests/components/ChatPane.quick-pill-popup-contract.test.tsx` runs
both pills through advertise / expand / Escape-from-inside / backdrop-dismiss and
asserts focus lands back on the pill. All six cases failed before this change.

---------

Co-authored-by: lefarcen <935902669@qq.com>

* Converge the visual Design Files prelude instead of clicking once (#6181)

`Playwright visual (settings-workspace)` went red on `[P2] captures the settings
BYOK surface` with a 10s `aria-selected` timeout: the Design Files tab sat at
`false` for all 23 polls with nothing re-clicking it. Seven other captures run
this identical prelude and passed, including the two neighbouring BYOK cases.

The defect is in the oracle, not the product. `prepareVisualWorkspaceFileList`
decided **once** whether to click — one instantaneous `isVisible()` probe of a
file row — and then asserted a state nothing retries. Any interleaving where the
workspace's own tab reconciliation lands around that single click leaves the
assertion permanently unsatisfiable for that attempt, and the lane runs
`OD_PLAYWRIGHT_FULLY_PARALLEL=1` with `retries: 0`, so it reports as a hard
failure rather than a flake.

`activateVisualDesignFilesTab` now converges on the goal state via `toPass`,
driving off `aria-selected` directly rather than through the file-row proxy —
the guard and the assertion were about different things even though
FileWorkspace derives both from one `activeTab === DESIGN_FILES_TAB` expression.
Repeat clicks are safe: the tab's handler is `setPersistedActive(DESIGN_FILES_TAB)`,
which is idempotent.

Why this is not a product regression from #6162: nothing in it touches tab
selection, tab persistence, or row visibility. Its only `design-files.css` change
deletes the hover cover-zoom, which cannot affect visibility, and a diff of
`apps/web/src` for `localStorage|activeTab|openTabs|restoreTab` comes back empty.
The lane's one green run before this failure (`4a6653186`, the #6174 repair of
the unrelated #5971 BYOK rename) is the whole basis for calling the baseline
clean.

Scope is the visual lane only — every caller is in `visual.ts` or
`visual-*.test.ts`, so the UI P0 lanes are untouched.

* fix(web): explain the AMR workspace-scope block instead of a dead send button (#6178)

An Open Design Cloud project run requires a resolved personal/team workspace
authority (21f452ffe). Failing closed there is correct and stays. Failing
closed SILENTLY was the bug: the send button went grey with no reason on
screen and no way out, while Home's equivalent dead end
(checkAmrBalanceGate -> AmrBalanceDialog reason `signed_out`) hands the user
an in-app sign-in. e2e/ui/amr-logout-requires-relogin.test.ts exists to
protect exactly that route, which a disabled composer can never reach.

Adds a classifier (`amrWorkspaceScopeBlock`) that turns "gate closed" into
the remedy that clears it, and a composer-adjacent notice that renders it:

  signed_out — the same AmrLoginPill action and `chat.amrBalanceGate.signInCta`
    copy Home's balance gate uses, so one identical action clears it from
    either surface.
  unresolved — a re-read of the project's workspace authority, exposed as
    `ProjectWorkspaceScopeState.revalidate`. An account action would be a guess.

The gate itself is untouched: `projectRunWorkspaceScopeReady` never consults
the classifier, and the `disabled: true` invariant stays asserted.

* test(updater): join the installer-reinstall floor across release and client (#6167)

* test(updater): join the installer-reinstall floor across release and client

A payload update never replaces the Electron outer shell, so a release whose
shell changed can only reach an old install through the installer. The
`control.launcher.version.{min,url}` floor is the one mechanism that forces
that, and it spans three owners: tools/release resolves the channel policy,
the feed carries it, and the desktop updater enforces it against the
physically installed outer version. Every side had unit coverage; nothing
joined them, so the only place a break in the chain would surface was a
release publish.

Adds six specs to the already-allowlisted packaged-launcher cross-boundary
suite, driving the real release-side resolver into a real feed and then into a
real packaged update check:

- an outer below the floor takes the installer, not the payload, and the
  launcher pointer proves nothing was adopted;
- the floor is judged by the installed outer rather than the payload it is
  running (two installs on the same payload, only the stale shell diverted);
- an unreadable installed outer fails closed as `outer-version-unreadable`
  with no installedVersion to render;
- a launcher schema beyond this client diverts on the ABI axis alone;
- with no floor configured a below-floor outer still swallows the payload,
  which is why a shell-changing release must set the repo vars deliberately;
- a floor above the release version stays refused.

Falsifying `remoteRequiresReinstall` to compare against `config.currentVersion`
-- the semantics the released 0.15.1/0.16.1 shells actually ship -- turns two
of these red, including the unreadable case being mislabelled as below-min.

Also pins the floor's operational shape against real channel version formats:
a bare stable floor sorts above every same-base `-beta.N`/`-preview.N`/
`-prerelease.N`, so inheriting it into those lanes hard-fails their publish.
The specs name that trap and pin the one-explicit-pair-per-lane configuration
that avoids it.

* test(updater): drive the reinstall floor on Windows too, off the real outer

Review (nettee) caught these specs running macOS only: the feed described
`platforms.mac`, the payload writer built a `.app`, and the updater config was
pinned to arm64/darwin. So `resolveInstalledOuterVersion`'s Windows branch —
`dirname(launcherLaunchPath)/resources/open-design-config.json`, a different
lookup from the mac bundle path — and the `installer` artifact shape never ran.
Since the installs most exposed to a shell/payload mismatch are the oldest ones,
leaving Windows uncovered left the half most likely to break unguarded.

The specs now run through `describe.each` over a per-platform target table
carrying the launch-path shape, outer-config location, installer artifact key,
and payload extractor.

They also stop using `OD_UPDATE_INSTALLED_VERSION`. That override returns before
the platform branch it was supposed to exercise, so it was masking the code under
test. Each scenario now materializes a real installed outer package at the real
per-platform location and lets the updater read the version off disk — which
also makes the unreadable-outer case genuinely unreadable rather than simulated.

Falsifying the Windows lookup (collapsing it into the macOS path) fails exactly
the two Windows specs that depend on reading a real installed version, while all
twelve macOS and publication-policy specs stay green.

* fix(web): partition the workspace plan nameplate like the wallet (#6182)

A workspace Vela Web shows as 免费 rendered as 专业版 Plus in the client's
account menu, while the 额度 row in the same card correctly read $0.00.

Money and plan travelled different paths. `GET /api/workspace/billing`
returns three independently scoped things, and only two of them are about
the workspace: `workspaceBalance` / `workspaceSnapshot` are proven for the
exact workspace + member, while `summary` is the caller's VELA ACCOUNT
billing (`workspaceId: null` by contract; the daemon reads it with one
unscoped `fetchBilling()` regardless of `?workspaceId=`). The rail's plan
nameplate read `response.summary` raw, so an account holding a personal
Plus reported `plus` in every workspace — a value that cannot change when
the workspace changes, because it was never about the workspace.

`workspaceBillingSummaryForContext` makes the partition key explicit, the
same `workspaceId` + `workspaceMemberId` pair money already uses:

  - personal workspace — the account IS the scope, summary passes through;
  - team workspace — plan comes from the context-authorized snapshot, and
    the account tier may stand in only when it is itself team-namespaced,
    since a personal tier cannot name a team workspace's plan.

That last clause keeps the 飞书 P0 fix intact: B omits planId/billingState
for a non-owner and `workspaceSnapshot` is an additive capability, so a
team-namespaced account tier is the only surviving evidence that a paying
MEMBER's team is subscribed.

`useWorkspaceBilling` is now this projection over the ambient context, and
EntryShell / SettingsDialog consume it instead of the raw summary. No
interaction, layout, or copy changes.

* Revert "fix(web): explain the AMR workspace-scope block instead of a dead send button (#6178)" (#6184)

This reverts commit ea259eb402.

* fix(daemon): resolve an unbound project against the caller's current workspace (#6185)

* fix(daemon): resolve an unbound project against the caller's current workspace

Every project must resolve to a workspace; when the project itself has no
binding, the workspace is the one the caller is currently acting in. There is no
other candidate.

`GET /api/projects/:id/workspace-scope` read only the persisted
`workspace_projects` row and the membership directory — never the request's
`x-od-workspace-*` identity — so a project with no row answered `unbound` for
every caller forever. `unbound` makes `projectWorkspaceScopeAuthorizesAmr` false,
which disabled the chat composer's send button for an Open Design Cloud run on
that project permanently, with nothing the user could do to clear it. #6178 wrote
user-facing copy for that state instead of fixing it (reverted in #6184).

`resolveProjectWorkspaceScopeForCaller` wraps the existing resolver instead of
branching inside it, so `resolveProjectWorkspaceScope` stays byte-identical and
the bound path is provably untouched. The fallback re-enters that resolver with a
synthetic binding naming the caller's own workspace, so the resulting
`workspaceMemberId` comes from the membership directory and never from the
request header: a caller can only select among workspaces their signed-in
identity is genuinely an active member of, and anything the directory cannot
confirm degrades back to `unbound` rather than to `unavailable` on a workspace the
project was never bound to.

Two cases keep today's behavior, both pinned by spec:

  unavailable — all three of its return sites. A project pinned to workspace X
    read by a member of Y answers X. The scope carries `workspaceMemberId`, the
    wallet that pays for the project's runs; resolving to Y would bill Y for X's
    project. Reachable because `GET /api/projects/:id` has no workspace gate, so
    a deep link or a workspace switch lands there.
  no caller identity — signed out, or a plain curl, has no current workspace to
    fall back to, and inventing one is worse than answering "none".

The fallback deliberately does not persist a binding.
`reconcileUnboundProjectBeforeMutation` was the preferred shape going in and is
wrong here: its own docblock is explicit that a passive read must not hand out
ownership just because it ran first, and this endpoint is a GET. Writing a row
from it would let whichever workspace opened the project first claim authorship
on no user intent, and would race two clients in different workspaces.

* fix(web): stop pre-emptively blocking an AMR send that has a wallet

An Open Design Cloud run is billed to the CALLER's own wallet, so the only
defensible client-side veto is "there is no billing principal at all". The gate
instead required THIS PROJECT's workspace scope to resolve, and blocked the send
whenever it did not: an unbound project, a membership-directory read that
transiently failed (offline / 504 / timeout all collapse into one `ok: false`
upstream), a workspace that is billing_past_due or locked, or a team the caller
has since left. In every one of those the user is spending their own quota.

It is also not the enforcement point. Real enforcement is server-side — the
daemon's `WORKSPACE_CONTEXT_REQUIRED` 401 plus vela's own billing check. The
client gate could only convert a request the server would have answered into a
dead, unexplained button, which is strictly worse than an honest server error.
It arrived as 21f452ffe, whose entire message is "fail closed on unresolved
workspace authority" with no body and no stated product requirement.

`workspaceIdentityCanBillAmr` names the invariant on the identity read, and the
gate now admits either witness of a billing principal: that identity, or a
project scope that already resolves to an explicit personal/team principal.
Strictly a widening — every state it newly admits was previously blocked, and
nothing previously admitted becomes blocked.

Deliberately NOT treated as "no wallet":

  loading — the identity read holds no answer yet. Reporting "signed out" on a
    frame that has not heard back is the bug shape this replaces.
  failure 'unavailable' — a transient outage taught us nothing about the user.
  failure 'unsupported' — an old daemon with no workspace endpoint keeps its
    legal pre-workspace behavior.

A genuinely signed-out caller — a settled, authoritative read that came back with
no workspace — stays blocked: there is no wallet, so the run cannot be billed and
cannot succeed.

No UI, no notice, no copy: #6178 wrote copy for this dead end instead of removing
it, and #6184 reverts that. This removes the dead end.

* fix(web): point every console entry at /dashboard, drop the wallet target (#6165)

* fix(web): point every console entry at /dashboard, drop the wallet target

Product ruling: there is no wallet page in the console's information
architecture any more. Balance, manual top-up and the auto-recharge policy were
all rehomed onto the console dashboard (vela #1055), so every entry that used to
open `/wallet` now dropped users on a surface the product no longer navigates
to.

Retargets all of them:

- `AMR_CONSOLE_URL` / `DEFAULT_AMR_RECHARGE_URL` and the per-profile console
  map (prod / test / local) now resolve `/dashboard`, keeping the
  `?source=open_design` attribution param.
- `teamConsoleUrl`'s `billing` section — the team account menu's 「额度 $0.00」
  row — is a plain dashboard visit.
- `teamConsoleUrl`'s `plans` section, `amrPlansUrlForProfile` and
  `amrPlansUrlForWorkspace` — the free-tier 「升级」 button and every other
  Upgrade affordance — send `billing=plan` instead of the wallet page's
  `view=plans`.
- `amrWalletUrlForWorkspace` is renamed `amrConsoleUrlForWorkspace` so the name
  matches where it now points.

`billing=plan` is the console's one state-aware upgrade intent: its dashboard
resolves it against the workspace's real subscription state, so a personal owner
lands on the personal plan modal — the dialog the console's own 「升级订阅」 hero
button opens, which is what product asked the free-tier 「升级」 button to reach —
while a team owner still gets checkout or change-plan. The team `upgrade` section
keeps sending its more specific `billing=checkout` hint, but that is now only a
hint: powerformer/vela#1127 makes the console fall back to the dialog that
actually matches, so a stale `hasActivePlan` can no longer strand the user on a
bare Overview page (recvpSQKna0LwR).

Comments carrying earlier live-verification findings are updated wherever this
change invalidated them, rather than left contradicting the code.

No new user-facing copy, so no locale changes: the affected labels are all
action-named (「充值」/「升级套餐」), never destination-named.

* fix(daemon): retire the wallet target in the AMR recharge link too

The daemon builds its own recharge URL for AMR balance failures — it lands in
the failure message and in `actionUrl`, which the client renders as the chat
error card's clickable link — and it still pointed at the console's retired
wallet page. Same product ruling as the client-side retarget: balance and manual
top-up report on the console dashboard now (vela #1055), so this link has to go
there.

Every existing assertion in `vela-errors.test.ts` referenced the constant
symbolically, so all of them would have kept passing while the link sent users
to a surface the product no longer navigates to. Added one that pins the literal,
which is the red spec for this change.

Also retargets the two e2e assertions that pinned the old destination
(`e2e/tests/amr/insufficient-balance.test.ts` on the daemon's emitted URL,
`e2e/ui/visual-workspace.test.ts` on the avatar upgrade link's `view=plans`), and
the web-side fixtures that mimic the daemon's emitted string so they stop
documenting a URL it no longer produces.

* chore(release): bump feat/workspace-team to 0.16.2 (#6188)

The published beta feed is self-inconsistent: metadata.json carries
baseVersion 0.16.1 alongside betaVersion 0.16.2-beta.144, because
0.16.2-beta.144 was published with an explicit release_version override
while every manifest on this branch still said 0.16.1. Every subsequent
beta build now fails its own consistency check:

  [release-beta] beta metadata.json baseVersion 0.16.1
                 does not match betaVersion 0.16.2-beta.144

`force` does not help — it only waives "base must exceed latest stable",
not the baseVersion/betaVersion match.

Raising the branch's base version to 0.16.2 makes the next beta
(0.16.2-beta.145) consistent with its own metadata, and removes the need
to pass force at all. Same 18 manifests the release bot touched in
23f9fcaac; test fixtures that mention 0.16.1 as data are left alone, as
that commit also left them.

Co-authored-by: lefarcen <ontf116@gmail.com>

* fix(collab): keep a project's "last changed" time out of sync's hands (#6189)

A member who opened the client hours later and pulled a shared project saw
「刚刚更新」 on a project nobody had touched, and the home 最近项目 strip kept
rendering the previous workspace's cards for a beat after switching workspaces.

Two independent defects, both reported by the product owner on a packaged
client.

1. `projects.updated_at` (and the `workspace_projects.updated_at` the project
   list folds into it with `MAX`) is what the project card shows and what the
   list sorts by, so it must answer "when did a person last change this
   project's conversations, files, or name". Sync was answering it too:
   `materializePulledTeamMirror` carried the origin's timestamp into the
   project row but not into the binding it wrote in the same transaction, so
   `MAX` surfaced the pull's own clock on EVERY pull. Four more sync writers
   had the same shape. `SYNC_KEEPS_UPDATED_AT` names the invariant and the
   sync paths now either carry the origin's time or keep the row's existing
   answer.

2. A loaded project list describes exactly one workspace identity, so leaving
   that workspace invalidates it. The re-list on switch runs in an effect —
   after the commit — so the browser painted workspace A's cards under
   workspace B's identity first. The list is now dropped during the render
   that first observes the new scope, so no frame shows another workspace's
   projects. No extra request: the refetch already existed and was already
   keyed on the resolved workspace.

Co-authored-by: lefarcen <ontf116@gmail.com>

* fix(web): key every workspace read cache on who is asking (#6198)

`coalesced-get` and `shared-cancellable-get` are CACHES (1s share window), not
just in-flight dedupers, so a key that omits the caller's identity hands the
previous workspace's answer to the next one — and an in-flight entry is joined
with no window at all.

Three reads were keyed on a constant:

- `useTeamMembers` — `'workspace-members'`. `GET /api/workspace/members` answers
  for whichever workspace the DAEMON is active in and reads nothing off the
  request, so the roster is a per-workspace answer with no per-workspace URL.
  A leaked roster renders the previous workspace's teammate name on project
  cards (`RecentProjectsStrip.resolveCreator`). Now keyed on the workspace
  identity, which also makes the hook re-read on a switch instead of waiting out
  its 15-60s poll, and a late read for the identity the user left no longer
  overwrites the newer roster.

- `useWorkspaceContext` — `'workspace-context'`. This is the read that
  ESTABLISHES the identity, so it cannot be keyed on the identity it fetches and
  has no workspace argument to borrow (the switch is a separate
  `PUT /api/workspace/active`). It is keyed on the identity GENERATION instead:
  one token per deliberate identity change, so a single broadcast heard by a
  dozen mounted consumers still collapses to one request, while two switches
  inside `forceCoalescedGet`'s 250ms burst window stop being mistaken for one.

- `useProjectWorkspaceScope` — `project-workspace-scope:<id>`. The scope carries
  the `workspaceMemberId` that pays for the project's runs, so a shared key
  serves one member the wallet of another.

The same read also fetched HEADERLESS, which made #6185's daemon fallback
unreachable: `GET /api/projects/:id/workspace-scope` resolves an unbound project
against the caller's current workspace, but only when the request carries the
caller's `x-od-workspace-*` identity. It now sends `workspaceProjectHeaders`,
the same set every other workspace-aware project read uses — and waits for the
caller's own identity to settle first, so a project open still costs one request
instead of a wrong headerless one followed by a corrected one.

* fix(collab): carry B's workspace name for personal workspaces too (#6199)

B names EVERY workspace, personal included: vela's CurrentWorkspaceContext
requires `workspaceName`, derives "«owner»'s workspace" for an unnamed
personal one (vela #964), and an owner may rename it outright.

The daemon read that field and then threw it away unless the workspace was a
team, because the only name on WorkspaceCollabContext was `teamName`. The
switcher's collapsed label therefore had nothing real to show for a personal
workspace and fell back to a hardcoded English "Personal workspace" until the
user opened the dropdown and the separate workspace-directory read landed.

Adds `workspaceName` to the context contract and carries it for both workspace
types, in the vela mapping and in the directory-item synthesis. `teamName`
stays team-only: it is the team switcher's field and doubles as an "is a team"
signal, so nothing may start reading it for a personal workspace.

The web side consumes the context the shell already holds at startup, so the
label is correct on first paint and NO new request is added — the diff adds
zero fetch call sites. The dev provider parses `workspaceName` too so the
dev/demo lane can drive a personal workspace's label the way B does.

Note this is not what the reported screenshot shows: there, B's real name for
that workspace IS the literal "Personal workspace", because an in-place team
upgrade flips the default workspace's stored type to 'team' and vela's
owner-derived naming then no longer applies. That half is upstream.

Co-authored-by: lefarcen <ontf116@gmail.com>

* fix(web): stop offering 升级 at the top plan tier (team_max) (#6205)

A 团队版 Max workspace is the top of the ladder — nothing is above it — but
both upgrade entry points still showed 「升级」, and clicking it could only
reopen the plan the user already holds.

The two surfaces got there two different ways, and neither shared a rule:

- The account menu's billing card gated on `billingUpgradeUrl &&
  canManageBilling` — a destination check and a permission check, with no
  TIER check at all. Every tier saw the button.
- The Settings AMR card asked a personal-ladder question
  (`canUpgradeVelaPlan`) about `amrCardStatus.account.plan` — vela's
  ACCOUNT-scoped login projection, which reports `free` for a user whose
  entitlement is held by a team workspace. So a team_max owner measured as
  "free" and got the button, while the PlanBadge beside it correctly read
  Max off the workspace-resolved tier.

Both now ask one predicate, `canUpgradeFromPlanTier`, about the same
resolved tier each surface already LABELS, so the button can never
contradict the nameplate next to it.

Team and personal tiers are two ladders, so the predicate reads the
team-namespaced id whole instead of stripping the prefix and asking a
personal question. Per the product ruling — 「个人档位都是要显示可升级的,
最顶的就是团队 max」 — personal `max` keeps the affordance: that user can
still move onto a team plan, and vela #1146 deliberately routes their
click to the Team upgrade dialog. Only `team_max` loses it.

Destination logic (`workspaceUpgradeUrl` / `amrPlansUrl`) is untouched;
this only decides whether the affordance renders. No copy, no new i18n
keys.

* fix(web): keep Send alive after using a community template (#6206)

Clicking 「使用」 on a community template seeds the template's own brief into
the composer and binds the template as the run's driver. For a template whose
`od.inputs` are `required: true` with no `default` -- the real
`example-html-ppt-pitch-deck` ships three such fields -- the bind left
`active.inputsValid` false, and `submitDisabled` folded that into the send
button. The user saw a full prompt next to a permanently dead Send whose
tooltip still read "type something to run". Typing did not help: the gate was
never about the prompt.

Those fields have no input surface on that path. #3645 removed the Home
composer's inline plugin-inputs form, and the template's query carries no
`{{...}}` placeholders, so prompt -> inputs write-back cannot reach them
either. A gate on values the user cannot supply anywhere is a dead end, not a
guard.

Narrow it to where it is actionable: `requiredInputsAreUserFillable` keeps the
gate when the media composer renders the fields as controls, or when the
tracked query template actually names the missing field as a `{{placeholder}}`
that editing the prompt writes back. A tracked template alone is not enough --
re-applying a plugin without an explicit template derives one from the
manifest, placeholders or not, which is the shape a reload lands in.

The daemon stays the authority: `validateInputs` still throws
MissingInputError. Since the seeded path no longer pre-empts the send, its
rejection now reports the fields it rejected instead of a generic "Failed to
apply", reusing the existing pre-submit wording.

* fix(collab): give every created project a workspace home (#6201)

* fix(collab): give every created project a workspace home

A project created with no `x-od-workspace-*` headers got no
`workspace_projects` row and still returned 200, so nothing surfaced the
loss. `GET /api/projects/:id/workspace-scope` then answers `unbound`,
which strips the workspace off the run request (`ProjectView`'s
`projectRunWorkspaceContext` -> an Open Design Cloud run nothing can
bill) and blanks the balance/plan area while that project is open
(`AvatarMenu`). The same orphan is also denied its first run outright by
`enforceWorkspaceResourceMutation`.

Headerless creates are not anonymous callers asserting "no workspace" —
they are callers that could not say which workspace they meant:
`od project create` and the MCP `create_project` tool mint no headers at
all, the web duplicate / design-system-copy / plugin-share paths read a
ref that drops the context's `loading` flag, and Orbit + scheduled
routines have no request to read headers from. So a create that names no
workspace now binds to the one this daemon is signed in to, read through
`workspaceContext.lastKnown()` — zero-network, synchronous, no failure
mode. A daemon that has resolved no workspace still binds nothing, which
keeps `unbound` correct for the signed-out single-player user.

Six creation paths were writing no binding at all, not merely skipping it
when headerless: the plugin share-project task, Orbit run projects,
scheduled routine projects, the library capture-as-page exit, the
project-location scan importer, and the `ds-*` project backing a design
system's editing workspace. All six now bind.

Deliberately unchanged: the collab pull's register-on-pull and
shared-project placeholder. Those are a teammate's shared project, whose
correct binding is written by `materializePulledTeamMirror` with
`visibility: 'team'`; claiming them into the reader's ambient workspace
as personal would misattribute someone else's resource.

No creation path gains a new failure mode. `createdProjectWorkspaceHome`
never refuses — a partial or denied header identity degrades to the
ambient workspace rather than turning a working 200 into a 4xx.

On the web side `createPluginShareProject` was the one create in
`state/projects.ts` with no `workspaceContext` parameter at all, so it
sent no workspace identity ever. It now forwards the context when known,
best-effort — deliberately not `resolvedWorkspaceContextForWrite`, which
throws while the identity read is in flight and would add a new block to
a path that works today.

* fix(collab): verify an asserted workspace before persisting it

`createdProjectWorkspaceHome` resolved a created project's workspace with
`resolveCreatedProjectWorkspace`, which only PARSES headers. It trusted
the caller-provided member status and permissions and never checked that
the workspace/member pair exists in the membership directory or agrees
with the daemon's last-known state. `x-od-workspace-*` headers are an
unauthenticated hint, so any local caller — the `od` CLI, a plain curl, a
compromised page — could assert an arbitrary pair and get a project bound
to a workspace it has no membership in, with a fabricated
`createdByWorkspaceMemberId`.

"Do not fail the create" and "trust unverified input" are not the same
constraint. The helper now VERIFIES, then DEGRADES — never rejects, never
trusts:

  1. asserted identity that verifies      -> bind to it
  2. asserted identity that does NOT      -> bind to the daemon's ambient
     verify, including an unreadable         workspace instead, or leave
     authority ("cannot confirm")            the project unbound
  3. nothing asserted                     -> ambient, as before

Creation still never answers 4xx, so the six previously-never-bound paths
keep binding and a signed-out daemon still creates usable local projects.
An unverifiable claim is simply never written as fact.

Verification is not re-implemented. It is `authorizeCreatedProjectWorkspace`
— the same directory lookup `POST /api/projects` gates on, which also
returns the DIRECTORY's authoritative context rather than the caller's
claimed one — plus `withLastKnownMembership`, the same cross-check the
mutation gates apply (now exported rather than copied). Only the failure
behavior differs: they refuse, this degrades. An absent directory fetcher
remains `authorizeCreatedProjectWorkspace`'s own documented local/dev
compatibility path, not a second weaker definition of verified.

Route modules now take one `CreatedProjectWorkspaceResolver` dep instead
of re-threading the three authorities, so there is a single notion of
"verified" per daemon.

* test(daemon): drive brand routes through the created-project workspace resolver

The brand harness constructed `registerBrandRoutes` without the new
`resolveCreatedProjectHome` seam, so the binding became a no-op and
"binds a freshly extracted brand project into the caller's ACTIVE team
workspace" went red. Inject the same production resolver `server.ts`
builds, in its no-directory (local/dev) configuration, so the test keeps
asserting the product behavior through the real seam instead of a
header-trusting shortcut.

---------

Co-authored-by: lefarcen <ontf116@gmail.com>

* fix(web): move the update-ready rocket above the rail account row (#6208)

* fix(web): move the update-ready rocket onto the rail account row

#5517 removed the entry topbar and parked the updater host in the rail
footer, which put the green rocket on its own line UNDERNEATH the account
row — bottom-left, detached from the identity it belongs to. Product
reported it from a real client: the rocket belongs at the right edge of
the name + plan wordmark line.

Move the mount instead of nudging it with CSS: the rail now owns the
updater host through an `updaterSlot` prop and renders it inside the
account row (`.entry-nav-rail__account-row`), as a SIBLING of the account
trigger so it never steals the account-menu click target. The rail footer
stays as the fallback home for the signed-out shell, which has no account
row to ride, so the rocket's appearance conditions are unchanged.

Same change also un-clips the 「更新已就绪」 prompt. The host is pinned to
the bottom of the rail, so a `top: 0` panel grew downward off the bottom
of the window and the copy was cut off mid-sentence. Anchor its BOTTOM
edge to the host instead, the same way `.entry-help-popover` already does
for the same reason.

Side effect worth having: the rail no longer grows a 52px footer strip
the moment an update lands, so the account row stops jumping.

* fix(web): put the rocket ABOVE the account row, right-aligned

Placement correction. 「放到名字啥的上面的右侧」 was read as "on the name
row, right side"; product clarified 「应该是在名字那个横条的上方,靠右对齐」
— the rocket gets its own right-aligned strip ABOVE the identity row, not a
slot inside it.

The strip (`.entry-nav-rail__account-updater`) is mounted unconditionally so
it stays the account trigger's immediately-preceding sibling, and carries
`:empty { display: none }` so an idle rail reserves no height for it. That
rule is load-bearing: without it every user without a pending update would
get a blank 34px row plus the container's 6px gap pushing the account area
up — a worse regression than the placement it fixes. Measured in a real
browser at 1280x900: the account trigger sits at y 850-884 in BOTH states,
so nothing shifts.

Everything else from the first commit stands: the rail owning the host via
`updaterSlot` instead of an opaque `footerExtra`, the sibling-not-child
relationship to the trigger (now moot in the best way — a strip above the
row cannot be button-in-button), the `bottom: 0` panel anchoring that
un-clips 「更新已就绪」, and the footer fallback for the signed-out shell.

* fix(web): stop the home model list offering picks the chip cannot honor (#6209)

On the Home composer, clicking a model in the chip's compact list did
nothing — the chip kept showing the previous model.

The compact list rendered `currentAgent.models` raw, so a model above the
caller's plan (`enabled: false`, straight from `vela model list --json`)
appeared as an ordinary selectable row. Clicking it wrote the pick to
config, `normalizeAgentModelChoice` reported the plan fallback instead, and
the re-normalization effect in `InlineModelSwitcher` persisted that
fallback — so the chip snapped back with no explanation, and the user's
previous pick was overwritten by the default in the process.

The sibling surface (`AvatarMenu`, the project composer's model list) had
already hand-rolled `agent.id === 'amr' && model.enabled === false` for
this. Copying the rule per surface is what let the newest list drop it, so
this introduces one definition instead: `agentModelIsSelectable`, defined
to be at least as strict as `normalizeAgentModelChoice` (pinned by a test)
so a gated row can never be a pick the config would revert. Both surfaces
now ask it, and every model write inside `InlineModelSwitcher` — the
compact list and the execution-settings picker — goes through a single
`applyAgentModel` sink that asks the same question, leaving no second path
for a future list to bypass.

Locked rows now read as locked: dimmed, `aria-disabled`, a lock glyph, the
existing `settings.amrModelUpgradeHint` reason, sorted below the available
models, and a click routes to the plans page instead of silently doing
nothing.

* fix(collab): verify the asserted workspace before claiming an orphan project (#6213)

* fix(collab): verify the asserted workspace before claiming an orphan project

`reconcileUnboundProjectBeforeMutation` claims a project this daemon has
never bound to ANY workspace into the current mutating request's
workspace, so a true orphan is not denied its first mutation by
`enforceWorkspaceResourceMutation`'s `!row -> false` rule (recvqbhor3pai2).

It resolved that workspace from `workspaceProjectContextFromRequest(req)`
— header PARSING with no authority check — and stamped
`createdByWorkspaceMemberId: ctx.workspaceMemberId` from the same headers.
`x-od-workspace-*` is an unauthenticated hint any local caller can forge,
so a plain curl could claim someone else's orphaned project into a
workspace it has no membership in AND install itself as the author.
Authorship is the dangerous half: `workspaceResourceAccess` derives
`selfCreated` from it, and that bit is what grants a non-privileged member
mutation rights over the row.

Both the workspace and the authorship now come from
`resolveCreatedProjectHome` — the same verify-then-degrade resolver #6201
introduced for created projects:

  - asserted identity VERIFIES against the membership directory -> claim
    it, attributed to the DIRECTORY's member id, not the header's;
  - it does NOT verify (foreign, inactive, permissions disagree,
    last-known says removed, or the authority is unreadable so it cannot
    be confirmed) -> claim the daemon's own ambient workspace instead,
    attributed to that verified session's member id;
  - neither available -> write nothing, and let the pre-existing gate
    answer.

The `null`/`'missing'` early return is unchanged and load-bearing, which
is why this does not reuse `createdProjectWorkspaceHome`'s own third
branch: the mutation gate's HEADERLESS branch answers 401 as soon as any
row exists for the resource, so claiming on a request that asserts
nothing would convert a working headerless mutation into a 401.

A verified caller is unaffected — `workspace-projects.test.ts`'s
recvqbhor3pai2 regression case still passes unchanged. A caller that
cannot prove membership over a never-claimed project is now refused by
that pre-existing gate instead of being let through on a binding it had
just invented for itself; #6201's e2e case is updated to assert the
persisted binding rather than the mutation's status, since both outcomes
satisfy the property it pins.

* fix(collab): persist only an asserted-and-confirmed workspace on an orphan

Review follow-up (mrcfps). `resolveCreatedProjectHome` is not a pure
verifier: by contract a foreign, inactive, removed, or unconfirmable
assertion degrades to `workspaceContext.lastKnown()`. Reconciliation then
persisted that ambient context onto a PRE-EXISTING orphan before the
mutation gate had rejected the asserted caller — and the
`getWorkspaceProjectByProjectId` guard one line above makes that write
sticky, so the rightful verified workspace could never reconcile it
afterwards. A forged request, or a legitimate one during an authority
outage after the active workspace changed, would permanently mis-file the
project.

Creation and reconciliation are not the same risk, which is the
distinction the original patch missed. Putting a BRAND-NEW project in the
ambient workspace takes nothing from anyone — that is #6201 and it stays.
Putting an EXISTING orphan there may take it from the workspace it
actually belongs to.

So a degraded ambient authority is persisted only when it names the very
pair that was asserted. Verified assertions resolve to the asserted pair
by construction (the directory lookup is keyed on it), so they are
unaffected; outage continuity still works for the ordinary legitimate
caller, whose client took its headers from this same daemon and therefore
agrees with it; a mismatch writes nothing at all.

Not in tension with 「所有 project 都应该能找到 workspace」: the
display-side fallback (#6185) already renders an unbound project inside
the caller's current workspace, so nothing reads as "unknown". The
binding row is a separate, durable fact and stays conservative.

Adds the fixture-matrix case the review asked for — a populated but
DIFFERENT ambient workspace, asserting the orphan stays unbound. The mock
authority's `/current` is now flippable so the fixture can create a true
orphan while ambient is absent and then populate ambient deterministically
through `GET /api/workspace/context`, rather than depending on a poll tick.

---------

Co-authored-by: lefarcen <ontf116@gmail.com>

* fix(web): use neutral Windows app background (#6218)

* fix(collab): let a headerless caller mutate its own daemon's project (#6216)

* fix(collab): let a headerless caller mutate its own daemon's project

`enforceWorkspaceResourceMutation`'s headerless branch answered
401 WORKSPACE_CONTEXT_REQUIRED as soon as ANY `workspace_projects` row
existed for the resource. That was survivable while headerless creates
left projects unbound. #6201 made every created project get a workspace
home, so the two rules combined into a project its own creator cannot
touch:

    od project create     -> 200, and now writes a binding
    od project duplicate  -> 401

The `od` CLI never sends `x-od-workspace-*` — only `od workspace …`
builds those headers — so this is not an edge case for it. It is every
CLI mutation of every project the CLI created, which breaks the
embeddability contract `AGENTS.md` makes the CLI responsible for.
Reproduced end to end through the real binary against a daemon-only
process (`od daemon start --headless`, no web, no browser).

INVARIANT, now named in `headerlessMutationAllowed`: every mutation
resolves to a workspace identity. A request that ASSERTS one is judged on
that assertion; a request that asserts NOTHING is the local daemon's own
signed-in user and is judged as that identity. Being unable to name a
workspace is not the same as having no standing in one — the same
"nothing asserted -> ambient" fallback the create path already applies,
so the gate and the creation paths finally agree about what a headerless
caller is.

Verified the ambient identity is available without any UI: a
daemon-only process resolves it from the vela session on disk, and
`GET /api/workspace/context` answers fully in that state. The
`ambientWorkspaceId` in `collab/workspace-hub-subscriptions.ts` is
unrelated — it is SSE subscription bookkeeping fed from the
disk-persisted selection store, not an authorization input.

Deliberately NOT weakened: `authorizeCreatedProjectWorkspace` still
refuses to let ambient stand in for an explicitly CLAIMED pair, and
`resolveProjectWorkspaceScope` still resolves a persisted binding without
consulting ambient. Both govern cases where something WAS asserted;
headerless is the third case.

What stays refused, because the original branch protected something real
(recvqbeDjAsejl / recvqbklNGDqYY, spec 04 §10): a resource bound to a
workspace the daemon is not currently in; a resource the daemon's own
identity may not mutate anyway (the same
`workspaceResourceMutationAllowed` computation runs); and everything when
the daemon has no signed-in identity to resolve. With no ambient wired
the branch is byte-identical to before.

The ambient snapshot type moves down into
`collab/workspace-resource-mutation.ts` so the gate and the creation
paths cannot drift into two notions of "the daemon's own workspace";
`created-project-workspace.ts` re-exports it and now reuses the shared
context builder instead of its own copy.

* test(e2e): pin that signed out, visible equals unbound equals mutable

「未登录也可以用自己 cli 修改未登录态下的那些 project, 这个一定要保证,
不然就是 P0 事故」

A signed-out client can only SEE unbound projects: the no-scope catalog
(`routes/project/index.ts:2432-2443`) reads no `x-od-workspace-*` at all
and joins through `listUnboundProjects`, so a project any workspace has
claimed never leaks to a caller with no identity to check it against
(spec 04 §10). This pins the other half of that set — everything visible
while signed out stays WRITABLE.

The case runs against a daemon with NO vela session at all, not merely a
request without headers, creates its draft in that same signed-out state
(the real user sequence), asserts the draft is listed by the no-scope
catalog, then renames it, duplicates it, and duplicates it again through
the real `od` binary.

It holds only because `headerlessMutationAllowed` short-circuits on "no
row anywhere" BEFORE it asks for an ambient identity, so it is
mutation-tested against that ordering rather than written red-first:
moving the short-circuit after the ambient check turns it red with
`expected 401 to be 200`.

* fix(collab): make the delete side effect use the gate's own context

Review follow-up (mrcfps). Allowing a headerless caller to mutate a
project the daemon's own identity owns made a latent inconsistency
reachable: `DELETE /api/projects/:id` re-derived its context with
`workspaceProjectContextFromRequest(req)`, which is null for a headerless
caller, so `requestTeamVisibility(..., 'personal')` was skipped while
`dbDeleteProject` still ran. The owner's local row and directory
disappeared and the hub kept serving the resource, so teammates went on
seeing a project that no longer existed — a cross-client data-consistency
bug, worse than the 401 this branch set out to fix.

Threading the context, not narrowing the capability:
`effectiveWorkspaceProjectContext` names the identity a mutation actually
acts under — asserted when the request carries one, ambient when it does
not — and its docblock states the invariant that any SIDE EFFECT must read
from it rather than re-deriving from headers, or the effect disagrees with
the gate that permitted it. The delete now uses it.

The no-context case becomes a refusal rather than a silent skip. It is
unreachable while the gate is intact (a team-bound row is admitted only
for an asserted identity or a matching ambient one), but falling through
is what produced a local-only delete of a still-shared project, so a
future gate change must not be able to reintroduce it quietly.

Audited every other capability this branch newly admits for a headerless
caller. Delete was the only one: `/move` and both share/unshare routes
each guard with their own `if (!ctx) return sendMissingWorkspaceContext(res)`
so headerless never reaches their hub work, and the upload route's
`workspaceProjectContextFromRequest(req) !== null` only shapes a 404 for a
missing project. Rename/duplicate/writeFiles/comment have no hub side
effect.

Adds the requested route regression to
`tests/routes/project-delete-unshares-team-share.test.ts`: real route,
real collab runtime, mutable fake hub, no request headers,
`workspaceContext.lastKnown()` populated — asserting the unpublish and
catalog removal happened and the project is gone locally only after. Plus
the other half: with no ambient identity either, the delete is refused and
nothing is destroyed. Mutation-tested — restoring the header re-derivation
turns the first red with "expected 0 to be greater than 0".

---------

Co-authored-by: lefarcen <ontf116@gmail.com>

* fix(web): draw the team wordmark for every tier of a team plan (#6219)

The account row of a team workspace showed the PERSONAL Plus wordmark. Product
ruling: 「团队版的订阅,这里应该都显示 team 的标识」…「产品期望团队从 free 到
max,徽标都显示 team 的那个,个人的还是维持现状不要动」.

The badge names the plan FAMILY, not the tier inside it, and it got that wrong
from both ends. `planBadgeTierForLabel` asked `plus` / `pro` / `max` BEFORE
`team`, and B namespaces team ids by EMBEDDING the personal tier word — so
`team_plus` matched `plus` and the `team` branch was reachable only from a bare
`team` id, the one team tier that happened to work. The free team tier missed
from the other side: B reports an unsubscribed workspace with a null `planId`
and an empty `membershipTier`, an id no different from a personal free account,
so no id can name it.

`planBadgeTierForWorkspace` states the whole rule once, and both surfaces read
it — the rail's account row and the Settings CLI card, which the owner requires
to move together (「设置中的这里应该一样的逻辑」). Team-namespaced ids go through
the shared `isTeamPlanTier`, so the badge cannot drift from the other team
gates; `workspaceType === 'team'` covers the free tier that no id can express.

The plan LABEL is untouched, deliberately: it answers a subscription question,
every user-created workspace in B is team-typed, and reading one as the other
is #146. 免费 beside the `team` wordmark is now the intended pairing — the
family is team, the subscription is not paid.

No interaction, layout, sizing, artwork, or copy changes; no new i18n keys.

* fix(collab): warm the workspace-scoped digest faces on switch and reconnect (#6222)

Every workspace-scoped cache in the daemon keys on the active workspace, so the
instant the user switches, `catalog` and `members` are both a miss and the FIRST
consumer in the new workspace refills them inline on its own request path
(`createPersistentSyncCache` fetches synchronously on a miss). The cost is the
same either way; the only question is who waits for it. A switch is a user
action followed by idle time, so paying it there is free — paying it inside the
first project load or agent run is not.

`PUT /api/workspace/active` now announces a CONFIRMED switch through a new
fire-and-forget `onWorkspaceSwitched` seam, and the daemon warms the two faces
from it. The announcement deliberately does not fire for a rejected or
rolled-back switch: warming there would refill the caches against a workspace
this daemon just refused to move to.

Warming goes through the cache functions, never their underlying fetchers.
`createSwrCache`'s read returns `entry.inflight ?? refresh(key)`, so a warm that
races the very consumer it is protecting joins that consumer's request instead
of issuing a second one. No request is added that would not otherwise be made.

Second half, same root cause: on hub reconnect the gap-closing catch-up read the
team-project and member lists THROUGH those same caches, so an entry that
settled just before (or during) the disconnect still counted as fresh, the diff
concluded "nothing changed", and the one cycle meant to close the gap emitted
nothing — the client stayed stale until an unrelated read happened to miss.
Reconnect now drops those two entries first, and `pollOnce` joins the in-flight
refresh that starts, so the pair still costs one fetch per face.

Co-authored-by: lefarcen <ontf116@gmail.com>

* fix(daemon): split AMR workspace proof failure from proof refusal (#6221)

An AMR run died in preflight with "its persisted workspace <id> is
unavailable for the signed-in member". Two causally unrelated situations
shared that one error and one outcome: a directory that answered and
genuinely listed no active membership, and a directory that could not be
read at all. The `.catch` swallowed a read failure into
`{ ok: false, items: [] }`, so a transient vela blip made a user's own
project unrunnable — and nothing downstream could tell the two apart.

Refusal now fails closed under `ProjectWorkspaceScopeRefusedError`
(`PROJECT_WORKSPACE_SCOPE_REFUSED`); an unreadable directory is retried
with bounded backoff and, if it still cannot be reached, the run proceeds
with no workspace, i.e. on the caller's own account wallet. That is a
product decision recorded in the docblock: a hard failure is worse than a
mis-attributed personal charge. The reverse direction, charging a personal
run to a team wallet, stays impossible — no fallback selects another
workspace, and a proven non-membership still refuses.

The decision was also invisible: a full diagnostics export carried no
directory-fetch line, no run-id in the daemon log, and only a generic
AGENT_EXECUTION_FAILED plus prose. Every branch now reports a closed-union
outcome to the daemon log, the run record (via `ApiError.details`, leaving
the wire error code unchanged) and telemetry.

* fix(comments): preserve rejected mutations

* fix(comments): preserve partial send results

* fix(web): keep workspace-scoped data fresh across account and workspace changes (#6225)

* fix(web): keep workspace-scoped data fresh across account and workspace changes

Three defects in one family — workspace-scoped data in the web client going
stale or being read under the wrong identity.

1. The workspace switcher's module-level directory cache had no invalidation.
   `/api/workspace/directory` answers "which workspaces can the SIGNED-IN
   ACCOUNT see", but nothing cleared the cache on an account change and
   `resetWorkspaceDirectoryCache` had no production caller — so signing in as a
   different account kept the previous account's workspace names on screen, and
   kept them confidently, since a non-empty cache also suppresses the loading
   state. The cache is now served only to an identity that can claim it, by
   membership id, which keeps the flash-free reopen it exists for. The
   `coalescedGet` key is identity-scoped for the same reason.

2. App.tsx read `/api/skills` without the caller's workspace headers and never
   refetched on a workspace switch. The daemon is fail-closed on a missing
   `x-od-workspace-id`, so a headerless read is not the unfiltered list — it is
   the list with every workspace-claimed skill removed, including the ones
   claimed by the workspace the user is in. Skills now get the identity headers
   and the same workspace-keyed refresh design systems already had. Launch still
   spends exactly one `/api/skills` request: the read moved from the boot pass to
   the workspace-keyed effect rather than being added alongside it.

3. `PUT /api/workspace/active` already returns the post-switch context, from the
   same producer `GET /api/workspace/context` serves and only after the daemon
   agrees the switch happened. The client parsed that body, discarded it, and
   made every consumer re-read the same data. The shell is now seeded from the
   response; the refresh broadcast still fires, because
   `useProjectWorkspaceScope` listens to it and passive tabs cannot be seeded.
   Switch path: 2 requests -> 1.

* fix(web): discard workspace-scoped reads that land after the identity moved

Review follow-up on #6225 (PerishCode, two threads — both real).

Keying a read by identity stops the wrong identity being SERVED a cached
answer. It does nothing about the other direction: a read issued for identity A
resolves later, and by then the caller may be identity B. Committing that late
answer restores A's data under B — the same staleness this PR removes, arriving
through the back door. A workspace switch is precisely when one read is in
flight and another starts, so reverse-order completion is reachable, not exotic.

Both sites now use one mechanism, `beginWorkspaceScopedRead`: capture the
identity the read is issued FOR, request with that captured context, and compare
against a ref before any commit. Its docblock states the invariant and the two
rules that make it hold (request with the captured context so request and guard
cannot disagree; compare against a ref, never a closed-over prop, which would
always match and guard nothing). It is the cross-component form of the
`requestEpochRef` ordering guard `useWorkspaceContext` already applies to its
own read.

- `refreshSkills` and `handleSkillsChanged` (App.tsx): a slow read for the
  workspace the user left no longer overwrites the current catalog. The
  registry-ready gate is intentionally skipped on a discarded response — it is
  not an answer about the current identity, and the newer read marks it.
- `loadWorkspaceDirectory` (EntryNavRail.tsx): an in-flight account-A read that
  lands after the rail moved to account B no longer repopulates either the
  module cache or the visible list. This was the sharper of the two, because it
  wrote into module scope and so persisted past the render that caused it.

Request counts are unchanged (startup 4, switch 1): the guard discards
responses, it does not issue requests.

* fix(web): make computing a workspace identity total, and key the skills trigger on it

Fixes the `Web workspace tests` failure 886f11b24 introduced. CI reported all
555 files and 5583 tests PASSING and still exited 1:

  Unhandled Rejection
  TypeError: Cannot read properties of undefined (reading 'canShareProjects')
   ❯ workspaceIdentityCacheKey src/collab/useWorkspaceContext.ts:112:32
   ❯ beginWorkspaceScopedRead src/collab/useWorkspaceContext.ts:159:20
   ❯ src/App.tsx:1770  ❯ src/App.tsx:1799  ❯ commitHookEffectListMount
  originating in tests/components/App.amr-plan-tier.test.tsx

`workspaceIdentityCacheKey` dereferenced `context.permissions` unguarded. That
survived while it was only called during render (a throw there fails loudly, in
the right place) and inside `fetchSkills`'s own try/catch. 886f11b24 put it
behind `beginWorkspaceScopedRead` in an async function launched with
`void refreshSkills()`, so a partial context turned a synchronous programming
error into an unhandled rejection — invisible to every assertion, fatal to the
process.

`permissions` is required on the contract, but API JSON reaches these objects
through an unchecked cast, so a partial context is reachable at runtime and not
only in fixtures. Computing an identity must therefore be TOTAL: a partial
context is its own cache partition, never an exception.

Second, independent defect, found while fixing the first: the skills trigger was
keyed on `workspaceId` while the commit guard compares the full identity digest.
A guard that discards without guaranteeing a successor is worse than the
staleness it replaced — two accounts in one shared team workspace differ only by
membership, so account A's pending read would be discarded for B while the
unchanged workspace id suppressed B's replacement read, leaving the functional
registry loading forever. The trigger now uses the same digest the guard
compares, so every transition that invalidates a response also starts its
successor.

Both are covered by tests that go red without the fix:
- tests/collab/workspace-identity-key-is-total.test.ts (new) — identity is total
  for null, partial and complete contexts, including a late commit-time guard.
- tests/components/App.skills-workspace-scope.test.tsx — a member-id-only change
  must start AND complete a successor read.

---------

Co-authored-by: lefarcen <ontf116@gmail.com>

* feat(landing): publish current pricing snapshot

* fix: durably deliver enterprise leads

* test(web): update ProjectView comment-status suites to the workspace-scoped signature (#6233)

`feat/workspace-team`'s `Web workspace tests` lane is red at the branch tip: 3
files / 4 tests, all comment-status assertions. Not a flake and not attributable
to any open PR — I had to prove twice while landing #6225 that a red lane was
inherited rather than mine, which is the cost a red base imposes on every
downstream PR.

Cause: `1c15574c2 fix(comments): preserve rejected mutations` gave
`patchPreviewCommentStatus` a fifth argument — the acting workspace context — so
the daemon can authorize the mutation (`routes/project/comments.ts`, same
commit). It updated `FileViewer.test.tsx` and added `registry.test.ts` coverage
but left three `ProjectView.*` suites on the four-argument signature.

The tests were stale, not the code. Checked before editing, because "update the
tests" is the wrong move if the assertions were right:

- Every asserted call still happens, unchanged. The failures are pure arity
  mismatches: `api-empty-response` expects `(…, 'failed')` and receives
  `(…, 'failed', null)`; likewise `needs_review` in run-cleanup and `applying`
  in run-isolation. No status transition changed.
- `1c15574c2` shipped the new contract WITH tests —
  `describe('preview comment scoped mutations')` asserting `x-od-workspace-id`
  reaches the PATCH. The fifth argument is deliberate and covered.
- The parameter is optional and additive; headers are spread only when a
  context is present.

Also restores two assertions that had gone silently vacuous. A four-argument
matcher cannot match a five-argument call, so `run-isolation`'s two
`not.toHaveBeenCalledWith` assertions could never match once the signature grew
and passed unconditionally — coverage actively lost, not merely failing. Both
pass with the correct arity, which independently confirms the new code does not
write `needs_review` after a canceled+done run and does not reset a queued
send's own comment to `open`.

The fifth argument is matched as an explicit `null` rather than
`expect.anything()`, which rejects null/undefined and would have re-broken the
positive assertions and re-vacuated the negative ones.

Tests only — no source file is touched.

Co-authored-by: lefarcen <ontf116@gmail.com>

* fix(collab): make the local pin the only workspace-selection truth (#6230)

* test(collab): pin the one-account-two-clients workspace scope behaviour

Executable evidence for the account-level Active Workspace investigation.
Models vela exactly as its source behaves at origin/main 2155a969f:

- active_workspace_selections is keyed by app_user_id alone
  (db/schema/public.hcl:792), so an account has exactly ONE selection.
- GET /api/v1/workspaces/current discards a ?workspaceId= hint
  (services/api/src/workspaces/routes.ts:166-176, and vela asserts it by
  name in services/api/test/workspaces-personal.test.ts:480).
- GET /api/v1/workspaces is scoped by app_user_id, not by the selection.

What it establishes: OD's local pin DOES hold against a competing client
(resolvePinnedWorkspace), so the switch does not 404. The damage is that
the losing client's context degrades to a directory synthesis - planId
null and a 0/0 seat summary, which derives isSeatFull: true - and its
context read gains a second, fallible round-trip the winning client
never makes.

No behaviour change.

* fix(collab): make the local pin the only workspace-selection truth

Picking a workspace is a local action, but `PUT /api/workspace/active`
gated it on mutating ACCOUNT-scoped state in vela: it PUT the account-level
active workspace first and failed the user's click with 502
`workspace_switch_rejected` when that write did not take, then rolled back
both the local pin and the backend switch if the follow-up context read
disagreed.

That backend row is keyed by app user alone, so it can only ever name ONE
workspace for an account. One account routinely runs several clients in
different workspaces, so every switch yanked the other clients' server-side
scope, and whichever client did not write the row last was answered for the
wrong workspace — forcing OD to synthesize its context from the membership
directory, which carries no billing plane. That client saw planId null and a
0/0 seat summary, and a 0/0 summary derives isSeatFull: true, so a workspace
with free seats read as FULL and blocked inviting. It also turned that
client's context read into two round-trips, the second of which could blip
and leave it with no context at all.

vela #1179 (1ba3eebcb, deployed to test 09:57Z) taught
`GET /api/v1/workspaces/current` to honour `x-vela-workspace-id`, the
per-request workspace scope vela already accepted on its resource plane,
billing scope routes and the Link gateway. So:

- `fetchCurrent` sends that header and drops the `?workspaceId=` query
  param, which vela ignores by design and asserts it ignores — it only ever
  made this call look scoped.
- `PUT /api/workspace/active` no longer calls vela. The membership
  directory it already reads is the authorization; once that confirms an
  active membership the switch cannot fail, so the 502 gate and the
  rollback are gone along with `selectWorkspace` and `putCurrentWorkspace`.
- An unconfirmed context read is no longer treated as a refusal: the route
  answers from the directory entry it already validated instead of
  reverting the user's choice, mirroring the invariant
  `resolvePinnedWorkspace` already follows in the same module.

Verified against the deployed test backend, GETs only: an explicit
workspace returns that workspace (not the account row), a workspace the
caller is not a member of is refused 404 `workspace_member_required`, and
both an absent and a blank header keep the previous account-level
behaviour. The URL query hint is still ignored.

vela's `active_workspace_selections` table and its write path are left
alone; deprecating it is a separate decision and #1179 kept omitting the
header backward compatible.

Two comments that asserted the now-false invariant are corrected.

* fix(collab): never answer a switch with a workspace the pin no longer holds

Review catch (mrcfps): `workspaceContext.current()` is not a passive read.
The vela provider's `resolvePinnedWorkspace` does its OWN fresh directory
lookup and, when that confirms the pinned workspace is gone, clears the pin
and re-pins a recovery workspace so a removed member is not locked out of
everything. The route's directory read can meanwhile have been served from
its 5s cache and still list that workspace.

The previous branch state read `context.workspaceId !== workspaceId` and
treated the provider's recovery as a merely-unconfirmed read, synthesized the
stale cached entry, and returned 200 claiming the requested workspace while
`activeWorkspace.get()` had already become the fallback. That shipped this
PR's own thesis — the local pin is the only selection truth — as a violable
invariant, and the user-visible symptom is the switch appearing to succeed
and then snapping back on the next context poll.

The pin is now re-read AFTER `current()` resolves. If the provider moved it,
the route reports `404 workspace_no_longer_available` for the workspace the
user asked for, never claims it, and does not warm its caches. The recovery
pin is deliberately left in place: reverting it would restore the very
"signed out of every workspace" state it exists to prevent.

The directory entry that authorizes the switch is also now required to be a
LIVE membership (`memberStatus === 'active'`, lifecycle not deleted) — the
same predicate the provider uses. Matching on id alone let a listed-but-
removed membership authorize a switch AND get synthesized into the response.

Tests wire the real provider against the production cached directory fetcher
over one shared pin store, exactly as server.ts does, so the cached and fresh
reads genuinely disagree. Disabling the new pin re-read turns "never claims a
workspace the pin no longer holds" red and nothing else.

---------

Co-authored-by: lefarcen <ontf116@gmail.com>

* fix landing pricing controls

* fix(daemon): resolve the AMR scope-telemetry app version in its own scope (#6240)

Every AMR run in 0.16.2-beta.148 died at spawn with
`spawn failed: appVersionForCapture is not defined`.

`appVersionForCapture` is a local of
`createFinalizedMessageTelemetryReporter`. #6221's
`amr_workspace_scope_resolved` capture called it from `startChatRun`,
~7,500 lines and one function away, so the name resolved to nothing.
The callback runs while building the agent's launch env, inside the
spawn `try`, so the ReferenceError surfaced as the generic
AGENT_EXECUTION_FAILED spawn failure for 100% of AMR runs.

`design.getAppVersion` is the accessor that scope actually has, and it
is the same one the unreachable helper falls back to, so the reported
value is unchanged.

A scope guard covers the class rather than the instance: it runs the
checker over `server.ts` with `@ts-nocheck` stripped and fails on any
identifier that reaches runtime unbound. It flagged one sibling, fixed
here too — the critique orchestrator's `skill` label read
`effectiveSkillId`, a local of `composeDaemonSystemPrompt`. A `typeof`
guard on an undeclared name does not throw, so instead of crashing that
label has silently been `undefined` for every run since #1485.

`@ts-nocheck` on `server.ts` is the enabling condition for both: an
undefined identifier in this file reaches users without any tool
objecting. Removing it is a separate project — 29 daemon files carry it.

* fix(web): isolate workspace identity snapshots

* test(e2e): move the server.ts identifier-scope guard into the merge gate (#6244)

`apps/daemon/tests/server-identifier-scope.test.ts` landed with the
0.16.2-beta.148 AMR spawn fix (#6240) but protected nothing: `ci.yml`'s
"Daemon workspace tests" step runs exactly one file
(`tests/project-watchers.test.ts`), so no other daemon suite executes in
any workflow. The guard sat outside every merge-gate lane.

Widening the daemon lane is not available as a local fix — that exact
command string is pinned by `workflowRunsOnlyAllowedDaemonTest` in
`scripts/check-certain-exempt-consumption.ts`, which conditions the
`trae-cli.test.ts` docs-consumption exception on the lane running nothing
else. Touching it is a coupled scope-policy change.

So host the guard where a lane already runs and the wiring is itself
guarded:

- Any change under `apps/daemon/src/` matches `certain-daemon-core`
  (`scripts/scopes.ts`), whose effects include
  `ui_p0_validation_required`; `run_e2e_vitest` is
  `isFull || web_tests_required || ui_p0_validation_required`. A
  `server.ts`-only change therefore arms `E2E Vitest` even at the merge
  queue's `certain` threshold, and an unresolved file list escalates
  fail-closed to full.
- The `daemon core boundary` guard (`scripts/lib/guard/scope.ts`) asserts
  `ci.yml` still contains both `run_e2e_vitest == 'true'` and
  `pnpm --filter @open-design/e2e test`, and runs in the always-on policy
  floor — so the premise cannot rot silently.

The check only ever reads `server.ts` as text (no daemon boot, no
fixtures), and reading another app's tree as a repository resource is
what root `AGENTS.md` sends to `e2e/tests/` — the same reason
`e2e/tests/critique-coverage.test.ts` lives there. Behavior is
unchanged, including both self-checks that keep the guard from going
vacuous.

Verified red on the pre-#6240 `server.ts`: exit 1 naming
`appVersionForCapture (line 9261)` and `effectiveSkillId (lines
9657/9658)`; green (exit 0) on the fixed file.

* fix(collab): align unbound project mutation identity (#6231)

* fix(web): name the caller when a run starts before its project scope resolves

Sending the 「水面焦散」 example prompt from Home failed instantly on a team
workspace:

    daemon 401: {"error":{"code":"WORKSPACE_CONTEXT_REQUIRED","message":"workspace context is required"}}

with `run_id: n/a`, because the refusal lands before the run is created. That
string is produced in exactly one place in the repo — the `!createResp.ok`
branch of the `POST /api/runs` create fetch in `providers/daemon.ts` — so the
refused call is run creation, gated by `enforceWorkspaceResourceMutation
('project', …)`.

The cause is client-side. Home creates the project WITH the caller's workspace
headers, so #6201 binds it to the team workspace; the follow-on auto-send then
went out with NO headers, because it took its identity from the PROJECT's
workspace scope — an async read still in flight when the auto-send fires (its
gate is `messagesInitialized` + `activeConversationId`, never the scope). Every
other project write in `ProjectView` (`patchProject`, `uploadProjectFiles`, the
comment mutations) already asserts the caller's own context; run creation was
the one write that did not.

INVARIANT, now named in `runWorkspaceIdentity`: a send names its caller while
the project's binding is still UNREAD, and never overrides an answer the daemon
actually gave. Deliberately not a blanket `?? caller` — every answered or failed
state keeps asserting nothing, because for them a headerless request is judged
on daemon-owned state while an asserted one is judged on client-controlled
headers:

  - `unbound` — `headerlessMutationAllowed` short-circuits on "no row anywhere"
    BEFORE asking for an identity, so asserting would turn a working 200 into a
    403 and break 「未登录也可以用自己 cli 修改未登录态下的那些 project」.
  - `unavailable` — the daemon's `resolveProjectWorkspaceScope` returns this
    both for an unreadable directory AND for a directory that answered with no
    active membership, i.e. a proven "you were removed".
  - a FAILED scope read — this identity also picks the wallet the AMR pre-run
    balance gate prices against, and guessing the caller's team wallet with the
    binding unknown blocks a send the daemon would have answered (the dead
    button 21f452ffe undid).

Second consequence of the same null, fixed by the same one-liner: with no
resolved scope, `checkAmrBalanceGate` was called with `undefined` and priced the
run against the ACCOUNT wallet instead of the team's. The red spec observes
that directly rather than arguing it.

`ProjectView.run-isolation.test.tsx` mocks `useProjectWorkspaceScope` with a
hand-written factory that enumerates every export, so the new export is added
there too — without it, adding one export breaks all 64 tests in that file with
"not a function" at render.

* fix(web): narrow the run-identity fallback to a project's first unread binding

Review catch (nettee): keying the fallback on `loading` served two opposite
situations, because `useProjectWorkspaceScope`'s trailing transition guard
re-enters `{ loading: true, scope: null }` whenever the resolved caller identity
stops matching — and every workspace switch does that:

  - first read, nothing known    -> the caller is the only identity available
  - revalidating a KNOWN binding -> the caller's current workspace is a guess
    that contradicts an answer we already have

So a send issued during a workspace switch asserted the workspace the user had
just moved TO for a project still bound to the one they moved FROM. Both
consumers break, and differently: `POST /api/runs` can 403 because
`enforceWorkspaceResourceMutation` still finds the project row under the old
workspace, and `checkAmrBalanceGate` preflights the new workspace's wallet —
worse than the `undefined` this fix was written to remove, because a wrong
wallet looks like a real answer.

`ProjectWorkspaceScopeState` now carries `initialLoadPending`, reported by the
hook rather than inferred from `loading`: true from mount until the first settled
answer (a scope OR a failure) for the current `projectId`, false for that
project's whole lifetime afterwards, and reset when `projectId` changes because a
different project genuinely has not been read. `runWorkspaceIdentity` keys the
fallback on that bit alone, and its docblock names which of the two loading
reasons it serves so the distinction cannot erode.

The field is required, not optional, so every construction site has to state
which case it is. Two pre-existing assertions in
`useProjectWorkspaceScope.test.tsx` now document the distinction they always
exercised: a project A->B rerender reports pending (B is unread), while a
same-project identity change reports not-pending — the exact window this fixes.

* fix(collab): align unbound project mutation identity

Allow an identified caller to mutate a project that no workspace has claimed, matching the existing headerless and unbound-resource behavior without persisting a new binding. Name the caller on Home auto-send while keeping AMR preflight scope derivation unchanged.

* fix(web): refresh the settled run billing scope

Track the project billing context in both callbacks that read it so an auto-send after scope settlement cannot preflight through a stale unscoped closure. Add a regression whose run identity stays referentially stable while the billing scope changes.

* fix(web): stop asserting disproved run scope

* fix(web): guard workspace-scoped catalog reads (#6249)

* fix(web): guard workspace-scoped catalog reads

* fix(web): guard remaining plugin catalog races

* fix(web): align design catalog guard scope

* fix(landing): show introductory team totals

* fix(daemon): label critique with project skill (#6251)

* fix CI scopes across file renames (#6252)

* fix(ci): preserve scopes across renames

* fix merge queue rename ceiling detection

* test raw merge compare responses

* fix(landing): make contact sales KV canonical

* fix(web): preserve Cloud sign-in during outages (#6274)

* fix(collab): allow shared project viewer comments

* fix(web): keep readonly comment creation available

* feat(web): personal/team project labels + collapse chat for shared non-owners (#6294)

* feat(web): rename sidebar project lists and collapse chat for shared non-owners

Workspace home left rail now labels personal vs team project lists
("Personal projects" / "Team projects", 个人项目 / 团队项目) across all
locales. Opening a confirmed shared team project that the viewer did not
create defaults the chat pane to collapsed so the design is primary;
owners and personal projects keep chat open, and expanding chat stays
sticky for the visit.

* fix(web): use effective ownership for shared non-owner chat default

Default-collapsed chat was latching on raw isOwner, so a catalog-confirmed
owner whose collab status was still missing ownerMemberId looked like a
non-owner and permanently collapsed chat. Expose isEffectiveOwner and
isSharedNonOwner from useProjectCollab (catalog + session-created + status)
and gate the sticky collapse path on the confirmed non-owner signal only.

* fix(web): stop narrow chat error cards stacking titles vertically (#6295)

Move the long Open Design Cloud switch CTA into UserActionCard footer
actions (same shell as run-recovery), add a card-width container query so
head actions reflow when ChatPane is narrow, and soften title wrapping so
CJK copy no longer renders one character per line.

* fix(workspace): make resource authority explicit (#6287)

* fix: make workspace authority explicit across resources

* fix(web): keep new team projects writable during scope load

* fix: close workspace authority review gaps

* fix(web): retry rejected project auto-send

* fix: propagate workspace scope to project consumers

* fix: cache project read workspace authority

* fix(web): stabilize project scope revalidation

* fix: cache project collaboration status authority

* fix: cache collaboration status owner reads

* fix: revalidate media wait tool authority

* fix: rehome member comments on local threads

* fix(web): stabilize project resource authorization

* fix(web): prioritize settled project authority

* fix(web): consume queued home auto-send handoffs

* fix: revalidate dirty comment pulls

* fix: isolate mutation authority reads by session

* fix: cache project presence read hot path

* fix(web): stop unauthorized project background writes

* fix(web): require settled scope for project background work

* fix(web): scope remaining project reads by workspace

* fix(web): scope remaining project resource consumers

* fix(web): partition project caches by full authority

* fix(daemon): pin run workspace billing scope

* fix(daemon): lease workspace read authority

* fix(daemon): keep presence reads on the hot path

* fix(web): refresh deck thumbnails across workspace scope

* fix(daemon): revoke stale unshared team mirrors

* perf(daemon): cache stale mirror read guards

* fix(web): gate deep-link reads on project workspace

* fix(web): keep deep-linked project tab scoped

* fix(web): preserve project routes during onboarding

* fix(web): keep project previews warm across file-list toggles

* perf(daemon): refresh presence without cold reads

* fix(web): keep retained preview iframes connected

* fix(web): reuse exact HTML thumbnail sources

* fix(web): settle read-only empty project chat

* fix(daemon): scope CLI run transports by workspace

* test(web): align preview cache refresh semantics

* fix(web): require positive shared viewer ownership proof

* fix preview auto-fit measurement feedback

* preserve preview fit during annotation freezes

* preserve preview measurement semantics on refresh

* stabilize preview overflow measurement test

* test design systems move exclusively to team

* fix design systems move exclusively to team

* test shared project title scope convergence

* test(daemon): cover project command workspace scope

* fix(daemon): scope project command transports

* fix shared title test project state type

* fix(daemon): preserve shared project catalog title

* fix(daemon): keep foreign mirrors creator-unattributed

* fix(web): recover first shared project conversations

* fix(web): fence conversation recovery generations

* test(web): verify conversation recovery cleanup fence

* UI polish batch: cloud sign-in, onboarding, composer menu (#6281)

* UI polish batch: cloud sign-in, onboarding, composer menu

Sign-in surfaces (rail callout, balance-gate dialog, onboarding) had
drifted from the visual system and lost some redundant chrome; this
batch tightens them up alongside a couple of composer/design-files
reorganizations that were sitting in the same working tree:

- Rail's signed-out callout: reorder copy above the login pill, give
  the pill an icon + accent-green label, restore the Escape hatch
  the pill had before it lost its close affordance.
- AmrBalanceDialog (insufficient-balance / signed-out gate): add a
  full-bleed art banner above the copy, drop the now-redundant icon
  badge, tighten spacing/shadows to match.
- Onboarding cloud landing: switch from a single centered column to
  a two-column layout (form pane + full-bleed art panel), move the
  language switcher into the footer, swap the local-CLI/BYOK links
  for icon Buttons, and add a dismiss control to the activation-retry
  hint.
- Composer "+" menu: fold 插件/设计百宝箱 back in as hover-expand
  submenu rows instead of standalone quick pills above the input.
- Design files panel: move new-document/upload actions out of the
  always-visible toolbar into the empty-state actions only.
- Message center: drop the redundant header close button (backdrop
  click and Escape still dismiss it) and restyle the list from
  bordered cards to a divider-separated list.
- New `key` / `robot` / `translate` / `arrow-right` icons; language
  menu switches from the `languages` glyph to `translate`.
- Tab launcher popover reverts to the glass material; language
  dropdown gets a max-height + scroll so long lists don't blow out
  the popover.

Rebased the composer-quick-pill removal onto this branch's own
accessibility work on those pills (opener-based focus return via
`ComposerStandalonePanel`) rather than reverting it — the "next step"
card still drives the same standalone-panel API, just without a pill
to hand focus back to. Also updated/removed the tests that pinned the
now-removed surfaces (quick-pill popup contract, message-center close
button, onboarding top bar, design-files toolbar upload trigger) so
none of this lands with a newly-red suite.

* perf(web): optimize onboarding cloud artwork

* fix(web): invalidate project file reads after mutations

* fix(web): disable readonly project starter mutations

* fix(web): preserve focus for standalone composer panels

* test(e2e): require explicit workspace scope

* fix(web): invalidate file reads on project events

* test(e2e): seed explicit settings workspace

* test(e2e): align manual edit with current inspector

* fix(web): refresh deployments when share opens

* test(e2e): follow composer design system entry

* fix(web): keep local project creation workspace-independent

* fix(web): preserve scoped auth recovery state

* test(e2e): follow current entry settings flows

* fix(web): preserve AMR retry across settings

* test(e2e): model explicit personal workspace recovery

* test(e2e): stabilize AMR logout settings recovery

* fix(projects): return workspace scope on create

---------

Co-authored-by: lefarcen <935902669@qq.com>

* fix(workspace): preserve scoped projects after main sync (#6320)

* fix(daemon): surface AMR OpenCode stall context (#6040)

* fix(daemon): surface AMR OpenCode stall context

* fix(daemon): accept runtime-omitted OpenCode diagnostics

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

* fix(ci): make static gate runner-independent (#6039)

---------

Co-authored-by: PerishFire <39043006+PerishCode@users.noreply.github.com>

* test(e2e): parallelize restoration group (#6169)

* test(e2e): parallelize restoration group

* test(e2e): identify conversations from persisted prompts

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

* test(e2e): wait for conversation persistence

---------

Co-authored-by: Looper <looper@noreply.github.com>

* feat(landing-page): sharpen the Codex agent page for "codex ui" intent (#6200)

The /agents/codex-design/ page ranks #1 for "codex design" but only ~6th
with a 1.3% CTR for "codex ui", a query that grew from 30 to ~90 daily
impressions this month. A live SERP check shows the intent is mostly
"build UI with Codex" (our intent), with a minority looking for the Codex
app's own interface.

- Retitle to "Codex Design: Build UI with OpenAI Codex" (title + H1).
- Lead the frontend section with the query phrase and an answer-shaped
  opening paragraph, so the section is extractable for AI answers.
- Add a disambiguation paragraph separating the Codex app's own UI from
  the UI you build with Codex, and route the former back to the main line.
- Add two FAQ entries covering both readings; they also feed the FAQPage
  schema, which already earns a text snippet on this query.

Applied across all 11 shipped locales (en, zh authored; the other nine
translated with the page's terminology preserved).

Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>

* Add Clone Audit plugin (#5687)

* Add Clone Audit plugin

* Update open-design.json

* fix: make clone audit skill reusable

* fix: sanitize clone audit provenance path

* fix(e2e): bound tools-dev runtime lifecycle (#6204)

* test(e2e): refresh full-pool UI fixtures (#6211)

* fix(web): allow skipping required chat questions (#6177)

* fix(web): allow skipping required chat questions

* fix(web): auto-skip unanswered chat questions

---------

Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>

* docs(tools-pack): add build cache contract (#6207)

* docs(tools-pack): add build cache contract

Document the tools-pack build-graph cache as a contract: the exact-match
acceptance model, determinant rules, materialization-time parameters, the
signing boundary, and fail-closed confidence grading.

The rules come from an audit of all 11 cache nodes. Two are written down
because nothing currently enforces them: a node key must carry the key of
every upstream node it consumes, and a node key must not restate a list that
already exists as a constant.

Known low-confidence points are declared explicitly so fail-closed grading
stays checkable rather than becoming a verbal convention.

* docs(tools-pack): clarify cache materialization exceptions

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

* docs(tools-pack): document NSIS base version scope

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

---------

Co-authored-by: Looper <looper@noreply.github.com>

* docs(landing-page): add 0.16.0 release post, localize and re-cover 0.15.1 (#6236)

* docs(landing-page): localize 0.15.1 post, fix its cover, add 0.16.0 release post

The 0.15.1 blog post shipped English-only and with an off-brand cover: a
Fauvist oil painting overlaid with large display type, which reads nothing
like the warm editorial illustrations every other release post uses. It also
pointed `socialImage` at a 309KB JPEG while a WebP sat unused beside it.

Regenerate the cover in the established house style (cream paper ground,
sage/terracotta watercolour washes, pencil sketch resolving into a calm
product surface), drop the stray JPEG, and add the full ten-locale i18n block
so /zh/, /ja/, /ko/, /de/, /fr/, /ru/, /es/, /pt-br/, /it/ and /tr/ read in
their own language instead of falling back to English.

Also add the missing 0.16.0 "Reliable Delivery" post with a cover plus two
inline figures in the same style, localized across the same ten locales. The
0.16.1 patch is a single preview fix, too thin to carry its own page, so it
lands as a closing section of the 0.16.0 post and the download CTA points
there.

* docs(landing-page): fix doubled apostrophes and locale copy in release posts

Three defects surfaced reviewing the localized bodies.

The French, Italian and Turkish bodies rendered every apostrophe twice —
"aujourd''hui", "all''interno", "0.16.1''i". The `''` escape belongs to
single-quoted YAML scalars, but `bodyHtml` is a block scalar, where content is
literal. 173 occurrences across the two posts.

Korean used Japanese corner brackets and Brazilian Portuguese and Turkish used
French guillemets. All three take curly double quotes.

The Chinese copy read as translation rather than prose. "标签" opens the release
line as a UI label, not a version tag; "抢画框", "编辑策展位" and "安静修复" are
literal renderings that say nothing in Chinese; "画廊" is not the term the
product itself uses for a gallery, which keeps the English word. The tag fix
also lands on 0.10.0 through 0.15.0, which share the phrasing — leaving the
series half-corrected would read worse than either state alone.

* docs(landing-page): drop the leading tag word from release post openings

Every release post opened with the word for a git tag before the version
identifier — "Tag open-design-v0.16.0", "Etiqueta …", "タグ …", and in Chinese
"标签", which reads as a UI label rather than a version. The word carries no
information the identifier does not already carry, and in several languages it
lands as a category noun the sentence never uses again.

Drop it across all nine release posts and every locale, in both the summary and
the opening line, leaving the version identifier to open the dateline. Spanish
and Brazilian Portuguese lose the feminine agreement that referred back to the
removed noun; Turkish loses the trailing "etiketi" apposition for the same
reason.

* docs(landing-page): correct the 0.16.1 interval in the 0.16.0 post

0.16.0 was published 2026-07-22 14:42 UTC and 0.16.1 on 2026-07-23 16:11 UTC —
25.5 hours apart. "Two days later" overstated it, and the same wording had been
carried into all ten localized bodies, so every published route repeated it.

---------

Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>

* feat(integrations): add the local Open Design Cloud flow for Codex (#6055)

* feat(daemon): expose Vela login through CLI and MCP

* feat(mcp): add interactive local brief card

* feat(packaged): bootstrap local MCP headlessly

* feat(daemon): secure local BYOK credential profiles

* feat(cli): manage secure BYOK profiles

* feat(web): store BYOK keys in secure profiles

* fix(runs): deduplicate cloud generation requests

* feat(daemon): add end-to-end plugin observability

* fix(pack): include native PTY runtime in desktop packages

Lazy-load node-pty in the daemon so missing native binaries only
disable Terminal and interactive Keychain operations instead of
crashing startup.

Ship and validate target prebuilds in macOS and Windows packages.
Repair the macOS spawn helper before signing, and invalidate stale
Windows packaging caches.

Cover the packaged PTY path in macOS and Windows smoke tests. Require
Vela CLI for every beta desktop build target.

* fix(mcp): localize and stabilize plugin briefs

Keep collect and confirm on one MCP Apps card, report intrinsic size changes,
and remove the fragile widget-state fallback.

Localize brief forms, summaries, status, and public errors from the current
request or Host locale while preserving stable protocol ids and selections.

Keep internal runtime identifiers out of user-facing MCP guidance.

* fix(packaged): register live Studio URLs with daemon

Register the packaged web sidecar loopback origin over namespace-scoped daemon
IPC after dynamic binding.

Invalidate install-info by the live web port and remove the second MCP URL cache
so run and project responses use the current Studio address.

Validate loopback origins and cover registration, rebind, and cache refresh.

* fix(mcp): stabilize brief confirmation lifecycle

Keep confirmed briefs locked after Host publication failures and retry only the cached follow-up message instead of confirming twice.

Replace blank initialization with a compact loading state, accept immediate and delayed tool results, and bump the MCP Apps resource to v3 so Codex does not reuse the stale card.

Add an executable JSDOM Host bridge harness covering double-submit, publication retry, delayed results, and repeated draft notifications. Validated with the full daemon suite, workspace typecheck, and guard.

* fix(mcp): prevent brief app resize feedback loops

Replace the self-observed intrinsic-height loop with deduplicated body measurements so Codex MCP app iframes do not repeatedly resize themselves and fail sandbox startup.

Bump the brief resource URI to v4 to avoid cached v3 cards and cover the regression with the focused brief-app spec. Validated with daemon tests/build, workspace guard/typecheck, a verified beta DMG, and a direct packaged MCP resource smoke.

* fix(mcp): use the final Open Design plugin identity

Make open-design the only accepted external plugin ID across MCP tools, run attribution, artifact version origins, and telemetry. Rename the Brief MCP App resource to v5 and reject the unpublished old ID instead of aliasing it.

Validated with 171 daemon tests, 5 web tests, daemon/web typechecks, and guard.

* chore(pack): bump Vela CLI to 0.0.27

* chore(nix): refresh pnpm dependency hashes

* fix(mcp): honor host and plugin request contracts

Respect independently advertised MCP Apps capabilities and prefer the Codex-native follow-up bridge so confirmed briefs render as readable user turns instead of context-only messages. Bump the brief resource to v6 to invalidate stale cards.\n\nMark Vela login and account probes as open-world interactions, and require stable request IDs for attributed generation so lost responses cannot create a second logical run.

* fix(cli): support keyless BYOK profiles

Add an explicit --no-api-key save mode for local and self-hosted providers while keeping credential-bearing profiles stdin-only. Reject missing or conflicting credential modes and cover both paths in the CLI tests.

* fix(pack): replace headless owner when desktop opens

Advertise headless packaged runtimes as having no visible window, then shut them down before a normal desktop launch continues instead of treating SHOW as a successful focus.

Validated with the focused packaged headless and launcher lifecycle tests (20 passed).

* fix(pack): clean up failed headless startup

Acquire the headless identity, sidecars, and IPC server through one failure-atomic lifecycle so later bootstrap errors close every previously acquired owner in reverse order.

Cover MCP-install and web-identity publication failures; validated with 22 focused tests and the packaged typecheck.

* fix(byok): remove raw provider request contract

Expose only daemon-owned BYOK profile references from shared run types and the web transport, while retaining the daemon's fail-closed rejection for legacy or untyped raw credential payloads.

Validated with 76 web provider tests plus web and contracts typechecks.

* fix(daemon): preserve interrupted run idempotency

Hydrate queued or running durable requests as DAEMON_RESTARTED failures before idempotency reuse, preserving the original request and workflow mappings instead of creating a second cloud operation.

Share the restart transition with boot reconciliation and expose synthetic terminal events to reattaching clients. Validated with 48 focused tests and the daemon typecheck.

* fix(byok): roll back failed credential metadata writes

Snapshot secure-store values before mutation and compensate create, update, and delete operations when profile metadata cannot be persisted, preventing orphaned or lost credentials.

Validated with nine focused credential-service tests and the daemon typecheck.

* fix(web): reconcile selected BYOK profile metadata

Treat the daemon's selected secure profile as authoritative for protocol, endpoint, model, and API version so CLI profile edits cannot leave the browser showing stale execution settings.

Validated with 66 config tests and the web typecheck.

* fix(byok): migrate legacy credentials before cleanup

Import each supported legacy browser credential into a stable daemon-owned secure profile before removing plaintext copies. Keep the original browser record on secure-store or unsupported-provider failures and surface a Settings recovery path.

Cover successful migration, duplicate projections, and injected secure-backend failure while preserving existing Bedrock downgrade behavior in memory.

* fix(byok): secure Windows profiles with DPAPI

Dispatch native Windows BYOK profiles to a CurrentUser DPAPI backend rooted under the resolved daemon data directory. Secrets enter PowerShell only through stdin, encrypted blobs stay separate from non-secret profile metadata, and API responses remain secret-free.

Add platform dispatch coverage, a Windows-only real DPAPI round-trip test, and the packaged Windows smoke path for create, resolve, response-redaction, and delete behavior.

* fix(web): preserve config writes during hydration

* fix(byok): persist secure credentials across entry flows

* test(byok): cover secure onboarding persistence

---------

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>

* test: strengthen E2E coverage and fixtures (#6150)

* test: strengthen e2e coverage and fixtures

* test: allow artifact route when starting conversation

* test: keep e2e count assertions stable

Use the shared stable-count helper for no-request and duplicate-event windows so delayed async events cannot escape the assertions.

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

* fix: add e2e assertion import extension

Use the explicit runtime extension required by the e2e NodeNext typecheck.\n\nGenerated-By: looper 0.11.0 (runner=fixer, agent=codex)

* fix: preserve design system provenance and settle e2e assertions

Keep disabled design systems available to project recovery and provenance while filtering only runtime injection. Make hover and cancellation checks observe their full settled windows.

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

* test(e2e): preserve unique restoration run IDs

Pass the restoration fixture's run ID prefix through routeSuccessfulRuns so each mocked request receives a distinct run identity.

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

* test(e2e): stabilize agent and async count fixtures

Route restoration agent requests through the shared JSON and SSE fixture, capture autosave baselines after a quiet period, and observe onboarding cancellation for a bounded stable window.\n\nGenerated-By: looper 0.11.2 (runner=fixer, agent=codex)

* fix: honor disabled design systems in daemon runs

Filter disabled persisted project selections at prompt and analytics boundaries, and keep restoration run fixtures unique.\n\nGenerated-By: looper 0.11.2 (runner=fixer, agent=codex)

* test(e2e): shorten cancellation duplicate check

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

* fix(daemon): continue stalled post-tool sessions (#6237)

* fix(daemon): continue stalled post-tool sessions

* fix(daemon): separate post-tool continuation budget

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

* fix(daemon): preserve retry telemetry budget

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

* fix(daemon): align retry failure telemetry

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

* fix(tools-pack): carry workspace build key into tarballs (#6235)

* fix(amr): recover late login failures and trace auth stages (#5986)

* fix(amr): recover late login failures and trace auth stages

* fix(amr): preserve scoped cancel attempt IDs

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

* fix(amr): defer unsupported Vela auth stages

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

* fix(amr): narrow fallback telemetry to shipped Vela

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

* fix(amr): preserve live login after stale cancel

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

* fix(amr): preserve cancel during login startup

Retain cancellation intent when a provisional cancel races the delayed canonical login response, then cancel the canonical attempt before polling can begin.

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

* fix(amr): rejoin newer login after startup cancel

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

* fix(amr): preserve cancel across web login starts

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

* fix(amr): retain cancel when status refresh fails

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

* fix(amr): cancel onboarding status preflight

Treat cancellation before an auth attempt exists as a local preflight cancel so a delayed status response cannot start login.\n\nGenerated-By: looper 0.11.1 (runner=fixer, agent=codex)

* ci: constrain Actions cache writes (#6270)

* fix(tools-pack): carry workspace build key into tarballs

* ci: constrain Actions cache writes

* fix(daemon): auto-mint assistantMessageId when clients omit pin (#6107)

* fix(daemon): auto-mint assistantMessageId when API clients omit it

Web always supplies assistantMessageId. Headless/MCP/eval clients that
already send conversationId but omit the pin left lastMessageId null and
skipped multi-turn native session resume (missing_cursor). Mint the id
server-side whenever a conversation is bound and the client did not, and
seed the user message the same way the old MCP-only path did.

* fix(daemon): validate conversation ownership before omit-pin seed

Reorder project-ownership checks before minting assistantMessageId and
seeding the user prompt so cross-project omit-pin requests cannot corrupt
foreign chats. Make ChatRunCreateRequest.assistantMessageId optional and
expose conversationId/assistantMessageId on McpRunCreateRequest with
contract tests for the omit-pin shape.

Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)

* fix(daemon): reject missing conversation before omit-pin mint

Return CONVERSATION_NOT_FOUND when a client supplies a stale or mistyped
conversationId so omit-pin does not mint an unpersisted assistantMessageId
and plugin snapshot resolve does not FK-crash with a 500.

Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)

* fix(daemon): seed latest turn and fallback-bound user prompts

Prefer currentPrompt over full message transcripts when seeding omit-pin
user turns, and still seed after conversationId fallback when the client
already supplied assistantMessageId.

Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)

* fix(daemon): preserve attachments on omit-pin seeded user turns

Copy request attachments and commentAttachments into the server-seeded
user message so omit-pin/headless clients keep chips and annotation
context after conversation reload.

Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)

* fix(daemon): treat empty currentPrompt as authoritative omit-pin seed

Attachments-only ChatRequest turns send currentPrompt as '' while message
is the flattened transcript. Prefer any string currentPrompt, including
empty, so seeded user rows keep empty content and do not duplicate history.

Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)

* fix(daemon): seed turn metadata and bump project activity on omit-pin

Persist sessionMode, runContext, and appliedPluginSnapshot on omit-pin
seeded user turns so reload/retry keep applied context. Call updateProject
after seed so listProjects reorders headless/API activity like PUT /messages.

Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)

* fix(daemon): require projectId for bound conversation ownership

Reject POST /api/runs when conversationId is present but projectId is
missing or non-string, so omit-pin cannot seed messages without owning
project context. Cover both early and post-fallback ownership checks.

Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)

* fix(daemon): preserve slideIndex and BMP kind on omit-pin seeds

Keep deck annotation slideIndex and classify .bmp as image when seeding
user messages for clients that omit assistantMessageId.

Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)

* fix(daemon): seed empty message when omit-pin has attachments

When currentPrompt is unset and message is empty, still seed the user
turn if attachment metadata is present so chips/annotations survive reload.

Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)

* fix(daemon): seed omit-pin chat content from original request

Plugin resolution may rewrite meta.message with a rendered scenario
brief for the run; persist requestBody currentPrompt/message for the
user turn so the internal brief is not shown as chat content.

Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)

* fix(web): persist browser preview viewport (#4899)

* fix(daemon): honor Kiro ACP turn completion (#6268)

* fix(daemon): isolate plugin-started Local Codex runs (#6273)

Disable Codex plugins only for externally attributed Open Design Plugin runs so Local Codex cannot recursively enter collect_brief or Cloud login. Preserve normal Local Codex plugin loading and the existing operator-wide override.

Validated with the focused runtime args suite, workspace typecheck, guard, and an isolated source smoke that produced a valid artifact without Cloud login.

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>

* fix(landing): accept signed Vela team plan leads (#6283)

* fix: durably deliver enterprise leads

* fix(landing): make contact sales KV canonical

* feat(prompts): introduce on-demand discovery in SP v2.0 (#6223)

* feat(prompts): introduce on-demand discovery in SP v2.0

* fix(prompts): prevent nested deck label overlap

* fix(plugins): stabilize od-default task routing

* fix: address prompt and deck audit regressions

* fix(prompts): restore plan mode precedence

* fix(daemon): accept all form answer headers

---------

Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>

* fix(web): surface BYOK migration validation errors (#6280)

* fix(web): surface BYOK migration validation errors

Preserve structured daemon errors during legacy credential migration and reserve offline guidance for fetch failures.

Keep legacy browser credentials until migration succeeds, and cover HTTP validation, malformed responses, and network failures.

Validated with focused web tests, web typecheck, workspace guard, and workspace typecheck.

* fix(web): stabilize BYOK persistence error telemetry

Map secure-profile HTTP and network failures to semantic error codes before emitting settings_byok_test_result, while preserving existing fallback behavior for unrelated connection-test errors.

Validated with the complete web test suite, web typecheck, workspace typecheck, guard, and git diff --check.

---------

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>

* fix(ui): improve dark-mode contrast for off-state toggles (#5825)

* fix(ui): improve dark-mode contrast for off-state toggles

The default off-state track uses --border-strong (#c9d0da light / #46433c dark).
In dark mode #46433c is only ~5% lighter than the --bg-subtle (#252321) panel
behind it, making the toggle nearly invisible when off and users unsure of the
control state.

Bump the off-state track to #6b6862 with a subtle inset border, and add a
thin outline to the white thumb so it stays visible against the (now lighter)
track. On-state already uses --text (#e8e4dc) and is left unchanged.

Applies to both .toggle-row (used by speaker-notes toggle in the deck tab)
and .compact-toggle (used by SurfaceOptions toggles). Hover lift gives an
additional interactive affordance.

Fixes #5298

* fix(ui): fix two dark-mode toggle blocking issues from review

Two correctness issues called out by mrcfps:

1. System dark theme never gets the contrast fix — the default "system"
   theme mode removes [data-theme] and falls back to
   @media prefers-color-scheme. Added a parallel block scoped to
   html:not([data-theme]) inside @media (prefers-color-scheme: dark).

2. Hover override wins over on-state — .compact-toggle:hover and
   .toggle-row:hover have higher specificity than .on
   variants, so hovering an ON toggle dropped its track from bright
   var(--text) to #75726c. Changed to :not(.on):hover so the hover
   lift only applies when off, leaving the on-state always bright.

Both applied to both selector paths (explicit data-theme and system dark).

* fix(web): keep Azure deployment names editable (#6034)

* chore(plugin-previews): refresh baked preview manifest (#6293)

Co-authored-by: open-design-bot <bot@open-design.ai>

* fix(byok): withdraw Windows DPAPI backend (#6308)

* fix(byok): withdraw Windows DPAPI backend

* fix(byok): clean up retired Windows secret blobs

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

---------

Co-authored-by: Looper <looper@noreply.github.com>

* fix(web): dismiss stale todo after continuation starts (#6307)

* fix(web): preserve shared project update timestamps

* fix(web): reconcile shared timestamps on home

* fix(web): preserve workspace scope in preview assets

* fix(collab): avoid unscoped shared cover requests

* fix(web): avoid double-wrapping preview assets

* fix(web): preserve first prompt across project refresh

* test(web): target the active shared project card

* test(web): keep empty BYOK profile type-safe

* test(e2e): require explicit workspace scope

* fix(web): finish scoped preview hydration

* test(e2e): align manual edit with current inspector

* fix(web): reconcile files after event stream readiness

* fix(web): keep local project creation workspace-independent

* fix(web): preserve scoped auth recovery state

* test(e2e): assert explicit Personal workspace scope

* test(e2e): preserve visual AMR account fixture

* test(web): await scoped cover source

* UI polish batch: cloud sign-in, onboarding, composer menu (#6281)

* UI polish batch: cloud sign-in, onboarding, composer menu

Sign-in surfaces (rail callout, balance-gate dialog, onboarding) had
drifted from the visual system and lost some redundant chrome; this
batch tightens them up alongside a couple of composer/design-files
reorganizations that were sitting in the same working tree:

- Rail's signed-out callout: reorder copy above the login pill, give
  the pill an icon + accent-green label, restore the Escape hatch
  the pill had before it lost its close affordance.
- AmrBalanceDialog (insufficient-balance / signed-out gate): add a
  full-bleed art banner above the copy, drop the now-redundant icon
  badge, tighten spacing/shadows to match.
- Onboarding cloud landing: switch from a single centered column to
  a two-column layout (form pane + full-bleed art panel), move the
  language switcher into the footer, swap the local-CLI/BYOK links
  for icon Buttons, and add a dismiss control to the activation-retry
  hint.
- Composer "+" menu: fold 插件/设计百宝箱 back in as hover-expand
  submenu rows instead of standalone quick pills above the input.
- Design files panel: move new-document/upload actions out of the
  always-visible toolbar into the empty-state actions only.
- Message center: drop the redundant header close button (backdrop
  click and Escape still dismiss it) and restyle the list from
  bordered cards to a divider-separated list.
- New `key` / `robot` / `translate` / `arrow-right` icons; language
  menu switches from the `languages` glyph to `translate`.
- Tab launcher popover reverts to the glass material; language
  dropdown gets a max-height + scroll so long lists don't blow out
  the popover.

Rebased the composer-quick-pill removal onto this branch's own
accessibility work on those pills (opener-based focus return via
`ComposerStandalonePanel`) rather than reverting it — the "next step"
card still drives the same standalone-panel API, just without a pill
to hand focus back to. Also updated/removed the tests that pinned the
now-removed surfaces (quick-pill popup contract, message-center close
button, onboarding top bar, design-files toolbar upload trigger) so
none of this lands with a newly-red suite.

* perf(web): optimize onboarding cloud artwork

* fix(web): invalidate project file reads after mutations

* fix(web): disable readonly project starter mutations

* fix(web): preserve focus for standalone composer panels

* test(e2e): require explicit workspace scope

* fix(web): invalidate file reads on project events

* test(e2e): seed explicit settings workspace

* test(e2e): align manual edit with current inspector

* fix(web): refresh deployments when share opens

* test(e2e): follow composer design system entry

* fix(web): keep local project creation workspace-independent

* fix(web): preserve scoped auth recovery state

* test(e2e): follow current entry settings flows

* fix(web): preserve AMR retry across settings

* test(e2e): model explicit personal workspace recovery

* test(e2e): stabilize AMR logout settings recovery

* fix(projects): return workspace scope on create

---------

Co-authored-by: lefarcen <935902669@qq.com>

* test(e2e): lock design system team partition

* fix(workspace): stabilize scoped project lifecycle

* test(e2e): stabilize retained workspace transitions

* fix(workspace): retain exact scope through transient outages

* test(workspace): lock outage identity boundaries

---------

Co-authored-by: Ray Xi <2667192167@qq.com>
Co-authored-by: PerishFire <39043006+PerishCode@users.noreply.github.com>
Co-authored-by: Looper <looper@noreply.github.com>
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
Co-authored-by: bestthanapon <thanapon.suwannasuk@gmail.com>
Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: Amy <58060647+AmyShang-alt@users.noreply.github.com>
Co-authored-by: Marc Chan <mrc@powerformer.com>
Co-authored-by: _HDCoder <2728073932@qq.com>
Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: Nicholas-Xiong <2482929840@qq.com>
Co-authored-by: mehmet turac <mehmetturac@gmail.com>
Co-authored-by: open-design-release-bot[bot] <295937643+open-design-release-bot[bot]@users.noreply.github.com>
Co-authored-by: open-design-bot <bot@open-design.ai>
Co-authored-by: CHENGLONG WANG <honam884844@gmail.com>

* fix(workspace): seed dashboard workspace input

* test(workspace): cover live dashboard workspace input

* fix(workspace): backfill legacy workspace names

* test(e2e): harden workspace collaboration coverage (#6312)

Harden Workspace collaboration authority, lifecycle cleanup, and browser coverage.\n\nRetry an expired authorized Team-project pull exactly once with a fresh receipt while preserving fail-closed scope, version, cleanup, and final freshness checks. Align the fake Vela receipt lifetime with the real two-second contract and use scalable E2E timeouts.

* fix(workspace): refresh dashboard input on switch

* fix(viewer): avoid inert attribute warning

* fix(chat): preserve user turn ordering

* fix(workspace): stop retracted Team plugins immediately (#6335)

Prevent stale Team-plugin listings, materializations, and delayed positive hub reads from reactivating a resource after retraction. Preserve independent Personal plugins with the same manifest ID and keep Team members able to share their own resources.

Add deterministic regression coverage for cache invalidation, binding lifecycle, and the superseded-positive activation race.

* fix(workspace): harden Vela billing proxy boundaries (#6348)

Reject invalid wallet balances without overwriting valid cache state, validate normalized proxy paths and Workspace scope, strip hop-by-hop headers, and tear down upstream requests on disconnect.

Keep team Workspace scope fail-closed when Connection nominates the protected header.

* fix(web): tolerate unnamed workspace directory rows (#6349)

Preserve a verified explicit Workspace context when legacy directory rows omit the display-only workspaceName field. Keep exact-row name backfill when a non-empty name is present, without inferring authority from active or default Workspace state.

* fix(workspace): enforce exact project-list scope

* fix(workspace): show team design systems in pickers

* fix(web): reserve image cards while thumbnails load

* fix(web): keep home creation types ready during refresh

* test(e2e): align Windows beta smoke with installer

* perf(web): start team catalog from exact directory identity

* fix(web): refresh revoked workspace context

* fix(web): order collaboration presence refreshes

* fix(amr): prefer selected Vela OpenCode companion

* fix(web): isolate collaboration presence sessions

* fix(web): parallelize scoped project route bootstrap

* perf(daemon): cache workspace project list authority

* fix(web): preserve project-open connection budget

* test(web): preserve memory toast exports in app mock

* fix(web): preserve exact project opening scope

* fix(daemon): coalesce scoped comment pulls

* fix(daemon): retry team comment relay delivery

* fix(daemon): atomically queue comment relay writes

* fix(collab): show team comments across local conversations

* fix(daemon): preserve catalog-only projects on unshare

* fix(collab): close comment realtime catch-up gaps

* docs: add workspace team rollout runbook

* fix(collab): retry failed Team project shares

* fix(workspace): isolate personal projects in recent lists

* fix(daemon): isolate personal design systems

* fix(workspace): authorize runs before plugin snapshots

* test(daemon): align workspace retry contracts

* fix: isolate plugins and skills by workspace member

* fix(workspace): scope design systems in run prompts

* fix(workspace): gate runs before plugin resolution

* fix(daemon): namespace team design system bindings

* fix: close plugin and skill scope leaks

* test(collab): cover project-scoped comment actions

* fix(workspace): scope project resource selection

* fix(workspace): preserve personal design system reads

* fix(workspace): authorize scoped run resources first

* fix(workspace): keep project design systems scoped

* fix(types): carry design system member scope

* fix(billing): scope personal balances by workspace

* fix(workspace): preserve unbound local design systems

* fix: resolve team design systems by canonical binding

* fix(workspace): invalidate nested project catalog cache

* fix: keep team design system runs canonical

* perf: batch durable comment relay delivery

* fix: cancel stale local comment relays

* fix(web): preserve scoped project display snapshots

* fix(web): synchronize project rename projections

* perf: reuse request-scoped workspace authority

* test: enforce personal project creator ownership

* fix(web): wait for project workspace authority

* fix(daemon): invalidate team project caches before signaling

* fix(web): order targeted project metadata refreshes

* fix(workspace): preserve team catalog on outages

* fix(web): isolate catalog display across accounts

* fix(web): scope extension marketplace catalogs

* fix(web): order broad team catalog refreshes

* fix(web): scope home plugins to workspace identity

* fix(web): isolate plugin views by workspace identity

* fix(web): polish sign-in and composer focus

* fix(web): preserve scoped project state after team moves

* fix(workspace): enforce scoped design and billing identity

* test(e2e): align workspace flows with scoped bootstrap

* fix(collab): drain newer comment revisions promptly

* test(e2e): keep owner actions after workspace lock

* fix(web): converge project renames across scoped lists

* fix(web): preserve pending projects across view changes

* fix(web): restore preview runtime after activation

* test(e2e): align workspace scope assertions

* test(web): cover hidden stream recovery

* fix(web): update deep-link project titles after rename

* fix(collab): retry project metadata propagation

* fix(web): preserve legacy design system logos

* fix(web): wait for activated preview bridge

* fix(design-systems): isolate team resource storage

* fix(plugins): reject team mirror mutations

* fix(web): partition extension catalogs by workspace

* fix(skills): isolate team mirrors by workspace

* fix(plugins): keep local share projects unscoped

* fix(web): retry preview state capture during activation

* test(e2e): scope project workspace fixtures explicitly

* feat(collab): coordinate team resource invalidations

* fix(collab): emit team resource changes after sync

* fix(web): invalidate project lists after updates

* fix(web): serialize project renames by scope

* fix(collab): preserve team project comment anchors

* fix(web): refresh shared resources after remote changes

* fix(collab): materialize team projects before rename

* fix(web): keep workspace catalogs current

* fix(web): close catalog and credential races

* test(workspace): harden team workflows and packaged deep links (#6358)

* test(workspace): harden team workflows and deep links

* fix(packaged): deliver hot invite deeplinks

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

* fix(workspace): catch up team resources safely

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

* fix(workspace): invalidate resource catch-up changes

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

* fix(workspace): catch up shared resource changes

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

* fix(packaged): preserve fallback invite deeplinks

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

* fix(workspace): hide retracted team skills

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

* test(e2e): align workspace CI fixtures with route bootstrap

* fix(collab): complete design system reconnect adoption

* feat(analytics): track Workspace interactions (#6397)

* feat(analytics): track Workspace interactions

* fix(analytics): dedupe community page views

---------

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

* fix(daemon): preserve team comments across conversations

* fix(collab): keep presence rosters converged

* fix(comments): preserve routed comment anchors

* fix(workspace): reconcile team project renames

* fix(collab): preserve member directory last-good state

* perf(collab): start scoped Team presence before status

* fix(comments): keep relay anchors internal

* perf(collab): warm presence from daemon cache

* perf(collab): cancel obsolete cold status polls

* fix: fence presence sessions against late heartbeats

* perf(collab): share team member roster scheduling

* fix(collab): preserve roster store through strict replay

* perf(collab): retain workspace member directory stores

* fix(collab): resolve presence identities from team directory

* fix(collab): hide unresolved presence identities

* fix(collab): prefer directory presence names

* perf(web): preload workspace member directory

* test(collab): preserve no-leave lifecycle contract

* fix(web): preserve personal workspace presence after share

* fix(web): refresh covers after team materialization

* fix(web): refresh files after team materialization

* fix(web): gate shared file reads on project authority

* fix(web): preserve workspace folder action sizing

* ci(e2e): split project-workspace UI P0 shard into project-workspace + project-collab

project-workspace pins workers=1 by design (deliberate, not incidental —
see e2e/AGENTS.md's UI test stability rules on cross-file worker carry-over),
so its 7 files ran fully serially: ~26min wall time in recent merge-queue
runs, the longest pole in the ui_p0 matrix by a wide margin over its
10-13min siblings.

workspace-multi-client-collab.test.ts alone (spins up two isolated
client/daemon runtimes per case) accounted for ~10 of those minutes.
Carving it and workspace-keyboard-flows.test.ts into a new project-collab
shard — still workers=1, still one file at a time — lets the two halves
run as separate concurrent CI jobs instead of one long serial chain,
without touching the single-worker-per-shard isolation the pin exists for.

Updates the topology guards that pin the exact matrix shape so the split
doesn't drift silently: DAEMON_RUNTIME_DEFINITION_MATRIX_NAMES (scopes.ts),
the full/candidate matrix name guards (guard/scope.ts), and the fixed
expectations in scopes.test.ts / packaged-smoke-workflow.test.ts.

Validated: pnpm guard (UI P0 shadow contract + CI topology checks pass),
pnpm typecheck (all workspaces), and the two topology test files
(107/107 passing).

* fix(collab): revoke unshared team project mirrors

* fix(web): preserve unbound project authority across workspace changes

* fix(web): pair scoped raw queries with headers

* test(e2e): pin writable scope for project actions

* test(web): cover Personal authority recovery after reload

* test(web): await version preview readiness

* fix(workspace): scope brand design system resources

* fix(workspace): materialize owner team design systems

* fix(web): fence design system reads by generation

* fix(web): propagate brand read generations

* fix(web): preserve workspace project visibility

* fix(preview): retain scope for runtime relative assets

* fix(web): explain design system delete denial

* fix(daemon): bound team resource prewarm scopes

* fix(daemon): recheck prewarm lease after queue

* fix(web): stabilize transient presence roster gaps

* chore(pack): pin Vela CLI 0.0.28

* fix(workspace): move design system project with team share

* fix(workspace): gate linked unshare on live authority

* fix(workspace): ensure backing project before design share

* fix(web): scope srcdoc preview assets

* perf(web): mint preview scope only for srcdoc

* perf(web): reuse srcdoc preview scope

* chore(nix): refresh pnpm deps hashes for vela-cli 0.0.28

pnpm-lock.yaml moved @powerformer/vela-cli from 0.0.27-test.7 to 0.0.28, but nix/pnpm-deps.nix was not regenerated, so `nix flake check` failed with a fixed-output hash mismatch for both the daemon and web derivations.

Hashes taken from the Nix-reported expected values in CI run 30933223422 (nix is not available on this machine to run `pnpm nix:update-hash` locally); the merge of origin/main did not touch pnpm-lock.yaml, so those values still describe this tree.

* chore(ci): retrigger workspace-team validation

The previous ci run for this branch sat pending with zero dispatched jobs; cancelling it and pushing an empty commit re-enters the ci-6142 concurrency group from a clean slot.

* perf(web): skip preview mint for authored base

* fix(collab): preserve presence through lease gaps

---------

Co-authored-by: Ray Xi <2667192167@qq.com>
Co-authored-by: CHENGLONG WANG <honam884844@gmail.com>
Co-authored-by: lefarcen <ontf116@gmail.com>
Co-authored-by: PerishFire <39043006+PerishCode@users.noreply.github.com>
Co-authored-by: Looper <looper@noreply.github.com>
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
Co-authored-by: bestthanapon <thanapon.suwannasuk@gmail.com>
Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: Amy <58060647+AmyShang-alt@users.noreply.github.com>
Co-authored-by: Marc Chan <mrc@powerformer.com>
Co-authored-by: _HDCoder <2728073932@qq.com>
Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: Nicholas-Xiong <2482929840@qq.com>
Co-authored-by: mehmet turac <mehmetturac@gmail.com>
Co-authored-by: open-design-release-bot[bot] <295937643+open-design-release-bot[bot]@users.noreply.github.com>
Co-authored-by: open-design-bot <bot@open-design.ai>
Co-authored-by: shangxinyu1 <shangxinyu@refly.ai>
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
2026-08-05 08:31:54 +08:00
PerishFire 3f670090d7 ci: narrow daemon core merge validation (#6126)
* ci: narrow daemon core merge validation

* ci: preserve runtime definition shadow
2026-07-27 15:51:57 +00:00
PerishFire 9db0d308e5 ci: establish guard library boundary (#6119) 2026-07-27 13:19:58 +00:00
PerishFire 43a8a92eae ci: add daemon UI P0 capability shadow (#6112) 2026-07-27 10:54:44 +00:00
PerishFire 783391e9fa ci: narrow packaged leaf merge validation (#6104) 2026-07-27 09:12:28 +00:00
PerishFire fac10139c0 perf(ci): narrow zero-effect queue validation (#6100)
* perf(ci): narrow zero-effect queue validation

* docs(ci): keep scope spec state-focused
2026-07-27 06:33:06 +00:00
Marc Chan 7787556cf1 fix(daemon): full ACP tool transcript and richer usage telemetry (#6057)
* fix(daemon): full ACP tool transcript and richer usage telemetry

Map all ACP tool_call frames to canonical tool_use/tool_result for
run.events, PostHog run_finished, and Langfuse spans. Preserve
artifact_count via Write/Edit normalization, tighten path extraction,
forward truncated results, and emit real tool startedAt.

Enrich usage scanning (thought_tokens, anthropic vs openai cache
semantics), canonical tool-family PostHog props, analytics recovery
finish snapshot, and shared content-tool redaction.

* fix(daemon): prefer ACP kind over noncanonical tool names

Normalize recognized ACP kinds (e.g. kind:read + name:read_file → Read)
before emitting tool transcripts so Langfuse content-tool redaction matches
canonical families and does not leak file contents.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): fail-closed redaction for unknown ACP tool payloads

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): flush open ACP tools as errors on session fail

When a session dies mid-tool (timeout, exit, RPC error), emit pending
tool_use/tool_result pairs with isError so transcripts stay complete.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): redact ACP Bash stdout in tool transcripts

Execute tools can dump private files (e.g. cat .env); replace Bash
tool_result content with a length summary before the canonical transcript
ships so Langfuse cannot receive raw execute output.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): redact Linux home paths in Langfuse Bash tool spans

Extend local-path masking for ACP Bash partial redaction to cover
/home and /root (not only /Users and Windows Users), so Linux home
paths do not leak into Langfuse tool inputs when content telemetry is on.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): redact custom ACP tool names before Langfuse telemetry

Sanitize path/token-like kind:other tool names at transcript emit and
canonicalize non-allowlisted names for Langfuse span labels, toolName
metadata, and redaction placeholders so untrusted custom names cannot
leak even when content telemetry is off.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* test(e2e): force-click unstable comment targets in preview iframe

UI P0 project-workspace timed out on comment attachment when Playwright
kept waiting for hero-title stability under auto-fit zoom / bridge inject.
Click once visible with force so the critical flow stays reliable.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): merge partial usage frames in reverse analytics scan

Keep provider_usage for thought/cache-only trailing usage events while
continuing the reverse scan to the latest primary input/output/total
record so run_finished and Langfuse do not drop complete token counts.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): flush open ACP tools before AMR no-output fail

Clean-flush pending non-think tools on successful session/prompt before
evaluating emittedConcreteToolEvent so missing terminal tool_call_update
does not mark a productive run as acp_no_visible_output.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): merge partial primary usage and flush tools on ACP abort

Require both input and output before ending the reverse usage scan so
output-only or input-only trailing frames still merge earlier counters.
Flush deferred open ACP tools as errored on abort so user-canceled runs
keep complete tool transcripts for Langfuse/PostHog.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): hash path-like ACP toolCallIds before telemetry

Adapters can embed user paths in toolCallId (e.g. read:/home/alice/.env).
Hash those values at tool_use/tool_result emit so Langfuse span ids and
metadata.toolCallId cannot leak secrets, while keeping the raw id as the
local frame-correlation map key.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): always hash ACP adapter toolCallIds for telemetry

Identifier-shaped secrets (sk-proj-*, ghp_*, JWTs) passed the prior
allowlist because they contain no path characters. Hash every non-empty
adapter toolCallId before tool_use/tool_result emission so Langfuse span
ids and metadata.toolCallId cannot leak tokens.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): keep reverse usage scan open for missing cache fields

When a newer usage frame already has input+output but omits cache
counters, continue scanning older frames so cache_read/cache_creation
still merge into run_finished and Langfuse.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): retain flushed ACP tool IDs against late terminals

Keep acpToolRunEventState entries after flushOpenAcpTools so a racy
tool_call_update cannot recreate the same id and emit a second
tool_use/tool_result pair after timeout, abort, or prompt flush.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): block kind:other Bash-like tool names

Custom ACP tools with kind:other must not emit Bash/shell/execute/terminal
names, which would unlock Langfuse partial redaction and leak rawInput.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)

* fix(daemon): fail closed on Bash names without execute kind

ACP tools missing kind must not preserve name/title Bash labels that unlock
Langfuse partial redaction; only recognized execute-family kinds may emit Bash.

Generated-By: looper 0.11.0 (runner=fixer, agent=grok-build)
2026-07-27 04:08:27 +00:00
PerishFireandLooper f9a6e18c11 feat(ci): promote the certain-exempt docs core to merge-queue trust (#6047)
* feat(ci): promote the certain-exempt docs core to merge-queue trust

Split the exempt surface into a certain-tier core (docs/, apps/landing-page/,
.vscode/, .idea/, .github/ISSUE_TEMPLATE/, LICENSE, CODEOWNERS) and the medium
residue (global markdown regex, workflow exacts, nix). A merge-queue group
confined to the core now drops to the unconditional floor lanes instead of
running everything; replaying the last 398 first-parent merges puts that at
12.3% of queue traffic.

The promotion is backed by a new floor-lane guard check, "certain-exempt
surface consumption": no skippable-lane source may reference a certain-exempt
path (dot-relative literals flagged everywhere; bare repo-relative literals
flagged outside test fixtures). guard.ts gains --list-checks as the machine
registry, and the rule-table invariant test now requires every certain rule's
guard field to resolve to a live check.

* docs(ci): record the scope confidence methodology in specs/current/ci.md

The living framework for confidence-tier changes: medium-tier cheap loop vs
certain-tier deliberate loop, the three-sentence promotion discipline, guard
strength levels and floor placement, evidence recipes (replay, offline plan,
queue trace extraction), tooling graduation criteria, and the open questions
deliberately left undecided. Indexed from AGENTS.md and .github/AGENTS.md
required reading.

* fix(ci): narrow certain-exempt fixture handling

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

* fix(ci): pin the trae-cli consumption exception to the daemon-lane wiring

The review round established that apps/daemon/tests/runtimes/trae-cli.test.ts
genuinely reads docs/agent-adapters.md. Its exemption from the consumption
guard was justified by a wiring fact (the ci.yml daemon lane runs only
project-watchers.test.ts) stated as prose, which would rot silently if the
lane ever widened. Make the exception conditional on that exact ci.yml needle:
when the daemon lane changes, the exemption disappears and the guard reports
the read, forcing reclassification instead of trusting a stale rationale.

* docs(ci): codify exception-precondition binding in the methodology

Backfill the pattern the first review round produced: guard allowlist entries
justified by local definitional facts may stay prose, but entries justified by
remote mutable facts (lane wiring, gate topology) must be conditional on a
checked precondition so the exception dies loudly with its premise. Records
the trae-cli/daemon-lane needle as the worked example.

* fix(ci): fold static paths in exempt consumption guard

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

* fix(ci): require exclusive daemon test wiring

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

* fix(ci): recognize daemon pnpm selector forms

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

* fix(ci): tokenize daemon workflow test commands

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

---------

Co-authored-by: Looper <looper@noreply.github.com>
2026-07-27 03:22:21 +00:00
PerishFireandLooper 14fcc411cd feat(ci): confidence-tiered scope rule table with merge-queue shadow evaluation (#6029)
* chore(scripts): make root scripts/ test-free and purify the guard entrypoint

Root scripts/ is now source-only, enforced by a new "scripts test-free"
guard check (any *.test.{ts,tsx,js,jsx,mjs,cjs} under scripts/ fails
pnpm guard). The root guard script becomes a pure tsx ./scripts/guard.ts
invocation instead of chaining a hand-maintained node --test file list.

Script behavior-contract coverage follows the boundary e2e/AGENTS.md
already declares for e2e/tests/scripts/: the fork PR approval security
tests migrate there (converted to Vitest, 24 tests passing), and the
remaining script unit tests are deleted — five of them (batch-design-
system-test, check-design-system-package-quality, plugin-previews-diff,
plugin-previews-gc, migrate-to-plugins/example) were orphans no runner
ever executed; the guard-chained checker tests covered logic whose
repo-wide enforcement remains in guard.ts checks. Coverage with a real
test claim gets rebuilt in e2e/tests/scripts/ on demand.

* test(ci): pin characterization goldens for scopes.ts scope detection

Golden tests run scripts/scopes.ts as a subprocess exactly the way ci.yml
does, with gh stubbed through the existing OPEN_DESIGN_GH_NODE_SCRIPT seam
(hermetic stub materialized per run), and pin the complete 20-key output
plan for a matrix of changed-file sets across pull_request,
workflow_dispatch (hot/full), and merge_group events.

They deliberately pin current quirks (skills/*.md arming daemon+web without
the ui-critical fallback; e2e/ui/*.md arming ui_p0 with workspace validation
off; dispatch-hot skipping the workspace_validation re-derivation) so the
upcoming rule-table restructuring can prove behavior preservation
byte-for-byte.

* refactor(ci): restructure scopes.ts into a confidence-tiered rule table

Scope detection becomes data plus a pure evaluator: every changed file is
classified by an additive rule table (union of matched effects, minimum
confidence across matched rules), and an evaluation context brings a trust
threshold — files below it, or matching no rule, escalate fail-closed to
the full radius. Today's behavior is exactly the degenerate case of this
structure (all rules medium, certain set empty): pull_request/hot believes
everything, the merge queue believes nothing and stays full. The
characterization goldens pass unchanged, byte-for-byte.

The exempt surface becomes an explicit empty-radius rule instead of a
special case, so exemptions can be split, rated, and promoted per rule.
Promotion to "certain" requires a guard field naming the enforcing check
(enforced by a rule-table invariant test).

New offline entry points for rule iteration: `plan --context
pr|merge-queue|full --files ...` evaluates without any GitHub environment
and prints { plan, trace }; `rules` lists the table. Every evaluation now
returns a decision trace (per-rule hit counts, per-file escalations, and
an ifTrustAll shadow plan that shows what medium-confidence rules would
have allowed — the evidence stream for future certain-tier promotions);
github-output mode logs it and writes a step-summary table without
touching the frozen 20-key output contract.

The legacy dispatch-hot asymmetry (no workspace_validation re-derivation)
is preserved behind an explicit option and pinned by the goldens.

* feat(ci): evaluate merge-group union diffs at the certain threshold with shadow trace

The merge queue now resolves the queued group's union diff
(compare base_sha...head_sha from the merge_group event payload) and
evaluates it through the rule table at the "certain" trust threshold.
While the certain rule set is empty every file escalates and the queue
keeps running everything — the goldens pin this byte-for-byte — but the
evaluation's ifTrustAll shadow column now records, for every queued
group, what medium-confidence rules would have allowed: the evidence
stream that future certain-tier promotions will cite.

Queue lanes derive from the evaluated outputs (ci_mode stays reported as
"full", but lanes are no longer unconditionally forced), so promoting a
rule to "certain" later is a pure rule-table change with no further
structural work.

Fail-safe behavior is explicit and tested: resolution errors and empty
resolutions fall open to the full plan without failing the scopes job
(queue throughput must never depend on this evaluation), while
pull_request resolution failures keep failing the run as before.

Changed-file resolution no longer filters out removed files (manual hot
runs previously did): deleting a runtime source file must trigger the
same validation scopes as editing it. This is the only behavior change,
and it is strictly fail-closed.

* fix(ci): preserve guard contracts and fail closed on compare limits

Restore the eight guard behavior suites in the e2e script lane and make merge-group compare resolution treat GitHub's 300-file ceiling as incomplete.

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

* fix(ci): enforce extension-agnostic script test boundary

Match every suffix after .test. and cover TypeScript, MTS, and non-test filenames in the e2e script suite.

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

* fix(ci): route script contract tests to e2e

Ensure changes under e2e/tests/scripts arm E2E Vitest with the same daemon and web scope effects as their root script counterparts, and pin the behavior with a PR golden.\n\nGenerated-By: looper 0.11.0 (runner=fixer, agent=codex)

---------

Co-authored-by: Looper <looper@noreply.github.com>
2026-07-24 05:18:39 +00:00
VikingOwl91 7c742163c5 fix: support pnpm install --ignore-scripts with pnpm bootstrap (#5874) 2026-07-20 14:18:50 +00:00
PerishFire 20c6c0a6ed ci(e2e): shard the full UI functional pool into a merge-ready matrix (#5883)
* ci: benchmark deduplicated UI merge sentinel

* ci: benchmark restoration-hosted sentinel

* ci: benchmark distributed P0 sentinel

* ci: benchmark split P0 sentinel

* ci: serialize split sentinel benchmark

* ci: finalize deduplicated UI merge topology

* ci: prototype all-shades UI domain topology

* ci: bound full UI runtime by shade

* test(e2e): stabilize full UI suite

* fix(web): unclip DS picker popover and propagate patched project metadata

Two regressions surfaced by re-enabling the full UI pool:

- The New Project design-system popover lost its portal and was clipped by
  short viewports and the modal body. Render it through createPortal with a
  viewport-aware anchor that flips upward when space below runs out.

- After a working-dir pick, onProjectMetadataChange forwarded only the
  metadata onto a stale project copy, so ProjectView.currentProject's
  recency comparison let an older useProjectDetail snapshot shadow the
  accepted PATCH and the picker label never updated. The callback now
  carries the daemon's authoritative post-patch project (fresh updatedAt
  included), matching the design-system switch path, and ProjectView hands
  it straight to onProjectChange.

* test(e2e): repair remaining stale full-suite UI contracts

Second repair batch for the failures surfaced by full diagnostic run
29480362163, plus one CI-invisible case that was serially skipped behind
the BYOK failure in the same file:

- Point the working-dir metadata spec at a directory that exists on the
  runner instead of a hardcoded absolute path, and assert the label from
  the picked path (this spec is red without the ProjectView patched-project
  fix).
- Navigate back to All project files before asserting the plugin-folder
  cards, since a finished run auto-opens the produced file tab.
- Resolve the create-run response race in real-daemon-run's sendPrompt via
  a single waitForResponse alongside the click.
- Refresh selectors and copy for entry topbar, home hero rail, chat todo
  autoscroll, project management, workspace keyboard, design files, manual
  edit, restoration, and DS picker clipping flows to match the current UI
  contracts while keeping each spec's oracles.

* ci: distribute the full UI suite over a generic 8-shard matrix

Replace the diagnostic four-domain P0/P1/P2 serial topology with a single
all-shades Playwright invocation per shard (--shard=N/8, two fully
parallel workers, 20-minute cap) and drop the scheduled trigger; full runs
stay manual until the merge-queue wiring lands. Update the workflow
topology guard accordingly.

* ci: serialize full UI shards and widen the matrix to twelve

The eight-shard two-worker experiment left six light shards healthy
(260-477s test steps) but both shards holding runtime-heavy files
degraded: repeated two-minute timeouts with retry recovery in one, and a
consistent create-run response starvation failure in the other. This
matches the earlier finding that runtime-heavy scenarios cannot share a
runner under two fully parallel workers. Run each shard with a single
worker and rebalance to twelve shards; fully-parallel stays on only for
test-granularity sharding.

* fix(web): preserve prompt-extracted plugin inputs across apply reconcile

The active-plugin apply reconcile rebuilt the inputs from the bind-time
optimistic snapshot, so edits the user typed during the apply roundtrip
(already extracted into active.inputs by handlePromptChange) were silently
reverted to defaults — the submitted pluginInputs then disagreed with the
visible prompt. Merge the apply result's defaults into the current inputs
inside the functional updater instead, mirroring the guard the prompt-text
reconcile already had.

* test(e2e): stabilize home gallery flows for all-shades execution

Generic all-shades sharding executes tests in orders the shade-filtered
domain topology never produced, exposing a home-surface remount window:
the async projects fetch can mount or remount HomeTemplatesReveal after a
test starts interacting, collapsing the gallery (inert) under the test's
cursor. Stabilize the shared helpers instead of individual specs:

- gotoEntryHome arms a projects-list response waiter before navigating and
  holds until it settles.
- revealHomeTemplates requires the revealed state (or a true pass-through)
  to survive a settle window, retrying when the wrapper mounts late; a
  card's isVisible() is true inside the collapsed container, so
  openHomePluginDetails now always routes through the reveal path.
- The browse-registry spec reveals before clicking.
- The DS-picker options moved out of the new-project-modal subtree with the
  portal fix; query the page-level popover per the clipping spec's
  convention.
- real-daemon-run's sendPrompt reports on timeout whether POST /api/runs
  was ever issued, so the CI-only BYOK failure can be split into
  swallowed-click vs unanswered-request on the next reproduction.

* test(e2e): make BYOK availability hermetic and reveal remaining gallery clicks

The BYOK missing-provider spec could only pass on machines with a real
`opencode` binary: daemon detection maps byok-opencode's availability
through the `opencode` agent's configured cli env, and the web composer
refuses to POST /api/runs for an unavailable agent (the sendPrompt
diagnostic confirmed "was never issued" on CI). Point the helper's
agentCliEnv at the fake opencode runtime — the run still fails pre-spawn
on the missing provider config, so the fake binary never executes.

Also route the two remaining collapsed-container gallery clicks (the
browse-registry starter journey and the detail-use force-click) through
revealHomeTemplates; an inert container swallows force-clicks silently.

* ci: probe a 24-shard full UI matrix against the four-minute wall target

The first green 12-shard run put eleven shards at 141-354s test steps but
concentrated the contiguous runtime-heavy chain in one 894s shard, setting
a ~16-minute wall tail. With ~3568 total test-seconds and ~90s per-job
overhead, 24 shards should land every job near four minutes.

* ci: settle the full UI matrix on twelve shards

The 24-shard probe stayed green but proved the runtime-heavy chain is a
serial group (test.describe.configure serial in real-daemon-run), which
Playwright keeps atomic within one shard: the tail stayed ~16 minutes while
the extra twelve cold starts raised aggregate runner time from ~76 to ~102
minutes. Twelve shards dominate; lowering the wall below the serial-chain
floor is a de-serialization follow-up, not a shard-count knob.

* test(e2e): drop serial mode from shard-splittable files

Playwright keeps a serial group atomic within one shard, so
real-daemon-run's file-level serial made its ~16-minute chain the floor of
the full UI matrix wall regardless of shard count. Every test in the three
serial files already performs its full setup per test (config reset,
route stubs, own project), so serial only added skip-after-failure and
shard atomicity. Verified order independence by running each file's two
standalone shard halves locally (fully parallel sharding, one worker):
21/21, 57/57, and 12/12 across the halves.

* test(e2e): settle the projects fetch in real-daemon-run home navigation

With serial mode gone, the plugin-authoring spec runs after arbitrary
shard predecessors, so the daemon holds more projects when it navigates
home; the late-resolving projects list re-renders the hero and left the
shortcuts trigger unstable for the whole click timeout on CI. Port the
projects-settle waiter already used by entry-chrome-flows into this
file's gotoEntryHome.

* test(e2e): name the plugins-load gate in the plugin authoring spec

The home shortcuts trigger is disabled while the plugins list loads, and
this spec's mid-flow reload re-fires that fetch after gotoEntryHome's
settle has already passed — on CI the click then times out opaquely for
120 seconds. Arm a response waiter across the reload and assert the
trigger's enabled state explicitly, so a slow or hung /api/plugins fails
with the actual cause named.

* test(e2e): enter plugin authoring through the plugins page create button

The home shortcuts trigger sits disabled on CI runners while a home
plugin apply hangs (plugins list resolves, the pending-apply gate never
clears); three reproduction attempts confirmed the anomaly is CI-only
and independent of this journey. The create button on the Plugins page
drives the same queuePluginAuthoring flow, and this spec's oracle is the
generated scaffold plus its action cards, so route the entry there and
track the disabled-trigger anomaly separately.

* ci: let configure-ci-parallelism drive full UI shard workers

The single-worker override predates the repairs that explained the
two-worker instability (BYOK agent availability and the reveal remount
race, both test-side and since fixed), so it was pinning throughput on a
stale diagnosis. Drop it and let the repo's nproc/2 convention apply —
two workers on the four-core ui_hot profile — halving the per-shard test
step if the runtime holds.

* ci: probe three full UI workers against Blacksmith's actual vCPU capacity

The ui_hot profile resolves to blacksmith-4vcpu runners that reportedly
provide six actual vCPUs while nproc's cgroup view says four, so the
nproc/2 convention derives two workers and idles capacity. Pin three
workers for this probe and print the CPU topology alongside for
verification.

* Revert "ci: probe three full UI workers against Blacksmith's actual vCPU capacity"

This reverts commit 63c8296ae0ead2c3dadfbfda41f6e068d58331ef.

* ci: skip the critical fallback for pure packaged-leaf changes

Playwright starts tools-dev web (daemon + web runtimes) and never
launches the desktop, packaged, or tools-pack entrypoints, so PRs
confined to apps/desktop, apps/packaged, or tools/pack were paying the
two-job critical fallback (5.4-13.4 minutes of wall each) for coverage
their package tests and preflight already provide — seven of the eight
fallback PRs in the latest 100-merge sample were such false positives.

Add a fail-closed ui_critical_validation_required scope: it clears only
when every non-exempt changed file sits under one of those three leaf
roots. tools-dev, any transitive package (including the undeclared
metatool edge), scripts, runtime resources, unknown roots, and mixed
leaf + runtime changes keep the fallback armed; root manifests keep
enabling P0 with the existing critical/P0 mutual exclusion; merge-group
full mode is unchanged.

* test(e2e): align question-form specs with inline Studio discovery

Rebasing onto main picked up #5496, which moved clarification forms
inline into the chat flow and removed the legacy Questions workspace
tab. Re-point the submit-persistence spec at the inline form, and fix
the oracles that no other lane had verified since: the answered summary
echoes the picked visual-style card title (not the option label), and
the submitted answer message carries the card title plus its value id.

* test(e2e): absorb post-rebase drift in BYOK gating and zh-CN hero copy

Two more contracts only the full pool guards moved under the rebase:

- The composer's BYOK gate silently swallows submits until the
  incremental /api/agents stream has delivered byok-opencode's
  availability, so a fast submit races the stream (the sendPrompt
  diagnostic reports the POST was never issued). Retry through that
  window; runErrorCard reads the last card, so swallowed attempts cannot
  shadow the provider-validation oracle. The gate erroring instead of
  waiting while agents are still loading is a product-side follow-up.
- The home hero title copy changed in every locale; re-point the zh-CN
  P2 smoke at the current translation.

* test(e2e): align gallery specs with the all-types default slice

The Community gallery now defaults to an All category pill (#5759)
instead of pre-selecting Deck: assert the new default state in the hero
and category-tab specs, and have the facet-filter spec pick Deck
explicitly before asserting the deck-only visibility set.

* test(e2e): align the first-run reveal spec with the all-types default slice

Same #5759 drift family as the entry-chrome-flows gallery specs, missed
by the first sweep because it lives in home-hero-rail: the revealed
gallery now shows the All pill selected instead of hiding it with
live-artifact pre-selected.

* docs(e2e): add UI test stability rules distilled from the full-pool work

Capture the invariants a UI test must hold to stay green under the generic
sharded full pool, learned from stabilizing that suite and two rebases:

- order independence (no serial groups; setup is per-file, not universal),
  and that neither shade-filtered local runs nor merge-path P0 prove it;
- retry-only passes are an order-leak/readiness signal, not flake;
- settle async surfaces (best-effort waiter + a state-survives-a-window
  check) before interacting;
- an enabled control is not a ready control when a streamed precondition
  (e.g. BYOK-OpenCode agent availability over the /api/agents SSE stream)
  gates it;
- never force-click into inert/collapsed reveal containers; use
  actionability, not visibility, as the readiness signal;
- hermetic agent availability via route mocks (with the terminal SSE `done`
  event) or fake CLI runtimes + agentCliEnv, never host binaries;
- realign stale oracles against the product and confirm them in the lane
  that actually executes the case, since a merged PR does not verify P1/P2
  or unregistered-file P0 oracles;
- name failure causes so a CI-only reproduction carries its own evidence.

Cross-linked from the top-of-file coverage-status pointer.

* test(e2e): realign BYOK missing-provider spec to the #5745 client-side preflight

main #5745 added a client-side BYOK preflight that blocks a missing-provider
run before any POST and opens the execution Settings section, instead of
letting the run reach the daemon and fail there. Rewrite the spec to the new
"blocked before spawn" behavior: submitting opens the execution-mode dialog,
no create-run request is issued, and no artifact is produced (persisted
across reload). This drops the now-obsolete daemon-failed-run assertions and
the SSE-availability retry loop, which the preflight makes unnecessary.

* docs(e2e): explain why the functional config ignores visual specs

Clarify that visual-*.test.ts are owned by playwright.visual.config.ts and
its playwright_visual lane, so excluding them from the functional config is
intentional scope (it keeps a bare test:ui or the full-pool shard run from
executing visual specs without their visual config), not a disabled lane.

* fix(web): portal the New Project brand-preview flyout with the popover

The earlier DS-picker unclip portaled the popover list but left its companion
brand-preview flyout on the old inline `position: absolute` against the
trigger wrapper. Once the popover moved to a body portal, the flyout for a
finalized `user:<id>` brand was still clipped by the modal body and no longer
anchored to the popover. Portal the flyout too and place it beside the
popover with the same viewport-aware logic (right of it when the 320px card
fits, else flipped left, then clamped), mirroring the popover's up/down
vertical anchor.

Red spec: e2e/ui/new-project-ds-picker-clipping.test.ts
"finalized-brand preview flyout is not clipped by the modal body" — red with
the popover portaled but the flyout inline, green with this change.
2026-07-20 09:25:09 +00:00
ScarlettandScarlett Huang 94d48f41f2 feat(plugins-home): order the template gallery by usage (OPEND-449) (#5106)
* feat(plugins-home): order the template gallery by usage (OPEND-449)

Non-prototype facets (Slides, Image, Video, …) now lead by real usage — a
trailing 28-day blend of distinct-users + runs from `run_finished`. The
Prototype facet keeps its curated editorial order; the default mode-seeds
(Web Prototype, Simple Deck) and no-preview / manually-sunk tiles drop to the
bottom of their facet and the All view. Ordering lives in one shared comparator
(`comparePluginGalleryOrder`) used by the grid and the Home rail; the All view
keeps its visual-appeal order with sunk tiles pushed last.

A weekly in-repo workflow rebuilds the popularity snapshot from PostHog and
opens a self-PR; a committed snapshot ships the ordering immediately.

* test(plugins-home): make Live Artifact grouping assertions order-insensitive

The rail + Community-section tests hardcoded the curated Live Artifact preset
order. Facet order is now usage/sink-driven (OPEND-449), so these grouping/
routing tests assert membership via a sorted comparison instead.

---------

Co-authored-by: Scarlett Huang <scarletthuang@Scarletts-MacBook-Air.local>
2026-07-17 09:50:05 +00:00
PerishFire 925db8d2af ci: move nix and docker off the merge validation path (#5682)
* ci: move nix and docker off the merge validation path

Core ci.yml / Validate workspace / merge_group no longer schedule or require
Nix flake check or Docker image builds. Packaging lives on two standalone
workflows: nix.yml (flake check) and docker-image.yml (PR smoke build +
tag/release publish). Update scopes, topology tests, and agent docs.

* fix(ci): detect Docker publish from release inputs, not workflow_call event

Reusable workflow callers keep their own github.event_name (usually
workflow_dispatch), so matching event_name == workflow_call never fires and
stable GHCR publish would silently smoke-build only. Gate publish on tag push
or non-empty release_version / publish_latest inputs instead.

* fix(ci): keep smoke-only Docker sha tags off the publish path

Gate pr-/manual-sha metadata tags on publish != true so release-stable
workflow_dispatch callers do not push extra manual-sha-* tags to GHCR.
2026-07-15 08:47:03 +00:00
Leon AburimeandLA 8d20f5566b feat(guard): catch @ts-nocheck files with unresolved relative imports (#5164)
* feat(guard): catch @ts-nocheck files with unresolved relative imports

`// @ts-nocheck` disables all type checking for a file, including TS2307
"Cannot find module". That silently hides broken relative imports: a barrel
move or file rename can leave a @ts-nocheck module importing a path that no
longer resolves, and `pnpm typecheck` stays green while the module fails to
load at runtime. tsc also never verifies the specifier of a dynamic import()
even in a fully-checked file.

Add a guard check (wired into `pnpm guard` and its unit-test list) that
re-establishes the one guarantee @ts-nocheck removes: every relative import /
export-from / import-type / dynamic import() specifier in a @ts-nocheck
TypeScript source must resolve to a real module on disk. NodeNext .js -> .ts
mapping, extensionless directory-index barrels, and asset extensions are all
handled; string literals in comments/expressions are ignored (AST-based).

Clean on the current tree: 33 @ts-nocheck files, 0 violations.

* fix(guard): resolve .mjs/.cjs imports extension-specifically

Review follow-up (#5164): resolvesRelativeSpecifier mapped .js/.mjs/.cjs to
one broad candidate list, so `import './x.mjs'` passed whenever `x.ts` existed
and `./x.cjs` whenever `x.ts`/`x.mts` existed — but NodeNext emits .mjs only
from .mts and .cjs only from .cts (a same-basename .ts emits .js), so those
imports fail at runtime. That was a false negative in the guard's core promise.

Split candidates by requested JS extension: .js -> [.ts,.tsx,.js,.jsx],
.jsx -> [.tsx,.jsx], .mjs -> [.mts,.mjs], .cjs -> [.cts,.cjs]. Add fixture
assertions for the accepted matches and the rejected .mjs/.cjs mismatches.

---------

Co-authored-by: LA <la@LAs-MacBook-Pro.lan>
2026-07-14 03:20:35 +00:00
lefarcen a36d6fb6c1 perf(bake): render plugin previews concurrently (bounded worker pool) (#5429)
The plugin-preview bake renders one preview at a time — a headless-Chrome
screencast + ffmpeg encode per plugin, ~16s each, fully serial. When a PR
touches the deck renderer (as #5083 did), every deck example's served preview
HTML changes, so the content-hash skip re-bakes them all: 80 clips × ~16s ≈
19 min of pure serial render, dwarfing the ~3 min of setup. The bake is the
single long pole on those PRs (24m vs ~5m for everything else).

Parallelize the render with a bounded worker pool (PREVIEW_CONCURRENCY, default
4 = the standard GitHub runner's vCPUs). Local A/B on 6 forced re-bakes: 83s
serial -> 32s at concurrency 3 (~2.6x); the 80-item CI sweep amortizes launch
cost across ~20 clips/worker, so it should land closer to ~4x — dropping the
render from ~19 min to ~5 min. Shared by the pre-merge, post-merge, and
release-cut bake workflows, so all three get faster from this one change.

Three things had to hold for concurrency to be correct, each verified locally:

- One browser PER worker, not a shared browser with N pages. CDP screencast
  only follows the ACTIVE tab, so a page captured while another is foreground
  is starved of frames — I reproduced both failure modes (a frames-0 skip and
  a 5KB near-empty clip). Browser-per-worker keeps every captured page the sole
  foreground tab; clip sizes then match the serial baseline exactly. Launch is
  lazy so an all-cached sweep (the common post-merge case) pays no browser.
- ffmpeg/ffprobe moved off execFileSync onto promisified execFile: a
  synchronous encode blocks the event loop and stalls every other page's
  screencastFrameAck (Chrome only sends the next frame after the ack).
- Clamp inter-frame durations to >=0. CDP frame timestamps are occasionally
  non-monotonic, and the resulting negative concat `duration` makes ffmpeg
  reject the whole clip ("Invalid data found when processing input") — which
  fails the strict pre-merge job. Pre-existing, but CPU contention from
  concurrent renders makes the blips more frequent. Reproduced serially
  (example-deck-swiss-international) and confirmed the clamp turns it green.

Also exit explicitly at the end: puppeteer leaves a lingering handle after
browser.close(), and with a browser per worker that keeps the CLI alive after
all work is done — on CI the step would hang to the 90-min job timeout. Drain
stdout/stderr first so the piped CI summary is never truncated.

Verified against a real daemon (127.0.0.1 /api/plugins): 6/6 baked, 0 skipped,
clean exit (0 lingering processes), clips byte-comparable to serial output.
2026-07-10 10:40:53 +00:00
bc6a246c65 feat(slides): enhance deck project experience (#5083)
* feat(deck): enhance slide deck functionality with speaker notes and navigation improvements

- Added a switch for including speaker notes in slide decks, defaulting to true.
- Updated keyboard navigation to include 'R' for resetting to the first slide.
- Implemented click navigation for half-slide transitions.
- Enhanced the deck framework to support hidden programmatic prev/next controls.
- Improved handling of file inputs in question forms to support file submissions.

This update aims to improve user experience in creating and presenting slide decks, ensuring better accessibility and functionality.

* feat(artifacts): enforce semantic naming for user-facing deliverables

- Updated guidelines to require semantic filenames derived from user briefs for new artifacts, moving away from defaulting to `index.html`.
- Enhanced documentation to clarify naming conventions for HTML files, emphasizing the use of descriptive names like `investor-pitch-deck.html`.
- Adjusted prompts and hints across the system to reflect these changes, ensuring consistency in user-facing outputs.
- Improved test coverage to validate the new naming conventions and ensure proper implementation in various contexts.

This update aims to enhance clarity and usability in project file management, promoting better organization and user experience.

* feat(file-viewer): enhance speaker notes display and improve file workspace functionality

- Updated the speaker notes section to include a styled placeholder for empty notes, improving user experience.
- Changed the icon in the file workspace from "slides" to "panel-left" for better clarity.
- Introduced new sub-category filtering options in the file workspace, enhancing organization and navigation.
- Improved styling for the page creator search input and added visual feedback for loading states.

These changes aim to enhance usability and visual consistency across the file viewer and workspace components.

* test(web): align workspace tests with pages switcher

* test(e2e): open design files through pages menu

* fix(deck): keep source mode reachable

* fix(workspace): restore uploaded file tabs

* feat(deck-thumbnail): implement virtualized thumbnail rail for improved performance

- Introduced a new `DeckThumbnailRail` component to optimize the rendering of deck thumbnails, reducing memory usage and improving load times.
- Enhanced the `FileViewer` to integrate the new thumbnail rail, allowing for lazy loading of thumbnails based on viewport visibility.
- Added tests for the `DeckThumbnailRail` to ensure correct functionality and caching behavior.
- Updated styles for better visual consistency and performance during thumbnail rendering.

These changes aim to enhance user experience by providing a smoother and more efficient way to navigate through deck slides.

* feat(deck): enhance deck rendering with thumbnail support and keyboard shortcuts

- Integrated `DeckSlideThumbnail` for rendering individual slides in a shadow root, improving performance and reducing main-thread load.
- Updated `DeckThumbnailRail` to utilize the new thumbnail component, allowing for efficient slide navigation.
- Implemented keyboard shortcuts for deck navigation, enhancing user experience during presentations.
- Added tests for `buildDeckRenderInput` to ensure proper functionality of the deck stage fallback.
- Improved styling for loading states in the viewer, providing a smoother visual experience.

These changes aim to optimize the deck viewing experience by reducing resource consumption and enhancing interactivity.

* feat(deck): improve thumbnail loading and speaker notes editing experience

- Refactored `DeckSlideThumbnail` to enhance loading states with a new shimmer effect, improving visual feedback during thumbnail rendering.
- Updated `DeckThumbnailRail` to manage loading states for thumbnails, ensuring a smoother user experience.
- Simplified the speaker notes editing interface by removing the toggle switch, allowing direct editing from the notes body.
- Enhanced accessibility by adding roles and focus states to speaker notes elements.
- Added tests to verify the new loading states and speaker notes functionality.

These changes aim to optimize the user experience in deck navigation and speaker notes management, providing a more intuitive and responsive interface.

* feat(file-viewer): enhance version export functionality and speaker notes interaction

- Introduced new export options for file versions, including PDF and image formats, improving the ability to download and share content.
- Implemented a streamlined process for exporting version content, with progress indicators and error handling for a better user experience.
- Enhanced speaker notes editing by refining event handling to prevent unintended focus shifts, ensuring a smoother editing experience.
- Updated styles for improved visual consistency in the file version download interface.
- Added tests to verify the functionality of version exports and speaker notes interactions.

These changes aim to optimize the file viewer's usability and enhance the overall experience when managing and exporting project files.

* fix(web): polish export menus and creator filters

* fix(file-viewer): fall back to preview exports

* test(file-viewer): cover version export fallbacks

* test(file-viewer): assert loading skeleton by role

* fix(file-viewer): restore html source mode affordance

* fix(file-viewer): capture full version fallback snapshots

* docs: trim slides handoff eof

* test(exports): cover full preview snapshot requests

* test(file-viewer): account for snapshot options arg

* refactor(file-viewer, file-workspace): streamline export functions and enhance page creator dialog visibility

* test(file-workspace): enhance slide template creation and mock plugin responses

* feat(export): add support for versioned exports and enhance HTML export functionality

* feat(export): implement versioned export support across multiple formats and enhance related tests

* feat(file-viewer): enhance manual edit functionality with commit sequence tracking and add test for inline text session saving

* style(speaker-notes): update filmstrip and thumb-frame styles for improved responsiveness and add corresponding tests

* feat(file-viewer): enhance comment composer functionality with portal metrics and improve draw toolbar layout for mobile views

* feat(analytics): track slide deck viewing experience

Add analytics for the new deck/slides experience introduced in this PR:
the deck viewer surface, in-deck slide navigation, thumbnail rail, and
speaker-notes authoring. Presentation entry stays on the existing
present_popover click to avoid double-counting.

- contracts: DeckViewerSurfaceViewProps (surface_view),
  DeckViewerClickProps (ui_click), SpeakerNotesSaveResultProps
  (new speaker_notes_save_result event), wired into the event unions.
- web: track* wrappers + fire helpers in FileViewer — deck_viewer
  surface_view on deck mount, slide_prev/next/reset, thumbnail_select,
  thumbnail_rail_toggle (expand/collapse), speaker_notes_edit, and
  speaker_notes_save_result (preview + presenter surfaces).
- tests: deck analytics suite asserting the emitted (event, props) pairs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(file-viewer, file-workspace): enhance export toast functionality and improve localization for page creation

* feat(home-view, plugins-home-section, agent-skill): update default facet preference, enhance category selection handling, and improve localization support

- Set `preferDefaultFacet` to true in HomeView component for better user experience.
- Introduced `handlePickCategory` function in PluginsHomeSection to manage category selection based on card layout visibility.
- Updated PRIMARY_CATEGORIES to reflect the new order and added localization for titles and descriptions in adaptAgentSkill.
- Enhanced tests to verify the correct handling of display copy and tags from SKILL frontmatter.

* feat(plugins-home, file-workspace): introduce commercial category labels and enhance deck handling

- Added a new module for resolving and localizing commercial category labels for plugins.
- Updated the FileWorkspace and PluginCard components to display the commercial category label on deck cards.
- Enhanced the PageCreatorPresetFrame to adjust frame classes based on deck presets.
- Refactored subcategory handling in facets to align with the new commercial category taxonomy.
- Improved localization support for new category labels across multiple languages.

* feat(deck-preview): implement deck preview scaling and enhance home hero layout

- Introduced `useDeckPreviewScale` hook to manage scaling of deck previews within a fixed 1280×720 logical viewport, ensuring proportional rendering across various templates.
- Updated `FileWorkspace`, `HomeHero`, and `PluginCard` components to utilize the new scaling functionality for improved visual consistency.
- Enhanced the layout of the home hero section to accommodate new subtype chips and descriptions, including a "More" dropdown for overflow handling.
- Improved localization support for new UI elements across multiple languages.

* fix(PreviewModal): clarify sidebar prop documentation and update test description

- Updated the documentation for the `PreviewSidebar` interface to specify that the sidebar's stage-edge handles are not shown when the sidebar prop is absent.
- Modified the test suite description for `PreviewModal` to be more concise and relevant.

* test(e2e): select prototype visual card

---------

Co-authored-by: free666799 <293857035+free666799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 09:41:47 +00:00