Commit Graph
254 Commits
Author SHA1 Message Date
lefarcenandGitHub fcd1c5f0b3 fix(release): keep smoke report uploads advisory (#9002) 2026-10-22 09:36:34 +00:00
Marc ChanandGitHub 417b81c752 [OPEND-4240] chore(tooling): migrate workspace to TypeScript 7 (#8957)
* chore(tooling): migrate workspace to TypeScript 7

* fix(docker): stage plugin manifests before native web type checking

* perf(guard): batch native parsing with bounded AST retention

* fix(tooling): port main's AST tests to native TypeScript
2026-10-22 08:40:45 +00:00
6ef59618e9 [OPEND-4274] fix(ci): isolate E2E startup from Next registry lookup (#8993)
* fix(ci): configure prerelease E2E worker budget

* fix(e2e): isolate Next version lookup from runtime startup

---------

Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-10-22 08:21:36 +00:00
75693e95f4 [OPEND-3114] feat(od-next): open registered agents and CLI BYOK (#8702)
* feat(od-next): open registered runtimes and CLI BYOK

Use the effective runtime registry for OD Next admission and remove retired
capability qualification. Keep native observation and existing resume/cold
recovery, with the evidence graph owned by observability contracts.

Add run-scoped BYOK connection flags with caller-selected environment key input
and reuse the existing provider bridge. Accept equivalent frozen Skill JSON
and Base64 representations while preserving content and ownership boundaries.

Validated with focused contracts, runtime, public Run and CLI regressions;
eight real native agents and a real BYOK request/production pair; guard and
workspace typecheck. Refs #8701.

* fix(web): describe BYOK project file tools

Explain that API-provider project runs use OpenCode file tools and require
OpenCode to be available. Update the existing notice in all 19 locales and
its component/browser regressions.

Validated with red/green notice checks, real settings and preview follow-ups,
guard and workspace typecheck. Refs #8701.

* fix(od-next): preserve hidden prototype form feedback

Keep HTML hidden semantics within the shared layout layer without overriding
authored product CSS or hidden-until-found behavior. This fixes name errors
remaining visible after a successful registration.

Validated with a red/green Chromium regression, three resource-boundary
checks, a real Claude follow-up with desktop/mobile form interaction, guard
and workspace typecheck. Refs #8701.

* test(od-next): make Run fixtures select their execution path

Explicitly select ordinary Runs for retry persistence, plain stdout replay
and the compatibility single-run telemetry case. Keep task hierarchy and
terminal fallback telemetry cases active, and assert the selected path.

Restore owner write permission to the leftover-agent fixture's frozen
directories only during teardown so recursive deletion works on POSIX.
Skip symlinks and preserve cleanup errors and every original behavior check.

Reproduced all four CI failures before the fix. Focused daemon and E2E
checks passed (12 tests), along with guard, workspace typecheck and focused
LSP diagnostics. Refs #8701.

* fix(daemon): avoid passing Pi resource directories as prompt files

Pi reads existing --append-system-prompt paths as files. Stop passing skills, design systems and task input directories there; their resource paths already travel in the composed RPC prompt.

Validated with 104 focused runtime/RPC tests, a real Pi 1.1.0 offline startup red/green probe, workspace typecheck and guard.

* fix(web): coalesce streamed calls in generation progress

Fold in-flight snapshots and terminal inputs through the existing tool-event reducer before rendering the Design Files progress feed. Keep the latest input, stable call identity and the limit of twelve distinct calls.

Reproduced the duplicate React key warning before the fix. Runtime and progress-feed regressions now pass alongside existing in-flight tool-row tests.

* fix(web): preserve forms and browser storage in prototype previews

Permit client-side form submission in opaque interactive previews. Route Web Storage artifacts through the existing isolated powered preview, including daemon hints for large files, and retain guards when isolation is unavailable.

Verified URL and srcDoc form interaction, persisted values after reload, and host isolation with focused Chromium regressions. Focused viewer/runtime and daemon tests, workspace typecheck and guard passed.

* test(web): keep srcDoc fixtures on the opaque preview path

Use an existing non-storage sandbox-shim signal in transport lifecycle fixtures now that Web Storage artifacts select the isolated preview origin. Preserve every reload, activation, recovery and manual-save assertion.

Validated all 338 cases in the three affected FileViewer test files, workspace guard and typecheck, and focused diagnostics.

* fix(byok): accept keyless IPv6 loopback providers

Recognize the bracketed IPv6 hostname returned by WHATWG URL parsing in both settings validation and the OpenCode BYOK runtime. Keep remote hosts key-required.

Validated with focused daemon and Web tests, settings browser checks, workspace typecheck and guard.

* fix(daemon): restore Pi sessions and preserve completed RPC turns

Load prior Pi history with switch_session and capture the authoritative sessionFile through nonblocking get_state. Treat a host-requested SIGTERM after a clean completed turn as shutdown, including Pi builds that report exit 143, while preserving real errors and cancellation.

Validated with focused protocol and public Run lifecycle tests, a real Pi generation and native resumed edit, workspace typecheck and guard.

* fix(preview): isolate project storage and preserve hidden controls

Give powered previews stable per-project loopback origins and reserve those hosts for matching powered GETs before API or static exceptions. Advertise the capability so newer Web clients retain the opaque fallback with older daemons.

Reuse the active powered transport in Present. Share a low-specificity hidden-control guard across URL and srcDoc previews without overriding explicit author rules or until-found; preserve doctype mode when synthesizing a missing head.

Validated with actual two-project storage, Workers, reload, Present and API isolation browser witnesses, form/hidden regressions, focused Web and daemon checks, workspace typecheck and guard.

* test(e2e): align public canaries with best-effort OD Next

Read the remaining recipe identity marker without the retired serialized package hash. Make the fake OpenCode help probe finite. Exercise the current public question-form action, completed question turns/new answer tasks, and no-artifact normal endings while retaining cancellation, artifact and deck assertions.

Validated with six fake-runtime cases and focused active/off public browser canaries with explicit rollout flags; no production completion policy or timeout was relaxed.

* fix(od-next): guide focus restoration after list updates

Prefer native modal lifecycle and restore focus to a current control after list rerenders, falling back to the primary action when a filtered item disappears. Real OpenCode and Pi prototypes exposed detached-trigger focus loss.

Keep this as best-effort build guidance, without output contracts or automatic repair rounds. Validated the actual artifacts through normal public edit tasks and browser interaction checks; profile resource tests passed.

* fix(od-next): retain focus through inline task updates

Extend the existing live-control guidance to inline list actions. Real Copilot and Qoder boards also lost keyboard focus when status updates rebuilt their cards; hidden filtered items need a visible fallback.

* fix: align Discovery integration tests and presentation storage

* test(daemon): tolerate transient memory origin cleanup races

---------

Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-10-22 05:14:59 +00:00
341641d71a [OPEND-3962] fix(motion): 修复预览声音与模板配乐约束 (#8777)
* fix(motion): avoid audio seek thrashing during preview stalls

* fix(motion): keep in-progress video previews silent

* fix(motion): let user briefs override template demo soundtracks

* fix(motion): keep project grid covers muted

All Projects grid and recent-project hover covers loaded motion sources
without odMotionThumbnail, so the embedded player autoplayed the
soundtrack. Covers now always request thumbnail mode: animation keeps
playing, audio stays muted until the project preview is opened.

---------

Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
2026-10-22 04:53:43 +00:00
Marc ChanandGitHub 2ba458503f [OPEND-4236] perf(pack): remove redundant checks and defer cache persistence (#8955)
* fix(pack): notarize macOS builds only once

* perf(pack): separate type validation and defer bounded Windows caches

* test(e2e): bound BuildKit cleanup job away from pack cache save

The deferred Windows pack-cache job checkouts the repo, so slicing
clean-closed-pr-buildkit to EOF made the no-checkout assertion fail.

Generated-By: looper 0.16.1 (runner=fixer, agent=omp)

* test(ci): bound cache cleanup assertions to their job

* fix(ci): preserve cache provenance when the release branch advances

* perf(ci): reduce compression work in Windows cache handoff

* fix(ci): align Windows cache restore and persistence paths

* perf(ci): retain latest complete Windows artifacts for repeat builds

* fix(ci): keep newer pack caches when ancestor saves finish later

Prefix pruning in cache-maintenance used cache creation time, so an older ancestor finishing after a newer same-ref release deleted that newer cache. Prune only when the validated source SHA is the current commit.

Generated-By: looper 0.16.1 (runner=fixer, agent=omp)
2026-10-22 03:58:10 +00:00
open-design-crew[bot]GitHubbone3deep1962-collabCheemsCheemszhouzhengWilliamopen-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com>NJUHua
3dfd41c6b3 [OPEND-2800][OPEND-4117] feat(od-next): integrate Discovery delivery and validated prototype fixes (#8804)
* fix(od-next): continue plans with keyed markers and honor explicit deliverables

* fix(od-next): retire output contracts and hand off continued tasks

* fix(od-next): record round settlement and recover production cold

* fix(od-next): resume marker sessions with context deltas

* fix(od-next): separate completion facts and honor plan-only intent

* fix(od-next): preserve physical failures and legacy chat compatibility

* test(web): align run recovery fixtures and await campaign mount

* fix(od-next): tolerate terminal production marker formatting

* fix(od-next): unblock follow-ups after a stop or unreadable history (#8423)

Treat a previous task this build cannot read as absent so the follow-up starts a new task, and stop counting a Run with a pending cancel as holding its conversation. Add end-to-end coverage for the marker continuation flow and restore the remote Workspace directory gate cases.

* feat(od-next): add deliverable discovery and ten-type routing

* fix(od-next): refine discovery and live delivery evidence

* feat(observability): expose actual task delivery receipts for evaluation

* fix(observability): settle successful headless task telemetry

* docs: clarify focused local validation

Keep local daemon and web validation scoped to affected files and integration boundaries. Reserve full suites for PR CI unless the user explicitly requests them.

* feat: add Motion Design skill and OD Next task route

* fix(web): rename motion design entry and place it second

* fix(web): reconcile motion design catalog consumers

* feat(web): add motion design templates to Home and Community

Five motion design example templates (3D procedural island, explainer,
product launch, showreel, data story), chosen by community reach, under the
Home motion-design chip and a new Community "Motion design" type.

Each template follows the bundled example layout: an OD-generated
example.html with its runtime libraries under assets/, an HTML preview baked
by the existing preview pipeline, and a SKILL.md that carries example.html
as the reference when the card is picked on the OD Next motion-design route.
Home cards ship curated copy for every UI locale. Motion templates stay off
the Video and HyperFrames chips.

* fix(skills): keep motion-design procedural layers visible in video export

The MP4 exporter seeks timelines with callbacks suppressed
(renderSeek(t, { suppressEvents: true })), so content drawn only from a GSAP
onUpdate callback is missing from exported video. Recommend a per-frame draw
loop that reads the timeline clock, and state that visible content must not
depend on timeline callbacks. The OD Next task asset is re-synced with
scripts/sync-motion-design-skill.ts.

* fix(motion): preserve reference assets and exported animation frames

* feat(skills): integrate curated motion helpers with complete runtime files

* feat(skills): add Huashu, Oil Motion and Motion.Lab adapters

* test: classify animate design guidance in narration guard

* feat(motion): refine five community templates and portable previews

* fix(motion): refine launch transitions and center gallery previews

* fix: allow free-form briefs on automatic motion task route

* feat(motion): add template soundtracks and audio export

* fix(motion): require rendered MP4 alongside editable video source

* test(motion): align strategy activation expectation with package version

* fix: scope motion delivery and make editable sources playable

* fix(motion): bundle source dependencies and valid closing protocol

* feat(motion): rebuild the five templates on the most-viewed community posts

Re-select topics by single-post views in the latest awesome-opus-5.5-video
snapshot and anchor each template to the top eligible post of its genre:
camera-focus explainer, showreel, product launch, procedural island and a
new wordless animated short that replaces the data story.

All five example.html files are hand-authored canvas/Three.js films drawn by
one render(t) from the GSAP master timeline, so preview, scrubbing and MP4
export produce identical frames. Each ships an original procedural
soundtrack, updated SKILL.md and 19-locale copy; Home order follows reach.

* fix(plugins): load bundled fonts in plugin previews

Plugin previews blocked every bundled font: the preview CSP had no font-src,
and relative url() references inside inline <style> blocks kept pointing at
the preview URL instead of the plugin folder. Allow same-origin fonts, map
inline-style url() references through the existing asset route (escapes,
absolute and data: URLs stay untouched), and serve font files with font
MIME types.

* feat(community): make motion template Use match the Home example pick

Community 「做同款」 on a motion design template now binds it as that type's
own example card, carrying the type's project metadata, so the run takes the
same OD Next motion-design route as a pick from Home's motion design list and
delivers the MP4 together with the editable source. Other task types keep
the existing plugin-pinning hand-off.

Community use clicks now record use_source (card | detail_modal) and
use_action (use | use_with_query); the details modal Use is tracked for the
first time. The Home hand-off no longer records a second community_gallery
click for uses Community already tracked.

* fix(plugins): count the five motion templates in the official registry metadata

* fix(media): keep HyperFrames audio filters compatible with FFmpeg 4.1

CI's bundled Linux FFmpeg 4.1 rejects adelay all=1 and amix normalize=0.
Make each track stereo and delay both channels explicitly, skip amix for a
single track, and undo amix averaging with a gain for several tracks.

* fix(motion): keep template previews bakeable on slow CI renderers

CI's software-GL baker skipped the 3D island (preview readiness timeout):
under slow frames GSAP lag smoothing slowed the timeline, so the 8 s
poster frame never arrived within the baker's 15 s wait. Play every
template on the wall clock and move the island poster to 6 s. With
simulated 300 ms frames all five now report ready within 10 s.

* feat(motion): add the kinetic typography template

Template selection now follows the motion design spec's four content types
(brand and product films, kinetic typography, explainer animation, procedural
art). Kinetic typography takes the fifth slot, replacing the animated short.
The new template is a 15-second, 120 BPM type film with an original
soundtrack and 19-locale copy.

* fix(motion): let the project motion player fit template stages

Inside a project the motion source player wraps the composition, fits it and
adds its own controls. The templates also scaled their stage, so editable
sources from Remix and Community use rendered at a fraction of the preview.
Templates now keep their own fit and controls only outside that player.

* feat(motion): restage the 3D island as a midday lagoon

The island moves from golden hour to a bright tropical midday closer to its
anchor post: a shader lagoon coloured by a baked depth map with ripples,
shallow-water shimmer and shore foam, a wide white-sand beach, palms and
instanced bushes, a village with a pier, soft sun shadows and a lower
camera path. The CPU wave mesh is gone, so frames cost no more than before.
Copy is updated in all 19 locales.

* fix(motion): keep each soundtrack inside the composition root

A Community use regenerated the island source tail and dropped the root
<audio> element, so its MP4 had no sound. Templates now place the element
inside the composition root next to the stage.

* docs(motion): state the template selection rule as complete works

* fix(motion): enforce video clocks and bound content verification

* feat(motion): replace the motion templates with remakes of viral posts

The five Motion design templates are now frame-faithful remakes of viral
motion posts, so each preview shows the look users came for: the 15-second
motion designer showreel, a high-energy motion reel (copy set in English), a
one-shape UI motion piece, a pixel-art wizard and a 20-second electric-blue
tech reel. Each ships open-licensed fonts, an original soundtrack, the
creator's published prompt where one exists, and credits its source post.

The explainer, product launch, 3D island and kinetic type templates are
removed. Tests follow the new set; a backward-seek check now covers hidden
WebGL layers.

* feat(motion): feature The Future Is Already Here first and start preview sound with the film

* chore: ensure PR CI

* fix(motion): match The Future Is Already Here controls and reorder the motion templates

* fix(motion): make the one-shape UI preview controls legible

* fix(motion): hide The Future Is Already Here controls inside the project player

* fix(od-next): maintain discovery prompt assets in English

* fix(motion): plan suitable sound and synchronize source audio playback

* feat(motion): keep remake soundtracks by default and fade exported audio where it stops

* test(motion): cover audible source controls in Chromium

* fix(test): use explicit ESM imports in audio player regression

* fix(od-next): preserve planning intent and make agent CLI probes inert

* fix(motion): align preview track discovery with video export

* feat(motion): autoplay looping HTML without a player bar

* fix(test): consume the built motion playback contract

* docs(motion): state explicit soundtrack rules for make-same and the music line in replies

* chore(motion): version the audible looping delivery profile

* docs(motion): let template sound rules lead the general policy and freeze the audio contract

* fix(motion): scope autoplay to declared video motion sources

* fix(motion): reject audible MP4s under an explicit silent contract

* docs(motion): drop the routine music note from make-same replies

* test(motion): align delivery regressions with looping source playback

* fix(od-next): route refreshable sources and preserve live templates

* fix(od-next): distinguish source refresh from preview reload

* fix(motion): isolate preview and audio export from legacy HyperFrames

* fix(od-next): retain live list containers for empty source data

* test(motion): retain the existing desktop legacy audio limitation

* fix(motion): suspend audio when previews leave the active workspace

* feat(motion): replace two motion templates with a product promo and a visual lesson; sign the showreel as Open Design

* fix(discovery): distinguish reference recreation from new prototypes

* fix(discovery): require skill reads before copying reference artifacts

* feat(motion): give the showreel the Open Design palette and a full-size stacked wordmark

* feat(motion): give the product promo a livelier mascot girl

* refactor(motion): rebuild The Future Is Already Here as readable, editable scene code

* fix(motion): pick gallery cover frames that survive the baker's capture delay

* feat(motion): sharpen the showreel opener, depth, truchet and end card

* fix(discovery): avoid implementation-only skills and false video delivery

* feat(motion): make the product promo mascot talk, add a speaking score and the flow pop

* fix(discovery): distinguish video rendering from editable animation delivery

* feat(motion): give the visual lesson the original's paper-collage texture

* fix(motion): bundle gallery covers for fresh branch checkouts

* feat(motion): feature and refine Bring Ideas to Life

Adapt the paper story around OpenDesign capabilities, with short rounded hands, clearer timing, continuous paper actions, and a larger audience invitation. Feature it first in Motion and synchronize localized names, metadata, cover, guidance, and regressions.

Keep product promo audio music-only with sparse percussion.

* fix(motion): stabilize artifact cards and capture declared covers

* fix(discovery): retain WebGL intent and isolate reference-only skills

* fix(discovery): complete authorized plans and verify audible delivery

* fix(discovery): gate skill reads on current production authorization

* fix(web): share creation type order with community

* fix(discovery): stop required-model attempts on authentication failure

* fix(discovery): retain required model provider and billing channel

* fix(discovery): stop opaque video failures without diagnostic generations

* fix(discovery): distinguish local state and tighten output adherence

* fix(preview): preserve local forms and hash routes in strict sandbox

* fix(prototype): keep validation feedback selectors consistent

* fix(prototype): preserve native hidden feedback states

* fix(discovery): align local routes and live filter states

* fix(prototype): preserve dynamic controls and dialog visibility

* fix(prototype): keep review and confirmation state coherent

* fix(web): stabilize preview activation and retain scoped local progress

* test(web): verify persistent previews through activation messages

* feat(web): refine task type selection and keyboard navigation

* fix(discovery): distinguish execution from requested delivery and expose recovery

* fix(web): use final run delivery state in folded task history

* revert(web): restore todo-based continuation controls

* revert(od-next): remove experimental continuation recovery changes

* fix(telemetry): bind Task traces to authenticated Vela identity

* test(telemetry): establish accepted Task roots in bridge fixtures

* test(discovery): align required CI fixtures with integrated behavior

* fix(web): preserve succeeded messages when run probe is unavailable

* test(discovery): verify selection clearing and production turn recovery

---------

Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: Cheems <hihicheems@gmail.com>
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: zhouzheng <zhouzheng@refly.ai>
Co-authored-by: William <zhouhaoran@refly.ai>
Co-authored-by: open-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
2026-10-21 09:05:01 +00:00
23cc8cb6a8 fix(e2e): compare motion assets with Buffer.equals (#8871)
Co-authored-by: ivy-ting <234137810+ivy-ting@users.noreply.github.com>
2026-10-20 03:54:26 +00:00
88b3d99314 fix(ci): harden Blacksmith Playwright apt setup (#8862)
Co-authored-by: ivy-ting <234137810+ivy-ting@users.noreply.github.com>
2026-10-19 11:23:58 +00:00
CaprikaGitHublefarcena1chztPerishCodeopen-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>alchemist
2b41bc5c3d feat(share): publish files as public links with stop/resume and synced comments (#8408)
* wip: wire deleted-file stop retry and cover owner-scoped HTTP behavior

* wip: persist published comment backfill state

* wip: connect backfill delivery receipts and preserve completed batches

* wip: expose exact-file backfill state and retry through HTTP and CLI

* wip: wire scoped comment sync pause into preview panel

* test: cover backfill terminal rejection and stale publication receipts

* wip: separate published content from unavailable viewer origin

* feat: distinguish publication success from unavailable viewer links

* fix(web): gate export share guide on authoritative project history

* test: restore publish backfill HTTP proof with current publisher pipeline

* fix(web): show authoritative stopped-share guidance

* fix(web): restore publish help before creating a share

* fix(web): show unread comments on collapsed rail

* fix: share annotated selectors across URL and srcdoc bridges

* test: verify saved comment selector reanchors in Chromium

* fix(web): warn about active shares before project deletion

* fix: authorize and bootstrap public share project bindings

* wip: prepare dispatched live share-chain probe (not run)

* fix(web): deliver per-file share residuals after deletion

* fix(web): prevent stopping shares in read-only viewers

* fix(e2e): retain complete share probe HTTP evidence

* fix(web): name shared projects in delete confirmation titles

* docs(e2e): clarify live share acceptance and human login boundaries

* fix(e2e): preserve share query context in diagnostic evidence

* fix(web): restore share-panel copy and comment-pin visuals per board8

chain1-publish G4: delete the stale "仅支持分享单个文件,内嵌图片等本地
资源暂不支持" sentence from fileViewer.publishSingleFileDescription (19
locales) — it contradicts the current multi-resource share and G4 required
its removal.

chain1-publish S1: add the missing "链接访问" title row + its static
description ("接收者可查看预览和已有评论,不包含对话和代码。") to
ShareTab's public-share section, as new fileViewer.linkAccessTitle /
linkAccessDescription keys (19 locales). No switch added — that control
overlaps the existing "停止分享" button and is left as a 需确认 item.

chain4-owner 4.2b: canvas comment pins go from 20x20/weight 600/2px white
ring/fixed #282828 to 28x28/weight 500/no ring, and now derive their
background/foreground from the same per-author color used by that author's
sidebar avatar (commentAuthorAvatarColor), both pure local CSS/TSX with no
packages/ or protocol change.

chain4-owner O7: formatCommentTime now buckets by calendar day (adds
"昨天", drops the "N 天前/N 周前" buckets, switches the hour bucket to
same-day instead of <24h elapsed, and formats older dates via
toLocaleDateString with year only when it differs from the current year),
and comment-side-time gets a title attribute with the full absolute
timestamp on hover.

Verification: pnpm --filter @open-design/web typecheck passes. Not run
through a real renderer — no pixel-level claim.

* fix(web): share entry icon, delete-confirm title, OP2 read-only hint

chain1-publish E0: swap the share toolbar entry's icon from RemixIcon
share-forward-line to the board's own upload-arrow glyph (13px path
"M12 4v11M7 9l5-5 5 5M5 14v5h14v-5") — same size/shape as the board's
.shareBtn icon, glyph only, no behavior change.

chain6-dialogs D-04: drop the extra "确定" from designs.deleteConfirm's
zh-CN/zh-TW title ("确定删除「{name}」?" → "删除「{name}」?") to match
the board and every other locale, which already read "Delete "{name}"?"
without an "are you sure" prefix.

chain4-owner OP2: add the missing "分享页评论不可编辑" explanation line
under a comment's textarea specifically when the viewer can't edit it
AND the comment is a share-page (external) one — team-member-reads-
external-comment (OP3) is unaffected, matching the board's OP2-only
scope. New comment.sharePageCommentReadOnly key, 19 locales.

Verification: pnpm --filter @open-design/web typecheck passes. Not run
through a real renderer — no pixel-level claim.

* test(web): update FileViewer specs for the G4/O7/4.2b restorations

Three specs encoded the exact pre-fix behavior the previous two commits
intentionally changed, so they went red:

- "reads the publish help without publishing" located the help bubble by
  the now-deleted stale sentence ("Only a single file can be shared for
  now..."); relocates it by the sentence that remains.
- "keeps relative comment-time boundaries stable across clock boundaries"
  asserted the old <24h-elapsed hour bucket; the O7 rewrite buckets by
  calendar day, so the fixture's 23h-ago comment (which crosses this
  fake clock's midnight) now correctly reads "昨天" instead of "N 小时前".
- "keeps D1-D4 marker and deck-page presentation on the rendered
  selectors" asserted the old 20px/weight 600/white-ring pin CSS; updated
  to the board's 28px/weight 500/no-ring geometry.

Verification: pnpm --filter @open-design/web typecheck passes;
tests/components/FileViewer.test.tsx: 334/334 passed (via
node_modules/vitest/vitest.mjs --pool=forks, run directly against the
node 24.18.0 binary — this shell's `npx`/`node` wrap nvm in a way that
blows bash's FUNCNEST under vitest's fork pool).

* feat(web): draw the export guide's countdown, title and description

The 10s countdown already existed as state - remainingMs, checkedAt and
paused were computed and consumed - but nothing rendered it, so the guide
showed no sign that it was about to dismiss itself.

The bar is one CSS width transition per run rather than a per-frame timer:
the remaining time is already known when the run starts, so animating to
zero over exactly that duration needs no re-render, and a pause just freezes
the transition.

Board copy added across all 19 locales.

* test(web): update AfterExportShareGuide specs for the P1 card restoration

Three assertions encoded the pre-restoration visual/DOM shape that
d2c72f1624 intentionally changed to match Board8 Chain-6-dialogs.dc.html's
P1 ("导出完成 · 分享引导条", `.s-m1eexportprompt .prompt[role=status]`)
mockup:

- role query changed region -> status: P1's `.prompt` div in the board
  markup carries `role="status"`, not the implicit `role="region"` that
  came from the old bare `<section aria-label>`.
- button count 3 -> 2, and the close-button-then-never-show sequence was
  replaced: the board's P1 card has no `.pclose` element (unlike the
  discarded EX1 alternative, which does have one) - its only controls are
  the `.pfoot` pair (`.pghost` "不再提示" / never-show, `.paction` "去分享"
  / open-share). The guide is still dismissible via never-show, open-share,
  or the unchanged 10s auto-collapse in after-export-share-guide.ts.
- dropped the dead `awaitingDesign` fixture key/assertion now that the P1
  design reference is in hand and the placeholder key has been removed.

Scoped run: 3 files / 40 tests passed via direct-node vitest (avoids the
nvm-wrapped node FUNCNEST issue under the fork pool noted in
od-side.md).

* feat(web): render the backfill, align, and shareStopped comment sync banners

CommentSyncBanner.tsx previously only rendered K8 (sessionMissing). The
backfill (K2), align, and shareStopped fields already existed on
CommentSyncState with i18n copy standing by, but no render branch consumed
them — this was flagged in .tmp/share-p0/visual-static/chain1-publish.md (K2)
and chain4-owner.md ("补传横幅/评论对齐横幅/分享已停用横幅").

Adds three branches, single-slot priority sessionMissing > shareStopped >
backfill(failed) > align(diverged), respecting the hard semantics documented
on packages/contracts/src/api/share.ts's CommentSyncState:
- backfill absent/pending/succeeded never renders a warning (absence is
  "never attempted", not success); the server already filters stale-revision
  backfill records, so any backfill this component sees is for the file's
  current publication.
- align absent or 'unknown' never renders as aligned or as a problem; only
  'diverged' is actionable.
- shareStopped: null (could not be read) never renders as stopped nor as
  healthy; only `true` renders.

Retry buttons call the existing retry endpoints (POST comment-sync-state for
backfill, POST comments/align for align) and re-fetch via a new
`refreshToken` option on useCommentSyncState. The backfill retry button is
only shown when `retryable === false`, per the field's own doc comment:
retryable:true means the server is already retrying on its own.

useCommentSyncState also gained an optional `filePath` param, threaded from
HtmlViewer's `file` prop through CommentSideDock -> CommentSidePanel, because
the backfill answer is per-file and the endpoint requires filePath to compute
it (readPublishedCommentBackfill returns undefined without one) — without
this wiring the backfill branch would be permanently unreachable regardless
of the new rendering code. This is plumbing an existing parameter to an
existing endpoint, not new triggering behavior.

Copy is the parent-approved text verbatim, added as three new i18n keys
across all 19 locales (fileViewer.commentSync.backfillFailedTitle/Body,
alignFailedBody); the retry button reuses the existing preview.retry key.

Tests: new tests/components/share/CommentSyncBanner.test.tsx (12 cases)
covering all silence/render rules above. Full apps/web typecheck, guard, and
tests/components/FileViewer.test.tsx (334/334) all pass.

Not done in this commit: the ShareTab panel's own shareStopped-driven hint
(a separate, pre-existing `publicationStatus === 'stopped'` render in
ShareTab.tsx using project-share-history data, not CommentSyncState) is
untouched — it is a different signal for a different surface.

* feat(web): add the board's "link access" switch to ShareTab

chain1-publish.md's S1/S4 rows show a `role=switch` "链接访问" toggle as
part of the panel's fixed structure that the current implementation never
had (flagged under chain1-publish.md's "需要人确认" #1 and chain6-dialogs.md
D-00). The user explicitly authorized adding it and asked me to resolve its
semantic overlap with the existing "Stop sharing" text button.

Resolution: the switch reads and drives the exact same `filePublished` state
through the exact same handlers already used by the primary button/ghost
button (publishCurrentFilePublic / unpublishCurrentFilePublic) — on -> off
calls unpublish, off -> on calls publish. Because both controls act through
the same two functions on the same state, they can never show or produce
inconsistent results; this is presentation only, not a new trigger path.

Did NOT remove the existing "Stop sharing" ghost button, even though the
board's own S4 mockup has no separate one — chain1-publish.md's own
"需要人确认" #2 explicitly warns not to delete it off this board alone,
since it may belong to a different board's scope (chain2's handoff notes
mention Chain-5). Keeping both is redundant but not contradictory, per the
task's own escape hatch ("你判断不该同时存在,那就说明理由并只做一个" —
here the judgment is the opposite: keep both, and make sure they can't
diverge).

Geometry/colors from chain6-dialogs.md D-00: 32x19 track, r999, off #D6D6D6,
on #282828, 15x15 white thumb. No new i18n keys — the switch's accessible
name reuses the existing `fileViewer.linkAccessTitle` key.

Also updates the pre-existing frozen DOM-baseline fixture
tests/fixtures/share-tab/share-panel.team.html. Confirmed via `git stash`
bisection that this fixture was ALREADY stale/failing before this commit —
the earlier "链接访问" title+description restoration (commit 721cb976cf,
prior round) changed the panel's markup without updating this baseline, and
nothing in CI caught it because this file wasn't in that round's targeted
test run. This commit's switch adds one more, unrelated divergence on top of
that pre-existing one, so the fixture is regenerated from the component's
actual current output rather than hand-edited, and captures both changes at
once. The `personal` fixture is untouched: canPublishPublic is false in that
scenario (no workspace context at all), so the entire link-access section
(switch included) never renders there, exactly as before.

Verification: pnpm --filter @open-design/web typecheck and pnpm guard both
pass. tests/components/FileViewer.share-tab-extraction.test.tsx (22/22),
ShareTab.copy-button/first-publish/publish-error/scope-keyboard.test.tsx,
tests/styles/share-scope-design.test.ts, and FileViewer.test.tsx
(394 tests total) all pass. Not rendered in a real browser — see the
evidence file for the "未经真实渲染验证" caveat.

* fix: scope deletion stops to the selected published file

* feat(contracts): define share bridge v2 geometry boundary

Define authoritative OD v2 viewport geometry, fallback pin bounds, display tokens and geometry-update gates. Add 16 passing boundary tests; root guard and typecheck passed.

Known pre-existing failure: share-contract.test.ts expects SHARE_SNAPSHOT_DISCOVERY_IN_P0=false while baseline bd39802907 exports true. The identical assertion was reproduced against unchanged HEAD source; this unrelated assertion is not modified. Vela runtime and browser acceptance remain separate.

* fix(comments): create anchors for personal public shares

* fix(comments): create anchors for personal public shares

* fix(web): prefer readable element names in comment titles

Use screen labels then element-text snapshots before localized type fallbacks. Keep synthetic pin/page titles and hide internal IDs. Export a pure resolver for share-page parity without new locale strings.

Verified 10 contract tests, all 334 FileViewer tests, all 19 existing locale key sets, root guard and root typecheck. Vela adoption and browser visual acceptance remain separate.

* fix: stop published files before file deletion

* fix: explicitly resume stopped publication generations

* Restore main comment UI styling while retaining share flows

* test: verify guarded resume through real Vela CLI

* fix(web): match main comment composer surface

* fix: register personal project owner before share history lookup

* fix(web): align comment avatar colors with shared page

* fix(share): revoke deleted sources through atomic resource removal

* fix(comments): seed client avatar colors only from authoritative author key

* test(share): expect the additive publish failure detail after merging main

#8402 adds an optional closed-token `failure` to failed publish responses.
The share-origin HTTP test compared the 502 body exactly, so it now also
expects { stage: 'push', reason: 'unknown' } for a synthetic upload error.

* test(contracts): assert deployed snapshot discovery in P0

* fix(share): revoke folder publications before deleting descendants

* fix(web): normalize share controls to supported font weights

* test(web): assert manual copy fallback after clipboard rejection

* test(share): align route fixtures with original owner authority

* feat(comments): expose explicit visitor comment pull to UI and CLI

* feat(cli): expose explicit project share resume command

* fix(cli): emit JSON for files delete

* refactor(share): name the unpublish response contract

* fix(comments): preserve Vela author key in inbound avatar identity

* test(share): use valid sharedAt timestamps in owner fixtures

* fix(share): use promise stat with production folder route fs

* fix(comments): use defined theme red for unread indicators

* fix(cli): preserve workspace context error codes

* fix(files): hide internal paths in deletion failures

* fix(comments): persist local author name and reconcile cloud author key

* fix(share): align owner sharing flows with final UI audit

* test(share): verify unboxed comment sync across owner states

* fix(share): apply second-round final design review to owner share UI

- Share panel: remove every yellow warning; show the public link row above
  the copy button in published states; oversized shares stay blocked and
  explain why in the red error row.
- Global toast for update success/failure and residual stop failure, with
  final design icons and copy; share panel buttons match the design.
- Comment sync: K1 turns the primary action into the sync progress bar, K4
  shows a busy "正在同步评论…" button, K5 retry is red.
- Deployment menu layers above the share popover; export-complete guide
  rebuilt to the final design.
- Share bridge pin label accepts exactly one letter so a CJK author's
  initial can label their pin.
- Update ui-audit capture specs for the intended copy/structure changes.

* fix(share): apply third-round design review to owner share UI

- Share toast rebuilt as ShareFeedbackToast to the design: solid red/green
  badge, plain underlined 重试, close; used for update success/failure and
  the deleted-project stop failure.
- The ··· deploy menu drops down under the trigger, right-aligned to the
  header tools, over the share panel.
- Signed-out share button reads 「登录后分享」; update notice loses its box;
  更新链接 / 登录后更新 / K5 retry use the design's blue and retry icon.
- Signed-out observed share reuses the published link row and copy button
  (chain icon, green check 已复制) and no longer overflows its card.
- Capture specs follow the intended copy and placement changes.

* fix(share): keep the signed-out share panel instead of re-homing on sign-out

Signing out on a shared file page intermittently landed on Home instead of
the signed-out share panel (Owner-S13), depending on which re-read returned
first:

1. The workspace-context refresh re-adopted the route's bootstrap witness
   from the previous account, re-keying ProjectView on the same file.
2. FileWorkspace treated that remount as a file switch and cleared the
   observed public link.
3. With no link left, the tab scope reset fell back to Home.

- useProjectRouteWorkspaceContext: a bootstrap witness issued before an
  identity refresh is retired and never adopted after it (the hook's
  documented rule for resolved authority now covers the witness too).
- isObservedShareFileSwitch: mounting or remounting a file view is not a
  file switch; only a change to a different file clears the observed link.

Red specs: hook re-adoption (red on previous source) and FileWorkspace
remount (red on previous source). S13 capture: 8/8 passes, was 2/7.

* fix(share): align remaining owner share copy and K1 sync count with final design

- S0: signed-out description uses the design copy via its own key; the
  publish-failure message keeps its original text.
- S5: update notice reads 「文件有新改动,访问者看到的还是上一版。」.
- K2/K5: the failed-sync notice drops the 「分享已发布」 title and uses the
  design copy; terminal variants keep their own wording.
- K1: first-publication sync shows 「同步已有评论 · N 条」. The backfill
  state carries optional total/synced from the daemon's backfill members
  table, driving a determinate bar when progress is known; older daemons
  fall back to the previous copy and the indeterminate sweep.
- Refresh the PR screenshot of the Share popup comment-sync state.

* chore(web): restore generated next-env.d.ts accidentally included in 30361fd670

* refactor(share): give each owner share UI element one owning component

Behavior-preserving consolidation of the owner share surfaces, verified by
pixel diff of all 17 Owner capture specs against the pre-refactor baseline
(every state within the known 0-0.25% noise from timestamps, placeholder
animation and the dev badge; the share panel itself is identical).

- Shared components: LinkAccessRow, ShareNoticeRow, ShareProgressButton
  (S2 upload + K1 sync), ShareErrorRow, ShareButton (primary / soft /
  soft-error / dark-sm on @open-design/components Button), ShareSignInButton
  over a new useCloudSignIn hook shared with CloudSignInTip.
- The popover skin moves from a :has(.panel) override to an explicit
  .chrome-unified-popover--share class; SignedOutObservedShare reuses
  SharePanelHeader and the extracted rows.
- Icons: every inline share SVG moves into components/Icon.tsx with
  byte-identical path data.
- Tokens: styles/share-tokens.css holds the share palette and sizes as fixed
  light values (not app theme tokens, so dark mode renders as before); share
  CSS modules reference them. Single-use decorative values stay local.
- Delete the unreachable React-component share popover (canShareCurrentFile
  is false by design, Owner-G3) and its dead state and handlers.
- Toasts: share-adjacent feedback uses ShareFeedbackToast; top toasts share
  one stacking order instead of overlapping at top:64px.
- Static CSS-contract tests read share styles through readShareCss, which
  resolves share tokens to the design values they assert.

Allowed visual changes (existing defects):
- The sign-in entry is a real button; signing/error messages render below
  it instead of inside a nested button (UI-040).
- The comment side panel's retry-failed message is red (UI-042).
- Simultaneous top toasts stack instead of overlapping (UI-039).
- 「正在开启…」 goes through i18n (UI-041).

* feat(daemon): resolve share links only from the Viewer origin

Public share links come from OD_SHARE_VIEWER_URL(S) (HTTPS root origin)
as https://<viewer>/artifact/{projectId}/{slug}; the console origin is
no longer a fallback. GET publish-public derives the URL from the
resolver instead of the stored row, so pre-upgrade console URLs are
never echoed, and reports link unavailable plus the slug when no Viewer
origin is configured. Remotely stopped rows are not live. Manual-revoke
recovery data carries projectId and a nullable url. od project share
prints the slug in the unavailable state. Test fixtures move from
OD_VELA_WEB_URL to OD_SHARE_VIEWER_URL.

* feat(web): show a link-unavailable state for URL-less publications

A publication is published when it has a slug, not a URL. Without a
Viewer origin the share panels show a translated link-unavailable
status with Stop sharing, never the Publish CTA, so the file is not
uploaded twice. registry normalizes the publish and read responses to
{slug, url|null}; auto-copy is skipped without a URL.

* feat(tools-dev): inject a default share Viewer origin for local daemons

The default stays empty until the test Viewer domain is decided
(decision 67 #7), so local shares report link unavailable. An operator
OD_SHARE_VIEWER_URL(S) always overrides it.

* feat(daemon): parse C3-LITE member comment pages in both collab transports

Add a single-page member pull to the Vela CLI and HTTP collab clients
that returns either a typed page or a rebuild-required signal.

- The page envelope is validated strictly; malformed comments are
  skipped per item and reported, so one bad stored payload cannot
  block the cursor.
- Minimal tombstones ({id, seq, deleted}) are accepted and get the
  requested projectId.
- INVALID_CURSOR / CURSOR_STALE / SCOPE_CHANGED map to
  rebuild-required; INVALID_PAGE_REQUEST stays an error.
- Paged requests send no If-None-Match.

* feat(daemon): drain member comment pages into SQLite atomically

Team projects on the Vela CLI transport now receive member comments
through bounded C3-LITE pages instead of the unbounded legacy pull.

- Each page's merges, tombstone deletes and cursor advance commit in
  one immediate transaction; the persisted cursor is the cloud's
  stateless page/resume token.
- The drain continues through empty hasMore pages, hands a finished
  snapshot off to an incremental round, and stops at the end of an
  incremental round.
- A remote tombstone deletes a row that has a pin_seq; unknown ids
  are a no-op.
- A wake during a pull triggers exactly one trailing rerun.
- Merge notifications fire only for committed, changed rows.
- Share-page (user) comments stay on the legacy pull until public
  paging exists. The legacy pull also keeps carrying member comments
  whenever paging did not complete, and a cursor that needs a
  rebuild is latched until the rebuild path lands.

* feat(daemon): rebuild member comment cursors and isolate sync scopes

An unusable member cursor is no longer latched. It is reset and rebuilt
from a fresh snapshot in the same drain.

- A per-scope ledger records the ids the member stream delivered. The
  terminal snapshot page, in the same transaction as the handoff cursor,
  removes cloud-origin rows the snapshot no longer contains. Rows the
  stream never delivered (never-synced local comments, share-page
  comments, rows another account learned) are never pruned, and
  nothing is deleted before the snapshot completes.
- Writes committed during the snapshot arrive through the handoff
  incremental round and are not pruned.
- Scope is (workspace, member, team, project). A scope must prove it is
  current before taking a project over. Every page is re-checked after
  its await and before its commit (active scope, team-share binding,
  bound context), so an old account's or unshared project's in-flight
  page is discarded.
- Account switch and stop sharing drop that scope's cursor and ledger
  but keep the local comment rows as local copies.
- INVALID_PAGE_REQUEST and repeated transport failures back off per
  scope (bounded exponential, capped at 5 minutes). A refused rebuild
  snapshot backs off instead of looping.

* feat(daemon): announce comment merges once per pull round

A member page drain used to fire `onMerged` after every committed page,
and the legacy pull fired once more, so one 3-page drain of 250 comments
pushed three or four `comment-changed` events and cost the open web view
as many full list reads.

- Every row a round commits (member pages, prune, tombstones, legacy
  share-page rows) is summed into one `onMerged` call when the round
  settles, including rounds that pause, lose their scope or throw after
  some pages committed. A trailing rerun is its own round.
- A throwing listener is reported through `onError` and neither masks
  the round's own error nor turns a successful round into an unredeemed
  wake.

* feat(web): single-flight the comment list refresh and keep unchanged comments

Every comment trigger (project SSE, fallback poll, visibility, stream
reconnect, status patch) started its own full list read; the generation
guard only discarded stale answers. A burst of 10 `comment-changed`
events cost 10 reads, and a refresh that found nothing new still
published a new list and re-rendered.

- `refreshPreviewComments` goes through a single-flight runner keyed on
  project and conversation: a trigger during a read buys exactly one
  trailing read with the latest closure; a different conversation starts
  its own read and the old loop stops rerunning.
- Fresh reads are reconciled into the current state: unchanged comments
  keep their objects, an unchanged list keeps its array (no re-render),
  and attached comments follow the same rule. The initial conversation
  read reconciles too.
- The remote half of an explicit pull has a 15s deadline and falls back
  to the local list, so a stalled daemon pull cannot hold every later
  refresh behind it.

Measured in ProjectView tests (10 SSE events during one read): 10 -> 2
list reads; an equal-data refresh: 1 -> 0 new lists/renders.

* fix(daemon): make a published private team project team-visible before its comment backfill

Publishing a public link for a private project in a team workspace registers
it in the team catalog, but only the later background reconcile marked the
local row team-visible. Until then the row was personal in a team workspace,
which is neither Team- nor personal-relayable: the publish backfill was
cancelled (BACKFILL_DELIVERY_DISCARDED) and comments written right after
publishing were never queued.

- publish-public marks the creator's private row team-visible right after
  catalog registration, before the publication transaction enqueues the
  backfill (decision 67 #11); the response carries madeTeamVisible.
- The relay re-queues a personal record as Team relay once its creator's
  project turned team-visible, and defers (bounded) a personal record in a
  team workspace whose publication is still current instead of cancelling it.
- A cloud seq is never adopted as pin_seq when another confirmed row of the
  file already holds it, which produced two pins numbered 1.

* feat(share): tell the owner a public link makes a private team project team-visible

The share panel (and the React component viewer's share menu) shows
"After publishing, team members will be able to see this project" before
the first publish of a private project in a team workspace, in all 19
locales, and updates the scope control and team project views once the
daemon reports madeTeamVisible. `od project share publish` prints the same
notice to stderr and --json keeps the madeTeamVisible field.

* feat(packaged): hand the daemon its channel's share Viewer origin

Packaged desktop and headless daemons get the share Viewer origin for
their release channel: stable sends OD_SHARE_VIEWER_URLS with the prod
profile mapped to https://open-design.app; other channels send nothing
until the test Viewer domain is decided (decision 67 #10), so shares
report link unavailable. An explicit OD_SHARE_VIEWER_URL(S) at launch is
forwarded unchanged instead. The daemon resolver stays the only
validator. The legacy payload-desktop handoff strips both keys so a
channel default never becomes a sticky override.

* fix(daemon): report madeTeamVisible when a catalog reconcile marked the project first

Registering a private team-workspace project in the team catalog lets a
concurrent catalog reconcile (hub team-projects-changed, a project list
read) rebind the local row to team before publish-public marks it. The
mark then changed nothing and the response omitted madeTeamVisible, so the
share panel kept showing "only me" and the CLI printed no notice. Seen in
the local integration rerun on the first UI publish.

publish-public now reads whether the row is the creator's private team row
before registration, and reports madeTeamVisible when that row is the
creator's team row afterwards, whichever writer flipped it. A project that
was already team-visible still reports nothing.

* fix(daemon): order synced comment revisions by cloud seq so replays never undo newer state

A share-page comment resolved at cloud seq 10 reverted to open after a daemon
restart: the legacy pull cursor is in memory, so the restarted daemon replays
from seq 0, and the merge stamped Date.now() on the share-page create (which
carries no updatedAt), making the replayed create look newest.

Each preview_comments row now records the highest cloud revision seq it
reflects (cloud_revision_seq) and the seq space it came from
(cloud_revision_stream: relay team + member stream epoch). An incoming
revision at or below that seq in the same stream is a replay and never
overwrites the row. Unseen revisions keep updatedAt last-writer-wins when they
carry an updatedAt; one without updatedAt applies in seq order and is stored
with its createdAt, never the local clock. pin_seq is untouched.

* feat(share): take the canonical share link from AMR

The daemon no longer builds the public share address from a hardcoded or
per-channel Viewer origin. AMR reports it: `vela share publish|bind|resume|
project-status --json` carry `url` (https://<share origin>/artifact/{projectId}/{slug})
for an active binding, or `link: {status: "unavailable", code}`.

Link rule, one place (resolvePublicShareLink): an explicit
OD_SHARE_VIEWER_URL(S) override wins (a present invalid override is
unavailable, never a fallback); otherwise the AMR address, accepted only when
it is byte-for-byte the canonical HTTPS root-origin address of this project
and slug; otherwise unavailable, passing AMR's code
(PUBLIC_SHARE_WEB_URL_UNAVAILABLE / PUBLIC_SHARE_IDENTITY_INVALID) through.
A rejected address is logged without echoing it.

The verified AMR address is persisted with the publication and refreshed on
publish, foreground bind/resume, the background binding retry and every
publish-public status read, without rotating the publication witness. When
the status read fails, GET falls back to the persisted link marked
`stale: true` (not for a queued stop); a stored legacy console URL fails
re-verification and stays hidden until the next online read replaces it.

Packaged daemons no longer get a per-channel Viewer origin and tools-dev has
no default; an explicit launch override is still forwarded. `od project share`
prints the same link, a stale notice on stderr, and says when an alias has no
Viewer address.

* fix(share): keep the link access switch thumb at the left when off

* fix(share): settle the share status after a stop or resume in the open panel

* fix(comments): keep a comment that was handed to the agent

Sending a saved comment to the agent deleted it as soon as the send was
queued, so a run that failed or was stopped left nothing to retry and
pushed a tombstone to the share page.

The hand-off is now carried by status alone. The three send paths no
longer delete after queue acceptance; they only clear the composer and
the selection for the accepted ids. A run that fails, is reported
canceled, or is stopped by the user releases its comments back to open
through releaseCommentsAfterUnsuccessfulRun. A successful run still ends
in needs_review.

* fix(comments): keep an unsent comment draft when another element is picked

Picking another element, lassoing, opening a saved pin, or selecting a
comment in the side panel retargeted the composer at once and dropped
the draft, queued notes, and context without a word.

All four pick paths now retarget through requestComposerRetarget. While
the composer holds unsent work the first pick changes nothing and shows
a notice inside the composer; the next pick confirms the discard and
retargets. Editing the draft disarms the pending discard. Closing the
composer, Escape, and switching files behave as before.

* fix(daemon): walk nested HTML in share/deploy plans and refuse root index.html conflicts

Referenced .html/.htm files (iframes, frames) are now walked like the entry:
their references resolve against their own directory at any depth, so their
stylesheets, images and child pages ship and missing/invalid ones are
reported. Share mode rewrites their root-absolute references relative to
their own directory and maps references to the entry to the published
index.html; standalone deploy keeps their bytes.

Parsing is deduped on each file's real path, which bounds the walk even with
in-project symlink loops (x -> .) and also fixes the existing unbounded CSS
@import recursion through such links.

A reference to an existing root index.html while sharing another page no
longer overwrites the entry: the plan records the conflict (referrer file,
attribute, line, suggested free name) and deploy refuses it with 409
ENTRY_INDEX_CONFLICT carrying a copy-to-agent fix prompt.

* fix(daemon): refuse share entry/index.html conflicts before reserving

The share plan summary carries entry-index-conflict blockers (with the agent
prompt), and publish-public now builds the plan, and refuses on size or
blockers, before reserving an alias or preparing Vela. A conflict returns
409 SHARE_ENTRY_INDEX_CONFLICT with the details in error.data. Freshness is
not compared against a blocked plan.

* fix(daemon): print share blockers and agent prompt in the od CLI

od project share preflight prints each blocker with its copy-to-agent
instruction and exits 1 when blockers exist (also with --json). A publish
refused with SHARE_ENTRY_INDEX_CONFLICT prints the instruction readably in
text mode before the structured error envelope.

* fix(web): explain share index.html conflicts with a copy-to-agent action

The share panel shows the entry-index-conflict blocker from the share plan or
a 409 publish refusal, disables publishing while it stands, and copies the
daemon's agent prompt (falling back to showing it for manual copy). New
strings are translated in all 19 locales.

* fix(comments): release a comment whose queued send never becomes a run

A saved comment is written 'applying' when its send is queued, before any run exists. The release added for unsuccessful runs only fired on run end, cancel and stop, so a send held by the pre-run balance gate, stopped or failed before its run was created, or taken out of the queue left the comment 'applying' and out of the comment list for good.

Queue drains now go through startQueuedChatSend, which releases the comments of a send that ended without a run. A send the gate holds no longer reserves its comments, and removing or editing a queued send releases what it carried. A held send that starts later reserves them again when its run begins.

* fix(share): keep a reopened share busy until its link has been read

A resume answers without a link, so the panel re-reads the share state. The busy state ended with the resume response, and for the seconds the re-read took the panel said the link was temporarily unavailable before swapping to the link.

settlePublicShareAfterMutation now writes the active state only once the re-read has settled, and the publish flow stays busy on it. A re-read that fails or carries no link still ends on link unavailable, without an error.

* fix(web): size the copy-for-agent button to its longest label

The soft share button is a fixed 88px slot sized for short labels, so
"Copy fix for agent" overflowed in English and longer locales. Let the
button grow to fit, and stack both labels in one grid cell so the width
stays put while "Copied" shows.

* test(e2e): reopen a stopped share through the switch in the share audit specs

The share audit specs stopped a share inside the panel and then clicked
the publish button to bring it back, which is the behaviour the panel no
longer has: a stopped share reads as stopped and reopens through the link
access switch with a resume request.

The mocked publish responses also lacked the receipt the client parses,
so they now return the real response shape.

* test(share): align CI cases with current share UI

* test(share): align CI cases with current share UI

* test(web): pin comment time boundary to local noon

* fix(share): allow reopening saved comment after save

* fix(daemon): wallet 401/403 no longer expires the AMR session

Billing is display-only (OPEND-3553). Vela maps internal billing
failures to 403, so a wallet rejection now only reports the balance as
unavailable instead of marking the credential expired, which dropped
workspace context and redirected to sign-in.

* fix(daemon): billing summary failure no longer expires the AMR session

A failed 'vela billing summary' (even an auth-shaped 401) only drops the
optional plan/balance from /status. Credential expiry stays owned by the
non-billing workspace directory path (OPEND-3553).

* fix(daemon): billing projection 403 no longer flushes workspace authority

A rejected workspace billing snapshot only marks the billing runtime
entry. It no longer invalidates the workspace directory, exact authority,
or exact context caches with auth_reject; Vela also maps internal billing
failures to 403, and membership revocation stays owned by the directory
and the hub workspace-access-revoked event (OPEND-3553).

* fix(daemon): carry share content fingerprint across stop and resume

Resume reopens Vela's saved version of the same alias but writes a new
local publication revision, so the fingerprint row (keyed to the old
revision) made freshness permanently 'unknown' and hid the update entry.
Rebind the stored fingerprint to the resumed revision of the same slug so
later edits read as 'outdated' (OPEND-3517).

* fix(daemon): redact identity argv from vela command failures

Node's exec error repeats the full command line in message, cmd and
stack, and presence/member/catalog callers logged it verbatim, leaking
--display-name and activity payloads. The vela command runner now
rewrites the values of --display-name, --name, --email, --activity-json
and --metadata-json before rejecting (stderr and the upstream status
signal are untouched), and the presence failure log records only the
classification and upstream status (OPEND-3520).

* fix(daemon): refuse plain publish of a stopped share before uploading

When Vela reports the file's share as stopped, a plain publish-public POST
now answers 409 SHARE_STOPPED_RESUME_REQUIRED before pushing bytes, since
Vela rejects it with 409 share_binding_stopped anyway; reopening the link
is the explicit { mode: 'resume' } request. The share publish wrapper also
keeps the upstream 'API request failed with status NNN: code' line so the
failure classifies as upstream_http instead of reason=unknown (OPEND-3510).

* test(daemon): satisfy test typecheck for batch-2 share fixes

* fix(daemon): keep vela argv redaction from crashing on non-string args

Some media callers pass optional argv slots through as undefined. The
redaction must never replace the real command failure, so it skips
non-string entries (caught by vela-media-safety-process tests).

* fix(daemon): increase comment id entropy

* fix(share): keep deployment menu alive through pointer press (OPEND-3537)

* fix(share): unify owner Share entry glyphs (OPEND-3535)

* fix(share): show pending link-stop feedback (OPEND-3513)

* fix(share): retain scoped preflight across panel reopen (OPEND-3536)

* fix(comments): wait for preview targets before lost writeback (OPEND-3516)

* fix(share): restore Owner toolbar hierarchy (OPEND-3580)

* test(share): align toolbar style contract with C0 (OPEND-3580)

* fix(web): preserve owner scroll after comment location

* fix(cli): distinguish share size refusals from connection failures

* fix(web): separate share eligibility and preserve comment identity UX

* test(cli): preserve daemon connection failure exit code

* fix(share): expose owner-scoped persisted stop tasks

* fix(contracts): bound share anchors at original selector lengths

* fix(web): recover pending deleted-share stop notices

* fix(contracts): define atomic share pin snapshot metadata

* test(web): provide retry preflight fixture and await completion

* fix(web): keep billing upgrade outside account identity boundary

* fix(share): protect staging and restore client continuations (#8675)

* fix share staging and client continuations

* docs: clarify share batch verification baseline

* fix(web): notify when sending to a deleted project or conversation

* fix(share): dismiss card share popover on conversation scroll (OPEND-3784) (#8689)

* fix(share): keep anchored popover geometry current (OPEND-3783) (#8694)

* fix(share): expose freshness checks and parallelize state reads (OPEND-3791) (#8708)

* fix(share): preserve safe publish errors and actionable messages (OPEND-3796) (#8709)

* fix(share): preserve safe publish failure details and actionable messages

* test(share): align failure copy expectations and cover classified messages

* fix(share): retain Button binding required by CI merge

* fix(share): add bounded state-read and resume diagnostics (#8710)

* Revert "fix(share): expose freshness checks and parallelize state reads (OPEND-3791)" (#8711)

This reverts commit 8f6c09db39.

The reported latency bottleneck remains unproven. Revert the full PR at the user request and defer presentation optimization. Preserve the subsequent OPEND-3796 changes.

* fix(share): preserve entry binding checks when opening Share (OPEND-3594) (#8712)

* fix(share): report preflight failures and allow explicit retry (OPEND-3771) (#8716)

* fix(share): use green for update-link action (OPEND-3790) (#8717)

* fix(share): retain confirmed PDF export guide during history refresh (#8719)

* fix(analytics): add passive share and comment observations (#8723)

* fix(analytics): define passive share observations and contain measurement failures

* fix(analytics): observe share receipts and selected comments without changing actions

* fix(web): isolate after-export guide observation from actions

* fix(analytics): connect passive guide observations and document minimized fields

* fix(analytics): observe saved comment creation and run handoff passively

* fix(analytics): exclude edited composer drafts from saved-comment attribution

* fix(e2e): align Team run fixtures with authoritative workspace catalog

* fix(analytics): wait for comment acceptance readiness in regressions

* fix(share): 修复超限评论恢复与资源读取失败,补验四项验收问题 (#8721)

* fix(share): recover oversized comments and reject unreadable dependencies

* fix(share): close real export completion and cloud failure gaps

* test(share): align export and delete relay regression assertions

* fix(export): handle interrupted ZIP saves in preview actions

---------

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

* test(share): align export failure assertions with current copy

* fix(share): guard user-scoped export guide dismissal (OPEND-3579) (#8738)

* fix(share): guard user-scoped export guide dismissal (OPEND-3579)

* test(web): align export guide failure assertions with product copy

* test(web): settle restored share state before publish assertions

* fix(chat): keep artifact Share actions static (OPEND-3854) (#8747)

Keep ChatPanel artifact Share actions static and remove card-only publication state tracking. Preserve the existing share panel and readonly/generation gates.

* Merge pull request #8750 from nexu-io/fix/opend-3888-comment-age

fix(web): preserve comment creation age (OPEND-3888)

* fix(comments): persist new static comment identities (OPEND-3516) (#8768)

* fix(comments): preserve authored identities through manual and AI edits

* fix(comment): prepare durable static source identities (OPEND-3516)

* fix(comments): integrate durable identity lifecycle (OPEND-3516)

* fix(comment): validate authored HTML identity tokens (OPEND-3516)

* fix(comment): mask raw text before authored tokenization (OPEND-3516)

* fix(comments): enforce prepared document and publication barriers (OPEND-3516)

* fix(comments): revalidate pending documents and guard updates (OPEND-3516)

* fix(comments): require app-owned source preparation consent (OPEND-3516)

* fix(comments): contain preparation consent focus (OPEND-3516)

* fix(sharing): diagnose project bootstrap failures safely (OPEND-3516)

* fix(comments): retire stale publication guard alerts on explicit retry (OPEND-3516)

* fix(comments): locate Personal identities in the live preview (OPEND-3516)

* fix(comments): surface lost Owner identity anchors (OPEND-3516)

* fix(comments): unblock publication after strict target loss

* fix sharing to keep file publication independent of team visibility (OPEND-3855) (#8759)

* fix sharing to keep file publication independent of team visibility (OPEND-3855)

* refactor sharing to one explicit policy path (OPEND-3855)

* test: isolate metadata snapshots from the trailing identity directive

* fix: align collaboration consumers with explicit project visibility

Carry trusted catalog roots through content receivers and suppress stale Team rows after explicit privacy changes. Read shared design-system brand assets from their resource snapshots. Align stopped-publication ordering and protocol/browser fixtures with the existing contracts.

* test: await explicit comment preparation and independent UI states

Complete the existing source-version consent and real frame-readiness flow before opening the comment sidebar. Bind the response waiter to the UI actions so failures retain their real cause. Await the independently loaded account callout and assert exact catalog-bound materialization wiring.

* test: expect private policy when unsharing a team project

Await the explicit visibility-private command instead of deleting the retained root registration. Assert that remove was not invoked and preserve all member quarantine, directory and file-read privacy checks.

* fix(comments): synchronize file lifetimes across member and public shares (#8764)

* fix sharing to keep file publication independent of team visibility (OPEND-3855)

* fix(comments): synchronize current file lifetimes across members and shares

OPEND-3870 / OPEND-3871. Stack on published OPEND-3855 dependency; preserve reviewed source tree exactly. Local validation does not establish PostgreSQL or native acceptance.

* refactor sharing to one explicit policy path (OPEND-3855)

* fix(collab): adapt comment sync to converged project lifecycle

* test: isolate metadata snapshots from the trailing identity directive

* fix(collab): preserve nested publication and pulled file authority

* fix: align collaboration consumers with explicit project visibility

Carry trusted catalog roots through content receivers and suppress stale Team rows after explicit privacy changes. Read shared design-system brand assets from their resource snapshots. Align stopped-publication ordering and protocol/browser fixtures with the existing contracts.

* fix(collab): preserve relay identity and retire captured file batches atomically

* test: await explicit comment preparation and independent UI states

Complete the existing source-version consent and real frame-readiness flow before opening the comment sidebar. Bind the response waiter to the UI actions so failures retain their real cause. Await the independently loaded account callout and assert exact catalog-bound materialization wiring.

* test: expect private policy when unsharing a team project

Await the explicit visibility-private command instead of deleting the retained root registration. Assert that remove was not invoked and preserve all member quarantine, directory and file-read privacy checks.

* fix(collab): retain the actual native root during mirror materialization

* fix(collab): fail closed on unbound active share identity

* fix(collab): retire comment lifetimes after local mutation commits

* fix(collab): commit local artifact invalidation with working-root changes

* fix(collab): reconcile committed root retirement without stopping new links

* test(collab): align CI fixtures with admitted file identities and receipts

* test(collab): model receipt-backed root file admission in the shared hub

* test(collab): distinguish bound and unbound publication recovery

* chore(pack): pin compatible test CLI and exclude dependency dotenv files

* test(share): verify paired CLI policy and immutable share contracts

* fix(share): remove comment preparation interruptions

* fix(web): offer after-export guide when sharing history fails

* fix(comments): preserve unknown ages and repair synced creation times

* style(web): restore comment reference visuals without changing behavior

* test(web): verify colored avatars and document deployed parity

* fix(share): collect literal JavaScript image dependencies

* fix(web): match canvas comment pins to author avatar colors

* test(comments): align existing UI and E2E color expectations

* test(share): align collab flow and snapshot with direct comments UI

* test(campaign): await verification before advancing impression frames

* test(cms): await verification and mount before impression frames

* fix(web): match canvas comment initials to sidebar avatars

---------

Co-authored-by: PerishCode <perishcode@gmail.com>
Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: alchemist <alchemist@alchemistdeMacBook-Pro.local>
2026-10-19 06:15:28 +00:00
1eaa2381aa feat(release): validate version-only bumps before approval-exempt queueing (#8800)
Co-authored-by: lefarcen <935902669@qq.com>
2026-10-16 04:11:27 +00:00
CheemsandGitHub 6410d3622a fix(od-next): restore production follow-up host instructions (#8770)
* fix(od-next): restore production follow-up host instructions

Compose production host instructions with the same fresh key persisted on the run, including native resume, cold-start input, and UI locale. Keep the instructions independent of retired model state declarations and exclude the nonce-bearing text from internal continuation retry identity.

Verify optional suggestions, message persistence, and repeated preparation through the real server and run registry. Validated with main red specs, focused regression tests, guard, and the workspace typecheck.

* test(od-next): preserve production test entry points

Keep the existing production test header and fixture setup while sharing host-protocol assertions with a separate suggestions case. This avoids adding a conflict with the pending agent rollout tests.

Validated with the focused real-server regressions, guard, and the full workspace typecheck.
2026-10-15 09:28:23 +00:00
Ray XiandGitHub 784025c04a fix(daemon): resolve the AMR default model from the caller's catalog (#8519)
* fix(daemon): resolve the AMR default model from the caller's catalog

A run that asked for `default` before the caller's `vela model list` had
arrived was concretized from the bundled `vela model preset` seed. The
seed is the same for every plan and OD orders deepseek-v4-flash first, so
free-tier callers (entitled only to deepseek-v4.1-flash since 09-24) had
Vela reject `session/set_model` before any work began. On 0.24.1 that was
22 runs from 21 users in the last 7 days, 13 of 16 bundled runs showing
the user's choice as `default`.

- When `default` is requested and only the preset is cached, wait up to
  12s for the caller's remote catalog (`getAuthoritative`).
- Remember remote catalogs separately; a later preset seed never displaces
  one when a default model is chosen.
- Order deepseek-v4.1-flash first: it is the one chat model every plan,
  including free, is entitled to, so a flagless list still lands on it.

* fix(daemon): bound the AMR default-model catalog wait per refresh

CI follow-up. A `default` run looked the catalog up twice (the resume probe
and the run itself) and each lookup waited up to 12 s for the caller's
remote catalog, so a slow or broken `vela model list` held the run queued
for up to 24 s. The AMR e2e runs timed out at 20 s that way.

- The wait is counted from the start of the remote refresh, so one run, or
  several concurrent runs, share a single wait.
- When the last refresh failed, the preset is used at once; the next
  refresh still starts in the background.
- e2e fake vela: answer `model list --all --format json`, the form the
  daemon actually sends. It only matched the flag-less form and hung.

* test(e2e): let the fake vela answer the daemon's `model list --all` probe

The catalog probe spawns `vela model list --all --format json`. The matcher
stripped `--all` with its preceding space only, so `' --format json'` never
equalled `'--format json'`: the probe fell through to the login check, the
cache recorded a failed refresh, and default runs settled on the preset. Match
the subcommand and the `--format json` flags in any order, as fake-vela.mjs and
turn.test.ts already do, and cover the exact daemon argv for both the catalog
and the 401 path.
2026-10-15 03:57:51 +00:00
open-design-crew[bot]GitHubbone3deep1962-collabWilliamopen-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com>NJUHuaAmy
14fee234e6 feat: add motion design workflow and curated motion skills (#8619)
* feat: add Motion Design skill and OD Next task route

* fix(web): rename motion design entry and place it second

* fix(web): reconcile motion design catalog consumers

* feat(web): add motion design templates to Home and Community

Five motion design example templates (3D procedural island, explainer,
product launch, showreel, data story), chosen by community reach, under the
Home motion-design chip and a new Community "Motion design" type.

Each template follows the bundled example layout: an OD-generated
example.html with its runtime libraries under assets/, an HTML preview baked
by the existing preview pipeline, and a SKILL.md that carries example.html
as the reference when the card is picked on the OD Next motion-design route.
Home cards ship curated copy for every UI locale. Motion templates stay off
the Video and HyperFrames chips.

* fix(skills): keep motion-design procedural layers visible in video export

The MP4 exporter seeks timelines with callbacks suppressed
(renderSeek(t, { suppressEvents: true })), so content drawn only from a GSAP
onUpdate callback is missing from exported video. Recommend a per-frame draw
loop that reads the timeline clock, and state that visible content must not
depend on timeline callbacks. The OD Next task asset is re-synced with
scripts/sync-motion-design-skill.ts.

* fix(motion): preserve reference assets and exported animation frames

* feat(skills): integrate curated motion helpers with complete runtime files

* feat(skills): add Huashu, Oil Motion and Motion.Lab adapters

* test: classify animate design guidance in narration guard

* feat(motion): refine five community templates and portable previews

* fix(motion): refine launch transitions and center gallery previews

* fix: allow free-form briefs on automatic motion task route

* feat(motion): add template soundtracks and audio export

* fix(motion): require rendered MP4 alongside editable video source

* test(motion): align strategy activation expectation with package version

* fix: scope motion delivery and make editable sources playable

* fix(motion): bundle source dependencies and valid closing protocol

* feat(motion): rebuild the five templates on the most-viewed community posts

Re-select topics by single-post views in the latest awesome-opus-5.5-video
snapshot and anchor each template to the top eligible post of its genre:
camera-focus explainer, showreel, product launch, procedural island and a
new wordless animated short that replaces the data story.

All five example.html files are hand-authored canvas/Three.js films drawn by
one render(t) from the GSAP master timeline, so preview, scrubbing and MP4
export produce identical frames. Each ships an original procedural
soundtrack, updated SKILL.md and 19-locale copy; Home order follows reach.

* fix(plugins): load bundled fonts in plugin previews

Plugin previews blocked every bundled font: the preview CSP had no font-src,
and relative url() references inside inline <style> blocks kept pointing at
the preview URL instead of the plugin folder. Allow same-origin fonts, map
inline-style url() references through the existing asset route (escapes,
absolute and data: URLs stay untouched), and serve font files with font
MIME types.

* feat(community): make motion template Use match the Home example pick

Community 「做同款」 on a motion design template now binds it as that type's
own example card, carrying the type's project metadata, so the run takes the
same OD Next motion-design route as a pick from Home's motion design list and
delivers the MP4 together with the editable source. Other task types keep
the existing plugin-pinning hand-off.

Community use clicks now record use_source (card | detail_modal) and
use_action (use | use_with_query); the details modal Use is tracked for the
first time. The Home hand-off no longer records a second community_gallery
click for uses Community already tracked.

* fix(plugins): count the five motion templates in the official registry metadata

* fix(media): keep HyperFrames audio filters compatible with FFmpeg 4.1

CI's bundled Linux FFmpeg 4.1 rejects adelay all=1 and amix normalize=0.
Make each track stereo and delay both channels explicitly, skip amix for a
single track, and undo amix averaging with a gain for several tracks.

* fix(motion): keep template previews bakeable on slow CI renderers

CI's software-GL baker skipped the 3D island (preview readiness timeout):
under slow frames GSAP lag smoothing slowed the timeline, so the 8 s
poster frame never arrived within the baker's 15 s wait. Play every
template on the wall clock and move the island poster to 6 s. With
simulated 300 ms frames all five now report ready within 10 s.

* feat(motion): add the kinetic typography template

Template selection now follows the motion design spec's four content types
(brand and product films, kinetic typography, explainer animation, procedural
art). Kinetic typography takes the fifth slot, replacing the animated short.
The new template is a 15-second, 120 BPM type film with an original
soundtrack and 19-locale copy.

* fix(motion): let the project motion player fit template stages

Inside a project the motion source player wraps the composition, fits it and
adds its own controls. The templates also scaled their stage, so editable
sources from Remix and Community use rendered at a fraction of the preview.
Templates now keep their own fit and controls only outside that player.

* feat(motion): restage the 3D island as a midday lagoon

The island moves from golden hour to a bright tropical midday closer to its
anchor post: a shader lagoon coloured by a baked depth map with ripples,
shallow-water shimmer and shore foam, a wide white-sand beach, palms and
instanced bushes, a village with a pier, soft sun shadows and a lower
camera path. The CPU wave mesh is gone, so frames cost no more than before.
Copy is updated in all 19 locales.

* fix(motion): keep each soundtrack inside the composition root

A Community use regenerated the island source tail and dropped the root
<audio> element, so its MP4 had no sound. Templates now place the element
inside the composition root next to the stage.

* docs(motion): state the template selection rule as complete works

* fix(motion): enforce video clocks and bound content verification

* feat(motion): replace the motion templates with remakes of viral posts

The five Motion design templates are now frame-faithful remakes of viral
motion posts, so each preview shows the look users came for: the 15-second
motion designer showreel, a high-energy motion reel (copy set in English), a
one-shape UI motion piece, a pixel-art wizard and a 20-second electric-blue
tech reel. Each ships open-licensed fonts, an original soundtrack, the
creator's published prompt where one exists, and credits its source post.

The explainer, product launch, 3D island and kinetic type templates are
removed. Tests follow the new set; a backward-seek check now covers hidden
WebGL layers.

* feat(motion): feature The Future Is Already Here first and start preview sound with the film

* chore: ensure PR CI

* fix(motion): match The Future Is Already Here controls and reorder the motion templates

* fix(motion): make the one-shape UI preview controls legible

* fix(motion): hide The Future Is Already Here controls inside the project player

* fix(motion): plan suitable sound and synchronize source audio playback

* feat(motion): keep remake soundtracks by default and fade exported audio where it stops

* test(motion): cover audible source controls in Chromium

* fix(test): use explicit ESM imports in audio player regression

* fix(motion): align preview track discovery with video export

* feat(motion): autoplay looping HTML without a player bar

* fix(test): consume the built motion playback contract

* docs(motion): state explicit soundtrack rules for make-same and the music line in replies

* chore(motion): version the audible looping delivery profile

* docs(motion): let template sound rules lead the general policy and freeze the audio contract

* fix(motion): scope autoplay to declared video motion sources

* fix(motion): reject audible MP4s under an explicit silent contract

* docs(motion): drop the routine music note from make-same replies

* test(motion): align delivery regressions with looping source playback

* fix(motion): isolate preview and audio export from legacy HyperFrames

* test(motion): retain the existing desktop legacy audio limitation

* fix(motion): suspend audio when previews leave the active workspace

* feat(motion): replace two motion templates with a product promo and a visual lesson; sign the showreel as Open Design

* feat(motion): give the showreel the Open Design palette and a full-size stacked wordmark

* feat(motion): give the product promo a livelier mascot girl

* refactor(motion): rebuild The Future Is Already Here as readable, editable scene code

* fix(motion): pick gallery cover frames that survive the baker's capture delay

* feat(motion): sharpen the showreel opener, depth, truchet and end card

* feat(motion): make the product promo mascot talk, add a speaking score and the flow pop

* feat(motion): give the visual lesson the original's paper-collage texture

* fix(motion): bundle gallery covers for fresh branch checkouts

* feat(motion): feature and refine Bring Ideas to Life

Adapt the paper story around OpenDesign capabilities, with short rounded hands, clearer timing, continuous paper actions, and a larger audience invitation. Feature it first in Motion and synchronize localized names, metadata, cover, guidance, and regressions.

Keep product promo audio music-only with sparse percussion.

* fix(motion): stabilize artifact cards and capture declared covers

* fix(web): share creation type order with community

* test(motion): exercise audio controls through project UI lifecycle

* fix(motion): scope live cover behavior to actual video sources

* test(motion): use real HEAD responses in cover regressions

---------

Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: William <zhouhaoran@refly.ai>
Co-authored-by: open-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
Co-authored-by: Amy <1184569493@qq.com>
2026-10-14 15:55:52 +00:00
CaprikaandGitHub 3faaab0f20 fix(e2e): align packaged Codex fixture with recipe identity (#8727) 2026-10-14 09:11:23 +00:00
e0b250663d fix(release): allow re-cutting an unchanged packaged version (#8725)
Co-authored-by: lefarcen <935902669@qq.com>
2026-10-14 07:53:13 +00:00
014bffbf77 fix(collab): synchronize owner devices with overwrite consent (#8683)
* fix(collab): synchronize owner devices with overwrite consent

* docs(collab): add owner device synchronization QA handoff

* chore(collab): move delivery reports and screenshots to Plane

* fix(collab): preserve owner pull fallback and ghost heal

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

* fix(collab): probe null owner heads for retractions

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

* fix(collab): preserve manual checks across coalescing

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

* feat(collab): hide update check behind a dismissed-version badge

Show the header entry only when the owner skipped a cloud version, as a
"Cloud update available" badge that re-opens the overwrite dialog.
Rewrite the dialog description in plain product language.

* fix(collab): clarify device sync copy and place entry before present

* fix(collab): silence update reminders without automatic overwrite

---------

Co-authored-by: Looper <looper@noreply.github.com>
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
2026-10-13 11:14:14 +00:00
424f5c68cc fix(od-next): remove output-contract gates and unblock continuation (#8356)
* fix(od-next): continue plans with keyed markers and honor explicit deliverables

* fix(od-next): retire output contracts and hand off continued tasks

* fix(od-next): record round settlement and recover production cold

* fix(od-next): resume marker sessions with context deltas

* fix(od-next): separate completion facts and honor plan-only intent

* fix(od-next): preserve physical failures and legacy chat compatibility

* test(web): align run recovery fixtures and await campaign mount

* fix(od-next): tolerate terminal production marker formatting

* fix(od-next): unblock follow-ups after a stop or unreadable history (#8423)

Treat a previous task this build cannot read as absent so the follow-up starts a new task, and stop counting a Run with a pending cancel as holding its conversation. Add end-to-end coverage for the marker continuation flow and restore the remote Workspace directory gate cases.

* docs: clarify focused local validation

Keep local daemon and web validation scoped to affected files and integration boundaries. Reserve full suites for PR CI unless the user explicitly requests them.

* fix(web): scope folded task completion to its final run

Keep the final physical Run key and conclusion together when OD Next history is folded or accumulated live. Reuse the existing predecessor offsets while retaining the full display history and remaining Todo snapshot.

Add regression coverage for completed and unfinished production, live continuation, multiple Runs, legacy history, wrong and quoted markers, question handoff, and failed or canceled Runs.

Validated the parent regression before the fix, 165 focused Web tests, workspace typecheck, and guard.

* fix(od-next): compact historical context without restricting clarification

Reuse confirmed artifact summaries for old assistant history, preserve cross-agent plans and original requests, and represent earlier questionnaires as neutral historical information without inferring answers.

Keep user replies, skips, changed answers, unpersisted bodies, examples, and unfamiliar content intact. Do not add question repetition checks, output gates, repair rounds, or limits on clarification.

Validated red regressions, shared context and Web provider tests, focused handoff/resume/cold-production coverage, real server and fake CLI input, workspace typecheck, and guard. The repeated-question server fixture remains successful.

---------

Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: Cheems <hihicheems@gmail.com>
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
2026-10-13 10:32:45 +00:00
lefarcenandGitHub 39e8301764 fix(daemon): drive Antigravity through agy's stream-json stdin channel (Windows ENAMETOOLONG) (#8594)
* fix(daemon): deliver Antigravity prompt on stdin to avoid Windows ENAMETOOLONG

agy received the full composed OD prompt as the -p argument. On Windows
that routinely exceeds CreateProcess's 32,767-char command line, so the
spawn failed with ENAMETOOLONG before agy started and users saw a generic
"This task failed to run" (OPEND-3495).

Pass a short fixed pointer as the -p value and deliver the prompt on stdin,
which agy print mode reads (verified on agy 1.3.1).

* fix(daemon): drive Antigravity through agy's stream-json stdin channel

The previous attempt passed `-p "Follow the instructions provided on
stdin."` and wrote the prompt to stdin, but agy ignores stdin whenever a
prompt is given with `-p` (agy 1.1.1), so the model never received the
OpenDesign prompt. Earlier "successes" came from the model reading files
in its working directory on its own.

agy's documented headless stdin channel is
`--input-format stream-json --output-format stream-json`, which reads
one NDJSON `{"event":"user","message":{"content":...}}` message per
turn. The prompt now travels only through that frame, handed over as a
complete file-backed stdin at spawn, so argv no longer grows with the
prompt (fixes Windows `spawn ENAMETOOLONG`, OPEND-3495).

- add an `encodeStdinPrompt` runtime hook used by the chat-run spawn and
  the connection test
- parse agy stream-json events (text deltas, tool steps, terminal
  result, usage) in json-event-stream, falling back to the Gemini JSONL
  branch for frames without `event`
- drop the antigravity-only plain-stdout buffering, Gemini re-parse and
  log-file empty-output classification
- recognise agy's headless signed-out output as AGENT_AUTH_REQUIRED and
  keep the per-model quota guidance for `Individual quota reached`
- fixtures recorded from agy 1.3.1 plus the samples in agy's headless docs

* fix(web): show Antigravity quota guidance instead of the generic run-failure copy

The Antigravity RATE_LIMITED card had no message key, so it rendered
chat.runError.fallbackMessage ("This task failed to run. Please retry")
-- the exact sentence in the OPEND-3495 report -- with no hint that the
per-model quota was the cause. Give it its own copy (switch model in the
model menu or via agy /model) and the quotaExhausted title, in all 19
locales.

* Revert "fix(web): show Antigravity quota guidance instead of the generic run-failure copy"

This reverts commit 68a84c3ff3.

The Antigravity rate-limit card intentionally uses the generic approved
copy (#8140, product copy supplement rev 96): blank product rows get no
invented copy, and the P0 e2e 'antigravity rate limits keep
classification and use only the Cloud switch' pins that behavior.
2026-10-12 03:57:38 +00:00
d9dd6fefa7 feat(release): add one-dispatch withdrawal for an unshipped cut (#8509)
* feat(release): add one-dispatch withdrawal for an unshipped cut

A release that was cut but will not ship had no undo. cut-release creates a
branch, a backport label, a Feishu card, and — through the prerelease build its
push triggers — moves prerelease/latest onto the new line. Abandoning that
version meant reversing four surfaces by hand, and the one that actually
matters is easy to forget: internal validators auto-update, so they sit on a
package nobody intends to release.

release-withdraw reverses each, in the order that leaves the least mess if a
later step fails: repoint prerelease/latest at an older published prerelease,
delete the label, delete the branch, post the matching withdrawal card. The
branch delete uses the release App token because the release/v* ruleset blocks
deletion and that App is one of its bypass actors — the same token that created
the branch.

It refuses more than it does. A version that reached stable is not an unshipped
cut, and the error names release-rollback instead. A branch carrying any commit
that is not on main and not from the release bots would lose work, so it stops.
Open PRs targeting the branch, or still carrying its label, stop it. The
prerelease pointer is read live rather than trusted from the input, and is only
moved when it actually belongs to the withdrawn version — at which point a
target becomes mandatory rather than silently skipped. dry_run defaults to true:
a withdrawal is never the urgent case.

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

* fix(release): make the withdrawal queue behind the pointer's other writer, and give the dry run teeth

Both findings from review.

Concurrency. `open-design-release-withdraw` was a group of one, so it serialised
this workflow against nothing, and the header comment claimed a guarantee it
never had. `release-prerelease.yml` is the only other writer of
`prerelease/latest` and holds `open-design-release-prerelease`; the withdrawal
now joins it.

The publish this races is the ordinary cut timeline, not a manual re-run: the cut
push and the preview-manifest push that follows each start
`notify-release-feishu` -> `release-prerelease`. Three failures came from the
unique group. Reading the pointer before an in-flight publish finishes resolves
`belongs=false`, so the repoint is skipped and the publish then installs the
withdrawn version after a green run. Interleaved writes mix `latest`, because
the publisher writes metadata first and feeds second while `applyLatestRollback`
writes feeds first and metadata last, and every PUT is unconditional. And a
publish landing *after* a repoint restores the abandoned pointer, because the
publisher's CAS refuses only backward moves.

The group is workflow-level and must stay that way, which is the part worth
keeping in mind: it is held through the branch delete, so a queued trailing
publish cannot enter the publisher until `release/vX.Y.Z` is gone and then fails
closed on a checkout of a deleted ref. A job-level group would release the lock
first and lose that. A P0 assertion pins the shared string against
release-prerelease — renaming the publisher's group now breaks a test rather
than the invariant — and pins the single workflow-level block.

`cut-release.yml` stays out of the group: it writes no R2 object of its own, and
what needed serialising was the publisher it triggers.

The branch also gets a compare-and-delete, re-reading the tip and refusing if it
has moved off the SHA that "nothing but cut commits" and "no open PRs" were
established against. Not a substitute for the lock, which is what fixes the
publish race — this covers the likelier window the lock does not see, a backport
landing while a human reads the dry run. GitHub's ref delete takes no
expected-SHA precondition, so the compare is explicit.

Dry run. `Setup pnpm`, `Install dependencies` and `Repoint prerelease latest`
were all gated on `!inputs.dry_run`, which removed the credentialed half of the
validation from the mode that exists to validate. `prerelease_to_version` only
had to be syntactically plausible: an absent, incompletely published or
feed-less target, or one below the prerelease launcher floor, passed the plan and
failed for the first time during the real run. It still fails before the label
and branch deletes, so nothing was destroyed by it — but the dry run is the path
this workflow tells the operator to trust, and `[P0] plans before it destroys`
required `!inputs.dry_run` on the repoint step, locking the skip in.

The three steps now run whenever the pointer needs moving and the planner takes
`RELEASE_DRY_RUN: ${{ inputs.dry_run }}`, matching how release-rollback already
wires it. "The dry run passed" now means the real run has nothing left to
discover, and the header and the step summary say so. The two deletions and the
Feishu card stay gated on the input.

Each of the five behaviours was mutation-checked: reverted at exactly one site
(three for the `if:` gate), exactly one test went red. Workflows re-linted with
the CI-pinned pair, actionlint 1.7.12 with shellcheck 0.11.0 on PATH, proven
wired by a probe workflow that reports SC2086.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 07:11:02 +00:00
PerishFireandGitHub 92fe7e87b1 feat(release): decouple stable Docker reconciliation (#8517) 2026-10-08 07:52:45 +00:00
5b19dfa435 fix(release): resolve the rollback tag from published metadata (#8508)
The first live dry run failed at the badge preflight: I had written the tag
as v<version>, but prepare-stable.ts publishes open-design-v<version>. A
hand-built prefix reads plausibly and is wrong, so take the tag from
.versionTag in the restored release's own immutable metadata — the value the
publisher recorded — probing the same prefix candidates rollback-latest.ts
probes, and hand it to the badge step as a step output.

The summary checklist had the same wrong shape; it now names the version
rather than constructing a tag. The regression test pins both: the tag must
come from .versionTag, and no step may rebuild one from the version input.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 03:41:33 +00:00
54139504d4 feat(release): add one-dispatch stable rollback (#8507)
* feat(release): add one-dispatch stable rollback

A stable release publishes its artifacts to an immutable
stable/versions/<version> prefix and then copies the pointer objects into
stable/latest. latest is therefore derived state, and retracting a release is
copying an older version prefix over it again — but until now nothing in the
repo could do that, so a bad stable release had no stop button.

Add `tools-release rollback-latest` plus a release-rollback workflow that
dispatches it. The CLI refuses unless the live pointer still serves the version
the operator typed as from_version, the target published completely (not
partial, not a dry run), every ready platform's manifest and updater feed exist
in the target prefix, and the current installer floor is satisfiable by the
target. It re-resolves control.launcher.version from live channel policy rather
than restoring the snapshot's, records rollback provenance, writes metadata.json
last so latest never advertises a version whose feeds it does not yet serve, and
verifies every object through both the origin and the public URL.

The workflow shares release-stable's publish concurrency group so a rollback
cannot overlap a publish, moves only the GitHub Release "Latest" badge (never a
release body), and ends with a checklist of what a rollback does not reach: the
download page's version-pinned links, the What's New card, the group
announcement, and above all the installs already on the retracted version —
the updater never offers a version that is not strictly newer than the running
one, so this stops a rollout and cannot recall one.

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

* fix(release): group the rollback summary fallback redirects

shellcheck SC2129 — CI runs actionlint with shellcheck wired in, which my
local actionlint could not see without it installed.

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

* fix(release): prove the restored tag has a release before writing

The badge move runs after the R2 write, so a missing GitHub Release for the
restored version would have failed the run with stable/latest already moved.
Check it up front instead.

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

* fix(release): pass the stable installer-floor policy into rollback

resolveLauncherVersionFloor() reads process env, and repository variables are
not process env. Without the handoff a rollback resolved no floor at all: it
would strip an active control.launcher.version from the restored metadata and
stop refusing targets below the current minimum — the safeguard the workflow
documents as enforced. Latent today only because no stable floor is set.

release-stable.yml passes the same pair to metadata publish and verify; mirror
it, with a topology assertion so a later edit cannot drop it silently. Also
stamp RELEASE_BRANCH/RELEASE_COMMIT so the rollback provenance is not blank.

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

* fix(release): require the restored release to be badge-eligible

gh release view succeeds for a draft or a prerelease, but GitHub will not put
the Latest badge on either — so the preflight passed and the edit failed with
the R2 rollback already done, which is the half-complete state the preflight
exists to prevent. Check isDraft and isPrerelease too, and cover the
eligibility guard in the workflow test alongside its ordering.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 03:21:12 +00:00
PerishFireandGitHub b52fafd474 feat(ci): enforce reusable workload plans (#8393)
* feat(ci): enforce reusable workload plans

* fix(ci): skip incompatible convergence upgrades
2026-09-28 08:29:12 +00:00
eb27acd322 feat(billing): support Coding Plan usage and wallet fallback (#8339)
* feat(billing): support Coding Plan usage and wallet fallback

* fix(web): keep AMR send off billing preflight

Workspace send no longer waits on advisory vela billing preflight; recovery still requests it. Realign zero-wallet P0 oracles and the credits-card wallet label.

Generated-By: looper 0.15.0 (runner=fixer, agent=omp)

* fix(billing): serialize recovery preflight and bound hung vela

Skip overlapping AmrBalanceDialog recovery ticks and stop the watch after
ten minutes even if a preflight is still pending. Give vela billing
preflight a 60s runVelaCommand timeout so a hung CLI cannot pin recovery.

Generated-By: looper 0.15.0 (runner=fixer, agent=omp)

* fix(web): restore coding plan text contrast in billing card

* feat(web): render Coding Plan windows and Go tier in the credits panel

- CodingPlanUsage renders one row per preflight window (name, used %,
  remaining USD, reset countdown + absolute time) with loading/unavailable
  states; refreshes once on mount and once at resetsAt (no 30s polling,
  setTimeout delay clamped to the 32-bit limit).
- Pure model in coding-plan-usage-model.ts; creditsPerUsd comes from
  `vela billing summary` through WorkspaceBillingSummary and falls back to
  10,000 only when the backend omits it.
- PlanWordmark/EntryNavRail accept the `go` tier (placeholder wordmark,
  design asset still pending).
- i18n: 7 new billing keys + entry.billingTierGo across all 19 locales;
  real translations for the #8339 keys; drop unused codingPlanNone /
  codingPlanRemaining.
- CLI wording: unstarted window reads "not started yet".

* build(pack): pin vela-cli 0.1.4-test.0 and gate on billing preflight

The packaged client needs `vela billing preflight` for the Coding Plan
window rows. Bump the bundled CLI to the npm `test` prerelease that ships
it and add a third capability gate in tools-pack: a CLI without the
subcommand prints the parent `billing` help and exits 0, so the gate
checks the help markers (preflight, --workspace-id, --model, --format)
instead of the exit code.

TEMPORARY: replace 0.1.4-test.0 with the stable 0.1.4 once it is
released; do not merge with a `test` prerelease pinned.

* fix(web): make Coding Plan rows legible on the inverted credits card

The credits card is a hard-coded #202020/#fafafa inverted surface, but
CodingPlanUsage.module.css read page-level --text-* / --bg-fill-* tokens,
so on the card the window names rendered #202020 on #202020 (1.00:1) and
the stat/reset lines sat below WCAG AA. Derive every colour from
currentColor instead (name 100%, stat 72%, reset/note 60%, separator ~50%,
track 36%, exhausted = --red mixed toward currentColor); measured in Chrome:
name 15.6:1, stat 8.7:1, reset 6.4:1, track 3.2:1, exhausted 6.5:1.

Keep the stat on one line (nowrap) and let the name ellipsize instead of
wrapping "60% / used" onto two lines; drop the block's 12px side padding so
it aligns with the card's own rows.

Also add a minutes tier to the reset countdown: the last hour before a
window resets used to show no countdown at all. New key
billing.codingPlanResetsInMinutes in all 19 locales.

* feat(web): rebuild the plan panel 1:1 from design PR #8364

Replace the improvised Coding Plan block with the design's panel, measured
against docs/ui-previews/plan-panels/index.html in Chrome (computed styles
and a 4x pixel diff of the Plus specimen):

- Header: tier name + 16px wordmark, 升级 pill (管理 on the top tier).
- One row 「7天额度免费用 · 已用 N% ›」 (7-day window only; rounded percent;
  100% when spent, no red state) over a 5px #b1f38b track, then the
  separator and the 「钱包余额 · US$x.xx ›」 row. No headings, window names,
  remaining USD, reset time, note or unavailable line.
- Team workspaces keep the pre-existing card unchanged (all team_* tiers);
  the quota row renders only for personal workspaces.
- Free personal workspace: no quota row, but the header sells 升级 like the
  design (a personal workspace needs no billing permission; an account B
  reports without a plan upgrades as free).
- Top-right pill shows the wordmark only, no balance.
- Wallet always reads US$ regardless of locale (product ruling 2026-09-23).
- zh-CN tier names follow the design: 免费版 / 标准版 / 专业版 / 旗舰版.
- Drop the dead billing.codingPlan* keys and model helpers; add
  billing.codingPlanWeeklyAllowance and entry.creditsManage in 19 locales.

* feat(web): show 管理 on personal Max per design PR #8364

The design's Max card sells 管理 (console auto-recharge settings) where
every other tier sells 升级; product ruled on 2026-09-23 to follow the
design for personal Max too, superseding the earlier "personal tiers are
always upgradeable" ruling. The gate now asks isMaxPlanTier and 管理 wins
over 升级 when both would show.

* feat(web): route the plan card's three entries per product ruling

2026-09-23 ruling on the card's landings: 升级 opens the console's pricing
dialog (billing=plan, unchanged), the allowance row opens the console
dashboard (unchanged), the wallet row asks for the manual top-up dialog
(new billing=recharge intent; B does not honor it yet and degrades to the
dashboard), and 管理 on Max opens the plain dashboard instead of the
auto-recharge dialog.

* fix(web): align personal plan cards with v2 design and scoped loading

* fix(web): center plan wordmarks inside the top-right pill

* fix(web): reuse shared billing cache for plan hover cards

* test(web): match personal billing preflight in avatar fixture

* fix(billing): require usable quota evidence for funding recovery

* test(billing): cover scoped preflight and quota event refresh end to end

* feat(daemon): relay scoped coding plan usage events and health

* fix(billing): refresh quota caches from scoped realtime events

* test(daemon): accept capability query on mock event stream

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-24 03:22:31 +00:00
PerishFireandGitHub 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
94f27cb0ab fix(e2e): align smoke fixtures with OD Next execution intent (#8329)
Co-authored-by: lefarcen <935902669@qq.com>
2026-09-21 11:57:41 +00:00
lefarcenandGitHub 792480fe21 fix(daemon): bound stored run events and keep crashed runs from acting on truncated prompts (#8170)
* test(daemon): red specs for unbounded persisted run events

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(amr): align Vela continuation contract with 0.0.37
2026-09-17 11:38:54 +00:00
+25
Ray XiGitHubwangchenglongopen-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
dc61db8e03 fix(daemon): show Codex and OpenCode tools before arguments finish (#8117)
* fix(daemon): stream early Codex and OpenCode tool previews

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

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

---------

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

* test(e2e): align AMR recharge link assertion

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

* fix(amr): honor selected dashboard profile

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

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

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

* ci: allow isolated full Windows smoke on dispatched revisions

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

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

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

* test: align run fixtures with OD Next terminal status

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

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

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

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

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
2026-09-14 01:37:47 +00:00
Ray XiandGitHub 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
98f2fd1a46 fix(chat): hide undesigned cards and auxiliary message panels (#7956)
* fix(chat): render cards inside execution records

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

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

* fix(web): buffer streamed card opener prefixes

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

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

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

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

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

* fix(web): hide undesigned assistant message panels

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

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

* fix(web): hide verification scorecards in ChatPanel

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

* fix(release): surface patch gate lookup failures

---------

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

Keep the readiness helper and application behavior unchanged.

Co-authored-by: lefarcen <935902669@qq.com>
2026-09-09 11:16:58 +00:00
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
8be9b0fcaf fix(daemon): deliver artifacts with syntax warning telemetry (#7925)
* fix(daemon): deliver artifacts with syntax warning telemetry

* docs: record main-based syntax warning acceptance

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

---------

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

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

---------

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

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

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

Each platform row now carries its build time (`用时 11m42s` once finished,
`已用 18m` while running) and the footer carries the whole round's clock, both
read from the `started_at` / `completed_at` already in the polled job list
plus the origin run's `created_at`. Live numbers are quantized to the minute
so a 30-second poll does not PATCH the card on every cycle.
2026-09-09 04:55:40 +00:00
d232620c16 fix(e2e): align packaged Home smoke with Codex app-server (#7914)
Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-09-09 03:56:35 +00:00
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
lefarcenandGitHub 25475defab fix(e2e): stop the packaged mac first-run gate failing on budget alone (#7899)
The `[P0] cold first Home run` packaged mac case has been red on almost
every beta build for eleven days (1 pass in 12 attempts) and nobody saw
it, because release-beta's smoke step is `continue-on-error: true`.

Four changes, in the order they matter:

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

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

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

4. Make the exempted red audible. The exemption stays (at a 1/12 pass rate
   a blocking gate would stall the daily beta), but `tools-release
   write-report` now turns a failed smoke into a run annotation and a
   caution banner at the top of the step summary.
2026-09-08 11:38:41 +00:00
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
lefarcenandGitHub 819c10d49f fix(chat): make the failure card, the queued action and the fork note stop disappearing (#7871)
* fix(web): ellipsize the fork divider note instead of hard-cutting it

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three prompt surfaces carried it:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also stops an empty pane-error string from shadowing the run's own detail.
That branch carried a comment saying the silence was a separate question;
this is the same question, answered the same way.
2026-09-08 08:50:41 +00:00
lefarcenandGitHub faba302437 feat(release): alert through the other Feishu bot when the prerelease card goes silent (#7873)
* feat(release): alert through the other Feishu bot when the prerelease card goes silent

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

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

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

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

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

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

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

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

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

Non-stable channels behave exactly as before.
2026-09-08 08:23:56 +00:00