mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
7245f543a7
* 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 fixesd61be13806reverted by accidentd61be13806was 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 commit0f923028c4, 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 to0f923028c4plus 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 at8015870095. 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 at8015870095, 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 at8015870095. 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 at8015870095. 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 byc121d81b17, 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 head877980fb17(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 one14622a644, 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 fixesd61be13806reverted by accidentd61be13806was 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 commit0f923028c4, 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 to0f923028c4plus 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 at8015870095. 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 at8015870095, 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 at8015870095. 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 at8015870095. 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 byc121d81b17, 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>