* feat(web): refresh the Home hero and composer (#7731)
* feat(web): add the SiriOrb run indicator
Ports the Siri Orb (SmoothUI, MIT) from #7635 as a standalone component: a rotating neon sphere with idle / thinking / streaming speeds, drawn entirely in its CSS Module. Unused by this PR; the rail run-status icon (PR-A of OPEND-2553) consumes it.
* feat(web): vendor the composer running orb
Adds the thinking-orbs 0.3.1 'solving' state at its 20px preset (MIT, attributed in the file) as a local component instead of a new runtime dependency: the composer needs one of the package's nine animations, pinned to the dark palette, and the transcription keeps the geometry, preset scaling, painter and frame loop of the published build so the canvas output is identical. Refs OPEND-2553 / #7635.
* feat(web): add the rotating Home headline i18n keys
homeHero.title now carries the {word} slot in every locale together with homeHero.titleWords (the comma-separated nouns the headline cycles), homeHero.subtitle and the attachment band's paging labels. Every locale owns its own sentence and noun list so the placeholder-parity check stays green (#7635 only rewrote en / zh-CN / zh-TW). Refs OPEND-2553.
* test(web): re-pin the Home hero and composer specs to the refreshed surfaces
Follows #7635's hero/composer contract: type row instead of the template dropdown, display+clear type pill, no Home mode picker, no resource submenus in the '+' menu, references in the working-directory row, lead-chip truncation, the 116px hero offset, the 220px execution-chip cap, the flat app wash and the stable send arrow. Carries over the Demo's new specs (dock collapse, Backspace-at-start, modelVersionLabel, icon-only design-system pill) and drops the ones for removed surfaces. Refs OPEND-2553.
* test(e2e): follow the Home type row and composer menu
Ports #7635's Playwright helper (homeTypeRow / clearHomeTemplate / pickHomeTemplate) and the home-hero-rail, entry-chrome-flows, project-management-flows, visual-navigation and entry-topbar changes that reach the composer through the type row, the working-directory group and the trimmed '+' menu. Refs OPEND-2553.
* feat(web): refresh the Home hero and composer
Replicates the Home hero + composer subset of #7635 on current main (OPEND-2553, PR-B): plain headline with the rotating noun and subtitle, a typeless cold Home whose type row (3 + 2 behind More) retires into the composer's display+clear pill, upload disc + design-system palette + type pill in the foot row, the supplied send mark and border beam, two-line staged-file chips in a paging band, the lead template chip with Backspace-at-start, references and linked checkouts in the working-directory row (one slot), the trimmed '+' menu without resource submenus or the mode picker, poster rows for examples, the model chip's brand mark and version-only label, the running orb + stop square in the project composer, and the flat app wash. HomeView gains the page/dock variant plumbing and drops the default type seed and the AppWashKineticGrid mount; the recent-projects grid stays for PR-C. Adds task_chip_clear to the composer click contract.
* test(e2e): follow the Home composer and type-row refresh
Migrates the Playwright specs that named surfaces #7635 removed or moved: the composer session-mode picker (every turn now carries the conversation's stored design mode, and design turns carry no message chip), the Home examples loading shell (a cold Home is typeless and shows only the type row while the catalogue loads), the fixed type row (three inline pills plus two behind More; every other create type reaches the hero through the cross-surface hand-off, which the shared pickHomeTemplate helper now drives), the working-directory row's single slot for references and linked checkouts, the '+' menu's working-dir group, the lead chip's eight-character title, the 32px send disc, and the flat app wash. The Home recent-projects specs keep main's shape (PR-C owns that move). Refs OPEND-2553.
* test(e2e): repair the visual captures the composer refresh broke
The projects captures clicked the rail's drafts item, which only renders
with a workspace context the visual fixture never has; Home already shows
the recent-projects strip, so the click is dropped. The integrations
capture reached its page by direct load, whose default tab is MCP and
whose cold mount outlasts a bare expect; it now clicks the connectors tab
like the MCP capture does. The BYOK switcher capture expected the link
glyph, but the chip leads with the selected model's brand mark and only
falls back to the glyph for a vendor without one. OPEND-2553 (PR-B).
* feat(web): add recent projects and run status to the entry rail (#7730)
* feat(contracts,daemon): report awaiting-input projects on the runs list
`ChatRunStatus` cannot express "waiting on the user": the run that asked a
`<question-form>` reports `succeeded` and exits while the project stays
blocked, so a client folding `GET /api/runs` into a per-project status shows
such a project as finished. Ship `awaitingInputProjectIds` beside the runs,
composed from the same `listProjectsAwaitingInput` read `GET /api/projects`
uses and intersected with the projects the returned runs already reveal, so
the field can never widen what a caller may see.
Ported 1:1 from upstream #7635 for OPEND-2553 (PR-A).
* feat(web): derive per-project run status from the runs feed
`Project.status` only arrives on the unscoped `GET /api/projects`, so a
workspace-scoped session cannot read it. Add the pure fold
(`foldRunsToProjectStatuses`) that mirrors the daemon's composition, the
per-project `listRunsForProject` read plus `useProjectRunStatuses` hook that
keeps it live off `RUNS_CHANGED_EVENT`, the one glyph per status
(`ProjectRunStatusIcon` over the SiriOrb), and the two conversation reducers
(`latestUserPromptText`, `runProgressSteps`) the Design Files empty state
will read.
No visual change on its own. Ported 1:1 from upstream #7635 for OPEND-2553
(PR-A).
* feat(web): list recent projects in the entry nav rail
Adds the 最近浏览过 disclosure under 插件 in the signed-in rail: the head of
the same recent catalog 全部项目 shows, newest first, capped at eight rows.
Each row leads with the project's live run status (running / needs input /
completed / failed) from the runs feed, opens through the shell's pull-first
opener, floats a cover preview beside the rail on hover, and carries a ⋮ menu
with rename, whole-project export and a two-step delete. An acknowledged ✓ is
spent by opening the project and re-raised by the next run.
The Home page's recent-projects grid stays; this only adds the rail entry.
Row geometry, easing and colours are pinned to the Demo (upstream #7635) by
`tests/styles/entry-rail-recent.test.ts`. OPEND-2553 (PR-A).
* fix(web): acknowledge the rail ✓ per finished run
The 最近浏览过 rows spent a project's ✓ by remembering the PROJECT id and only
forgot it after observing a live queued/running status. The section polls
only while it is open, so a run that started and finished while it was
collapsed was never observed: on re-expanding, the stale acknowledgement
still matched and the new completion's ✓ never showed. (The Demo, #7635,
carries the same defect; this deliberately diverges from it.)
Acknowledge by run identity instead. `foldRunsToProjectRunSummaries` reports
the newest terminal run's id beside each project's status (the status-only
fold is derived from it and unchanged for its callers), `useProjectRunSummaries`
exposes it live, and the rail stores `{ projectId: acknowledgedRunId }`: a ✓
is acknowledged for one specific finished run, and a newer finished run is a
new notice. The reset-on-running effect is gone. A store in the old array
shape reads as nothing acknowledged, so at worst one already-seen ✓ shows
once more.
Red spec: tests/components/EntryNavRail.recent-section.test.tsx
"re-raises the ✓ for a newer finished run even when its running phase was
never seen". OPEND-2553 (PR-A).
* fix(web): close the Home hero/composer acceptance issues against #7731 (OPEND-2553 PR-B2) (#7769)
* fix(web): keep the staged attachment band in sight as the prompt scrolls
The scroller sat on .home-hero__prompt-flow, which is also the attachment
band's parent, so scrolling a long prompt carried the band away with the
text: an attachment the user had just staged left the screen while they
were still writing about it.
Move the scroll down to a new .home-hero__prompt-surface holding the
prompt and its lead chip and nothing else. That element keeps the
position: relative the out-of-flow chip needs as a containing block, so
the chip still scrolls with the text it leads rather than pinning itself
to the top of the box.
Ported from nexu-io/open-design#7635 (2981a2025).
* feat(web): lead staged videos and vectors with their own thumbnail
Only rasters got a thumbnail in the composer; a staged .mp4 or .svg fell
back to a generic file chip, and a.kind could not tell them apart because
the daemon splits attachments into image/file only, so a video arrives as
'file'. FileTypeIcon decides from the name and MIME type instead, and
both the home hero and the chat composer now draw a first frame for video
and a rendered preview for vectors, opening into the same preview card.
Every other type leads with its own mark from the supplied artwork.
The card's close control was a 36px-tall pill with 0 16px of padding
around a 14px glyph; pinning its width to its height and dropping the
padding makes it the circle it was meant to be, and flex: 0 0 auto stops
a long filename from squashing it back into an oval.
Adds a Vitest spec for the FileTypeIcon resolution table (fig, html, svg,
mp4, png and the MIME fallbacks) and for unique gradient ids.
Ported from nexu-io/open-design#7635 (d3b251de0).
* fix(web): make the Figma import modal's close control visible and round its chrome
The global `button` rule in styles/primitives.css sets `padding: 0 16px`,
which on the modal's 34px border-box close button left 2px of content
width; the 18px close glyph is a shrinkable flex item, so it collapsed to
0px and the button rendered as an empty box. Drop the padding so the
glyph has room, and round the close control, the mode tabs and the
footer actions into pills per product.
The notes field had the same global-rule problem from the other side:
`textarea { width: 100% }` plus the field's own 16px side margins made it
32px wider than the modal, whose overflow clipping hid the right edge.
It is a stretched flex item, so `width: auto` hands the width back to
the column.
The backdrop now reads the shared scrim tint token instead of a hard
coded dark tint, matching the other overlays.
Adds a style spec pinning the close control's padding and size, the pill
radii, the notes width and the scrim token.
Ported from nexu-io/open-design#7635 (FigmaImportModal.module.css).
* fix(web): carry the entry typography ladder into the composer "+" menu popup
The Home hero and the project composer each scope the interim 600 text
weight to their own root until the app-wide ladder lands. The "+" menu
popup renders through a portal on document.body, outside both scopes,
so its rows fell back to the app's current 400/500 defaults and read
visibly lighter than the trigger that opened them and than the Demo.
Extend the same `:where()` stand-in to `.plus-menu__popup`, with the
note that it goes away with the global ladder, and pin it with a style
spec so the rows keep inheriting the weight rather than naming a lighter
one.
* fix(web): show the Home design-system trigger's tooltip through the app bubble
The palette trigger and the "+" trigger share one row on Home. The "+"
names itself through the app's `od-tooltip` bubble; the palette carried
only a native `title`, so hovering the two neighbours showed two
different tooltip styles.
Give the unselected palette trigger the same `od-tooltip` class and
`data-tooltip` text and drop the native title (the `aria-label` keeps
the accessible name). Once a system is picked its name is on the pill,
so the bubble is dropped there, mirroring the "+" trigger's rule that a
labelled control does not repeat its label in a tooltip.
The composer variant keeps both `title` and `data-tooltip`: TooltipLayer
suppresses the native title while its bubble is shown, so the title is
the fallback for hosts without the layer, not a second tooltip.
* fix(web): hide the recent-folders submenu when there are no recent folders
The working-directory panel always rendered its "Recent folders" row,
and with nothing to list the flyout opened onto a faint "No recent
folders" line: a menu whose only content was that it was empty, which
read as a broken row rather than a submenu.
Render the row only when there is at least one recent directory. The
empty-state line and its `homeWorkingDir.recentEmpty` key had no other
consumer, so both go, from the module CSS, the typed Dict and all 19
locales.
Adds a component spec that opens the panel with and without recent
folders.
* fix(web): preserve labeled attachment previews in chat
Use filename, path, then image kind to select media consistently in the staged chip and preview card. Cover labeled images, extensionless images, vectors, and videos through the staging event.
Generated-By: looper 0.12.0 (runner=fixer, agent=codex)
* fix(web): even out home template rows, deck posters, and the placeholder caret (#7830)
Four home-composer fixes from the OPEND-2553 acceptance round.
- OPEND-2687: the template row's hover fill carried 8px above and 16px below
its content, so the poster and text sat low in the block. Vertical padding is
now 12px each side; the 80px row pitch and the four-row viewport are
unchanged.
- OPEND-2705: a white or near-white document poster merged into the page. A
1px hairline ring (border token mixed down to 70%) is drawn over every
poster by a pseudo element. Default treatment pending design sign-off.
- OPEND-2698: the typewriter placeholder's caret was 1.5px wide and 14px tall
(line box minus a 3px margin pair) while the native caret on the 14px
composer text is 1px by 17px, so focusing the field made the caret jump.
The placeholder caret is now sized to the native one.
- OPEND-2702: deck posters showed the agenda page instead of the cover. The
bake script probes the advancing input before capture, which moves the deck
to slide 2, and decks that persist their position in the URL hash reopened
there after the reload. The deck's persisted position is now reset before
the reload, and BAKE_VERSION is bumped to 7 so every deck re-bakes.
* feat(web): show the page taking shape while a run builds it (#7772)
* feat(contracts): add the preview-build-focus bridge contract
Cross-runtime protocol a sandboxed artifact preview uses to tell the host
where in the rendered page the agent is writing right now, either by a
literal string it just wrote or by one of the page's own top-level
sections. The frame has no allow-same-origin, so it posts to '*' and the
host identifies it by event.source. The browser side is serialized as a
string so web and daemon inject the identical script; the module stays
free of browser APIs. Shipped as a subpath export like the sibling
preview-observability bridge.
Ported from nexu-io/open-design#7635 (f7c39b9d2) for OPEND-2553.
* feat(daemon): inject the build-focus bridge into opt-in raw previews
A raw project HTML preview requested with odPreviewBridge=buildfocus (or
build-focus) now carries the preview-build-focus bridge script before
</body>, next to the scroll/selection/snapshot bridges. It stays opt-in,
injects once, never rides along with the other bridges, and leaves
non-HTML responses untouched. No new endpoint and no path change: the
route keeps serving files from the resolved project root.
Ported from nexu-io/open-design#7635 (f7c39b9d2) for OPEND-2553.
* feat(web): carry a text anchor on run steps and pick the page being built
runProgressSteps now attaches an anchor to write/edit steps that touched
an HTML file: the last run of visible text taken from the tool's own
input, so a live preview can be scrolled to the part just written without
diffing the file. selectBuildPreviewHtmlEntry picks the page to watch
while a run works — the shallowest index.html, else the newest HTML file —
and deliberately ignores markdown and media, unlike the auto-open ranking.
latest-user-prompt goes away: nothing on main consumed it once the Home
composer landed, and the Design Files pane names what the run is doing
instead of echoing the prompt.
Ported from nexu-io/open-design#7635 (f7c39b9d2) for OPEND-2553.
* feat(web): show the page taking shape while a run builds it
The Design Files pane used to sit on its starter CTAs, then jump straight
to a grid of file cards once the first artifact dropped in. Everything the
run was doing in between happened somewhere the user could not see.
While a run is in flight and has produced a page, the pane now renders
that page live (a sandboxed, click-through URL-load preview reloaded on
every settled write) with a feed of the run's steps docked in the corner
and a cursor parked on the part being written. After each load the
frame's build-focus bridge broadcasts the page's top-level sections; the
host walks the cursor over the ones that just appeared, then falls back
to the current step's text anchor. A topbar switch flips between the
building preview and the file grid in both directions and resets when the
next run starts. Runs that have written no page keep the pane as it was.
Ported from nexu-io/open-design#7635 (f7c39b9d2) for OPEND-2553.
* test(web): cover the design files building preview
Component tests for DesignFilesBuildingState (cache-busting src, opt-in
bridge, sandbox, section walk, anchor fallback), DesignFilesPanel's
building/grid/toggle gating, and the module CSS invariants the drop target
and reduced-motion rely on.
Ported from nexu-io/open-design#7635 (f7c39b9d2) for OPEND-2553.
* fix(web): key the building preview off a run in flight, not disabled actions
FileWorkspace's streaming prop is the composer's "actions disabled" state:
it is also true for a read-only viewer of a shared project, a conversation
still loading, or a run with no billable principal. The building preview
used it as "a run is writing this page", so a viewer with nothing running
saw their page under a cursor captioned "thinking" instead of the grid
(the Demo wires it the same way; CI's visual-settings account-balance
capture caught it on a read-only launchpad project).
FileWorkspace now takes an explicit runInFlight flag, fed by ProjectView
from the conversation's streaming/attached-run state, and the Design Files
building preview keys off that alone. Red spec: the new FileWorkspace test
fails on the previous commit and passes here.
* fix(web): limit building preview to pages written during the active turn
Generated-By: looper 0.12.0 (runner=fixer, agent=codex)
* fix(daemon): complete project-scoped run lists from durable history after restart (#7771)
* fix(daemon): complete project-scoped run lists from durable history
`GET /api/runs?projectId` listed only the runs that happened to be in the
in-memory map. After a full daemon restart the startup scan rebuilt the
clientRequestId and pluginWorkflowId indexes but never put persisted runs
back into the map, and terminal runs also leave the map after the TTL, so a
project's newer succeeded run could be missing while an older failed one
was still present. Consumers that fold the list into one status per project
then showed the wrong outcome and lost the awaiting-input flag, because the
route intersects the awaiting set with the projects the returned runs reveal.
Index every persisted and created run id by project at startup and creation,
and make `list({ projectId })` hydrate the not-yet-loaded ids of that one
project through `hydrateDurableRun` before filtering. The unscoped list is
unchanged and hydration never loads other projects.
Refs OPEND-2629, follow-up to #7730.
* test(e2e): cover project run list across a daemon restart
Drive a failed then succeeded run on one project and a succeeded run plus an
unanswered question-form on another through the production HTTP APIs, then
restart the daemon over the same data directory and assert that
`GET /api/runs?projectId` still returns every persisted run and the
awaiting-input project id, so the per-project fold gives the same answer
before and after the restart.
Refs OPEND-2629.
* feat(web): entry materials and rail layout from #7635 (OPEND-2553 PR-C1) (#7832)
* feat(web): port the entry materials and rail layout from #7635 (OPEND-2553 PR-C1)
Materials: one app scrim (60% white tint under a 50px blur) read by the
shared <Dialog> backdrop and every app-owned overlay; the rail column sits
flat on the entry ground while the content column becomes the one base card
(85% white frost, 16px corner, inset ring, 12px inset); --material-dim /
--vibrancy-label-on-dim join material.css. Image lightboxes stay dark on
purpose.
Entry layout: the search + rail toggle move out of the rail into the chrome
row (the pinned Home pill is hidden there); 设置 sits under 插件 on the
signed-in rail too; every rail destination is a 38px / 12px row on the
shared quiet ink with one hover for the whole column (OPEND-2700); the
workspace switcher row matches; the recent list drops its 8-row cap and
scrolls past ~11 rows, shrinking with the window (OPEND-2757); the section
is titled 最近项目 (OPEND-2703); the chat project switcher leads rows with
the run status and previews the hovered row with the rail's own card
(OPEND-2694); the community view docks Home's composer at its foot; the
Home recent-projects grid is gone wherever the rail carries the list
(OPEND-2683). RecentProjectsStrip takes the Demo's card / header restyle.
* test(e2e): follow the entry grid to 草稿 and the viewport-wide switcher backdrop
A workspace-bound Home carries no recent-projects grid any more, so the
visual project wait accepts the rail's 最近项目 row and the workspace-switch
specs read the 草稿 grid; the switcher backdrop spans the viewport now that
the rail column paints nothing, so its dismiss click lands in the content
column instead of under the tabs chrome.
* test(e2e): dismiss the What's New popup before driving the entry chrome
The shared <Dialog> scrim now mounts on <body> above the tabs chrome, so a
release announcement blocks the rail toggle and the settings triggers until
it is closed. Specs on a real daemon (no standard mocks) hit it whenever the
build ships highlights; the rail and settings helpers close it first.
* fix(web): keep the rail's menu click-catchers scoped to the rail column
The rail panel's backdrop-filter used to make it the containing block for
the fixed workspace-switcher / account-menu backdrops; with the frost gone
they spread over the viewport and swallowed clicks on the content beside
the rail while a menu was open. A no-op transform restores that scope
without painting anything.
* fix(web): bound rail status polling and restore local Home controls
Poll only recent rows intersecting the rail scrollport, preserve the page grid callbacks, and keep the Community dock free of the local project grid.
Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
* feat(web): land the 600 default weight app-wide and retire the Home stand-in rules (#7840)
Set the product UI's default text weight to 600 on body and button (base.css,
primitives.css, @open-design/components), fold the remaining stylesheets onto
the consolidated 500 / 600 / 700 ladder, and delete the scoped stand-in rules
that home-hero.css, plus-menu.css and chat.css carried while the app-wide
default was pending. font-weight-normalization.test.ts pins the default, the
ladder and the absence of stand-ins; entry-layout.css, EntryNavRail.module.css
and design-files.css stay on an explicit pending list because concurrent PRs
own them.
* feat(web): sync #7635 run-status glyphs and zh-CN headline, fix template preview chrome and reference rows (OPEND-2553 PR-F3) (#7881)
* feat(web): one badge for stopped runs, a warm orb for a pending question
Every status that ended without delivering — failed, canceled, and a run
that finished with declared work undone — draws one static badge now,
instead of three unrelated things (an orange orb, nothing at all, and an
amber orb). The row only has to say "this did not finish"; the reason
belongs to the status text beside it. `canceled` drew no glyph before, so
`hasRunStatusGlyph` had to stop excluding it or the badge would never
reach the two surfaces that reserve the column.
That moves the split from "did it go well" to "is it still going". A
stopped run is never spun, because the rotation is what says "working".
Awaiting a reply keeps the orb and turns at running's speed — a pending
question is a live run, not a stalled one, and only `queued` may turn
slowly. It is recoloured to a warm three-step ramp: orange base, amber
second accent and bloom, gold glint.
Recolouring at all needed a new `literal` mode on SiriOrb. The stock
build is tuned around #00FF08 in six places — three slots hardcode green
where `colors` cannot reach, and the white highlights, the saturate/
contrast pair and the overlay texture pass each edit whatever hue they
are handed. None of them disturb the green, whose R sits at 0.00 and G at
1.00, pinned at the channel bounds. #FF8D02 has a mid green channel and
came out green, then yellow, then red as each was found. `literal`
collapses the unreachable slots onto c1 and stops the build editing the
colour; `colors` now accepts c5 so a caller can name the glint that white
is no longer allowed to draw.
* fix(web): reword the Chinese home headline
「我们来设计点原型」→「让我们创建原型」(per product).
The `{word}` placeholder stays. HomeHero splits the string on it to
decide whether a locale opts into the rotating noun at all, so dropping
it would leave Chinese with a frozen headline while en and zh-TW kept
cycling. Two comments quoting the old sentence are updated with it;
zh-TW keeps its own wording.
* fix(web): drop the Remix footer and clear the macOS traffic lights in the template preview
The lightweight community template preview (the one the Home active-template
chip opens) carried a footer bar with the category and a Remix / 使用 action.
Per product that bar goes (OPEND-2692); the header already names the category,
so the panel is two rows — head and stage — and the `__foot` / `__actions`
rules leave the stylesheet with it. `onUse` / `busy` stay on the props, made
optional, so the HomeView call site type-checks unchanged. Ported from #7635.
On the macOS desktop host the window is `hiddenInset` and the OS draws the
traffic lights over the top ~32px of the web content. The overlay covers the
whole window with an even 28px inset, so on any window short enough to hit the
fit-to-window clamp the panel's top edge cut straight through the buttons
(OPEND-2691). The top inset is now a variable the darwin host raises to 56px —
the same band the shared modal drag strip reserves — and the panel's height
clamp subtracts that inset so it shrinks instead of sliding back under the
lights. The overlay also joins the drag-strip backdrop list, so the uncovered
band above the panel drags the window like every other full-screen backdrop.
Keyed off the `data-host-platform` mark App.tsx stamps; browsers and Windows
keep 28px.
The two mapping specs that asserted the footer's Remix now assert the header
carries the category and no footer exists.
* fix(web): size project reference rows to their content
Each row in the 引用其他项目 dialog is a <button>, so the global primitive
(styles/primitives.css) hands it `height: 36px; line-height: 1;
white-space: nowrap`. The module never overrode the height, and a row with a
28px icon beside two lines of text needs ~54px: the box was shorter than its
content, the content overflowed evenly above and below, the selected border
framed only the title band, and the overflow drew over the neighbouring rows'
meta text (OPEND-2787).
The row now sizes to its content (`height: auto`, `line-height: normal`, 8px
vertical padding) with the two text lines and the 已选 tag on fixed line
heights, so default, hover and selected share one box — the border is always
drawn, transparent at rest, and the states only recolour it. A measurement
spec pins the row box and forbids the state rules from touching it.
---------
Co-authored-by: wangchenglong <honam884844@gmail.com>
* fix(web): rail recent list scrolls within the sidebar, row menu gains duplicate/move, delete confirms in a dialog (OPEND-2553 F1) (#7878)
OPEND-2757: the entry shell's grid had no row track, so the implicit auto row
grew to the rail's content height and pushed the footer's Discord / X / mail
links under the window. Pin the row to minmax(0, 1fr) and let the rail opt out
of its content minimum so the existing flex chain down to the recent list can
shrink; the list scrolls inside the sidebar at any window height.
OPEND-2794 / OPEND-2686: the rail row menu is now 重命名 / 复制项目 / 转入团队空间
(team workspaces only) / 删除, dropping 导出. Duplicate and move go through the
same handlers and endpoints the project cards use; move reuses the shared
MoveToTeamConfirmDialog and reports progress / failure in the row menu.
OPEND-2797: delete confirms in the shared project delete dialog again instead
of arming the menu item in place. The dialog, the delete flow, the move flow
and the duplicate flow are extracted into components/project-actions/ and
consumed by both RecentProjectsStrip and the rail.
* fix(web): read project run status from one shared feed for the rail and the tab switcher (#7877)
OPEND-2795: the rail's recent rows and the project switcher held separate
copies of the run-status feed with different display rules, so a finished
project read as done in one and default in the other. OPEND-2762: the rail
asked for statuses only after a scroll-observer hop and reset its request
set on every catalog re-render, so rows painted a round trip ahead of their
glyphs and flashed on each hand-over.
Both surfaces now subscribe to one module-level store (one request per
project, last known answer on remount, shared per-run acknowledgement), and
the rail asks for the head of the list in the commit that paints it.
* feat(web): seat the rail toggle first in the window chrome, before the search (OPEND-2553 F5) (#7889)
The entry chrome's rail toggle now takes the first slot after the
traffic-light space, with the search beside it, so the sidebar switch sits
where a macOS sidebar toggle is expected and stays put across open and
collapsed states. Both glyphs stay mounted and cross-fade on the ease-out
curve (enter 200ms, exit 140ms) instead of popping.
Refs OPEND-2685.
* fix(web): quiet the template preview eye, colour the lead type chips, name awaiting replies, drop the community dock (OPEND-2553 F4) (#7891)
* fix(web): quiet the template preview eye, colour the lead type chips, name awaiting replies, drop the community dock (OPEND-2553 F4)
Four home-entry polish items from the second acceptance round, each pinned
by a test that went red before the source change:
- OPEND-2697: the preview eye on a template row's poster is a secondary
affordance. It stays hidden at rest, fades in with the row's hover or
keyboard focus (200ms in / 140ms out on the shared ease-out curve), sits
on a translucent light ground with dark ink instead of the dark scrim
disc, and drops one size step (20 -> 16px, glyph 13 -> 11px).
- OPEND-2684: the type chips under the composer wear a visible 1px ring
mixed from the text ink (20%, 40% on hover) instead of the #EDEDED that
vanished on both grounds. 原型 / 幻灯片 / 文档 each carry their own icon hue
through new --type-prototype / --type-deck / --type-document tokens (with
dark values), the label stays ink, and the composer pill that names the
picked type takes a 10% tint of that hue with a 36% ring. 更多 and every
other type stay neutral.
- OPEND-2744: a run that ended on an unanswered <question-form> is titled
"Awaiting your reply" (assistant.awaitingReplyLabel, 19 locales) in the
footer and on the task activity card until the immediate user reply
submits or skips the form; older, locked forms and failed/canceled runs
keep their wording.
- OPEND-2793: the community view no longer mounts the docked HomeView
composer at its foot (product decision B; phase three brings it back in
a template-bound shape). The dock variant itself is kept. A card's Use
now hands the prompt and plugin driver to Home, as the standalone
/community route already did.
* fix(web): only an answerable form holds the awaiting-reply title
An unterminated <question-form> (the run hit its output limit mid-form)
renders neither a form nor a skip once the run is terminal, so the
"Awaiting your reply" title would point at nothing and never clear. Derive
the title from parsed, unanswered forms only; the unterminated case keeps
holding back the next-step card as before. Regression test added.
* feat(web,daemon): enter the pending project frame on send and bound create preparation (#7890)
* feat(web,daemon): enter the pending project frame on send and bound create preparation
OPEND-2617 (OPEND-2553 PR-F6). Pressing Send on Home now switches to the
project frame on the same tick the create request leaves, built from the
creation record alone (name, prompt, staged attachments) so it no longer
depends on the optimistic row surviving a project-list refresh. The frame
ports the Demo's inert ChatComposer so ProjectView takes over in place, and
a failed create hands the staged File objects back to the remounted Home
through a one-shot stash.
The daemon ports Demo #7635's request-wide 15s deadline for every read
POST /api/projects performs before its transaction (design-system and skill
validation, plugin and location lookups, registry loads, template seeding,
which now runs before the commit with compensation). Overruns answer 504
with the new PROJECT_CREATE_PREPARATION_TIMEOUT contract code, retryable,
and commit nothing; OD_PROJECT_CREATE_PREPARATION_TIMEOUT_MS is a test
seam. The Web maps the code to a localized toast (19 locales) and
`od project create --json` prints the structured error envelope.
* test(daemon): keep the create deadline out of the smoke test's accelerated 15s window
run-failure-telemetry-smoke stubs every setTimeout(…, 15_000) to fast-forward
the Langfuse terminal fallback. POST /api/projects now races its own 15s
preparation deadline, so the stub fired that deadline immediately and the
headerless case saw 504 instead of 200. Create the project first, then install
the stub for the run window only.
* fix(web): hand attachments back to a mounted Home and keep pending image previews StrictMode-safe
Review follow-ups on #7890. Backing out of the pending frame before the
create fails leaves Home already mounted, so the stash's mount initializer
never ran and the files were lost; stashing now also dispatches
HOME_COMPOSER_ATTACHMENTS_EVENT and the page HomeView takes the slot on it.
The pending frame's image chips minted blob URLs in a useMemo and revoked
them from a separate cleanup, which StrictMode's double mount turned into
dead links; creation and revocation are now paired in one attachments-keyed
effect like DesignSystemAssetDropzone.
* fix(web,daemon): snapshot the attachment stash and bound the example-card reads
Review round 2 on #7890. The Home attachment stash consumed on read inside a
useState initializer; React StrictMode double-invokes that initializer and
keeps only one result, so the stay-on-pending-until-timeout path handed the
files to the discarded call and Home mounted empty. The slot is now a
snapshot: HomeView peeks it in the initializer and the event listener, clears
it explicitly once the files are in state, and App clears it when a create
succeeds or a new optimistic create starts.
POST /api/projects also left the example-card lookup and manifest digest
outside the preparation deadline; both now go through the same
request-wide race, with their own stage strings.
* fix(home): curate template presets and clarify starter prompts (#7808)
* fix(home): curate template presets and clarify starter prompts
* fix(i18n): translate home presets for all supported locales
---------
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
* feat(web): community cards, Home-aligned type tabs, empty state and message center archive (OPEND-2553 S3) (#7971)
* feat(web): port the Demo community gallery, type tabs, empty state and message center archive (OPEND-2553 S3)
Community (CommunityView / CommunityTemplatePreview / plugin-marketplace-demo.css)
- Card is surfaceless: the 16:9 plate carries the hairline stroke and 16px
corners; the caption is the template title over a publisher byline
(manifest author, else the source); Remix / 做同款 are 30px frosted pills
overlaid on the plate and revealed on hover.
- Type tabs are the Home type row's pills (home-hero.css shared contract),
fixed to 原型 / 幻灯片 / 文档 / 图片 with a 更多 popover for every other
kind the catalogue carries; the sub-facet row and header search are gone.
- Document joins TemplateType; an empty tab shows the blueprint empty state
(community-empty-mark.svg) once the catalogue has answered.
- 图片 / 视频 tabs lay out as an uncropped masonry; MediaSurface publishes
the poster ratio for it.
- Preview modal: Esc closes it, panel scaled 8% down, 16px corners.
Message center
- Archive as a local third state (archived-ids store survives the sign-in
hand-off), header shelf toggle, card restyle (title + chevron, media on
expand, type · date), no hover states, no subtitle or settings footer.
i18n: community.typeDocument / emptyTitle / emptyBody, messageCenter.archive
/ unarchive / archivedTitle / emptyArchivedTitle across 19 locales;
community.usePrompt now reads 做同款 / Try it now.
* fix(web): close template previews on iframe Escape
Handle Escape in host and accessible preview documents, rebind on frame load, and focus the dialog on open. Cover keyboard capture, reload cleanup, and inaccessible frames.
Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
* feat(web): dock the account module at the foot of the entry rail, keep only credits top-right (OPEND-2553 阶段二) (#7976)
* feat(web): dock the account module at the foot of the entry rail, keep only credits top-right (OPEND-2553 阶段二)
Port the Demo (#7635, 997a0a08f3) account increments onto feat/home-entry-refresh:
- EntryTopRightCluster keeps the GitHub chip and the credits / 升级 pill in
the chrome host; the account module (avatar + name, message-centre bell,
update rocket, hover/pinned menu) portals into a new `accountHost` at the
foot of the rail's nav column. The project route (no rail) drops it.
- Free plan: the pill becomes the green 「✦ 升级」 CTA; paid plans lead with
the plan wordmark and the bare amount (subscribed $0 keeps the wordmark
only). The billing card hangs under the pill as a hover panel.
- Account menu: centred identity card, 账单 (console dashboard, workspace-id
fallback), 获取帮助, 提交建议, 退出登录. 设置 and 消息中心 leave the menu
(rail item / bell). Menu height is bounded to the rail card and scrolls.
- Social links move into the dock above the identity row with labels
(labels drop under a 190px container query); signed-out keeps the footer
row. Update rocket falls back to the top-right home while a narrow window
(≤1080px) auto-collapses the rail.
- entry-layout.css: dock / bell / head / social / pill / panel rules per
Demo; rail panel is a size container again.
- AvatarMenu uses modelVersionLabel; UserActionCard gains the
chat-panel-next alignment layer; contracts add the `billing` account-menu
analytics element; i18n adds entry.socialMail / entry.accountBilling and
shortens the help / feedback labels.
- Tests re-pinned to the new form (account-billing, updater placement,
message-centre bell, credits hover panel, zero-balance pill, project
route); e2e updater and team-interaction specs follow the new homes.
* fix(web): restore keyboard access to account dock actions
Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
* fix(web): use workspace fallback for billing card actions
Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
* feat(web): restore the project chat pane material and shell per Demo (#8120)
Port the OPEND-3090 shell deltas from the #8113 Demo (877980fb17) onto
feat/home-entry-refresh without cherry-picking:
- The chat card paints nothing of its own (transparent, no backdrop
blur) so the app wash / window vibrancy shows through; the 85% white
frost is gone per product decision. Reduced-transparency and
no-backdrop-filter fallbacks still flatten it to a solid surface.
- The resize gutter is a 4px track: `--project-chat-handle-width`
seeds and registers at 4px, the handle sizes from the variable with
no min-width, and `SPLIT_RESIZE_HANDLE_WIDTH` matches so the layout
math and the CSS agree. No strip is painted at rest, hover, or drag.
- The composer's design-system trigger uses the Home variant; the Home
foot-row rules gain a `.composer-row` twin so it renders as the same
36px palette disc beside the upload trigger.
- AvatarMenu pins its popover between the composer shell's edges,
right-aligned to the trigger, and re-measures through a
ResizeObserver on the shell; the popover is a flat `--bg` surface
with no glass backdrop.
A new style spec pins each value to the Demo's; FileWorkspace's split
style expectation and the project e2e flows follow the new handle
width and trigger test id.
* chore(web): sync main into feat/home-entry-refresh (#8124)
* fix(web): stop asserting unknown project facts while entering a project (#7428)
* fix(web): stop asserting unknown project facts while entering a project
Entering a personal project cold showed two statements that were not true yet.
The read-only banner rendered on viewerOnly, which is fail-closed and also
covers the window where ownership is simply unproven. So your own project told
you it was someone else's shared project for as long as the workspace context
took to resolve. The gate stays -- controls should be disabled until proven --
but the banner asserts a reason, so it now waits for isSharedNonOwner, whose
docblock already promises positive evidence and never mere !isOwner during the
status-unknown window.
The design-files empty state has the same shape: zero files before the first
authoritative read is indistinguishable from a genuinely empty project, and its
CTAs create NEW content. The panel already draws this distinction for a team
mirror that is still downloading; a local list that has not returned yet now
gets it too, keyed on the files snapshot generation, which advances only on an
accepted list (a transport failure deliberately does not advance it).
Measured in Chrome against a personal project with one artifact: the read-only
banner appeared at 4.8s and the empty state was rendered then removed; both are
now absent for the whole entry. Neither change touches what is disabled -- only
what the UI claims while it does not know.
* fix(daemon): keep one preview scope per project so a refetch is byte-identical
The web client builds its srcDoc preview from GET /raw/<file>?odPreviewBridge=…,
and that route minted a fresh preview scope on every request, injecting it as
<base href="…/preview/<uuid>/">. So the same artifact served different bytes on
every read: any refetch produced a different srcDoc string, React assigned it,
and the iframe reloaded -- the artifact visibly vanished and came back.
Measured on a team-workspace entry before this change: the srcDoc was rebuilt
1-4 times per entry depending on timing (one run wrote an empty document in
between), each rebuild carrying a different base href.
Preview reads now acquire the live scope for that exact (project, workspace),
renewing its TTL, and only mint when none exists. The grant is identical -- same
project, same member, same TTL bound -- only its name is now stable.
Deliberately not folded into mint(): export flows mint a scope and revoke it
when the render finishes, and sharing an id with a live preview would revoke the
preview out from under it. acquire() is a separate entry point used only by the
preview read.
Verified two ways. Unit: two reads of one artifact return the same base href;
swapping acquire back to mint turns it red. Browser: recording a team-workspace
entry and measuring frames where the preview goes blank after content had
appeared -- 2 such episodes before (one lasting 74ms), 0 after.
* fix(web): say the file list is loading instead of showing nothing
Suppressing the empty-state CTAs removed a false claim but left the panel blank
until content arrived, which reads as stuck rather than busy -- a worse first
impression than the wrong copy it replaced.
The unknown window now renders the same pill the team-mirror sync uses, with the
existing common.loading copy, so the slot always says which of the three states
it is in: loading, syncing, or genuinely empty. No new copy, no new i18n keys.
This is a consolation, not a cure: the real cost is how long the list takes to
arrive. That is tracked separately -- the project-files read is keyed on the
workspace identity, and workspaceType inside that key is echoed straight back
from the request's own x-od-workspace-type header, so a stale header during
entry changes the key and defeats the request sharing that exists to prevent
exactly these duplicate reads.
* perf(web): single-flight the design-system catalog read
Entering a project mounts several independent catalog readers in the same
tick. `fetchDesignSystemsResult` fetched `/api/design-systems` directly, so
three concurrent readers meant three requests — while the team-index step in
the very same function was already coalesced. The catalog read was the half
that got missed.
Identical concurrent reads are not merely redundant here. The daemon answers
them serially, so each duplicate holds one of the browser's ~6 per-host
connections for seconds. Measured entering a real project, three catalog
reads took 1.8s / 3.1s / 3.7s while this project's own `/files` read — 17ms
when uncontended — waited 1.2s for a connection.
Route it through the same `coalescedGet` the team index uses, partitioned by
workspace identity and by the caller-supplied team witness. Two invariants
are preserved deliberately:
- A forced read (`forceTeamMaterialization`, fired BY a mutation) evicts any
in-flight snapshot instead of joining it, exactly as the team index does
one function above. A mutation must observe its own effect.
- `coalescedGet` declines to cache a rejection, but this reader reports
failure as a resolved `{ ok: false }`, so that is evicted explicitly rather
than shared for the rest of the window.
Measured entering the same project, three runs before and three after:
design-systems requests 4 / 4 / 4 -> 2 / 2 / 2
slowest of those 3678/5056/5318 -> 1501/745/1769 ms
requests over 1s 37 / 53 / 27 -> 16 / 7 / 9
Total request count moved too but its ranges overlap across runs, so no
claim is made there. The remaining 2 reads are the two identity generations
of entry (provisional, then resolved); collapsing those is a separate change.
Red spec first: the burst assertion failed with "expected 3 to be 1" before
the fix. The forced-read guard was green before and after — it locks in the
invariant rather than reporting a defect.
* fix(daemon): never let a live preview adopt a one-shot preview scope
Review caught a real defect in this branch's own `acquire`. It searched every
registry entry by (project, workspace), and entries carried no record of who
minted them or why. A screenshot/PDF export mints a scope for the same tuple
and `revoke`s it in its `finally`, so a preview that acquired the export's
entry would lose its `<base href>` the moment that export finished -- blanking
the artifact, which is the exact symptom this area exists to prevent.
The comment above `acquire` claimed the mint/acquire split already prevented
this. It did not: the split only stopped `mint` from handing out a shared id,
while `acquire` remained free to find an export's entry.
Mark the lifetime on the entry instead. `mint` creates one-shot scopes whose
owner revokes them; `acquire` creates and matches only reusable scopes, which
are released by expiry alone. The public surface is unchanged -- both go
through one internal `create`.
Red spec first, at the HTTP boundary: `preview-url` mints exactly the way an
export does, so it stands in for one without needing a renderer. Before the
fix the bridged raw read handed back the *same UUID* that mint had just
issued:
expected 'e60f3014-…-642f00aeadc2' not to be 'e60f3014-…-642f00aeadc2'
* fix(web): stop the remaining surfaces claiming an unproven share
Browser verification of the earlier banner fix showed the claim was shortened,
not removed. Measured entering an OWNED personal project, three runs each:
main "这是共享项目…" shown ~4.1s
banner fix only shown ~1.0s, 3 of 3 runs
this commit not shown, 3 of 3 runs
The banner was only one of three surfaces deriving the claim from
`projectMutationReadOnly` (= `viewerOnly || materializationPending`), which is
fail-closed and therefore also true while ownership is merely unknown. The
chat composer placeholder and the project title tooltip each re-derived it,
so gating the banner alone left the same false sentence on screen.
Give the reason one name. `projectReadOnlyClaim` returns undefined while the
share is unproven, and all three surfaces read that one value. Disabling still
comes from the fail-closed flag — only the CLAIM waits for evidence.
This is the same defect class as the hook-level fixes in
use-project-collab.context-seed and use-project-collab.created-by-viewer; it
survived them because these three sites bypass the hook's guard and read the
flag directly.
* fix: restore the fixes d61be13806 reverted by accident
d61be13806 was meant to carry one web change. It also silently reverted the
daemon preview-scope fix, the design-files loading placeholder, and the
file-workspace banner gate — every fix this PR had landed so far.
Cause: verifying the branch in a browser meant swapping files to their
origin/main content and back. `git checkout origin/main -- <paths>` does not
only change the working tree, it STAGES those paths. Restoring the working
tree afterwards left the index still holding main's content, and `git commit`
commits the index, not the working tree. The check I ran before committing
(`git diff HEAD -- apps/daemon`) compares the working tree, so it reported
clean while the index was not. `git status` did say `MM`; I read the second M
and not the first.
This commit restores all five files to their state at the merge commit
0f923028c4, verified marker by marker rather than by eye:
daemon acquire / reusable scope entries present
server-context acquire on the deps interface present
design-files loading placeholder + gate present
file-workspace banner requires a reason present
No behavior is intended to change relative to 0f923028c4 plus d61be13806's
ProjectView work; this only undoes collateral damage.
* fix(daemon): keep the whole bridged document stable, not just the scope id
Review caught that this branch's preview-scope reuse does not actually stop
the flicker it was written for. `acquire` renewed `entry.expiresAt` on every
call, and that expiry is serialized into the bridge script of the very
document the scope is injected into (`buildPreviewBaseHrefBridge` JSON-encodes
`{href, expiresAt}`). So two repeated reads returned the same scope id and the
same `<base href>` while the full body still differed — React reassigns
`srcDoc`, the iframe reloads, and the artifact blinks exactly as before.
Stop renewing on acquire. Lifetime extension already has its own path: the
client renews explicitly via `x-od-preview-scope-renewal`, and that response
is not a document, so extending there changes no served bytes.
The existing spec could not see this: it compared only the `<base href>`. It
now compares the complete returned document, which is what `srcDoc` actually
receives — any byte that moves per request reloads the iframe just as surely
as a fresh scope id would.
Verified end to end against a running daemon, three repeated reads of one
artifact:
before 6ccf51baf7238d5c / 3a7c49a353b5efdd / 0e02c96c1da882f4
after dfd91bb06572dd65 / dfd91bb06572dd65 / dfd91bb06572dd65
Not verified: a browser-level before/after on the srcDoc transport. Every
"before" attempt resolved to the URL-load path instead, where changing bytes
at a stable `src` does not reload the iframe at all, so the two runs were not
comparable. The HTTP-level evidence above measures the defect itself.
* docs(daemon): say that acquire reuses a scope without renewing it
Both docblocks still described the behaviour the previous commit deliberately
removed. The dependency contract in server-context.ts promised a renewed TTL,
and the summary above the implementation said the same, so a future consumer
could reasonably assume repeated acquisition keeps a scope alive -- or make
the implementation match the comment and reintroduce the byte instability
that reloads the iframe.
Both now state that acquire reuses without renewing, that ttlMs applies only
when it has to create a new reusable scope, and that keeping an in-use scope
alive is renew's job.
* fix(daemon): freeze the expiry a preview document reports
The previous commit stopped `acquire` from renewing, but left the other half:
the explicit `renew` endpoint still moves `entry.expiresAt`, and the next
bridged read serializes that new value. Review measured it — `read -> renew ->
read` kept the same scope and the same length, changed the embedded expiry,
and produced one additional iframe `load` in Electron. The client renews on a
timer for as long as a preview is open, so this is the steady state, not an
edge case.
Split the two meanings the field was carrying. `expiresAt` stays the live
lifetime that renewal extends, and JSON responses keep reading it.
`documentExpiresAt` is frozen when the scope is created and is what a served
body embeds, so nothing a renewal does can change the bytes of a document.
The host does not lose anything: it uses the embedded value only to seed its
first renewal, and every renewal after that is scheduled from the renew
response (FileViewer keeps the returned expiry, it does not re-read the
document for it).
Red spec first: `keeps the bridged document identical across an explicit
renewal` failed on two byte-different bodies. Mutation-checked by pointing the
route back at the live `expiresAt`. Verified against a running daemon —
read, renew, read, renew, read returned 7a2f2c060559759e three times.
* feat(chat): rebuild the chat panel against the design spec, and fix what real runs exposed (#7518)
* fix(chat): close remaining scroll and retry gaps
* fix(chat): preserve portrait artifact previews
* fix(chat): reconcile media task files after path changes
OPEND-2543 / OPEND-2544. A media task persisted its generation-time path, so
an agent move/rename left the chat card requesting a 404 path. Resolve the
task file against the project's currently registered files using a bounded
size+mtime identity witness; ambiguous matches fail closed rather than
guessing from filenames. Retry stays gated on run-terminal state so it cannot
race the agent's own provider fallback.
* fix(chat): stop scoring legacy question forms as successful runs
OPEND-2549. An agent emitting the legacy XML question-form shape failed JSON
parsing, and the run still terminated as a success with no artifact. Detection
now recognises both shapes at the daemon boundary so an unanswered form ends
the run as waiting, not done.
* feat(chat): align artifact cards and memory shell with the latest design
Tracks PR #7170 at 8015870095. Adds a --radius-2xlarge (16px) product token
instead of copying raw radii, applies it to the memory shell and summary so
expanded and collapsed keep one silhouette, and unifies artifact action
buttons at 28px with the top-right controls (OPEND-2560).
* fix(chat): register produced artifacts on the turn that made them
OPEND-2550. A finished run could leave the chat rail with no artifact card
because registration depended on a workspace read that had not landed yet.
Registration is now driven by the turn's own file operations, so the card
belongs to the turn that produced it.
* docs(chat-panel): record the PR #7170 design diff and artifact version design
The design-to-implementation audit against PR #7170 at 8015870095, the chat
artifact version-semantics design awaiting review, and the rolling issue
ledger. Plane attachment binaries stay out of git; the manifest records their
SHA-256 and where they came from.
* fix(chat): keep agent-written next steps on turns with no file output
OPEND-2497. The host gated next-step suggestions on the turn having produced a
file, so a turn that only answered in prose - add-to-chat follow-ups, a wording
change, a proofread - silently dropped suggestions the agent had already
decided were worth offering. Delivery evidence is now named per lane: the
default lane trusts the agent's own suggestions, while host-authored lanes
still require a deliverable to anchor to. Failed and canceled turns stay
withheld. Clicking a suggestion still only fills the composer draft.
* feat(media): publish batch coordinates for sequential image generation
OPEND-2195. The chat rail could only guess how many images a turn was making
by counting 'media generate' in the command string, so parallel tasks drew
separate one-cell rows instead of one row of N. Tasks now carry the batch they
belong to, their position in it, and its size, derived from overlapping
lifetimes on the same run and surface rather than a timing constant.
One-at-a-time generation overlaps nothing and reports a batch of one.
Also closes the OPEND-2543 gaps this exposed: a file another task resolves by
its own path is off-limits, a file more than one moved task can claim is
withdrawn from all of them, and kind must agree - so two tasks recording the
same size and mtime can no longer both take the same image. A rename landing
just after run-terminal is picked up by a bounded reconciliation on runs this
pane watched go live, instead of pinning the card to the pre-rename path.
* feat(chat): rebuild the upgrade card against the latest design
Tracks PR #7170 at 8015870095. The CTA moves out of the balance header into a
bottom row beside the explanation, and the colour contract flips from green
ink on black to a green surface with dark ink. Low-balance and zero-balance
share one layout. Identity branching is unchanged - the card only draws the
exit and calls onUpgrade.
The error card takes the 16px radius through the chat seam with the product
token as fallback. The reconnect counter inherits its weight rather than
pinning 500: the design raised its own body weight in the same commit, so a
literal 500 here would put a heavier number inside a lighter sentence.
* fix(chat): treat an empty produced-file list as unknown, not as proof
A turn's producedFiles is a client-side diff of project file names taken
before and after the run, not a daemon verdict. It is empty whenever the turn
edited a file that already existed, whenever the baseline could not be read,
and whenever a settled file-list read wins the race with file-change
invalidation - the last of which is OPEND-2550's own scenario. Treating empty
as 'this turn produced nothing' therefore recreated the missing card it was
meant to fix. Whether a turn has an artifact is now decided by its own
write/edit tool rows; the produced list only contributes when it is non-empty.
Also covers what was already implemented but untested: the toolbar Share and
Export panels close on a second click (OPEND-2552), and artifact previews keep
their aspect ratio across image, video, HTML, doc and pending forms without
leaking onto execution-record thumbnails (OPEND-2547). Two assertions that
passed vacuously - comparing two heights that both computed to auto, and
reading a fit attribute without its computed style - now pin real values.
Next-step suggestions take the design's medium weight (OPEND-2558, partial).
* fix(daemon): emit each Claude tool call once, from whichever frame arrives first
The stream had two paths that could emit a tool_use - the incremental
content_block_stop and the assistant wrapper frame - but the guard set was
named for the incremental one and only written by it, while the wrapper read
it as if it meant 'already emitted'. Every tool in a recorded session came
through twice.
Adding a check to the wrapper alone would have been wrong in both directions:
the set is never written by that path so the check could not fire, and a
canceled turn where only the incremental path arrives would have lost its one
emission. The set now means what its readers assume - emitted by any path -
and both paths check and write it, neither waiting for the other, so a turn
that produces only one of the two frames still emits exactly once.
Flipping the order exposed a second defect the old sequence had hidden: the
wrapper frame often carries an empty input object, which would now overwrite
the real command. The wrapper prefers the delta-assembled input when that
block is still open and parses, and falls back to its own when truncated.
* fix(chat): make every composer send path agree on what it will send
OPEND-2551. The Send button asked whether the composer had a payload, which
counted quotes, while Enter asked whether the composed prompt was empty, and
quotes fold into that prompt - so the two hands disagreed and the disabled
button could still send. Both now read one canSend.
The deeper half: four send paths existed and only one folded the quote prefix,
so sending an annotation from the Mark panel cleared the chip and delivered
nothing to the agent. All four now compose the outgoing prompt the same way.
Also maps the design's user-bubble ground and muted action tier to chat-scoped
tokens rather than literals, splits the queue grip and action icon sizes that
an override layer had merged, and hides the default design router chip while
leaving the underlying plugin binding intact (OPEND-2412).
* feat(chat): give question forms a real color and amount control
Tracks PR #7170 at 8015870095. The color question was a bare native picker and
the amount question a read-only slider. Presets, the system picker and a typed
hex now drive one answer, an unparseable hex disables Next, and the number
above the slider is editable and clamped to the contract's min/max/step.
Canonical hex is six lowercase digits at one site in the parser, matching what
the native input sanitizes to, so the controlled value cannot fight the DOM.
Alpha and three-digit shorthand stay rejected rather than widening the
protocol; a value that cannot be normalized renders as written, never rewritten.
The selected counter splits at its existing translation's count slot, so each
locale keeps its own word order rather than concatenating fragments.
Also fixes option punctuation colliding with the card border (OPEND-2402) and
the cramped option row (OPEND-2401).
* fix(chat): let option descriptions wrap and give confirmed answers a surface
An option row is a button, so it inherited the global button rule that forbids
wrapping - the description then overflowed the card and was clipped. The
earlier min-width and overflow-wrap were correct but idle: when wrapping is
forbidden outright, neither is ever consulted. The option row's existing
button reset now zeroes white-space alongside the other global defaults it
already undoes; the global primitive keeps nowrap, which is right for genuine
single-line buttons.
The confirmed-answer block had no padding, ground or radius at all, so it read
as bare text rather than the design's panel. All three land through the 16px
token, and an answer carrying a thumbnail drops to the 12px tier - keyed on the
block actually having one, since a catalog card without a preview collapses to
plain text like any other answer.
* feat(chat): drop the thinking scroll window and collapse finished runs
Product ruled against the design here: reasoning now reads like ordinary body
text - natural height, no fixed viewport, no auto-scroll, no fade mask. The
mask was what made it hard to read. The gray container and the blur-in reveal
stay; the scroll lifecycle is deleted rather than left dormant. The indent
moves onto the container so text does not shift when a stream ends.
OPEND-2557: a finished run stayed open because details' toggle event cannot
say who opened it - React writing the controlled value back fires one too, and
that echo latched 'the user chose this' on the frame the shell first opened,
disabling the collapse for the rest of the run. An echo always reports the
value we already hold; a real click always reports the opposite.
OPEND-2195: media cells are placed by their batch coordinate, so parallel
generation draws one row of N instead of N rows of one. Placement is
all-or-nothing - a batch missing or colliding on an index falls back to
arrival order rather than silently displacing half its cells.
Three failing tests were outdated rather than regressions: the completion word
is deliberately withheld until a run settles, every turn now states its own
terminal outcome since the pinned todo card was removed, and the icon slot
moved one level down. Each keeps its original intent and gained coverage.
* fix(chat): let jump-to-latest and the plan pill trade places by scroll position
The two share one floating slot, but the plan pill won unconditionally - and
it is present for the whole of any planned run, so scrolling up during one
left no way back to the bottom. Scroll position now decides which occupies the
slot: scrolled up shows jump-to-latest, near the bottom shows the plan pill.
The bottom padding stays keyed on whether the turn has a plan at all, not on
what is currently shown - removing it mid-scroll would shrink the scroll
height, shorten the distance from bottom, and oscillate.
OPEND-2546: re-quoting the same passage now says so. The decision runs in a
synchronous mirror rather than inside the state updater, which StrictMode
invokes twice; the toast key is a counter, since two clicks inside one
millisecond share a timestamp and React would keep the first dismiss timer.
The steer button carries a visible label, which is what the design gives it
and the only thing that separates it from send-now - the two never render
together, so a different glyph on a button seen alone would say nothing. They
are genuinely different actions: steer writes into the live run's stdin,
send-now stops and restarts it.
* fix(chat): anchor the quote bar to the visible selection, above it
Two defects stacked. The bar flipped the wrong way by default: the design puts
it above the selection and drops it below only when the selection is against
the panel's top edge, and the repo's own mirror gallery already renders it that
way - the runtime disagreed with its own gallery page.
The distance came from Range.getBoundingClientRect(), which is the union of
every client rect including the zero-width caret at the selection's end. A drag
that overshoots by a hair puts that caret on the next block's first line -
nothing looks highlighted, but the union's bottom edge has already moved down
there. Anchoring now uses the first and last rects that actually have area,
and centres on the one it is touching rather than on the whole span.
Together that is why a selection in a dense execution list threw the bar
hundreds of pixels down onto the composer, while ordinary prose was only off
by one line height and read as a spacing nit.
* fix(chat): put the whole turn footer on one muted ink tier
The design gives that row a single rule; we had split it into feedback and copy
classes, so muting only the feedback half would have left two greys side by
side. Both now consume the same token the timestamp already uses, and hover
changes the ground rather than the ink - it previously jumped to near-black.
Selected states keep their semantic green and red; the guard that proves it is
order-sensitive for the positive rating, and the constraint is written next to
the rules that depend on it.
The support dialog takes the design's 16px radius through the chat seam, and
its quoted excerpt is corrected - a stale quote of the source is worse than
none.
* fix(daemon): carry the original request when a headless continue cannot resume
od run continue sends only the continue instruction, on the assumption that the
session holds the request it refers to. The daemon may refuse to resume for
reasons the caller cannot see - a changed model, a changed cwd, a moved cursor -
and then that instruction lands in a blank session that never saw the request.
Callers now declare that their message is a continuation, and the daemon seeds
the original request from the rejected session's own cursor when it starts
fresh. The field is optional and the behavior only fires when set, so existing
callers are byte-identical.
The web client already handles both branches: it sends the full transcript and
the latest turn separately, and the daemon picks by whether it resumed. It is
deliberately left alone.
* feat(chat): follow a live thinking stream, and stop the rail cutting the mark
Thinking sticks to the bottom while it streams, parks when the reader scrolls
up, and re-arms when they return to the bottom or reopen the drawer. It reuses
the chat log's own stick-to-bottom module rather than growing a second set of
predicates: intent changes only on scroll, so content growth cannot masquerade
as a gesture, and the geometry baseline refreshes on every resize settle even
while parked - otherwise a parked reader could never get back. The follow is
one write to scrollTop; the slow stepped scroll and the fade mask stay gone,
and the height cap is max-height, so short reasoning is not padded out.
OPEND-2417: the rail started at a hard-coded 25px while the status mark is
centred in its row, so the mark's bottom moved with row height and crossed the
rail the moment a title wrapped to two lines - and this PR's 12px to 13px title
bump had already eaten most of the single-line clearance. The mark now sits at
a fixed offset like the plan card's, and the rail derives its start from the
row's own padding, slot and gap. Single-line rows land on 25px exactly as
before; the difference is that they no longer drift when an input changes.
* test(chat): measure next-step weight through the real cascade
The previous test injected only the component's own module, so it measured a
product that does not exist: the global button rule already sets weight 500,
and the declaration added to 'fix' it changed nothing. The test now injects the
global reset first, in the order the app does, and pins the declaration's actual
job - holding the design's value on the component itself, so it survives the day
that legacy global button styling is cleaned up.
* fix(chat): let a long next-step suggestion wrap instead of being cut off
The suggestion is the sentence that will be sent; a reader who cannot see all
of it cannot decide whether to click it. Two things were cutting it: the text
layer asked for a single line, and the global button rule leaked nowrap, a
fixed 36px height, a line-height of 1 and centred alignment into the row - the
design's own button reset carries none of those. Both are undone at the
component, leaving the global primitive alone, where nowrap is right for a
genuine single-line button.
The arrow now aligns to the first line rather than floating to the middle of a
wrapped row, following the queue row's written precedent for the same shape. A
single-line row still measures 36px, so nothing moves until a suggestion is
actually long enough to wrap.
* feat(chat): let a long single-choice list collapse behind more options
A select with more than a handful of options now renders as a menu: common
entries first, the rest behind a host-owned 'more options' toggle, capped at a
scrolling height. The trigger is the option count or the presence of the new
grouping fields, not the type alone - a legacy two-option select keeps the flat
list it has today, and the one real product select (a hundred voices) gets the
menu without the model changing anything. The toggle's own copy belongs to the
host and is generic; a model's group labels still render as headings inside.
The authoring rules now cap options at six or seven, ask for plain language
over jargon, and put a length target on an option label with long text moving
to its description - the missing constraint behind suggestions that had to be
truncated. They live in the general form layer, so a mid-conversation form is
covered rather than only the opening brief.
Those rules and the type list exist in six hand-maintained copies across the
daemon, contracts and a bundled skill, with no shared constant; a type could be
dropped from any one of them and every test stayed green. A parity spec now
pins all six against the renderer's own union rather than against each other,
which six copies going stale together would satisfy.
* feat(chat): show a tool call the moment it starts, not when it returns
An in-flight call rendered nothing at all, so a run that spent fourteen minutes
inside one command showed the previously finished rows and a ticking total and
nothing else - which is what 'it ran forty minutes and produced nothing' was
describing. Everything derivable from the call itself now appears immediately;
only what genuinely needs the result waits for it. The shell's span also counts
from the in-flight call, so a long stall stops reading as no progress.
The row keeps its identity across both phases, so a result swaps the state of
the row that is already there rather than adding a second one. Pending means
the call has not returned, which is not the same as still running: a cancelled
turn leaves rows pending, and they settle to a neutral mark rather than
spinning forever or claiming a success or a failure that never happened. The
elapsed slot is present but empty while pending, which is what keeps the
chevron from shifting when the value lands.
* feat(chat): give the panel the design's typographic baseline
Every component measured byte-identical to the design and still read wrong,
because the panel inherited the app's baseline rather than the draft's: weight
400 against 500 and 14px against 13px. Line height was already right. Both now
land on the chat seam, so the panel matches the draft while the sidebar,
settings and home keep the baseline they were designed against - the draft only
ever covered this panel.
The mono face moves with it. The design relabels the same font file from 400 to
500, which is bookkeeping rather than a new weight: with one static face the
glyphs are identical either way, and the label simply has to agree with the
baseline that requests it. Doing one without the other would leave every
elapsed time, path and hex asking for a weight the face does not claim.
* fix(prompts): tighten the new form rules and raise the charter ceiling
The five rules landed as explanatory prose and pushed the slim charter past
its byte ceiling, which is paid on every turn of every run. Rewritten as short
imperatives they cost 644 bytes instead of 1,315.
That still does not fit: the charter sat 44 bytes below the ceiling, so no
amount of compression would have. The rules cannot move somewhere cheaper
either - no conditional section can predict that a turn will emit a form, and
the on-demand atom that carries them for OD Next is not reachable from a
skill-less slim run. The ceiling moves, documented beside the constant in the
format the previous raise used, to the next step rather than to just fit: 44
bytes of slack is what made the next sentence hit a wall.
* fix(chat): stop our own scrolling from reading as the reader's
Positioning a question form animated the log upward, and a frame of that
animation looked exactly like someone scrolling up, so following stopped. On a
quiet turn it healed itself - the animation finished at the bottom and re-armed
- which is why it survived. While the model is still streaming it does not:
the destination was fixed when the animation began and does not follow the
growing content, so it lands somewhere that is no longer the bottom and the
escape stands. Nobody touched anything.
Positioning is now instant, which collapses the window to nothing rather than
trying to recognise and ignore our own frames inside it. The two near-identical
copies of this logic - one already fixed in an earlier pass, one not - are one
function now, so they cannot disagree again.
Recovery had the mirror problem: it demanded the layout hold perfectly still,
and a stream never does, so a reader scrolling all the way back to the bottom
had their gesture thrown away on the frames where content grew. Reaching the
bottom while the bottom is moving away can only be a real gesture; reaching it
because the content shrank underneath still is not, and stays refused.
* fix(chat): stop a cancelled turn spinning forever, and give the stream its weight
An image cell kept spinning after its turn was cancelled: the row knew the
call had not come back but not whether anything was still working on it. It
now takes the same turn-state gate the tool rows use, so tool and image rows
share one vocabulary end to end - same field, same mark, same question asked
of the turn.
The thinking stream's body weight had to be written against the stream box
rather than the paragraph class: the text inside belongs to a different CSS
module, so the obvious selector would have matched nothing. The test proves
the two class names differ rather than assuming it, and pins the specificity
margin so a later simplification back onto the paragraph class fails loudly
instead of depending on bundle order.
Three fixtures were typed strictly enough to catch the new required field, and
two more were asserting a shape they did not have - one carried a property that
does not exist on the type at all, hidden behind a cast that is now gone.
* feat(web): measure the chat panel's own runtime health
Nothing measured the panel as a thing: long tasks fired without a conversation
or run attached, boot timing covered the page rather than opening a
conversation, and a panel that mounts and then renders nothing was invisible.
Seven events now cover first paint, DOM growth, memory pressure, stream health,
interaction latency, protocol anomalies and recovery.
They are built to be findable, not just countable. Every event carries the run
id - which is simultaneously the PostHog join key, the Langfuse trace id and the
diagnostics-bundle handle - plus the conversation, project, agent, model and
channel. A pressure event carries the heap trend and the breadcrumbs leading up
to it rather than only the moment it broke. Timing events declare whether the
measurement can be trusted, so a reading taken before stylesheets landed is
excluded rather than reported as a regression.
They are also built not to become the problem they measure: interaction latency
filters in the browser, a run with no jank emits nothing, and anomalies dedupe
per run so a render-time detection cannot end up measuring typing speed.
* docs(chat-panel): record the component gap audit, scroll research and dispatch log
The 24-component / 89-state audit against PR #7170, the streaming stick-to-
bottom research that recommends extracting our own machine rather than adopting
a library, and the rolling dispatch ledger with the product rulings that
override the design draft. Attachment binaries stay out of git; the manifest
records their SHA-256 and where each came from.
* fix(chat): show the retry button on a failed message, and animate the audio waveform
A message that failed to send offered no way to retry: the action row hides
itself with opacity, and opacity does not just fail to inherit - it composites,
so a child cannot opt back in. The design hides the individual controls and
excludes the retry, which is what this now does, along with the pointer-events
the same rule carries: the invisible copy button used to be clickable.
The audio waveform never moved. The keyframes it referenced did not exist and
the per-bar offset was never written, so a stalled render was the only possible
outcome. Bars now stagger off their own index, honour reduced motion, and the
play control sits inside the row as the design nests it rather than beside it.
Plan steps take the design's explicit 400. They inherited 400 before the panel
baseline moved to 500, so they were accidentally right; afterwards a
not-yet-current step would have sat one notch below the current one instead of
two, flattening the card's hierarchy.
* feat(chat): route artifact cards by the version each kind should show
An HTML card's face is the screenshot taken on the turn that made it, while
clicking it opens whatever the workspace holds now. The two deliberately
disagree - the card is a record of that turn, the click is a door to the
current file - and the comments say so, so nobody reconciles them later. An
image card is the opposite: face, click and export all read that turn's own
bytes, which is what stops a later overwrite from rewriting history.
With no snapshot - an old conversation, a capture that failed, no renderer, a
full quota - HTML shows the live latest and an image shows the current file.
Neither says anything about it: a card that announces its own failure is worse
than a card that quietly shows the best thing available.
A snapshot URL is only handed out once the snapshot is ready. A half-written
one renders as a broken image, which is worse than the fallback it replaced.
* feat(desktop): capture a first-viewport thumbnail without growing to the page
The existing image export measures the document and grows the window to it, up
to twenty thousand pixels, which is the long strip it is meant to produce and
exactly wrong for a card cover. A capture mode now says which of the two is
wanted: the thumbnail path fixes the viewport, never issues the height probe,
freezes animation and transitions, tells the page motion is unwelcome before
running a script that finishes what is already animating, and captures the
first screen only. The export path is byte-identical - the branch happens
before the code it used to share.
Resource waiting learned to stop at the first viewport, because a cover that
waits for assets below the fold pays for page length it will never show.
The renderer reports why a capture failed rather than returning a blank or
fabricated image, so a card that cannot get a cover knows to show the live
file instead of a broken one.
* feat(daemon): keep an immutable copy of what each turn produced
A chat card read the workspace file by path, so overwriting an image rewrote
every earlier card that showed it. A turn's output is now copied into a
content-addressed store the moment it exists and referenced by digest, which
separates three things that used to be one: where the file lives now, what it
contains now, and what this turn produced.
Media capture takes the provider's own buffer before the file is written, so
exactness is structural rather than a re-read that could race. The general path
copies at the run's terminal boundary and stats either side; drift fails the
snapshot rather than installing the newer bytes, because a snapshot of the
wrong version is worse than no snapshot. Where both paths cover one file the
media bytes win, scoped to the run so an earlier turn's copy can never stand in
for this one's.
Recovery is deterministic because byte-sourced intents record their digest up
front: an interrupted capture either completes from what is on disk or is
marked interrupted, and a path-sourced one only completes while the source
still matches. Sweeping is off until enabled; identity survives a rename and a
tombstone releases the path without resurrecting the old row.
* fix(chat): pick the main artifacts when a turn declares nothing
Declaring was meant to be the only way a card appeared, but a turn that edits
without creating declares only a quarter of the time - the instruction asks for
'a file you created this turn', and a small edit does not read as creating
anything. So most edit turns showed nothing, and the earlier fix for that
showed everything instead: a page, its script, its stylesheet and its images
all became cards, which is the pile the marker existed to prevent.
Silence now gets an answer rather than a blank or a pile. Pages and documents
win; if a turn wrote none, its images stand on their own; stylesheets, scripts,
icons and data files never do. An unrecognised extension counts as a
deliverable, because the wrong way to fail is to quietly hide what someone just
made.
The instruction now says that changing a file is delivering it, and that
declaring narrows rather than being the only door. Five places claimed
otherwise, two of them tests that had pinned the old sentence in place.
* fix(chat): put the tool rows and step interludes on the muted tier
A tool row read as dark as the step title above it, so the record had no depth:
the thing that happened and the thing that describes it weighed the same. The
row, its elapsed time and its icon now share the muted ink the design gives
them, and a row that failed keeps its own colour.
Interludes between steps were meant to be smaller and lighter than the opening
line, and were neither. The rule excluded any turn that had a plan - which is
most of them - while the thing it was written to protect, the opening line, is
already excluded by being the first child. Removing that exclusion is what
makes the summaries between steps read as summaries.
Thinking now sits on the same surface whether it is still going or already
done; it used to gain a background while streaming and lose it once expanded,
so the same passage changed shape when it finished. The class that was supposed
to carry that surface had never existed, so the rule attached to it had never
run - and a CSS Module proxy hands back a class name for any key, which is why
no unit test could see it.
* fix(chat): hang the chain on steps only, and align surfaces to their own column
The vertical rail and its indent belong to things on the chain - the steps of a
plan and the summaries between them. Thinking, tool rows and prose at the top
level were getting both, so a passage that had nothing to do with any step
still read as hanging off one. A step now says so itself, which means a block
type added later stays off the chain until someone puts it there, rather than
joining by accident.
The grey surfaces were positioned to put their text on the column, which left
the box itself jutting eight pixels past the rows around it - and since the
offset came from nesting depth rather than from where the sibling rows sit, two
surfaces at different depths missed their neighbours by different amounts. The
box now lands on the column and the text keeps the padding the design gives it.
A failed command row says it is failed rather than being inferred from having a
reason attached, which is also what lets the failure colour be written the way
the design writes it.
* fix(chat): let the next-step button size itself the way the design does
The button carried a minimum width and a horizontal padding that appear nowhere
in the design, and the round that added them wrote both into a test, so the
invented numbers were guarding themselves. The padding was also the wrong tier:
the design's footer buttons are all small, and the small tier overrides the base
padding it was copied from - which is why the button read as oversized rather
than merely wide.
Only the height stays, because that is the one number the design specifies
there, and it explains why: a height written down does not drift when the font
size moves, while one grown from padding does. That drift is exactly what
happened when the panel baseline went to 13px.
The shared button already supplies the padding, size and weight the design
gives this tier, which the test now proves by removing that stylesheet and
watching the assertions fail.
* fix(daemon): stop teaching the model a protocol we retired
The critique grammar kept appearing in answers, and four rounds of stripping it
out never held, because the feature had two entrances and retiring it closed
only one. The protocol itself stopped being injected; the atom carrying its
prose stayed in every design run's stage list, telling the model to follow a
tagged protocol that was no longer there - so the model wrote the tags from the
description. None of the attribute names match the real protocol; every one of
them traces to a sentence in that document.
The body now travels with the protocol: injected together or not at all, from
the decision the server already made rather than a second copy of the
predicate, since a second copy is a third entrance. The document itself
describes what to check rather than what to emit, and no longer forbids prose
outside an envelope - which is also why a turn could finish with no closing
summary at all.
Stripping is fixed where it failed and extended where it was absent: a held
fragment no longer gives up the moment a tag carries an attribute, thinking is
stripped before it is persisted rather than only on its way to the screen, and
the client covers the execution record and not just the prose beside it.
A spec now asserts that the assembled prompt contains none of this grammar, so
a future path that reintroduces it fails rather than reaching a user first.
* fix(chat): stop the feedback panel yanking the view to the top
Rating a turn scrolled the page even when the reason panel was already fully
on screen: it asked for the panel's top edge to meet the viewport's top,
unconditionally, so a panel sitting comfortably in the middle still dragged
everything the reader was looking at. Asking for the nearest position instead
leaves a visible panel exactly where it is and, when one really is off screen,
moves the least amount that reveals it - the browser's own primitive rather
than a hand-rolled visibility check.
It was also the one programmatic scroll on the chat log still animating, which
is the invariant the question-form fix wrote down: a scroll we start ourselves
is instant, and anything that wants animation releases follow first.
* fix(chat): keep the quote bar with the start of a long selection
Dropping below the selection exists to get out of its way, which works for a
phrase and stops working for a selection that fills the screen: the bar got out
of the way and landed most of a screen from anything the reader was looking at,
on top of the artifact preview. A selection taller than half the panel now
keeps the bar at its start when it has to flip, which is where the design
anchors it in the first place - flipping below is a correction for having no
room above, not a change of reference point.
Short selections still clear the whole passage, including a two-line one
pressed against the top edge, which is the case the new rule must not capture.
* test(chat): pin the thoughts icon against four ways it can silently degrade
The icon is already the one the design hands over, byte for byte, so this
round has no implementation to change - which is exactly the case where a spec
earns its place. A name that stops resolving does not throw: it falls through
to a stroke fallback with no case for this one and renders nothing, leaving a
blank where the icon was. Asserting the name would not catch that, so the spec
asserts the rendered path, entire rather than by fragment, since the filled and
outlined variants agree on precisely the strokes a fragment would match.
It also pins that the in-progress state keeps its spinning orb, so a later
attempt to make the two states consistent replaces motion with a static glyph
loudly instead of quietly.
* fix(daemon): stop an unfinished marker from swallowing the answer after it
An upstream reconnect cut a next-step marker mid-attribute, and the scanner
tracking quotes paired that unclosed quote with the next tag's, so it never
found an end: every closing bracket after it counted as text inside a string.
The buffer grew past its limit and the overflow branch handed the whole thing
to the screen, and to the database, where a refresh could not remove it.
A value cannot contain a newline or a raw angle bracket, so either one proves
the quote never closed. Seeing one now retires just the broken tag and keeps
reading, rather than waiting for an end that cannot come; overflow discards
rather than releasing. A held fragment at end of stream is discarded too, the
way the artifact marker already did.
A reconnect is also no longer dropped on its way to storage. It is a real
upstream event that happened once, not the polling noise the rest of that list
suppresses - and without it a conversation that was interrupted and rewritten
reads, on reopening, as though a second answer appeared from nowhere.
The qoder stream decodes with a per-handler decoder instead of converting each
chunk alone, so a character split across chunks survives.
* fix(chat): give the thoughts drawer one box instead of two
Hovering the thoughts header lit a background that started seven pixels left of
the passage underneath it - twenty-nine when nested - because the header row
stretched its hover target to the shell edges while the body was pulled back to
the column. Both were right on their own and wrong together.
The column now belongs to the drawer, and the header inside it carries none:
the same arithmetic reaches the same place, so nothing about the title or its
icon moves, and the two backgrounds share an edge because they share a box.
Which is what the thing is - a panel with a lid, not a row that happens to be
followed by a panel. The row rule that stretches hover targets is untouched;
removing it turns eight assertions red, which is the point of keeping it.
* fix(chat): say a generation failed while it still might be retried by the agent
A cell that failed mid-run already could not be clicked - the host withholds
the callback until the turn settles, so the agent's own provider retry cannot
race a manual one. But it still read 'Retry' next to a retry icon, so the only
thing the reader learned was that the button was broken. It now says what
actually happened, as a statement rather than a dead control, and becomes a
real button once the turn is over.
Over includes cancelled and failed: what the gate protects against is a second
retry running beside the agent's own, and once the turn stops there is nothing
to race. Wanting one image after abandoning the batch is a normal next step.
The wording reuses the label the tool rows already carry in every locale rather
than storing the same word a second time.
* fix(web): distinguish auto-expanded folds from user-opened ones
`defaultOpen` is only read on the mounting frame, so a fold that opens
because its subject is running has no way back: when the subject finishes
and the key has not changed, the same instance keeps the same `selfOpen`
and stays sprawled open.
`lifecycleOpen` follows the subject's lifecycle on every change instead of
just the first — until the user touches it, after which the fold is theirs
and neither direction is forced back.
Telling a real click from React's own write-back is the whole problem:
`<details>` fires `toggle` for both. The predicate compares values, not
occurrence — an echo reports the state we already hold, a click reports the
opposite — and reads it through a ref, because the memoised handler's
closure would still hold the first frame's value by the time a queued echo
arrives. Same predicate ExecutionShell already uses, verbatim.
Opt-in: every caller that does not pass `lifecycleOpen` keeps its exact
current behaviour, and controlled folds never reach it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* style(web): drop the step rail and restore the hover overhang
The designer retired the grey vertical line that chained the steps
together, so both `::before` pseudo-elements go, along with the five
variables that existed only to place them and the two `position: relative`
they were positioned against. Nothing is kept "in case the line comes
back" — re-deriving it from a fresh measurement beats reading stale
constants.
The status dot's own guard survives the line that used to clear it: it was
never about the rail, so its test moves to a file named after what it
actually pins. The offset stays 1.5px even though the step title has since
grown to 13px, which puts single-line centring 0.75px away; the assertion
pins "under a pixel of drift" rather than pretending the two agree.
The hover background reads slightly larger than the panel below it because
the design gives them different insets, not because anything is added: the
row bleeds 7px to each side and the summary pads 7px back, while the body
panel sits flush at 8px. An earlier attempt zeroed the summary's leading
inset to line the two up, which made them one rectangle and read as the
panel itself flashing. Text lands exactly where it did.
Indentation of the between-step notes keeps its 22px for now — the design
justifies it twice, once independently and once by the rail — and the
second half of that rationale is gone. Flagged for a ruling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* feat(web): show elapsed time on rows that are still running
A row that has been running for twenty-eight minutes showed nothing at all
until it finished, which is exactly backwards: the number matters most
while the user is waiting for it. The design leaves the slot open for this
— `ToolRow` needed no code change, only data.
One live end-of-span for the whole turn, `turnIsLive ? nowMs : endedAtMs`,
the same sentence the shell header already uses. Steps, thinking and tool
rows all read it, so none of them can settle to a different endpoint than
the one they were counting toward: a step that reported "1m 2s" while
running would otherwise collapse to "2.0s" the moment the turn stopped,
because the settled path counts to the last timestamped event rather than
to now.
The live value is computed after span bookkeeping, never into it, or the
silence-detection span would be identically zero.
No new timer: every number is `nowMs - startedAt` off the one interval that
already drives the shell header, and a test pins that rendering four live
rows calls `setInterval` no more often than rendering one.
Rows whose start is unknown still print nothing rather than inventing a
zero.
Known gap: a tool row can tick back by one frame when its result arrives
stamped earlier than the last tick. Strict monotonicity needs a per-row
high-water mark, which means component state; the test pins the magnitude
instead so the drift cannot grow silently.
Also lands the one line the fold-lifecycle change was waiting on: a running
todo drawer now follows its subject's lifecycle instead of only its first
frame, so it stops staying sprawled open after the step finishes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* docs(chat): pin where the failure card contradicts the daemon
A colleague hit a TLS certificate failure and got a card offering Retry as
its primary action. The daemon had already classified it correctly and said
on the wire, three separate ways, not to retry. The web side reads none of
those three and re-derives the answer itself.
The survey is exhaustive rather than sampled — all 67 failure reasons run
through the real resolver — because the question product has to answer is
not "what about certificates" but "how far apart are the two verdicts".
Forty cells offer Retry against an explicit not-retryable; thirty-two of
those are reachable under an opaque error code. Six more are not missing
rows but opposite conclusions, which is the cheaper half to fix: no new
copy is needed to stop saying the wrong thing.
The blocker is structural. Nine primary actions exist and none of them is
"go fix your environment", while the ladder's own comment claims to cover
that rung. The error catalog already ruled on the shape twice; only the
button has nowhere to land.
The daemon-side test locks the classification that is already correct, so
the gap cannot later be misread as a backend bug. The web-side red test
stays out of the tree until the ruling lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* docs(run-errors): bring the error-UX design back into the repo
The design that settles the failure-card questions has been living only in
Feishu. The implementation audit's header says the source file sits on this
branch, but git has never held it — the audit has been pointing at a file
that does not exist for a week.
That matters more than usual right now: today's survey of the failure ladder
rediscovered a gap this design had already ruled on. The environment class
was never an open question — S30 names the copy, the slots it interpolates,
and the button order. The audit had already recorded that neither of the two
actions those scenarios need exists in the codebase.
Feishu stays authoritative; this copy is for reading alongside the code and
for grep. The screenshots are short-lived signed links, so only their
descriptions survive here.
One line in S30 is now known to be stale: the daemon does classify
certificate failures, and has since the detail was introduced. The web side
never reads the answer. Left as written rather than edited silently, since
the doc is under review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* fix(chat): pin every newly sent message to the top, not just typed ones
Two independent defects were splitting the behaviour in half, which is why
it looked intermittent.
Whether to pin was something each send site had to declare by raising a
flag, and that flag was raised in exactly one place: the composer's own
callback. Seven of the eleven entrances that put a new user message on
screen never went through it — question-form answers, the home page's first
send, annotations, anything the queue released, resume, and image retry.
Pinning now follows from the transcript itself: the trailing user message
changed identity, so a new turn is on screen, regardless of which button
produced it. New entrances are covered by construction, which matters
because no one has ever remembered to add the line.
The other half is the reason it varied run to run. The pin scrolled
smoothly, and nothing distinguishes our own animation from a user dragging
the scrollbar — position is all either one reports. Mid-animation frames
sit far outside the release tolerance, so the first one dropped the pin and
froze the tail spacer; the final frame lands exactly at the bottom while
the reply is still empty, which re-arms stick-to-bottom and drags the
message back off the top. A reply that starts quickly grows the log past
that point and the pin survives; a slow one does not. Same code, opposite
outcome.
Self-initiated scrolls are already required to be instant — the
question-form anchor was moved off smooth for this exact reason — and this
was the last one still animating.
Visible change: the pin now lands on the frame instead of gliding, losing
roughly 300ms of animation. That animation was the defect.
Also updates the feedback-panel assertion left behind by c121d81b17, which
changed that scroll to nearest/auto without carrying its test along.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* fix(chat): anchor the quote bar to highlighted text, not to a swallowed box
The bar was not being clamped to the bottom of the panel — it was faithfully
anchoring to a box that had no highlight in it at all.
Selection geometry came from the whole Range's client rects, and per CSSOM
that list contains the border box of every element the Range fully encloses,
mixed in with the text line boxes. Dragging a selection a hair past the end
of a bubble — invisible on screen, since what follows is blank — swallows the
tail spacer: full width, real height, not one character. The earlier fix
asked only whether a rect had area, so that box passed as the selection's
last line, and the bar chased it down to the composer and centred itself on
the panel.
Painted text is what "the visible selection" means: no text, no highlight, no
anchor. Both edges are found by walking a short way in from each end of the
Range rather than over the common ancestor, which grows to the whole log
during a cross-message drag and would re-measure every text node per frame.
The bar also has to leave the pane. It is positioned in viewport coordinates
and declares `position: fixed`, but an ancestor carrying backdrop-filter
becomes the containing block for fixed descendants — so those coordinates
were being read as pane-relative, shifting the bar down by the pane's top
edge and letting the pane's overflow clip it. The composer is portalled out
for exactly this reason and that rule says so in its own comment.
Adjacent, deliberately left alone: a quote taken from the user's own message
carries a null message id, because the lookup reads the attribute the
assistant bubbles use. Its own red test, its own change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* fix(chat): stop disabled options reacting, and say when a question was skipped
Two defects in the same component.
An option that cannot be chosen because the limit is reached was picking up
the global `button:disabled` fill, which outranks the chip's own transparent
background, and then `.qf-chip:hover` outranked that in turn — so the row
carried a permanent grey pill AND still lit up under the cursor. Disabling a
button stops clicks, never styling. The design draws no disabled state for
options at all: one background, transparent. The repo had already fought
this exact leak for the visual-direction cards and won it back with a second
class; the option row never got the same treatment.
Skipping was reported as "Answers sent". Skip is not a separate path — it is
a submission whose …
* feat(web): align the home and project composer Add menus with the Demo (#8128)
Port the Demo (877980fb17) Add-menu shape onto main's shared ComposerPlusMenu:
one 36px plus disc on both composers, a paperclip attach row, the context
actions flat under it, optional plugin / connector / MCP submenus, no design
toolbox flyout and no working-directory group, and a 4px inset on every
composer tag. Both callers pass the three resource submenus with their add
handlers and analytics; the Home add-plugin row lands on the plugins page.
Refs OPEND-3085
* feat(web): dock the conversation history in the project toolbar (#8129)
Port the OPEND-2553 chat S3 slice from the Demo (#8113 @ 877980fb17):
- ProjectView hosts a 32px `chat-history-dock` seat right after the docked
project dropdown; ChatPane portals its history control there when given
`historyPortalTarget` and renders in place otherwise (side chat, flows).
- The history trigger uses the Demo's filled discuss-line glyph; the single
"new session" button moves from the card header into the dropdown's
search row (new `ConversationHistoryDock.module.css`).
- The dropdown drops its heading, conversation count, per-row message-count
prefix and delete button; rows show recency only (`runtime/chat/
conversation-time.ts`: now / Nm / Nh Mm / Nd), replacing the run-duration
labels and the ChatPane-local relTime helpers.
- Toolbar row grows to 48px, centred, `position: relative`; the collapse
button follows the dock (`order: 2`) with a transparent seat.
- QueuedSendStrip remounts per conversation.
- Tests: Demo's history-dock and conversation-time specs (red before the
change), timestamps spec updated, w124/w129 follow the moved entry,
msg-count spec removed with the feature; e2e locators updated and the
delete-recovery flows parked since the UI no longer has a delete entry.
Plane: OPEND-3087
* fix(web): show the chat message rail from the first user message and drop the retract state (#8123)
Port the message-rail deltas from the chat module demo (#8113) onto the
current ChatPane:
- the rail mounts as soon as a conversation has one user message
(`CHAT_RAIL_MIN_USER_MESSAGES` 2 -> 1) and unmounts when the active
conversation has none;
- every dash rests at 6px instead of 8px (`CHAT_RAIL_DASH_BASE_PX`);
- clicking a marker only closes the preview card. The `retracted` state,
its document `pointermove` release listener and the `.is-retracted`
rules are removed, so the markers stay hoverable right away;
- `.split:has(.chat-message-rail) > .split-resize-handle::before` keeps
the resize hitbox flush against the rail.
Tests: port `message-rail.test.tsx` (red before, green after), drop the
retract-state specs and refresh the threshold comments in the remaining
rail specs.
Refs OPEND-3088.
* feat(web): stack queued chat messages as layered cards (#8125)
Replace the flat queued-send strip under the chat composer with the Demo
QueuedSendStack: the newest queued message sits in front while the two
behind it peek out shifted 12px up, scaled down 6% and faded (0.6 / 0.36);
anything deeper is hidden until the region is hovered, focused or dragged,
which spreads every card into a 2px-gapped column of at most six visible
cards that scrolls for longer queues. Cards slide in from below and animate
out before their node is removed.
Per-message send-now / edit / remove / drag-reorder controls keep their
callbacks; the region is labelled "queued · N" and the cards behind the
front one are inert. The legacy `.chat-queued-send-strip` /
`.chat-queued-send-list` rules and DOM are removed together with the two
specs that pinned them; the ported Demo specs cover the stack behaviour.
Refs OPEND-3086.
* feat(web): blur the chat transcript progressively under the project toolbar (#8127)
Port the Demo ChatScrollEdge: a 40px four-layer backdrop-filter overlay at
the top of .chat-log-viewport that activates once the transcript scrolls
past the first pixel, plus a viewport mask that lets the real surface show
through instead of a painted cap. The project header becomes transparent
and loses its ::after glass gradient.
Refs OPEND-3089.
* fix(web): use one idle glyph for projects in the rail and the tab switcher (#8150)
A project with no run to report led with two different marks depending on
where the user found it: the rail's 最近项目 row drew a chat mark, the
project switcher above the chat drew the tab's folder icon. The two already
share ProjectRunStatusIcon for a project that has a status (OPEND-2795), so
the resting state is now shared the same way.
- ProjectRunStatusIcon.tsx exports ProjectIdleGlyph, the chat mark that used
to live inline in RailRecentRow, sized by the caller and inked by
currentColor so each slot keeps its own box and colour.
- RailRecentRow draws it at 16 in its 18px slot, as before.
- WorkspaceTabsBar.leadGlyphFor draws it at 14 for a project tab with nothing
to report; non-project tabs keep their own icon. hasRunStatusGlyph is
unchanged.
- New spec ProjectIdleGlyph.test.tsx renders both surfaces with an idle
project and asserts they hold the same glyph (red before, green after).
OPEND-3129
* fix(web): keep context actions in the Add menu and drop the template eye badge (#8151)
OPEND-3126: the Home working-directory menu offered its own copies of
"Reference another project" and "Link local code" next to the Add menu's
entries. Product wants the two reachable from the Add menu only, so the
working-directory row is back to folder rows (choose / recent) and the
WorkingDirPicker props, rows, and divider that carried the copies are gone.
The plus_pick analytics are emitted from the Add menu alone.
OPEND-3100 (supersedes OPEND-2697): the template poster no longer carries an
eye badge or a "Preview" tooltip, at rest or on hover. The whole poster still
opens the preview; only the badge DOM, its tooltip, and its CSS are removed.
Tests: HomeView.context-picker asserts the working-directory menu no longer
lists the two entries and routes the existing reference flows through the Add
menu; HomeHero.rail asserts the poster renders no badge or tooltip at rest and
on hover while the poster click still opens the preview; the home-hero style
spec asserts no eye-badge rule survives. The Home e2e flows use the Add menu.
* fix(web): project list filter, title spacing and naming (OPEND-3107/3110/3142) (#8153)
OPEND-3107: the project list's type filter now offers exactly the six
product types in order — Prototype, Slides, Document, Image, Website
clone, Design System. Live Artifact and Media are no longer offered.
Projects resolve to a bucket through `projectKindFilterCategory`, built
on the card-chip vocabulary: Document (`intent: 'document'`) and Image
(`kind: 'image'`) get their own buckets, live-artifact projects fold into
Prototype, video/audio projects match only "Any type". New i18n keys
`recentProjects.kindDocument` / `recentProjects.kindImage` in all 19
locales; Slides reuses the existing `recentProjects.kindSlides`.
OPEND-3110: the page title sat 29px below the content card's top edge
because the head row centred the 28.8px heading inside the 39px-tall
filter row. The title block now pins to the row's top (`align-self:
flex-start`), so the title box starts on the 24px gutter like its left
edge.
OPEND-3142: the sidebar entry and the project list page title read
"Projects" instead of "Personal projects" (`entry.navDrafts`,
`workspaceSwitcher.draftsTooltip`), using each locale's existing
"Projects" noun. Copy only; route and data are unchanged.
* fix(web): entry rail account and workspace polish (OPEND-3119/3112/3113/3153/3116) (#8152)
* fix(web): entry rail account and workspace polish (OPEND-3119/3112/3113/3153/3116)
QA acceptance batch G1 on feat/home-entry-refresh, five fixes in the rail's
account and workspace area:
- OPEND-3119: the billing card's plan label names the personal tier itself
(Plus / Pro / Max) instead of folding plus and max into the Pro label, so
a Max account no longer reads "Pro Max" beside the max wordmark. Adds
entry.billingTierPlus / entry.billingTierMax to every locale.
- OPEND-3112: the workspace switcher row and the switcher menu rows render
their names through a new MarqueeLabel, which slides an overflowing name
leftwards once on hover (CSS `.od-marquee` primitive; trigger, direction,
speed and leave behaviour match the Demo).
- OPEND-3113: the switcher's hover fill is the same ink-10% veil the
selected nav row wears, not --bg-subtle.
- OPEND-3153: the account menu's hover bridge moves off the overflow-y:auto
menu (where an absolutely positioned child below its box added 8px of
scrollable overflow and a permanent scrollbar) onto the account container,
gated on the open state; the identity card's vertical padding is
tightened 16/18 -> 12/14.
- OPEND-3116: the rail panel's bottom gutter is 8px instead of 12px.
Red specs for each ticket live in apps/web/tests (components + styles).
* fix(web): respect RTL direction in workspace name marquee
Derive marquee travel from the clipping slot computed direction and cover inherited LTR and RTL row layouts.
Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
* fix(i18n): translate the home-visible working-directory and type-chip strings (#8154)
* fix(i18n): translate the home-visible working-directory and type-chip strings
Fill the home-visible subset of OPEND-2841 / OPEND-2843: homeWorkingDir.*,
homeHero.chip.* names, descriptions and next-step copy, and the six
account-menu entry.* labels, in the 15 locales that still carried the
English source (plus the last 8 keys in ru and 4 in zh-TW). Brand and
technical names (HyperFrames, WebGL, Design Files, iOS, Android, PDF)
stay as they are.
* test(web): lock the home-visible i18n strings for OPEND-2841 / OPEND-2843
Red on feat/home-entry-refresh (39 of 46 cases), green with the
translations: no target key may equal the English source, non-Latin
locales must use their own script, and the ko / ja strings the tickets
name are pinned.
* feat(web): bring the local CLI/BYOK home onto the entry rail layout (#8156)
* test(web): red specs for the local shell on the entry rail layout (OPEND-3140)
The signed-out CLI / BYOK shell still keeps Home's recent-projects grid and
has no 最近项目 section, no 项目 destination and a bare message-centre rail
item. These specs describe the target layout at the unit and browser
boundaries and go red on the current feat head.
* feat(web): list local projects in the entry rail's recent section (OPEND-3140)
Without a cloud identity the catalog EntryShell hands the rail is the local
project list, so the signed-out branch now renders the same 最近项目 section
the signed-in branch has. The row menu keeps only what a local project can
do (rename / duplicate / delete): 转入团队空间 already gates itself off a null
workspace context.
* docs(web): state the local-project contract of the shared run-status feed (OPEND-3140)
The store the rail and the tab switcher share already reads per project
without Workspace headers when there is no cloud identity, and the daemon's
headerless branch answers for unbound local projects. Record that this is
the contract the local shell's 最近项目 rows rely on, so nobody adds a
local-only copy of the feed.
* feat(web): reach the 项目 page from the local entry rail (OPEND-3140)
The signed-in rail's 项目 item opens /drafts; the local shell had no such
destination and EntryShell bounced /drafts back to Home without a
workspace. Without a workspace every local project is a draft, so the view
is now allowed on both branches and the local rail carries the same item.
* feat(web): dock the local shell's message centre beside a local identity row (OPEND-3140)
Signed in, the message-centre bell rides the account dock at the rail foot
(阶段二 S2); signed out it was still a bare rail destination under 设置. The
signed-out footer now renders the local twin of that dock under the sign-in
card: the social links, a 本地模式 identity row that opens Settings, and the
bell in the same trailing slot with the same unread dot. Adds the
entry.localAccountName key to every locale.
* feat(web): drop Home's recent-projects grid from the local shell (OPEND-3140)
With the rail's 最近项目 section, the 项目 destination and the foot dock now
live on the local branch too, Home no longer needs the grid it kept as the
migration constraint's other half (OPEND-2683): the strip mount and its
filter / sort / view controls leave Home on both branches, along with the
card handlers HomeView only carried for that grid. Specs that reached the
catalogue through the Home grid now go through the rail rows or the 项目
page.
* fix(web): duplicate from project cards and icon-only social links (OPEND-2686/3180) (#8164)
* fix(web): offer Duplicate project from the project list cards (OPEND-2686)
The Personal projects (/drafts) and All projects card "more" menus only
showed Rename / Delete. RecentProjectsStrip already renders a Duplicate
item between the two whenever it is handed `onDuplicate` (the rail row and
the DesignsTab both wire the shell's `onDuplicateProject` through), but the
two strip mounts in EntryShell never passed it, so the item silently stayed
out of the card menu. Pass the same handler so both card grids duplicate
through `POST /api/projects/:id/duplicate` like the rail row does.
* fix(web): icon-only Discord / X / mail links at the rail foot (OPEND-3180)
Drop the visible names from the social row above the account dock (and its
signed-out local twin) so only the three glyphs remain: 28px square targets
at one 4px gap, left-aligned so the first glyph sits on the avatar's own
left edge. Each link keeps its aria-label and now surfaces the same copy
through the shared od-tooltip layer on hover; hrefs are unchanged. The
label-only CSS (and its narrow-rail container query) goes with the labels,
as does the orphaned `entry.socialMail` key.
* fix(web): folder glyphs everywhere and an unread dot for completed projects (OPEND-3129/3133) (#8168)
The rail's recent-project rows and the project switcher now both lead an idle
project with the folder, and a finished run no longer replaces that glyph with
a checkmark: the folder stays and a 6px #1F68FE dot at the row's end says
"done, unread" until the project is opened. Both surfaces read the dot from the
one shared run-status store, so opening the project from either spends it in
both.
This supersedes the first OPEND-3129 pass (#8150), which had unified the
resting glyph on a chat-bubble mark; product and design settled on the folder
instead (design reference: 4acbb899f5).
- ProjectRunStatusIcon: drop ProjectIdleGlyph and the succeeded badge; add
ProjectFolderGlyph, ProjectCompletionDot and hasCompletionNotice;
hasRunStatusGlyph answers no for succeeded.
- RailRecentRow / WorkspaceTabsBar: folder in the lead slot, dot after the
name (entry-nav-recent-unread / workspace-tabs-dropdown-unread).
- entry-layout.css / routines.css: the dot's shared measurements.
- Tests: ProjectGlyphs spec (rail + switcher, all four states, spend from
either side), CSS measurement spec, e2e run-status spec for the dot.
* fix(web): give chat containers a material under the transparent pane (#8166)
* fix(web): give chat containers a material under the transparent pane
After OPEND-3090 made the project chat pane transparent, the question form,
its Confirmed answer block, the thoughts window, the nested terminal block,
the composer shell, the project dropdown and the conversation history menu
kept the opaque white / panel fills they used to borrow from the pane and
showed up as flat slabs over the app wash (OPEND-3177, OPEND-3175,
OPEND-3178; composer shell for OPEND-3173).
Content-layer cards now read the Regular material and the material
separator from styles/material.css; the two floating menus read the liquid
glass pair. The record primitives get the material through new --chat-
material-* seam tokens on ChatRoot. Every token flattens back to a solid
surface under reduced transparency or without backdrop-filter, and dark
carries its own values. The tint level stays on the tokens for design to
tune; the left/right pane grounds are untouched.
* test(web): re-pin the thoughts window surface to the Regular material
The record surface spec pinned the thinking window to the draft's opaque
#fafafa; under the transparent pane the window now reads the Regular
material tint from styles/material.css. The fact the section guards (one
defined tint, not the context-dependent fill-tertiary) is unchanged, so the
pin follows the paint and the resolver learns the material tokens.
* fix(web): enter the pending view immediately for AMR sends from Home (#8169)
A Home send with OpenDesign Cloud (AMR) selected awaited the pre-run balance
gate (wallet + authoritative workspace billing read, 1-2s upstream) before
creating the project, so the optimistic pending frame F6 introduced never
showed for that path and the user sat on a frozen Home. Local agents entered
the frame on the click tick.
EntryShell now hands off to the project frame first (App-owned
beginOptimisticProjectCreation) and runs the balance gate behind it. The
gate's hard-block dialog moves to App (HomeAmrBalanceGateDialogs) because
EntryShell is unmounted on the project route; a dismiss rolls the hand-off
back to Home with the draft and staged files intact, an unreadable wallet
rolls back with an explicit notice, and an allow creates the project under
the same optimistic id.
Refs OPEND-2614.
* fix(web): keep one loading state from the Home send to the first turn (#8174)
* fix(web): enter the pending view immediately for AMR sends from Home
A Home send with OpenDesign Cloud (AMR) selected awaited the pre-run balance
gate (wallet + authoritative workspace billing read, 1-2s upstream) before
creating the project, so the optimistic pending frame F6 introduced never
showed for that path and the user sat on a frozen Home. Local agents entered
the frame on the click tick.
EntryShell now hands off to the project frame first (App-owned
beginOptimisticProjectCreation) and runs the balance gate behind it. The
gate's hard-block dialog moves to App (HomeAmrBalanceGateDialogs) because
EntryShell is unmounted on the project route; a dismiss rolls the hand-off
back to Home with the draft and staged files intact, an unreadable wallet
rolls back with an explicit notice, and an allow creates the project under
the same optimistic id.
Refs OPEND-2614.
* fix(web): keep one loading state from the Home send to the first turn
After the optimistic hand-off, the pending frame dropped the moment
POST /api/projects answered and ProjectView then walked through its own
loaders in the chat column: a whole-column spinner while the conversation
id resolved, the transcript skeleton while messages loaded, then an empty
log until the auto-send painted the user turn. Three forms in ~300 ms
before the same prompt bubble and assistant row came back.
The creation record now outlives the request: App marks it persisted when
the project frame is allowed to open, ProjectView keeps drawing the
hand-off's chat card (ProjectCreationPendingChat, extracted from the
pending view) on top of its chat column with the real ChatPane laid out
hidden underneath, and releases it once the first transcript settles (the
auto-sent turn is painted, or there is nothing to send, or the load
failed). App retires a card that never settles after 8 s.
Refs OPEND-2170.
* fix(web): restore composer portal containment outside handoff
Keep layout/style containment and translateZ(0) on the base composer layer. Limit the creation-handoff hidden selector to visibility.
Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
* fix(web): collapse the project list toolbar to the Demo's three controls (OPEND-3107) (#8186)
The project list's control row carried five controls on feat: 多选, the
type filter, a clear-filters chip (once a filter narrowed the grid), a
sort button, and a grid / list toggle pair. The Demo (877980fb17) keeps
that row at three: 多选, the type filter, and one ⋯ button whose menu
folds sort and view into two labelled groups with the current item
checked. G4 (#8153) already aligned the type filter's six options; this
lands the row itself.
- Merge the sort button and the view toggle pair into a ⋯ trigger
(RemixIcon more-2-line, aria-label "Sort projects · View mode",
aria-haspopup="menu") whose menu is two role="group" blocks — Sort
(Newest / Oldest / Name) and View (Grid / List) — of menuitemradio
buttons carrying aria-checked and a check glyph on the current item.
Escape closes the menu and returns focus to the trigger.
- Drop the standalone clear-filters chip: the type trigger already prints
the picked value, and picking "Any type" back is the reset. Its CSS and
the unused view-toggle group rule go with it; the i18n key stays, as on
the Demo.
- Port the Demo's menu styles: viewport-capped min/max width, the 196px
display variant, group separators, group labels, and the 13px check
slot; anchor label-pill menus on the left through
`:has(> .recent-projects__filter)` so the icon-only ⋯ keeps its right
anchor regardless of how many pills precede it.
- Red spec: tests/components/RecentProjectsStrip.toolbar.test.tsx (seven
cases, all red on the parent commit). The clear-chip test and the
list-view Playwright step follow the new structure.
* fix(web): opaque composer card, compact queue, and one container material under the transparent pane (#8185)
After OPEND-3090 made the project chat pane transparent, the containers on
it went two different ways: H1 (#8166) gave the question form, its Confirmed
block, the thoughts window, the nested terminal block, the composer shell and
the two floating menus a frosted Regular / glass material, while the
direction settled on since (#8165 commit 1, the Demo reference) is the
opposite — one opaque floating card for the content layer and one plain
elevated menu for the popovers. This lands that direction on the feat branch
by hand and withdraws H1's frost (OPEND-3177 / OPEND-3175 / OPEND-3173), and
compacts the queued-send stack the same way (OPEND-3086).
Composer shell: the fixed-layer shell reads the same floating-card family
the queued-send cards stacked above it already use — `--chat-floating-card-bg`
ground, `--chat-border-soft` edge, `--chat-radius-xl` corners — with no
backdrop blur and no shadow; the chat.css glass block for it and the
per-appearance dark overrides are gone, dark follows the token.
Queue: rows are compact (`gap: 4px; padding: 6px 4px`) and lose the index
column; the expanded viewport shows five complete cards plus half of the
sixth as the scroll cue (`expandedHeightLimit`), still keeps every queued
item and still fits short windows; the "jump to latest" button yields while
the stack is open (`QueuedSendStack.onExpandedChange`) and returns when it
folds. Send / edit / remove / reorder / quote / draft restore are untouched.
Containers: the question form (shell, confirm variant; head / body / foot /
pill stay transparent), the Confirmed answer block, the thoughts window and
the nested command block read `--chat-floating-card-bg` + `--chat-border-soft`
with no blur; the project switcher menu and the conversation history menu
use the action-menu recipe (`--bg`, `--border-soft`, `--shadow-md`), the
history search field goes back to its solid mix. The H1 `--chat-material-*`
seam tokens have no consumer left and are removed.
Specs: `chat-transparent-pane-materials.test.ts` is re-pinned to the new
values (red on the H1 state, green here); `queued-send-viewport.test.tsx`
follows the reference numbers (502→471, 670→684, 236/322→196/218) and pins
the expansion callback; the answered-surface and record-surface specs pin the
floating card; the four queue specs and the manual-edit e2e stop expecting
the index column.
* fix(web): show real HTML and deck covers in the recent-project hover preview (#8187)
The rail's recent-project hover preview (shared with the chat project
switcher) resolved covers with only the cheap half of the grid's pipeline
and treated every html cover as "not a picture", so prototypes, decks,
documents and web clones showed nothing but the tinted glyph
(OPEND-2766).
- Extract the grid's cover decision (files read, cover pick, HEAD probe,
deck cover-slide preload, design-system special cases) from
RecentProjectsStrip into lib/project-cover-pipeline so both surfaces
resolve one decision and write it back through the same snapshot key.
- Render html covers in the preview plate the way the grid does: a plain
page in a sandboxed 1280px frame scaled to the plate, a deck as its
cover-slide srcDoc; the frame is pointer-inert and stays hidden behind
the glyph until its document has loaded, so a first hover swaps once.
- Join concurrent resolves of the same project so repeated hovers do not
repeat the files read and probe; the glyph is the fallback only when
the project really has nothing to show.
* feat(web): rename, duplicate, move-to-team and delete from the project switcher (#8183)
* feat(web): rename, duplicate, move-to-team and delete from the project switcher
The docked project switcher (the chat header's tab dropdown) gains a ⋮ menu
per project row with the four actions the rail's 最近项目 rows already offer:
重命名 (in place), 复制项目, 转入团队空间 and 删除 (OPEND-2686 / OPEND-3128).
All four run through the shared project-actions flows so the switcher, the
rail rows and the project cards cannot drift: `useProjectDeleteFlow` +
`ProjectDeleteConfirmDialog`, `useProjectDuplicateFlow` (which now reports
its outcome so a menu can stay open on failure), and `useWorkspaceProjectMove`
+ `MoveToTeamConfirmDialog`. 转入团队空间 is a real move into the team space,
shown under the rail row menu's conditions: only in a team workspace with
`canShareProjects`, inert once shared, and disabled with the ownership hint on
a project someone else shared. The move response doubles as the row's
ownership witness until the team catalog lists it, through the same optimistic
ownership helpers EntryShell uses for the cards.
Opening a row's menu suppresses that row's hover preview; the menu takes the
preview's slot beside the dropdown. New locale key
`recentProjects.duplicateInProgress` across all 19 locales.
* fix(web): tolerate a workspace context without permissions in the switcher menu gate
A context carrying no permissions block must read as "cannot share", not
throw during render; some App-level fixtures build contexts that way.
* fix(web): one artifact type list for Home and Community, centred overflow, neutral type chips, document and WebGL community tabs (#8198)
OPEND-3146: the Home type row keeps 原型 / 幻灯片 / 文档 inline and folds every
other create type into 更多 in product order (图片, HyperFrames, 网站克隆, 视频,
音频, 实时产物, WebGL). The duplicate web-clone catalog entry left by the S0
merge is removed.
OPEND-3098: the Community filter row's 更多 is the fixed list HyperFrames /
视频 / 音频 / 实时产物 / WebGL, rendered whether or not the catalogue has a
template of that kind, with a WebGL template type mapped to the Home webgl chip.
Both popovers open centred on their row (one line, Home may wrap when narrow)
instead of hanging off the 更多 trigger.
OPEND-3103: type chips and Community tabs carry no per-type colour — icons rest
at currentColor and the picked composer pill is the brand pair, per the #7635
Demo. This withdraws the colour half of OPEND-2684 (F4 #7891); the ring
contrast half stays. The --type-* tokens are removed.
OPEND-3118: facets.ts gains document (prototype mode + the docs/reports tag
group) and webgl (webgl / webgl2 / shader / gpu) primary categories ahead of
prototype, so the bundled document and webgl-* examples land in their own
Community tabs. The docs-reports prototype scene is retired with it.
* fix(desktop): pixel-scan startup splash, 44px top chrome, and a mailto fallback when no mail handler exists (#8197)
* feat(desktop): boot on the inlined pixel-scan wordmark splash (OPEND-3202)
Port the Home hero's pixel-scan wordmark into the startup splash as plain
WebGL, inlined with the logo art because the splash is up before any HTTP
origin exists; the sweep loops for as long as the boot takes instead of
playing a one-shot clip and freezing. The Home kinetic grid also plays a
scripted startup sweep once per page load so the effect introduces itself.
Base: Demo #7635 head 877980fb17 (splash-pixel-scan.ts and its spec taken
as-is; runtime.ts and AppWashKineticGrid.tsx merged by hand against feat).
* fix(web,desktop): 44px top chrome with the traffic lights re-centred (OPEND-3111)
The shared top chrome (Home / community / all-projects entry pages and the
project page's chrome row) is 44px per the design update, down from the
52px tab-bar rhythm. The workspace-shell grid row, the header height and
the --workspace-tabs-chrome-height token move together; the generic
.app-chrome-header base rule follows. macOS trafficLightPosition.y goes
20 -> 16 so the 12px circles sit on the new 22px midline.
Design update beyond the Demo (Demo and feat both measured 52).
* fix(desktop): copy the support address when no mail handler can open a mailto (OPEND-2799)
On Windows the rail's "email us" button did nothing when no mail client is
registered: shell.openExternal(mailto:) resolves and opens nothing. Check
app.getApplicationNameForProtocol('mailto:') first on non-mac platforms and,
when nothing is registered or the registered handler refuses the launch,
copy support@open-design.ai to the clipboard and say so in a main-process
dialog (zh/en by OS locale). The macOS LaunchServices / Apple Mail path is
unchanged.
* fix(web): let thoughts, confirmed answers, terminal blocks and question forms sit directly on the transparent pane (#8199)
* fix(web): let thoughts, confirmed answers, terminal blocks and question forms sit directly on the transparent pane
Refs OPEND-3177 / OPEND-3175 / OPEND-3173 (OPEND-3178 is the design evidence).
Corrects the content-layer half of K1 (#8185): after the design settled on
2026-09-16 (OPEND-3178 recording + acceptance-3178-0.png, OPEND-3177 comment
"去掉底部的白色底"), the thoughts window, the Confirmed answer block, the nested
command + output block and the question form (shell / confirm variant /
Confirmed) paint no ground, edge or shadow of their own; the thoughts body
reads `--chat-text` (#494949 light) instead of the muted stream ink so it stays
legible straight on the pane. The opaque floating card stays with the composer
shell and the queued-send cards only. The user bubble already matches the
recording (#121212 light / --text-strong dark) and is pinned, not changed.
Re-pins the K1 measurement specs (chat-transparent-pane-materials,
question-form-answered-surface, record-surface-and-terminal-ink): 13 red on
e14622a644, green here.
* test(web): restore the K1 floating-card and stream-ink reverse anchors in the record-ink spec
The BEFORE table lost the two keys the re-pinned assertions read
(floatingCard / streamInk), which broke the web typecheck in CI.
* test(web): re-pin the thoughts body ink to --chat-text in record-ink-layers
The "thoughts stay on the muted stream ink" case encoded the pre-2026-09-16
design; the OPEND-3178 recording moves the thoughts body to the chat body
ink (#494949) now that the window paints no ground, so the case pins that
value and still guards both boundaries (not --text-strong, not #a3a3a3).
* fix(web): all-projects entry with recent / personal / team tabs, twelve-type filter, and a Demo-clean project header (#8202)
* fix(web): all-projects entry with recent / personal / team tabs, twelve-type filter, and a Demo-clean project header
OPEND-2553 home-entry QA, P2 card (OPEND-3107 / 3108 / 3128 / 3258 / 3201,
plus the OPEND-2787 second pass).
- OPEND-3107: the project list's 任何类型 filter lists every creation
type in the product order (原型 / 幻灯片 / 文档 / 图片 / HyperFrames /
网站克隆 / 视频 / 音频 / 实时产物 / WebGL / 设计体系), labelled with the
Home chip keys; `projectKindFilterCategory` buckets by creation
metadata (intent outranks kind) and every project resolves to one bucket.
- OPEND-3108: the rail's project entry is 全部项目 (19 locales, withdrawing
G4's 项目); the page splits one catalog into 最近浏览过 / 个人项目 /
团队项目 tabs on the Demo head grid; the team workspace's separate
团队项目 rail entry is gone and `/all-projects` opens the 团队项目 tab.
- OPEND-3128 / OPEND-3258: the chat card renders no project-title row on
the project page (S3's kept header withdrawn; renaming lives in the
switcher row menu), the pending-creation card drops the same row, and
the switcher trigger sits on the pane with no white pill or shadow.
- OPEND-3201: the card footer keeps the relative time whole - the
creator ellipsises first, then the kind chip wraps under the row.
- OPEND-2787: the reference-project rows were shrinking again as flex
items of the capped list; pinned with `flex: 0 0 auto`.
e2e flows that clicked `entry-nav-all-projects` or edited `project-title`
now go through the 团队项目 tab and the switcher row menu.
* fix(web): switcher carries the catalog title authority; team-tab e2e follow-ups
CI follow-up for #8202 (UI P0 entry-settings, run 35087499546):
- The switcher is now the only place the open project is named, so it
reads the same catalog-title authority ProjectView reconciles with
(`projectsForWorkspaceChrome`): a shared project opened by another
member shows the catalog name, not the local mirror's stale one. The
chat card's inline title used to carry that and was removed with it.
- Entering 全部项目 from the rail opens 最近浏览过 again (the legacy
`/all-projects` deep link still lands on 团队项目).
- e2e: the move-in / move-out flow asserts on the 个人项目 tab, where a
move must take the card away from (最近浏览过 spans both sides); the
failed first-open case asserts `/drafts` + the 团队项目 tab instead of
the retired `/all-projects` URL.
* chore(web): sync main into feat/home-entry-refresh (second pass) (#8204)
* fix(web): stop asserting unknown project facts while entering a project (#7428)
* fix(web): stop asserting unknown project facts while entering a project
Entering a personal project cold showed two statements that were not true yet.
The read-only banner rendered on viewerOnly, which is fail-closed and also
covers the window where ownership is simply unproven. So your own project told
you it was someone else's shared project for as long as the workspace context
took to resolve. The gate stays -- controls should be disabled until proven --
but the banner asserts a reason, so it now waits for isSharedNonOwner, whose
docblock already promises positive evidence and never mere !isOwner during the
status-unknown window.
The design-files empty state has the same shape: zero files before the first
authoritative read is indistinguishable from a genuinely empty project, and its
CTAs create NEW content. The panel already draws this distinction for a team
mirror that is still downloading; a local list that has not returned yet now
gets it too, keyed on the files snapshot generation, which advances only on an
accepted list (a transport failure deliberately does not advance it).
Measured in Chrome against a personal project with one artifact: the read-only
banner appeared at 4.8s and the empty state was rendered then removed; both are
now absent for the whole entry. Neither change touches what is disabled -- only
what the UI claims while it does not know.
* fix(daemon): keep one preview scope per project so a refetch is byte-identical
The web client builds its srcDoc preview from GET /raw/<file>?odPreviewBridge=…,
and that route minted a fresh preview scope on every request, injecting it as
<base href="…/preview/<uuid>/">. So the same artifact served different bytes on
every read: any refetch produced a different srcDoc string, React assigned it,
and the iframe reloaded -- the artifact visibly vanished and came back.
Measured on a team-workspace entry before this change: the srcDoc was rebuilt
1-4 times per entry depending on timing (one run wrote an empty document in
between), each rebuild carrying a different base href.
Preview reads now acquire the live scope for that exact (project, workspace),
renewing its TTL, and only mint when none exists. The grant is identical -- same
project, same member, same TTL bound -- only its name is now stable.
Deliberately not folded into mint(): export flows mint a scope and revoke it
when the render finishes, and sharing an id with a live preview would revoke the
preview out from under it. acquire() is a separate entry point used only by the
preview read.
Verified two ways. Unit: two reads of one artifact return the same base href;
swapping acquire back to mint turns it red. Browser: recording a team-workspace
entry and measuring frames where the preview goes blank after content had
appeared -- 2 such episodes before (one lasting 74ms), 0 after.
* fix(web): say the file list is loading instead of showing nothing
Suppressing the empty-state CTAs removed a false claim but left the panel blank
until content arrived, which reads as stuck rather than busy -- a worse first
impression than the wrong copy it replaced.
The unknown window now renders the same pill the team-mirror sync uses, with the
existing common.loading copy, so the slot always says which of the three states
it is in: loading, syncing, or genuinely empty. No new copy, no new i18n keys.
This is a consolation, not a cure: the real cost is how long the list takes to
arrive. That is tracked separately -- the project-files read is keyed on the
workspace identity, and workspaceType inside that key is echoed straight back
from the request's own x-od-workspace-type header, so a stale header during
entry changes the key and defeats the request sharing that exists to prevent
exactly these duplicate reads.
* perf(web): single-flight the design-system catalog read
Entering a project mounts several independent catalog readers in the same
tick. `fetchDesignSystemsResult` fetched `/api/design-systems` directly, so
three concurrent readers meant three requests — while the team-index step in
the very same function was already coalesced. The catalog read was the half
that got missed.
Identical concurrent reads are not merely redundant here. The daemon answers
them serially, so each duplicate holds one of the browser's ~6 per-host
connections for seconds. Measured entering a real project, three catalog
reads took 1.8s / 3.1s / 3.7s while this project's own `/files` read — 17ms
when uncontended — waited 1.2s for a connection.
Route it through the same `coalescedGet` the team index uses, partitioned by
workspace identity and by the caller-supplied team witness. Two invariants
are preserved deliberately:
- A forced read (`forceTeamMaterialization`, fired BY a mutation) evicts any
in-flight snapshot instead of joining it, exactly as the team index does
one function above. A mutation must observe its own effect.
- `coalescedGet` declines to cache a rejection, but this reader reports
failure as a resolved `{ ok: false }`, so that is evicted explicitly rather
than shared for the rest of the window.
Measured entering the same project, three runs before and three after:
design-systems requests 4 / 4 / 4 -> 2 / 2 / 2
slowest of those 3678/5056/5318 -> 1501/745/1769 ms
requests over 1s 37 / 53 / 27 -> 16 / 7 / 9
Total request count moved too but its ranges overlap across runs, so no
claim is made there. The remaining 2 reads are the two identity generations
of entry (provisional, then resolved); collapsing those is a separate change.
Red spec first: the burst assertion failed with "expected 3 to be 1" before
the fix. The forced-read guard was green before and after — it locks in the
invariant rather than reporting a defect.
* fix(daemon): never let a live preview adopt a one-shot preview scope
Review caught a real defect in this branch's own `acquire`. It searched every
registry entry by (project, workspace), and entries carried no record of who
minted them or why. A screenshot/PDF export mints a scope for the same tuple
and `revoke`s it in its `finally`, so a preview that acquired the export's
entry would lose its `<base href>` the moment that export finished -- blanking
the artifact, which is the exact symptom this area exists to prevent.
The comment above `acquire` claimed the mint/acquire split already prevented
this. It did not: the split only stopped `mint` from handing out a shared id,
while `acquire` remained free to find an export's entry.
Mark the lifetime on the entry instead. `mint` creates one-shot scopes whose
owner revokes them; `acquire` creates and matches only reusable scopes, which
are released by expiry alone. The public surface is unchanged -- both go
through one internal `create`.
Red spec first, at the HTTP boundary: `preview-url` mints exactly the way an
export does, so it stands in for one without needing a renderer. Before the
fix the bridged raw read handed back the *same UUID* that mint had just
issued:
expected 'e60f3014-…-642f00aeadc2' not to be 'e60f3014-…-642f00aeadc2'
* fix(web): stop the remaining surfaces claiming an unproven share
Browser verification of the earlier banner fix showed the claim was shortened,
not removed. Measured entering an OWNED personal project, three runs each:
main "这是共享项目…" shown ~4.1s
banner fix only shown ~1.0s, 3 of 3 runs
this commit not shown, 3 of 3 runs
The banner was only one of three surfaces deriving the claim from
`projectMutationReadOnly` (= `viewerOnly || materializationPending`), which is
fail-closed and therefore also true while ownership is merely unknown. The
chat composer placeholder and the project title tooltip each re-derived it,
so gating the banner alone left the same false sentence on screen.
Give the reason one name. `projectReadOnlyClaim` returns undefined while the
share is unproven, and all three surfaces read that one value. Disabling still
comes from the fail-closed flag — only the CLAIM waits for evidence.
This is the same defect class as the hook-level fixes in
use-project-collab.context-seed and use-project-collab.created-by-viewer; it
survived them because these three sites bypass the hook's guard and read the
flag directly.
* fix: restore the fixes d61be13806 reverted by accident
d61be13806 was meant to carry one web change. It also silently reverted the
daemon preview-scope fix, the design-files loading placeholder, and the
file-workspace banner gate — every fix this PR had landed so far.
Cause: verifying the branch in a browser meant swapping files to their
origin/main content and back. `git checkout origin/main -- <paths>` does not
only change the working tree, it STAGES those paths. Restoring the working
tree afterwards left the index still holding main's content, and `git commit`
commits the index, not the working tree. The check I ran before committing
(`git diff HEAD -- apps/daemon`) compares the working tree, so it reported
clean while the index was not. `git status` did say `MM`; I read the second M
and not the first.
This commit restores all five files to their state at the merge commit
0f923028c4, verified marker by marker rather than by eye:
daemon acquire / reusable scope entries present
server-context acquire on the deps interface present
design-files loading placeholder + gate present
file-workspace banner requires a reason present
No behavior is intended to change relative to 0f923028c4 plus d61be13806's
ProjectView work; this only undoes collateral damage.
* fix(daemon): keep the whole bridged document stable, not just the scope id
Review caught that this branch's preview-scope reuse does not actually stop
the flicker it was written for. `acquire` renewed `entry.expiresAt` on every
call, and that expiry is serialized into the bridge script of the very
document the scope is injected into (`buildPreviewBaseHrefBridge` JSON-encodes
`{href, expiresAt}`). So two repeated reads returned the same scope id and the
same `<base href>` while the full body still differed — React reassigns
`srcDoc`, the iframe reloads, and the artifact blinks exactly as before.
Stop renewing on acquire. Lifetime extension already has its own path: the
client renews explicitly via `x-od-preview-scope-renewal`, and that response
is not a document, so extending there changes no served bytes.
The existing spec could not see this: it compared only the `<base href>`. It
now compares the complete returned document, which is what `srcDoc` actually
receives — any byte that moves per request reloads the iframe just as surely
as a fresh scope id would.
Verified end to end against a running daemon, three repeated reads of one
artifact:
before 6ccf51baf7238d5c / 3a7c49a353b5efdd / 0e02c96c1da882f4
after dfd91bb06572dd65 / dfd91bb06572dd65 / dfd91bb06572dd65
Not verified: a browser-level before/after on the srcDoc transport. Every
"before" attempt resolved to the URL-load path instead, where changing bytes
at a stable `src` does not reload the iframe at all, so the two runs were not
comparable. The HTTP-level evidence above measures the defect itself.
* docs(daemon): say that acquire reuses a scope without renewing it
Both docblocks still described the behaviour the previous commit deliberately
removed. The dependency contract in server-context.ts promised a renewed TTL,
and the summary above the implementation said the same, so a future consumer
could reasonably assume repeated acquisition keeps a scope alive -- or make
the implementation match the comment and reintroduce the byte instability
that reloads the iframe.
Both now state that acquire reuses without renewing, that ttlMs applies only
when it has to create a new reusable scope, and that keeping an in-use scope
alive is renew's job.
* fix(daemon): freeze the expiry a preview document reports
The previous commit stopped `acquire` from renewing, but left the other half:
the explicit `renew` endpoint still moves `entry.expiresAt`, and the next
bridged read serializes that new value. Review measured it — `read -> renew ->
read` kept the same scope and the same length, changed the embedded expiry,
and produced one additional iframe `load` in Electron. The client renews on a
timer for as long as a preview is open, so this is the steady state, not an
edge case.
Split the two meanings the field was carrying. `expiresAt` stays the live
lifetime that renewal extends, and JSON responses keep reading it.
`documentExpiresAt` is frozen when the scope is created and is what a served
body embeds, so nothing a renewal does can change the bytes of a document.
The host does not lose anything: it uses the embedded value only to seed its
first renewal, and every renewal after that is scheduled from the renew
response (FileViewer keeps the returned expiry, it does not re-read the
document for it).
Red spec first: `keeps the bridged document identical across an explicit
renewal` failed on two byte-different bodies. Mutation-checked by pointing the
route back at the live `expiresAt`. Verified against a running daemon —
read, renew, read, renew, read returned 7a2f2c060559759e three times.
* feat(chat): rebuild the chat panel against the design spec, and fix what real runs exposed (#7518)
* fix(chat): close remaining scroll and retry gaps
* fix(chat): preserve portrait artifact previews
* fix(chat): reconcile media task files after path changes
OPEND-2543 / OPEND-2544. A media task persisted its generation-time path, so
an agent move/rename left the chat card requesting a 404 path. Resolve the
task file against the project's currently registered files using a bounded
size+mtime identity witness; ambiguous matches fail closed rather than
guessing from filenames. Retry stays gated on run-terminal state so it cannot
race the agent's own provider fallback.
* fix(chat): stop scoring legacy question forms as successful runs
OPEND-2549. An agent emitting the legacy XML question-form shape failed JSON
parsing, and the run still terminated as a success with no artifact. Detection
now recognises both shapes at the daemon boundary so an unanswered form ends
the run as waiting, not done.
* feat(chat): align artifact cards and memory shell with the latest design
Tracks PR #7170 at 8015870095. Adds a --radius-2xlarge (16px) product token
instead of copying raw radii, applies it to the memory shell and summary so
expanded and collapsed keep one silhouette, and unifies artifact action
buttons at 28px with the top-right controls (OPEND-2560).
* fix(chat): register produced artifacts on the turn that made them
OPEND-2550. A finished run could leave the chat rail with no artifact card
because registration depended on a workspace read that had not landed yet.
Registration is now driven by the turn's own file operations, so the card
belongs to the turn that produced it.
* docs(chat-panel): record the PR #7170 design diff and artifact version design
The design-to-implementation audit against PR #7170 at 8015870095, the chat
artifact version-semantics design awaiting review, and the rolling issue
ledger. Plane attachment binaries stay out of git; the manifest records their
SHA-256 and where they came from.
* fix(chat): keep agent-written next steps on turns with no file output
OPEND-2497. The host gated next-step suggestions on the turn having produced a
file, so a turn that only answered in prose - add-to-chat follow-ups, a wording
change, a proofread - silently dropped suggestions the agent had already
decided were worth offering. Delivery evidence is now named per lane: the
default lane trusts the agent's own suggestions, while host-authored lanes
still require a deliverable to anchor to. Failed and canceled turns stay
withheld. Clicking a suggestion still only fills the composer draft.
* feat(media): publish batch coordinates for sequential image generation
OPEND-2195. The chat rail could only guess how many images a turn was making
by counting 'media generate' in the command string, so parallel tasks drew
separate one-cell rows instead of one row of N. Tasks now carry the batch they
belong to, their position in it, and its size, derived from overlapping
lifetimes on the same run and surface rather than a timing constant.
One-at-a-time generation overlaps nothing and reports a batch of one.
Also closes the OPEND-2543 gaps this exposed: a file another task resolves by
its own path is off-limits, a file more than one moved task can claim is
withdrawn from all of them, and kind must agree - so two tasks recording the
same size and mtime can no longer both take the same image. A rename landing
just after run-terminal is picked up by a bounded reconciliation on runs this
pane watched go live, instead of pinning the card to the pre-rename path.
* feat(chat): rebuild the upgrade card against the latest design
Tracks PR #7170 at 8015870095. The CTA moves out of the balance header into a
bottom row beside the explanation, and the colour contract flips from green
ink on black to a green surface with dark ink. Low-balance and zero-balance
share one layout. Identity branching is unchanged - the card only draws the
exit and calls onUpgrade.
The error card takes the 16px radius through the chat seam with the product
token as fallback. The reconnect counter inherits its weight rather than
pinning 500: the design raised its own body weight in the same commit, so a
literal 500 here would put a heavier number inside a lighter sentence.
* fix(chat): treat an empty produced-file list as unknown, not as proof
A turn's producedFiles is a client-side diff of project file names taken
before and after the run, not a daemon verdict. It is empty whenever the turn
edited a file that already existed, whenever the baseline could not be read,
and whenever a settled file-list read wins the race with file-change
invalidation - the last of which is OPEND-2550's own scenario. Treating empty
as 'this turn produced nothing' therefore recreated the missing card it was
meant to fix. Whether a turn has an artifact is now decided by its own
write/edit tool rows; the produced list only contributes when it is non-empty.
Also covers what was already implemented but untested: the toolbar Share and
Export panels close on a second click (OPEND-2552), and artifact previews keep
their aspect ratio across image, video, HTML, doc and pending forms without
leaking onto execution-record thumbnails (OPEND-2547). Two assertions that
passed vacuously - comparing two heights that both computed to auto, and
reading a fit attribute without its computed style - now pin real values.
Next-step suggestions take the design's medium weight (OPEND-2558, partial).
* fix(daemon): emit each Claude tool call once, from whichever frame arrives first
The stream had two paths that could emit a tool_use - the incremental
content_block_stop and the assistant wrapper frame - but the guard set was
named for the incremental one and only written by it, while the wrapper read
it as if it meant 'already emitted'. Every tool in a recorded session came
through twice.
Adding a check to the wrapper alone would have been wrong in both directions:
the set is never written by that path so the check could not fire, and a
canceled turn where only the incremental path arrives would have lost its one
emission. The set now means what its readers assume - emitted by any path -
and both paths check and write it, neither waiting for the other, so a turn
that produces only one of the two frames still emits exactly once.
Flipping the order exposed a second defect the old sequence had hidden: the
wrapper frame often carries an empty input object, which would now overwrite
the real command. The wrapper prefers the delta-assembled input when that
block is still open and parses, and falls back to its own when truncated.
* fix(chat): make every composer send path agree on what it will send
OPEND-2551. The Send button asked whether the composer had a payload, which
counted quotes, while Enter asked whether the composed prompt was empty, and
quotes fold into that prompt - so the two hands disagreed and the disabled
button could still send. Both now read one canSend.
The deeper half: four send paths existed and only one folded the quote prefix,
so sending an annotation from the Mark panel cleared the chip and delivered
nothing to the agent. All four now compose the outgoing prompt the same way.
Also maps the design's user-bubble ground and muted action tier to chat-scoped
tokens rather than literals, splits the queue grip and action icon sizes that
an override layer had merged, and hides the default design router chip while
leaving the underlying plugin binding intact (OPEND-2412).
* feat(chat): give question forms a real color and amount control
Tracks PR #7170 at 8015870095. The color question was a bare native picker and
the amount question a read-only slider. Presets, the system picker and a typed
hex now drive one answer, an unparseable hex disables Next, and the number
above the slider is editable and clamped to the contract's min/max/step.
Canonical hex is six lowercase digits at one site in the parser, matching what
the native input sanitizes to, so the controlled value cannot fight the DOM.
Alpha and three-digit shorthand stay rejected rather than widening the
protocol; a value that cannot be normalized renders as written, never rewritten.
The selected counter splits at its existing translation's count slot, so each
locale keeps its own word order rather than concatenating fragments.
Also fixes option punctuation colliding with the card border (OPEND-2402) and
the cramped option row (OPEND-2401).
* fix(chat): let option descriptions wrap and give confirmed answers a surface
An option row is a button, so it inherited the global button rule that forbids
wrapping - the description then overflowed the card and was clipped. The
earlier min-width and overflow-wrap were correct but idle: when wrapping is
forbidden outright, neither is ever consulted. The option row's existing
button reset now zeroes white-space alongside the other global defaults it
already undoes; the global primitive keeps nowrap, which is right for genuine
single-line buttons.
The confirmed-answer block had no padding, ground or radius at all, so it read
as bare text rather than the design's panel. All three land through the 16px
token, and an answer carrying a thumbnail drops to the 12px tier - keyed on the
block actually having one, since a catalog card without a preview collapses to
plain text like any other answer.
* feat(chat): drop the thinking scroll window and collapse finished runs
Product ruled against the design here: reasoning now reads like ordinary body
text - natural height, no fixed viewport, no auto-scroll, no fade mask. The
mask was what made it hard to read. The gray container and the blur-in reveal
stay; the scroll lifecycle is deleted rather than left dormant. The indent
moves onto the container so text does not shift when a stream ends.
OPEND-2557: a finished run stayed open because details' toggle event cannot
say who opened it - React writing the controlled value back fires one too, and
that echo latched 'the user chose this' on the frame the shell first opened,
disabling the collapse for the rest of the run. An echo always reports the
value we already hold; a real click always reports the opposite.
OPEND-2195: media cells are placed by their batch coordinate, so parallel
generation draws one row of N instead of N rows of one. Placement is
all-or-nothing - a batch missing or colliding on an index falls back to
arrival order rather than silently displacing half its cells.
Three failing tests were outdated rather than regressions: the completion word
is deliberately withheld until a run settles, every turn now states its own
terminal outcome since the pinned todo card was removed, and the icon slot
moved one level down. Each keeps its original intent and gained coverage.
* fix(chat): let jump-to-latest and the plan pill trade places by scroll position
The two share one floating slot, but the plan pill won unconditionally - and
it is present for the whole of any planned run, so scrolling up during one
left no way back to the bottom. Scroll position now decides which occupies the
slot: scrolled up shows jump-to-latest, near the bottom shows the plan pill.
The bottom padding stays keyed on whether the turn has a plan at all, not on
what is currently shown - removing it mid-scroll would shrink the scroll
height, shorten the distance from bottom, and oscillate.
OPEND-2546: re-quoting the same passage now says so. The decision runs in a
synchronous mirror rather than inside the state updater, which StrictMode
invokes twice; the toast key is a counter, since two clicks inside one
millisecond share a timestamp and React would keep the first dismiss timer.
The steer button carries a visible label, which is what the design gives it
and the only thing that separates it from send-now - the two never render
together, so a different glyph on a button seen alone would say nothing. They
are genuinely different actions: steer writes into the live run's stdin,
send-now stops and restarts it.
* fix(chat): anchor the quote bar to the visible selection, above it
Two defects stacked. The bar flipped the wrong way by default: the design puts
it above the selection and drops it below only when the selection is against
the panel's top edge, and the repo's own mirror gallery already renders it that
way - the runtime disagreed with its own gallery page.
The distance came from Range.getBoundingClientRect(), which is the union of
every client rect including the zero-width caret at the selection's end. A drag
that overshoots by a hair puts that caret on the next block's first line -
nothing looks highlighted, but the union's bottom edge has already moved down
there. Anchoring now uses the first and last rects that actually have area,
and centres on the one it is touching rather than on the whole span.
Together that is why a selection in a dense execution list threw the bar
hundreds of pixels down onto the composer, while ordinary prose was only off
by one line height and read as a spacing nit.
* fix(chat): put the whole turn footer on one muted ink tier
The design gives that row a single rule; we had split it into feedback and copy
classes, so muting only the feedback half would have left two greys side by
side. Both now consume the same token the timestamp already uses, and hover
changes the ground rather than the ink - it previously jumped to near-black.
Selected states keep their semantic green and red; the guard that proves it is
order-sensitive for the positive rating, and the constraint is written next to
the rules that depend on it.
The support dialog takes the design's 16px radius through the chat seam, and
its quoted excerpt is corrected - a stale quote of the source is worse than
none.
* fix(daemon): carry the original request when a headless continue cannot resume
od run continue sends only the continue instruction, on the assumption that the
session holds the request it refers to. The daemon may refuse to resume for
reasons the caller cannot see - a changed model, a changed cwd, a moved cursor -
and then that instruction lands in a blank session that never saw the request.
Callers now declare that their message is a continuation, and the daemon seeds
the original request from the rejected session's own cursor when it starts
fresh. The field is optional and the behavior only fires when set, so existing
callers are byte-identical.
The web client already handles both branches: it sends the full transcript and
the latest turn separately, and the daemon picks by whether it resumed. It is
deliberately left alone.
* feat(chat): follow a live thinking stream, and stop the rail cutting the mark
Thinking sticks to the bottom while it streams, parks when the reader scrolls
up, and re-arms when they return to the bottom or reopen the drawer. It reuses
the chat log's own stick-to-bottom module rather than growing a second set of
predicates: intent changes only on scroll, so content growth cannot masquerade
as a gesture, and the geometry baseline refreshes on every resize settle even
while parked - otherwise a parked reader could never get back. The follow is
one write to scrollTop; the slow stepped scroll and the fade mask stay gone,
and the height cap is max-height, so short reasoning is not padded out.
OPEND-2417: the rail started at a hard-coded 25px while the status mark is
centred in its row, so the mark's bottom moved with row height and crossed the
rail the moment a title wrapped to two lines - and this PR's 12px to 13px title
bump had already eaten most of the single-line clearance. The mark now sits at
a fixed offset like the plan card's, and the rail derives its start from the
row's own padding, slot and gap. Single-line rows land on 25px exactly as
before; the difference is that they no longer drift when an input changes.
* test(chat): measure next-step weight through the real cascade
The previous test injected only the component's own module, so it measured a
product that does not exist: the global button rule already sets weight 500,
and the declaration added to 'fix' it changed nothing. The test now injects the
global reset first, in the order the app does, and pins the declaration's actual
job - holding the design's value on the component itself, so it survives the day
that legacy global button styling is cleaned up.
* fix(chat): let a long next-step suggestion wrap instead of being cut off
The suggestion is the sentence that will be sent; a reader who cannot see all
of it cannot decide whether to click it. Two things were cutting it: the text
layer asked for a single line, and the global button rule leaked nowrap, a
fixed 36px height, a line-height of 1 and centred alignment into the row - the
design's own button reset carries none of those. Both are undone at the
component, leaving the global primitive alone, where nowrap is right for a
genuine single-line button.
The arrow now aligns to the first line rather than floating to the middle of a
wrapped row, following the queue row's written precedent for the same shape. A
single-line row still measures 36px, so nothing moves until a suggestion is
actually long enough to wrap.
* feat(chat): let a long single-choice list collapse behind more options
A select with more than a handful of options now renders as a menu: common
entries first, the rest behind a host-owned 'more options' toggle, capped at a
scrolling height. The trigger is the option count or the presence of the new
grouping fields, not the type alone - a legacy two-option select keeps the flat
list it has today, and the one real product select (a hundred voices) gets the
menu without the model changing anything. The toggle's own copy belongs to the
host and is generic; a model's group labels still render as headings inside.
The authoring rules now cap options at six or seven, ask for plain language
over jargon, and put a length target on an option label with long text moving
to its description - the missing constraint behind suggestions that had to be
truncated. They live in the general form layer, so a mid-conversation form is
covered rather than only the opening brief.
Those rules and the type list exist in six hand-maintained copies across the
daemon, contracts and a bundled skill, with no shared constant; a type could be
dropped from any one of them and every test stayed green. A parity spec now
pins all six against the renderer's own union rather than against each other,
which six copies going stale together would satisfy.
* feat(chat): show a tool call the moment it starts, not when it returns
An in-flight call rendered nothing at all, so a run that spent fourteen minutes
inside one command showed the previously finished rows and a ticking total and
nothing else - which is what 'it ran forty minutes and produced nothing' was
describing. Everything derivable from the call itself now appears immediately;
only what genuinely needs the result waits for it. The shell's span also counts
from the in-flight call, so a long stall stops reading as no progress.
The row keeps its identity across both phases, so a result swaps the state of
the row that is already there rather than adding a second one. Pending means
the call has not returned, which is not the same as still running: a cancelled
turn leaves rows pending, and they settle to a neutral mark rather than
spinning forever or claiming a success or a failure that never happened. The
elapsed slot is present but empty while pending, which is what keeps the
chevron from shifting when the value lands.
* feat(chat): give the panel the design's typographic baseline
Every component measured byte-identical to the design and still read wrong,
because the panel inherited the app's baseline rather than the draft's: weight
400 against 500 and 14px against 13px. Line height was already right. Both now
land on the chat seam, so the panel matches the draft while the sidebar,
settings and home keep the baseline they were designed against - the draft only
ever covered this panel.
The mono face moves with it. The design relabels the same font file from 400 to
500, which is bookkeeping rather than a new weight: with one static face the
glyphs are identical either way, and the label simply has to agree with the
baseline that requests it. Doing one without the other would leave every
elapsed time, path and hex asking for a weight the face does not claim.
* fix(prompts): tighten the new form rules and raise the charter ceiling
The five rules landed as explanatory prose and pushed the slim charter past
its byte ceiling, which is paid on every turn of every run. Rewritten as short
imperatives they cost 644 bytes instead of 1,315.
That still does not fit: the charter sat 44 bytes below the ceiling, so no
amount of compression would have. The rules cannot move somewhere cheaper
either - no conditional section can predict that a turn will emit a form, and
the on-demand atom that carries them for OD Next is not reachable from a
skill-less slim run. The ceiling moves, documented beside the constant in the
format the previous raise used, to the next step rather than to just fit: 44
bytes of slack is what made the next sentence hit a wall.
* fix(chat): stop our own scrolling from reading as the reader's
Positioning a question form animated the log upward, and a frame of that
animation looked exactly like someone scrolling up, so following stopped. On a
quiet turn it healed itself - the animation finished at the bottom and re-armed
- which is why it survived. While the model is still streaming it does not:
the destination was fixed when the animation began and does not follow the
growing content, so it lands somewhere that is no longer the bottom and the
escape stands. Nobody touched anything.
Positioning is now instant, which collapses the window to nothing rather than
trying to recognise and ignore our own frames inside it. The two near-identical
copies of this logic - one already fixed in an earlier pass, one not - are one
function now, so they cannot disagree again.
Recovery had the mirror problem: it demanded the layout hold perfectly still,
and a stream never does, so a reader scrolling all the way back to the bottom
had their gesture thrown away on the frames where content grew. Reaching the
bottom while the bottom is moving away can only be a real gesture; reaching it
because the content shrank underneath still is not, and stays refused.
* fix(chat): stop a cancelled turn spinning forever, and give the stream its weight
An image cell kept spinning after its turn was cancelled: the row knew the
call had not come back but not whether anything was still working on it. It
now takes the same turn-state gate the tool rows use, so tool and image rows
share one vocabulary end to end - same field, same mark, same question asked
of the turn.
The thinking stream's body weight had to be written against the stream box
rather than the paragraph class: the text inside belongs to a different CSS
module, so the obvious selector would have matched nothing. The test proves
the two class names differ rather than assuming it, and pins the specificity
margin so a later simplification back onto the paragraph class fails loudly
instead of depending on bundle order.
Three fixtures were typed strictly enough to catch the new required field, and
two more were asserting a shape they did not have - one carried a property that
does not exist on the type at all, hidden behind a cast that is now gone.
* feat(web): measure the chat panel's own runtime health
Nothing measured the panel as a thing: long tasks fired without a conversation
or run attached, boot timing covered the page rather than opening a
conversation, and a panel that mounts and then renders nothing was invisible.
Seven events now cover first paint, DOM growth, memory pressure, stream health,
interaction latency, protocol anomalies and recovery.
They are built to be findable, not just countable. Every event carries the run
id - which is simultaneously the PostHog join key, the Langfuse trace id and the
diagnostics-bundle handle - plus the conversation, project, agent, model and
channel. A pressure event carries the heap trend and the breadcrumbs leading up
to it rather than only the moment it broke. Timing events declare whether the
measurement can be trusted, so a reading taken before stylesheets landed is
excluded rather than reported as a regression.
They are also built not to become the problem they measure: interaction latency
filters in the browser, a run with no jank emits nothing, and anomalies dedupe
per run so a render-time detection cannot end up measuring typing speed.
* docs(chat-panel): record the component gap audit, scroll research and dispatch log
The 24-component / 89-state audit against PR #7170, the streaming stick-to-
bottom research that recommends extracting our own machine rather than adopting
a library, and the rolling dispatch ledger with the product rulings that
override the design draft. Attachment binaries stay out of git; the manifest
records their SHA-256 and where each came from.
* fix(chat): show the retry button on a failed message, and animate the audio waveform
A message that failed to send offered no way to retry: the action row hides
itself with opacity, and opacity does not just fail to inherit - it composites,
so a child cannot opt back in. The design hides the individual controls and
excludes the retry, which is what this now does, along with the pointer-events
the same rule carries: the invisible copy button used to be clickable.
The audio waveform never moved. The keyframes it referenced did not exist and
the per-bar offset was never written, so a stalled render was the only possible
outcome. Bars now stagger off their own index, honour reduced motion, and the
play control sits inside the row as the design nests it rather than beside it.
Plan steps take the design's explicit 400. They inherited 400 before the panel
baseline moved to 500, so they were accidentally right; afterwards a
not-yet-current step would have sat one notch below the current one instead of
two, flattening the card's hierarchy.
* feat(chat): route artifact cards by the version each kind should show
An HTML card's face is the screenshot taken on the turn that made it, while
clicking it opens whatever the workspace holds now. The two deliberately
disagree - the card is a record of that turn, the click is a door to the
current file - and the comments say so, so nobody reconciles them later. An
image card is the opposite: face, click and export all read that turn's own
bytes, which is what stops a later overwrite from rewriting history.
With no snapshot - an old conversation, a capture that failed, no renderer, a
full quota - HTML shows the live latest and an image shows the current file.
Neither says anything about it: a card that announces its own failure is worse
than a card that quietly shows the best thing available.
A snapshot URL is only handed out once the snapshot is ready. A half-written
one renders as a broken image, which is worse than the fallback it replaced.
* feat(desktop): capture a first-viewport thumbnail without growing to the page
The existing image export measures the document and grows the window to it, up
to twenty thousand pixels, which is the long strip it is meant to produce and
exactly wrong for a card cover. A capture mode now says which of the two is
wanted: the thumbnail path fixes the viewport, never issues the height probe,
freezes animation and transitions, tells the page motion is unwelcome before
running a script that finishes what is already animating, and captures the
first screen only. The export path is byte-identical - the branch happens
before the code it used to share.
Resource waiting learned to stop at the first viewport, because a cover that
waits for assets below the fold pays for page length it will never show.
The renderer reports why a capture failed rather than returning a blank or
fabricated image, so a card that cannot get a cover knows to show the live
file instead of a broken one.
* feat(daemon): keep an immutable copy of what each turn produced
A chat card read the workspace file by path, so overwriting an image rewrote
every earlier card that showed it. A turn's output is now copied into a
content-addressed store the moment it exists and referenced by digest, which
separates three things that used to be one: where the file lives now, what it
contains now, and what this turn produced.
Media capture takes the provider's own buffer before the file is written, so
exactness is structural rather than a re-read that could race. The general path
copies at the run's terminal boundary and stats either side; drift fails the
snapshot rather than installing the newer bytes, because a snapshot of the
wrong version is worse than no snapshot. Where both paths cover one file the
media bytes win, scoped to the run so an earlier turn's copy can never stand in
for this one's.
Recovery is deterministic because byte-sourced intents record their digest up
front: an interrupted capture either completes from what is on disk or is
marked interrupted, and a path-sourced one only completes while the source
still matches. Sweeping is off until enabled; identity survives a rename and a
tombstone releases the path without resurrecting the old row.
* fix(chat): pick the main artifacts when a turn declares nothing
Declaring was meant to be the only way a card appeared, but a turn that edits
without creating declares only a quarter of the time - the instruction asks for
'a file you created this turn', and a small edit does not read as creating
anything. So most edit turns showed nothing, and the earlier fix for that
showed everything instead: a page, its script, its stylesheet and its images
all became cards, which is the pile the marker existed to prevent.
Silence now gets an answer rather than a blank or a pile. Pages and documents
win; if a turn wrote none, its images stand on their own; stylesheets, scripts,
icons and data files never do. An unrecognised extension counts as a
deliverable, because the wrong way to fail is to quietly hide what someone just
made.
The instruction now says that changing a file is delivering it, and that
declaring narrows rather than being the only door. Five places claimed
otherwise, two of them tests that had pinned the old sentence in place.
* fix(chat): put the tool rows and step interludes on the muted tier
A tool row read as dark as the step title above it, so the record had no depth:
the thing that happened and the thing that describes it weighed the same. The
row, its elapsed time and its icon now share the muted ink the design gives
them, and a row that failed keeps its own colour.
Interludes between steps were meant to be smaller and lighter than the opening
line, and were neither. The rule excluded any turn that had a plan - which is
most of them - while the thing it was written to protect, the opening line, is
already excluded by being the first child. Removing that exclusion is what
makes the summaries between steps read as summaries.
Thinking now sits on the same surface whether it is still going or already
done; it used to gain a background while streaming and lose it once expanded,
so the same passage changed shape when it finished. The class that was supposed
to carry that surface had never existed, so the rule attached to it had never
run - and a CSS Module proxy hands back a class name for any key, which is why
no unit test could see it.
* fix(chat): hang the chain on steps only, and align surfaces to their own column
The vertical rail and its indent belong to things on the chain - the steps of a
plan and the summaries between them. Thinking, tool rows and prose at the top
level were getting both, so a passage that had nothing to do with any step
still read as hanging off one. A step now says so itself, which means a block
type added later stays off the chain until someone puts it there, rather than
joining by accident.
The grey surfaces were positioned to put their text on the column, which left
the box itself jutting eight pixels past the rows around it - and since the
offset came from nesting depth rather than from where the sibling rows sit, two
surfaces at different depths missed their neighbours by different amounts. The
box now lands on the column and the text keeps the padding the design gives it.
A failed command row says it is failed rather than being inferred from having a
reason attached, which is also what lets the failure colour be written the way
the design writes it.
* fix(chat): let the next-step button size itself the way the design does
The button carried a minimum width and a horizontal padding that appear nowhere
in the design, and the round that added them wrote both into a test, so the
invented numbers were guarding themselves. The padding was also the wrong tier:
the design's footer buttons are all small, and the small tier overrides the base
padding it was copied from - which is why the button read as oversized rather
than merely wide.
Only the height stays, because that is the one number the design specifies
there, and it explains why: a height written down does not drift when the font
size moves, while one grown from padding does. That drift is exactly what
happened when the panel baseline went to 13px.
The shared button already supplies the padding, size and weight the design
gives this tier, which the test now proves by removing that stylesheet and
watching the assertions fail.
* fix(daemon): stop teaching the model a protocol we retired
The critique grammar kept appearing in answers, and four rounds of stripping it
out never held, because the feature had two entrances and retiring it closed
only one. The protocol itself stopped being injected; the atom carrying its
prose stayed in every design run's stage list, telling the model to follow a
tagged protocol that was no longer there - so the model wrote the tags from the
description. None of the attribute names match the real protocol; every one of
them traces to a sentence in that document.
The body now travels with the protocol: injected together or not at all, from
the decision the server already made rather than a second copy of the
predicate, since a second copy is a third entrance. The document itself
describes what to check rather than what to emit, and no longer forbids prose
outside an envelope - which is also why a turn could finish with no closing
summary at all.
Stripping is fixed where it failed and extended where it was absent: a held
fragment no longer gives up the moment a tag carries an attribute, thinking is
stripped before it is persisted rather than only on its way to the screen, and
the client covers the execution record and not just the prose beside it.
A spec now asserts that the assembled prompt contains none of this grammar, so
a future path that reintroduces it fails rather than reaching a user first.
* fix(chat): stop the feedback panel yanking the view to the top
Rating a turn scrolled the page even when the reason panel was already fully
on screen: it asked for the panel's top edge to meet the viewport's top,
unconditionally, so a panel sitting comfortably in the middle still dragged
everything the reader was looking at. Asking for the nearest position instead
leaves a visible panel exactly where it is and, when one really is off screen,
moves the least amount that reveals it - the browser's own primitive rather
than a hand-rolled visibility check.
It was also the one programmatic scroll on the chat log still animating, which
is the invariant the question-form fix wrote down: a scroll we start ourselves
is instant, and anything that wants animation releases follow first.
* fix(chat): keep the quote bar with the start of a long selection
Dropping below the selection exists to get out of its way, which works for a
phrase and stops working for a selection that fills the screen: the bar got out
of the way and landed most of a screen from anything the reader was looking at,
on top of the artifact preview. A selection taller than half the panel now
keeps the bar at its start when it has to flip, which is where the design
anchors it in the first place - flipping below is a correction for having no
room above, not a change of reference point.
Short selections still clear the whole passage, including a two-line one
pressed against the top edge, which is the case the new rule must not capture.
* test(chat): pin the thoughts icon against four ways it can silently degrade
The icon is already the one the design hands over, byte for byte, so this
round has no implementation to change - which is exactly the case where a spec
earns its place. A name that stops resolving does not throw: it falls through
to a stroke fallback with no case for this one and renders nothing, leaving a
blank where the icon was. Asserting the name would not catch that, so the spec
asserts the rendered path, entire rather than by fragment, since the filled and
outlined variants agree on precisely the strokes a fragment would match.
It also pins that the in-progress state keeps its spinning orb, so a later
attempt to make the two states consistent replaces motion with a static glyph
loudly instead of quietly.
* fix(daemon): stop an unfinished marker from swallowing the answer after it
An upstream reconnect cut a next-step marker mid-attribute, and the scanner
tracking quotes paired that unclosed quote with the next tag's, so it never
found an end: every closing bracket after it counted as text inside a string.
The buffer grew past its limit and the overflow branch handed the whole thing
to the screen, and to the database, where a refresh could not remove it.
A value cannot contain a newline or a raw angle bracket, so either one proves
the quote never closed. Seeing one now retires just the broken tag and keeps
reading, rather than waiting for an end that cannot come; overflow discards
rather than releasing. A held fragment at end of stream is discarded too, the
way the artifact marker already did.
A reconnect is also no longer dropped on its way to storage. It is a real
upstream event that happened once, not the polling noise the rest of that list
suppresses - and without it a conversation that was interrupted and rewritten
reads, on reopening, as though a second answer appeared from nowhere.
The qoder stream decodes with a per-handler decoder instead of converting each
chunk alone, so a character split across chunks survives.
* fix(chat): give the thoughts drawer one box instead of two
Hovering the thoughts header lit a background that started seven pixels left of
the passage underneath it - twenty-nine when nested - because the header row
stretched its hover target to the shell edges while the body was pulled back to
the column. Both were right on their own and wrong together.
The column now belongs to the drawer, and the header inside it carries none:
the same arithmetic reaches the same place, so nothing about the title or its
icon moves, and the two backgrounds share an edge because they share a box.
Which is what the thing is - a panel with a lid, not a row that happens to be
followed by a panel. The row rule that stretches hover targets is untouched;
removing it turns eight assertions red, which is the point of keeping it.
* fix(chat): say a generation failed while it still might be retried by the agent
A cell that failed mid-run already could not be clicked - the host withholds
the callback until the turn settles, so the agent's own provider retry cannot
race a manual one. But it still read 'Retry' next to a retry icon, so the only
thing the reader learned was that the button was broken. It now says what
actually happened, as a statement rather than a dead control, and becomes a
real button once the turn is over.
Over includes cancelled and failed: what the gate protects against is a second
retry running beside the agent's own, and once the turn stops there is nothing
to race. Wanting one image after abandoning the batch is a normal next step.
The wording reuses the label the tool rows already carry in every locale rather
than storing the same word a second time.
* fix(web): distinguish auto-expanded folds from user-opened ones
`defaultOpen` is only read on the mounting frame, so a fold that opens
because its subject is running has no way back: when the subject finishes
and the key has not changed, the same instance keeps the same `selfOpen`
and stays sprawled open.
`lifecycleOpen` follows the subject's lifecycle on every change instead of
just the first — until the user touches it, after which the fold is theirs
and neither direction is forced back.
Telling a real click from React's own write-back is the whole problem:
`<details>` fires `toggle` for both. The predicate compares values, not
occurrence — an echo reports the state we already hold, a click reports the
opposite — and reads it through a ref, because the memoised handler's
closure would still hold the first frame's value by the time a queued echo
arrives. Same predicate ExecutionShell already uses, verbatim.
Opt-in: every caller that does not pass `lifecycleOpen` keeps its exact
current behaviour, and controlled folds never reach it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* style(web): drop the step rail and restore the hover overhang
The designer retired the grey vertical line that chained the steps
together, so both `::before` pseudo-elements go, along with the five
variables that existed only to place them and the two `position: relative`
they were positioned against. Nothing is kept "in case the line comes
back" — re-deriving it from a fresh measurement beats reading stale
constants.
The status dot's own guard survives the line that used to clear it: it was
never about the rail, so its test moves to a file named after what it
actually pins. The offset stays 1.5px even though the step title has since
grown to 13px, which puts single-line centring 0.75px away; the assertion
pins "under a pixel of drift" rather than pretending the two agree.
The hover background reads slightly larger than the panel below it because
the design gives them different insets, not because anything is added: the
row bleeds 7px to each side and the summary pads 7px back, while the body
panel sits flush at 8px. An earlier attempt zeroed the summary's leading
inset to line the two up, which made them one rectangle and read as the
panel itself flashing. Text lands exactly where it did.
Indentation of the between-step notes keeps its 22px for now — the design
justifies it twice, once independently and once by the rail — and the
second half of that rationale is gone. Flagged for a ruling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* feat(web): show elapsed time on rows that are still running
A row that has been running for twenty-eight minutes showed nothing at all
until it finished, which is exactly backwards: the number matters most
while the user is waiting for it. The design leaves the slot open for this
— `ToolRow` needed no code change, only data.
One live end-of-span for the whole turn, `turnIsLive ? nowMs : endedAtMs`,
the same sentence the shell header already uses. Steps, thinking and tool
rows all read it, so none of them can settle to a different endpoint than
the one they were counting toward: a step that reported "1m 2s" while
running would otherwise collapse to "2.0s" the moment the turn stopped,
because the settled path counts to the last timestamped event rather than
to now.
The live value is computed after span bookkeeping, never into it, or the
silence-detection span would be identically zero.
No new timer: every number is `nowMs - startedAt` off the one interval that
already drives the shell header, and a test pins that rendering four live
rows calls `setInterval` no more often than rendering one.
Rows whose start is unknown still print nothing rather than inventing a
zero.
Known gap: a tool row can tick back by one frame when its result arrives
stamped earlier than the last tick. Strict monotonicity needs a per-row
high-water mark, which means component state; the test pins the magnitude
instead so the drift cannot grow silently.
Also lands the one line the fold-lifecycle change was waiting on: a running
todo drawer now follows its subject's lifecycle instead of only its first
frame, so it stops staying sprawled open after the step finishes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* docs(chat): pin where the failure card contradicts the daemon
A colleague hit a TLS certificate failure and got a card offering Retry as
its primary action. The daemon had already classified it correctly and said
on the wire, three separate ways, not to retry. The web side reads none of
those three and re-derives the answer itself.
The survey is exhaustive rather than sampled — all 67 failure reasons run
through the real resolver — because the question product has to answer is
not "what about certificates" but "how far apart are the two verdicts".
Forty cells offer Retry against an explicit not-retryable; thirty-two of
those are reachable under an opaque error code. Six more are not missing
rows but opposite conclusions, which is the cheaper half to fix: no new
copy is needed to stop saying the wrong thing.
The blocker is structural. Nine primary actions exist and none of them is
"go fix your environment", while the ladder's own comment claims to cover
that rung. The error catalog already ruled on the shape twice; only the
button has nowhere to land.
The daemon-side test locks the classification that is already correct, so
the gap cannot later be misread as a backend bug. The web-side red test
stays out of the tree until the ruling lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* docs(run-errors): bring the error-UX design back into the repo
The design that settles the failure-card questions has been living only in
Feishu. The implementation audit's header says the source file sits on this
branch, but git has never held it — the audit has been pointing at a file
that does not exist for a week.
That matters more than usual right now: today's survey of the failure ladder
rediscovered a gap this design had already ruled on. The environment class
was never an open question — S30 names the copy, the slots it interpolates,
and the button order. The audit had already recorded that neither of the two
actions those scenarios need exists in the codebase.
Feishu stays authoritative; this copy is for reading alongside the code and
for grep. The screenshots are short-lived signed links, so only their
descriptions survive here.
One line in S30 is now known to be stale: the daemon does classify
certificate failures, and has since the detail was introduced. The web side
never reads the answer. Left as written rather than edited silently, since
the doc is under review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* fix(chat): pin every newly sent message to the top, not just typed ones
Two independent defects were splitting the behaviour in half, which is why
it looked intermittent.
Whether to pin was something each send site had to declare by raising a
flag, and that flag was raised in exactly one place: the composer's own
callback. Seven of the eleven entrances that put a new user message on
screen never went through it — question-form answers, the home page's first
send, annotations, anything the queue released, resume, and image retry.
Pinning now follows from the transcript itself: the trailing user message
changed identity, so a new turn is on screen, regardless of which button
produced it. New entrances are covered by construction, which matters
because no one has ever remembered to add the line.
The other half is the reason it varied run to run. The pin scrolled
smoothly, and nothing distinguishes our own animation from a user dragging
the scrollbar — position is all either one reports. Mid-animation frames
sit far outside the release tolerance, so the first one dropped the pin and
froze the tail spacer; the final frame lands exactly at the bottom while
the reply is still empty, which re-arms stick-to-bottom and drags the
message back off the top. A reply that starts quickly grows the log past
that point and the pin survives; a slow one does not. Same code, opposite
outcome.
Self-initiated scrolls are already required to be instant — the
question-form anchor was moved off smooth for this exact reason — and this
was the last one still animating.
Visible change: the pin now lands on the frame instead of gliding, losing
roughly 300ms of animation. That animation was the defect.
Also updates the feedback-panel assertion left behind by c121d81b17, which
changed that scroll to nearest/auto without carrying its test along.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* fix(chat): anchor the quote bar to highlighted text, not to a swallowed box
The bar was not being clamped to the bottom of the panel — it was faithfully
anchoring to a box that had no highlight in it at all.
Selection geometry came from the whole Range's client rects, and per CSSOM
that list contains the border box of every element the Range fully encloses,
mixed in with the text line boxes. Dragging a selection a hair past the end
of a bubble — invisible on screen, since what follows is blank — swallows the
tail spacer: full width, real height, not one character. The earlier fix
asked only whether a rect had area, so that box passed as the selection's
last line, and the bar chased it down to the composer and centred itself on
the panel.
Painted text is what "the visible selection" means: no text, no highlight, no
anchor. Both edges are found by walking a short way in from each end of the
Range rather than over the common ancestor, which grows to the whole log
during a cross-message drag and would re-measure every text node per frame.
The bar also has to leave the pane. It is positioned in viewport coordinates
and declares `position: fixed`, but an ancestor carrying backdrop-filter
becomes the containing block for fixed descendants — so those coordinates
were being read as pane-relative, shifting the bar down by the pane's top
edge and letting the pane's overflow clip it. The composer is portalled out
for exactly this reason and that rule says so in its own comment.
Adjacent, deliberately left alone: a quote taken from the user's own message
carries a null message id, because the lookup reads the attribute the
assistant bubbles use. Its own red test, its own change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLzsEWJ1UAjk9WjXEozQiW
* fix(chat): stop disabled options reacting, and say when a question was skipped
Two defects in the same component.
An option that cannot be chosen because the limit is reached was picking up
the global `button:disabled` fill, which outranks the chip's own transparent
background, and then `.qf-chip:hover` outranked that in turn — so the row
carried a permanent grey pill AND still lit up under the cursor. Disabling a
button stops clicks, never styling. The design draws no disabled state for
options at all: one background, transparent. The repo had already fought
this exact leak for the visual-direction cards and won it back with a second
class; the option row never got the same treatment.
Skipping was reported as "Answers sent". Skip is not a separate path — it is
a sub…
* fix(web): white card for question forms, opaque tab launcher, no rail workspace-settings entry, team tab only in a team workspace (#8207)
OPEND-3282: the question form (input / choice region) keeps the floating
white card on the transparent pane; the read-only Confirmed block, thoughts
and terminal blocks stay bare. Narrows the OPEND-3177 reading P3 applied.
OPEND-3257: the rail no longer renders the Workspace settings entry under
recent projects in either workspace (2026-07-20 decision withdrawn).
OPEND-3285: the 团队项目 collection tab renders only in a team workspace and a
personal workspace that lands on it falls back to 最近浏览过 once the context
is known. The tab strip's + launcher is an opaque panel (no glass).
---------
Co-authored-by: wangchenglong <honam884844@gmail.com>
Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: 陈志谦 <89645338+simpleqt@users.noreply.github.com>
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
Co-authored-by: David Z <18444050+davezfr@users.noreply.github.com>
Co-authored-by: Chanhyo Jung <roian6@naver.com>
Co-authored-by: Eric Lemesre <eric.lemesre@gmail.com>
Co-authored-by: lorenzozane <wyz0707@proton.me>
Co-authored-by: lbjzz-hash <52551822+lbjzz-hash@users.noreply.github.com>
Co-authored-by: Tal <zey8840@naver.com>
Co-authored-by: taljeon <169621860+taljeon@users.noreply.github.com>
Co-authored-by: lefarcen <ontf116@gmail.com>
Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
Co-authored-by: zhouzheng <zhouzheng@refly.ai>
Co-authored-by: open-design-release-bot[bot] <295937643+open-design-release-bot[bot]@users.noreply.github.com>
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com>
Co-authored-by: nettee <nettee.liu@gmail.com>
Co-authored-by: PerishFire <39043006+PerishCode@users.noreply.github.com>
Co-authored-by: QiuQiuNB666 <gentergumina@gmail.com>
Co-authored-by: QiuQiuNB666 <318713393+QiuQiuNB666@users.noreply.github.com>
Co-authored-by: Austin W <133685420+readwrightexecute@users.noreply.github.com>
Co-authored-by: airano <244096521+airano-ir@users.noreply.github.com>
Co-authored-by: jax-novita <jax.zhang@novita.ai>
Co-authored-by: jax-novita <jax-novita@users.noreply.github.com>
Co-authored-by: jax-novita <jax@novita.ai>
Co-authored-by: Tony Box <tonybox@gmail.com>
Co-authored-by: maoxin1234 <875408344@qq.com>
Co-authored-by: John Kattenhorn <john.kattenhorn.personal@gmail.com>
* refactor(release): extract the patch-cut pre-flight out of inline shell
Faithful port, no behavior change: resolve-patch-cut.ts computes the same
version and the same X.Y.0 gate target the shell did, and runs the same
gh release lookup. Moving it out of YAML is what makes the gate decision
testable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(release): pin the patch cut on the previous patch, not the minor base
Red against the current guard: with release/v0.22.2 cut and unshipped, the
pre-flight gates open-design-v0.22.0, finds it published, and cuts v0.22.3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(release): gate the Thursday patch cut on the previous patch
The guard checked the minor base X.Y.0, so it only ever guarded a line's
first patch. Once X.Y.0 shipped, X.Y.2, X.Y.3, ... all passed no matter how
many unshipped releases had stacked up beneath them.
Gate on the release the cut actually stacks on — the highest release/vX.Y.Z
below it — which is the same target cut-release.yml uses for the Tuesday
minor, and still gives the answer the old minor-base comment wanted (cutting
0.15.1 checks open-design-v0.15.0, not the older 0.14.0).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(release): surface patch gate lookup failures
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ci): publish the What's New card from the repository
Move the post-update highlight document into the repo and publish it from
GitHub Actions, so changing the card is a pull request instead of a task
that needs personal Cloudflare credentials.
- docs/whats-new.json is the source of truth, next to its spec doc.
- pnpm guard validates it through the daemon's own parser on every PR, so a
document that would silently show no card cannot merge.
- whats-new-publish.yml uploads on merge to main (and on manual dispatch,
with a read-only dry-run mode) and verifies the object by reading it back
from the origin the daemon fetches, past the edge cache.
* docs: name the CI job that actually runs the guard check
* fix(ci): gate the What's New publish behind a main-only environment
A workflow_dispatch runs the workflow file from the ref it is dispatched
against, so the in-workflow main-only assertion was editable by whoever
triggered it: anyone with write access could publish unreviewed content to
every installed client from their own branch.
Split the workflow so credentials live in one place and that place is gated by
GitHub rather than by YAML. `validate` holds no secrets and runs on any ref;
`publish` declares the `whats-new-publish` environment, whose deployment-branch
policy allows main only and which owns the R2 secrets. Dropping the environment
declaration to escape the branch policy also drops the secrets.
Also points the card at the new Arena URLs supplied by product.
* fix(ci): pin the What's New publish trust boundary in a check that runs
The missing-credentials error still told operators the R2 keys are
repository secrets. Following that string is the exact setup the previous
revision closed: repository secrets are readable from any job on any
branch, so a modified workflow dispatched from an unreviewed ref could
publish to every installed client — or just take the keys. Point the
error at the `whats-new-publish` environment and refuse the repository
path in the same breath.
The boundary itself had no coverage: nothing read the workflow, so moving
`environment:` or a `secrets.*` expression back onto the arbitrary-ref
job would stay green while still publishing correctly from main.
That coverage cannot be lane-routed. Running the planner over
`.github/workflows/whats-new-publish.yml` selects neither
`web_tests_required` nor `ui_p0_validation_required`, so the `e2e_vitest`
workload does not run for a workflow-only edit — an assertion living only
there would be skipped for exactly the change class it guards. The check
therefore lives in `pnpm guard`, which CI invokes from the unconditional
preflight job, same reasoning as the document guard next to it.
It asserts that the credential-bearing job declares the environment by
its exact name, needs `validate` and is gated on its output with no
`always()`/`cancelled()` escape, reads all four bucket bindings, and that
no other job attaches an environment or reads any secret at all.
The e2e suite drives it over the real workflow mutated into each of those
regressions in turn, so every assertion is proven able to fail; gutting
the checker turns all eleven red.
* chore(whats-new): seed the document from the live card
Bringing the workflow in with new copy attached ties the infrastructure to
a content decision. The Arena CTA this document pointed at is not live —
`/llm-arena-for-design/` and `/zh/llm-arena/llm-arena-for-design/` both
404 — and the card renders regardless, so merging would have put a dead
button on every client's Home.
Seed the repository copy from the object that is already serving instead:
byte-identical to `https://whatsnew.open-design.ai/whats-new.json`, id
`0.21.0`. That puts the current state under version control and makes the
first automatic publish idempotent — it uploads exactly what is already
there, so the merge exercises the whole chain (validate, upload, read-back
against the origin the daemon fetches) without changing anything a user
sees.
The Arena copy is not lost; it moves to the PR body as a follow-up for the
release owner, who can now change the card through this workflow without
holding personal Cloudflare credentials.
* fix(ci): detect secrets by context, not by access shape
The guard added last round matched `${{ secrets.NAME }}` with a regex over
the raw workflow text. A guard whose whole job is to stop a secret reaching
the arbitrary-ref job had a blind spot for most of the ways to read one.
Verified against the previous implementation, each returning zero
violations while a secret reached `validate`: `secrets['NAME']`,
`secrets["NAME"]`, `secrets [ 'NAME' ]`, `secrets . NAME`,
`toJSON(secrets)`, a bare `secrets`, and `fromJSON(toJSON(secrets)).NAME`.
Extending the regex would have repeated the mistake that caused it. One
bypass proves why: a double-quoted YAML scalar may carry a backslash line
continuation, so
STOLEN: "${{ sec\
rets.NAME }}"
parses to a live secret read while the source text contains no such
substring. No text scanner can see that, however many shapes it lists.
So the workflow is read through a YAML parser, and detection is a single
property instead of an enumeration: GitHub's expression language has no
string-to-context evaluation, so a secret cannot be reached without naming
the `secrets` context as an identifier. Finding that identifier inside any
`${{ … }}` expression covers every access form, including ones nobody has
thought of.
Parsing also retires the hand-rolled job splitter and its own failure
modes, and lets the check distinguish "no violations" from "nothing was
examined" — an unparseable document, a flow-style jobs mapping, or a
renamed publish job now fail closed rather than passing quietly.
Two holes the shape list never covered are closed as a side effect: a
job-level `secrets:` key passes credentials to a called workflow with no
expression at all, and workflow-level `env:` is inherited by every job.
`yaml@2.9.0` becomes a root devDependency. It was already in the lockfile
via `tools/release` and pinned repository-wide by the existing pnpm
override, so the lockfile gains only the root importer entry.
Test coverage moves with it: 26 of 46 cases now go red when the checker is
gutted, up from 11.
---------
Co-authored-by: lefarcen <ontf116@gmail.com>
* perf(ci): make prerelease mac Intel opt-in
build_mac_intel is the prerelease critical path: ~33 min on the
macos-15-intel fleet against ~21 min for mac arm64 and Windows, which
stretches every release-branch push to ~56 min end to end.
Gate it behind a new `enable_mac_x64` input that defaults to false, the
same shape build_linux already uses, and let publish tolerate a skipped
Intel job. Push-triggered prereleases now finish in ~21 min; a manual
dispatch can still turn Intel back on.
The stable promotion gate is unchanged and still hard-requires
platforms.macIntel in the promoted prerelease metadata, so an Intel-less
prerelease fails promotion loudly rather than shipping unvalidated.
* perf(ci): stop the prerelease pipeline waiting on work it does not need
Run 34102146769 took 56m, 42m of which survived turning macOS Intel off.
Four things in that 42m were wall clock nobody was buying anything with.
Tests no longer sit in front of packaging. functional_e2e, e2e_vitest,
daemon_unit_tests and verify take ~11 min; packaging takes ~21 min, and the
build jobs needed all four before they could start. They now need only
metadata and run alongside the tests. Nothing about the safety of a release
changes: `publish` is what writes metadata.json, and it already required all
four gates to be `success` — a red suite still cannot ship. What it costs is
CI minutes, since a failing gate now stops builds that have already run.
The Feishu card stops cloning the repository to read one `git log`. That
checkout was `fetch-depth: 0` against a 2.2 GiB pack and took 4m09s to
produce a `PREV..CUR` commit list. The list now comes from the compare API,
reproducing `git log --no-merges --pretty=format:'%s (%h)'` line for line —
verified against 35 commits of real history, where the only difference was a
leading space that feishu.ts trims anyway. Every fallback the old script had
(no baseline, unreachable commit) still produces an empty changelog rather
than a failed notification.
Packaged smoke and Apple notarization become opt-in, like Intel already is.
Smoke is advisory — `continue-on-error`, so it has never blocked publication
— and costs ~2.5 min on the Windows critical path. Notarization is a
round-trip to Apple that no prerelease consumer needs. Signing is NOT
optional and stays on: prepare-stable.ts requires platforms.mac.signed ==
true, so an unsigned prerelease could never be promoted. `mac_sign_mode: no`
is offered for parity with beta and now correctly reports signed=false
rather than lying in the metadata.
Windows drops setup-node's pnpm store cache and every job checks out
shallow. Measured across four prerelease runs the store cache missed 3 times
out of 4, costing 100s/66s/43s to save with no install speedup, and on its
one hit traded a 32s restore for a 36s faster install — while competing for
the 10 GB cache budget against tools-pack caches worth ~11 min each.
Nothing in this workflow reads git history.
* perf(ci): let a prerelease ship on the build's schedule, not the tests'
prerelease is the internal channel. Its job is to put a package in a
colleague's hands so a human can test it; automated CI is a second, parallel
opinion about the same commit. Wiring the two in series meant a single flaky
suite cost the team a whole day's package — the expensive failure, and the
one this pipeline kept producing.
The four test jobs (functional_e2e, e2e_vitest, daemon_unit_tests, verify)
now gate nothing. They are absent from `build_*` and from `publish`. They
still run on every prerelease and their results leave the workflow as
outputs — functional_e2e_result and friends, plus a tests_all_green /
failed_test_jobs rollup — so the notification layer can raise the alarm
without holding the package hostage. Stable is untouched: its promotion gate
reads the prerelease metadata and runs its own checks.
The packaged smoke suites move for the same reason. They were already
`continue-on-error`, so a smoke failure never failed the job — but they sat
between the build and the R2 upload, which meant the package waited on them
and a hung smoke could still eat the job. Every smoke now runs AFTER
"Publish <platform> platform" and "Upload <platform> publish manifest", and
every step after that manifest upload is continue-on-error, so nothing
post-delivery can fail a build job and retroactively block publish.
The results travel through a `test_signals` job rather than the
`jobs.<job_id>.result` expression GitHub documents for workflow_call outputs,
because actionlint has rejected that property since 2023
(rhysd/actionlint#343) and ci.yml runs actionlint at a pinned version.
One consequence to hand to the notification work: a failed advisory job now
makes the overall workflow conclusion red even though the package shipped, so
`needs.build.result` is no longer the right thing to colour a card by.
`version_metadata_url` is the authoritative "did a package ship?" signal and
is documented as such.
* perf(ci): move prerelease validation out of the pipeline it validates
release-prerelease.yml holds one repository-wide concurrency group with
cancel-in-progress: false, so any job that outlives `publish` keeps that
group held and stops the NEXT prerelease from starting. Making the test
jobs advisory did not fix that: they still held the group for 45 minutes
after the package had shipped.
The pipeline is now metadata -> build_* -> publish plus two dispatcher
jobs that nothing depends on. The four code suites, the packaged
mac/Windows smoke, and the release card each run in a workflow of their
own, keyed on the origin run.
The packaged smoke no longer installs electron-builder's output
directory on the machine that produced it. It reads the published
version metadata, downloads the DMG / setup.exe out of R2, verifies its
sha256 sidecar, and writes it to the single path
`tools-pack <platform> install` reads - which is why the specs need no
changes. That is both a better test and what lets the smoke live on a
different runner at all.
The release card becomes progressive. A Feishu application bot can PATCH
a message it sent, so one card is created when the first platform lands
in R2 and edited in place as the rest of the platforms, the code tests,
and the smoke report in. A PATCH replaces the whole card, so exactly one
job may write it: the watcher owns the message and polls the three runs.
The card is withheld until a package exists, because a "building..."
card posted at dispatch time becomes a permanent lie when a build dies -
with total build failure as the one exception, where silence would hide
an incident.
macOS Intel goes back to on-by-default: stable ships Intel and stable's
gate is a validated prerelease artifact, so an Intel-less prerelease
cannot be promoted at all. The switch stays, forwarded as
`github.event_name != 'workflow_dispatch' || inputs.enable_mac_x64` -
`inputs.enable_mac_x64 || true` would be unturnoffable, because a
dispatch with the box unchecked also yields true.
Also fixes the signal batch 2 left wrong: a reusable workflow reports
failure if any job inside it failed, including work that runs after
publication, so `needs.build.result` painted the download card red while
its own buttons worked. `version_metadata_url` is non-empty only when
publish wrote metadata, and now drives both the header colour and the
smoke annotations.
Two topology tests were already red on this branch - batch 2 changed the
workflow without updating them. Both now assert the new invariant.
The existing webhook card stays until the new one has carried a couple
of real releases; a gap in release notification is worse than a
duplicate.
The maintainer who received this alert could not tell what it was. The
headline jumped straight to a verdict, so nothing said an upstream release
had been detected or where the message came from; the body opened with
semver theory; the blocking branch never mentioned the version we pin,
making the most urgent card the least informative one; and nothing said
when upstream shipped, so there was no way to tell a release from this
morning apart from one that had been sitting for a fortnight.
The headline now names the check and only the check — severity travels in
the header template, which Feishu renders before any text. The first
sentence is three facts with no reasoning attached: what upstream
released, when, and what we still pin, on both severities. The
consequence follows, and the semver rationale is compressed into a
parenthetical behind it. The three-file checklist is untouched.
Reading the publish date means asking npm for the full packument: the
compact one (`application/vnd.npm.install-v1+json`) carries no `time` map
at all. `readPublishedAt` degrades to null for every shape short of that,
because the date is one sentence of context on an alert whose entire
reason to exist is being sent.
Also replaces a self-check assertion that could never fail:
`String(card.header).length === 0` is "[object Object]".length whatever
the card says. It now reads the fields a human reads.
Claude-Session: https://claude.ai/code/session_01B8MaA1ABkvbnfaYUKw3UH3
* chore: remove extracted landing-page from the monorepo
The marketing site now lives in a separate repository. Drop the
Astro app, its GitHub workflows/scripts, and the remaining
monorepo coupling so this workspace no longer builds or deploys it.
* fix: drop leftover landing-page coupling from Teams and skills
Point development enterprise URLs at the deployed marketing origin,
remove nextjs-app skill outputs that no longer emit a page, and stop
advertising apps/landing-page in plugin marketplace metadata.
Generated-By: looper 0.12.0 (runner=fixer, agent=omp)
* fix(ci): restore DSH installer E2E Vitest routing
Removing dsh-bootstrap-identity dropped the only planner route from
tools/release/resources/dsh-bootstrap/ to web_tests_required, so
installer-only PRs skipped e2e/tests/dsh-installer-version-policy.test.ts.
Generated-By: looper 0.12.0 (runner=fixer, agent=omp)
* docs: drop leftover landing-page paths from template assets
The skill contract no longer advertises the extracted Astro app, but
styles.css, compose.ts, and example.html still named deleted
apps/landing-page sources. Describe the standalone template behavior
instead and keep the bundled plugin example in sync.
Generated-By: looper 0.12.0 (runner=fixer, agent=omp)
* docs: keep landing-page off review and test surfaces
Add apps/landing-page to the forbidden-surface review list and stop naming the extracted path in plugin registry acceptance tables.
Generated-By: looper 0.12.0 (runner=fixer, agent=omp)
* fix: drop leftover landing-page refs and bump plugin version
Stop naming the deleted apps/landing-page in the landing-deck demo and
plugin-registry plan. Bump example-open-design-landing to 0.1.1 and keep
the official catalog version aligned with the bundled manifest.
Generated-By: looper 0.12.0 (runner=fixer, agent=omp)
* fix(runtimes): support the dsh release line users are actually served
Two days after #7153 widened the version policy from one release candidate
to the `0.1.0-rc.N` line, upstream published `0.1.1-rc.1` and then
`0.1.1-rc.2`, and made the latter the `latest` tag. Scoping to `0.1.0-rc.N`
was still a pin, just a longer one: every user on what npm now serves is
told their CLI is untested, our installer hands out a version two releases
behind, and the connection component's peers do not resolve against the
host they land on.
That last one is not a warning. semver only lets a prerelease satisfy a
range when some comparator carries the SAME major.minor.patch tuple AND a
prerelease tag, so `>=0.1.0-rc.6` matches nothing on the 0.1.1 line — a
verified `satisfies('0.1.1-rc.2', '>=0.1.0-rc.6') === false`, the same rule
that made the original pin uninstallable.
Move all three to the 0.1.x line: the policy accepts any `0.1.N`, release
candidate or stable; both installers pin `0.1.1-rc.2` with the resolution
window reset to just after its publish time; the peers gain a comparator
for the new line.
This will expire again. A comparator per prerelease line is what semver
allows, so `0.1.2-rc.1` will not resolve until someone adds one, and
nothing in the repo notices when upstream moves. The check that catches
installer/policy drift only compares the two against each other.
* ci: watch upstream dsh releases instead of waiting for a bug report
Twice in one week `@deepseek-ai/dsh` published a new release line while
this repo kept pinning the old one, and both times the detection mechanism
was a person reporting that their install did not work.
The check added with the first fix compares our installers against our
agent def. Both are our own config, so it stays green precisely when they
are equally out of date — which is the state that shipped, twice. Nothing
here looks at the registry.
Add a daily scheduled comparison against npm's `latest` that separates the
two failures worth telling apart: `behind` means our installer hands out an
older version than the registry serves, everything still works;
`unsupported` means a user who has what npm serves is outside what we claim
to support, which is an "untested" warning in Settings and peer ranges that
do not resolve. The card names all three files that must move together,
because forgetting the peer ranges is the one that breaks an install rather
than warning about it.
Standalone and outside the merge gate, like nix.yml and docker-image.yml:
an upstream release is not a reason to fail someone's PR. The workflow runs
`self-check` before touching the registry so a broken checker is loud
rather than quietly in-sync.
* fix(ci,runtimes): stop the drift watch losing what it exists to report
Four defects from review, each of which would have made this quieter than
having built nothing.
The widened policy over-claimed. `/^0\.1\.\d+(?:-rc\.\d+)?$/` accepts
`0.1.2-rc.1`, which the peer ranges cannot install — so the daemon would
suppress the untested warning for a version whose companion fails to
resolve, which is worse than the warning and silent. Narrow prerelease
acceptance to the tuples and floors the peers actually carry; stable
releases on the line need no comparator and stay accepted. This also
retires an over-claim that predates the branch: `0.1.0-rc.2` and `rc.3`
are published and below the `>=0.1.0-rc.6` floor.
`e2e/tests/dsh-installer-version-policy.test.ts` now runs both authorities
over a version matrix and fails when the def accepts anything the peers
reject, so the two cannot drift apart again.
Importing the script ran the CLI. Collecting the new test fetched the live
registry — measured at 354ms with the verdict printed — and once drift
existed would have attempted a real Feishu post before any assertion. Move
the dispatch behind a direct-entry check and prove it with a test that
stubs fetch and re-imports.
Feishu answers a rejected webhook with HTTP 200 and a nonzero code, so
treating every 2xx as delivered discards the alert and logs success doing
it. Mirror the landing notifier's contract: code 0 or absent is delivered,
429/5xx/9499 retry, anything else raises.
The workflow chose the landing webhook but signed with an unpaired secret,
so a bot that verifies signatures would reject the card. Pair them the way
the landing workflow does, and pin the mapping in the topology test.
* test(e2e): check each dsh peer range on its own
The compatibility guard scanned the whole manifest and merged every
comparator into one tuple table, so a single updated peer answered for the
other four. A bump that updates one range and forgets the rest — the
realistic way this goes wrong — read as compatible, which is the partial
state the guard exists to catch.
Parse `peerDependencies`, keep the `@deepseek-ai/dsh-*` entries, and require
every one of them to admit a version before calling it installable.
`cordis` is excluded: it has its own version line and never moves with dsh.
Two cases cover it: a fixture where one peer carries the new comparator and
the others do not, and the floor itself, since a comparator for the right
tuple still rejects a candidate below it. Verified against the real
manifest by downgrading four of the five ranges, which reds the matrix with
`expected [ '0.1.1-rc.1', '0.1.1-rc.2' ] to deeply equal []`.
* test(e2e): harden coverage and runtime isolation
* test(e2e): gate prerelease on full functional suite
* test(daemon): enforce prerelease coverage
* test(daemon): stabilize full-suite fixtures
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* test(e2e): preserve interaction regression coverage
Keep deck navigation subject to Playwright actionability checks and restore the current rail updater/composer stacking witness as an explicit expected failure.
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* fix(ci): close conformance validation gaps
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* test(e2e): restore MCP snippet style guard
Restore the browser-computed style witness that was over-deleted when the OAuth flow moved to Integrations, and navigate through the current Settings MCP server entry.\n\nGenerated-By: looper 0.11.8 (runner=fixer, agent=codex)
* fix(ci): close directive and job-boundary gaps
Compare normalized MUST_FIX directives as sets so identical multi-item rounds remain weak debates, and bound workflow topology assertions to their immediate sibling jobs.\n\nGenerated-By: looper 0.11.8 (runner=fixer, agent=codex)
* ci: auto-rerun once when runner/spot cancel kills ci jobs
Add rerun.atom.yml as a trusted workflow_run consumer that requests one
`gh run rerun --failed` when completed pull_request/merge_group ci runs
lose leaf jobs to infra cancel (shutdown signal / cancelled runner),
while skipping ordinary assertion failures, stale heads, and ejected
merge-queue entries.
* fix(ci): grant rerun atom read scopes and refuse mixed failures
Add checks/pull-requests read permissions so the helper can fingerprint
infra cancels, and require every non-success leaf to be infra-classified
before authorizing gh run rerun --failed.
Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)
* fix(ci): require open PR for live-head stale gate
Stop treating a same-repo branch tip as live-PR evidence. Closed PRs can
leave the source branch at the failed run SHA with no open association;
query open pulls by repository-qualified head ref instead, and cover the
closed-PR + unchanged tip shape in self-check.
Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)
* fix(ci): require infra marker for cancel rerun
Bare cancelled jobs without shutdown/spot annotations are ordinary
(manual cancel) and must not authorize gh run rerun --failed.
Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)
* fix(ci): ignore aggregate gate jobs in infra-cancel classify
Validate workspace and Runtime summary fail as always() mirrors of leaf
outcomes and never carry runner/spot markers. Counting them as ordinary
blocked pure infra-cancel reruns whenever the workflow still reached the
gate. Exclude those names from classification and cover the shape in
self-check.
Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)
* fixer: address PR #6453 follow-up items
Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)
* fix(ci): refuse mixed ordinary+infra job annotations
Jobs that record both a real failure annotation and a later runner
shutdown marker must classify as ordinary, not pure infra-cancel, so
gh run rerun --failed cannot auto-retry assertion failures.
Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)
* fix(ci): paginate check-run annotations before infra classify
GitHub defaults annotations to 30 results; a later ordinary failure must still
block gh run rerun --failed. Walk pages like paginate_jobs and cover the split
with a multi-page request fixture.
Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)
* fix(ci): drop tracked pycache from infra-cancel helper
PerishCode BLOCKING on #6453: remove the accidental CPython 3.14
bytecode under .github/scripts/__pycache__/ and ignore __pycache__/
plus *.py[cod] so local self-check/compile output stays out of git.
* fix(ci): open-PR merge_group freshness; ignore warning annotations
Merge-group infra-cancel retries no longer require the synthetic head to
still sit in the merge queue — workflow_run completed fires after required
checks already ejected it. Freshness is an open PR still associated with
the run (commit→pulls or gh-readonly-queue pr-N ref).
Warning-level annotations (e.g. scopes.ts full-plan fallback) no longer
count as ordinary failures that suppress pure infra-cancel retry.
Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)
* fix(ci): match merge_group PR heads and arm rerun e2e Vitest
Compare the live open PR head with the PR head SHA encoded in the
merge-queue branch before authorizing infra-cancel retry, and enroll the
rerun atom surfaces in scopes so PR CI runs the topology self-check.
Generated-By: looper 0.11.8 (runner=fixer, agent=grok-build)
* fix(web): scope plugin URL imports to workspace
* test(web): cover billing interest remount lifecycle
* feat(packaged): enable the workspace-team transport for test-profile builds
The vela test backend now serves the workspace-team API, so a packaged build
baked with the test AMR profile should drive team projects / collab / resource
sharing there instead of leaving them dormant. Replace the single feature-test
equality check with a profile -> vela web origin map so adding a backend is one
entry, and keep every unlisted profile (prod above all) off.
* test(daemon): cover billing catch-up after SSE reconnect
* fix(collab): surface and start first materialization when opening a shared project
QA P0: a brand-new member on a fresh install joins someone's workspace, opens a
project from it, and sees no loading state and no files — content only appears
on a SECOND open, much later.
Two defects, both on the first `/collab/status` request that opening a shared
project makes.
1. No pull is started. The daemon's self-materialization block was gated on
`callerIsOwner`, so a non-owner member got the placeholder record registered
and nothing else. The web's auto-pull is gated on `publishedVersion`
advancing past its cursor, and a fresh daemon's first status response cannot
carry a published head: `collab.publishedVersion()` reads an in-process map
that has never been written, and the real hub head is fetched
fire-and-forget into `headEnrichmentCache` for a LATER poll to consume.
Materialization therefore arrived only whenever a proactive lane (hub push /
reconnect catch-up / the 30s recovery floor) next fired — the "second open"
the report describes.
2. No download state is shown. `useProjectCollab.downloadPending` — which picks
DesignFilesPanel's "Syncing files from the team…" over its empty state and
create-a-file CTAs — reads only `publishedVersion > cursor`,
`contentTransferState`, and an in-flight pull. All three are blank on that
first response, so the member was shown an empty project inviting them to
start creating over content that was still on its way.
The fix makes the placeholder stamp the load-bearing signal on both sides:
- `materializePlaceholderOnOpen` (routes/collab-sync.ts) replaces the
owner-only block: any viewer opening a shared project whose only local record
is an unmaterialized placeholder starts its pull on that same request,
through the same coalesced flow. The retracted-share heal in its catch stays
owner-only — a member who hits the hub tombstone has merely lost access and
must not unshare anyone's project.
- `/collab/status` reports `awaitingFirstMaterialization`, a purely local,
synchronous fact that needs no hub round-trip and is therefore available on
the very first response. `od collab status` prints it too.
- `localFilesAreNotTheContentYet` (useProjectCollab.ts) makes it always
download-pending, bypassing the member-only `shouldAutoPull` gate: whether the
local files are the content is a fact about the record, not about who may
pull it, so the owner of an unmaterialized placeholder (the reinstall case,
recvqzaDvUU6B3) stops seeing the same empty state too.
Red evidence: all six new specs fail on origin/feat/workspace-team @ 793d7b92c
— the first status response reported `awaitingFirstMaterialization: undefined`
with publishedVersion/materializedVersion/contentTransferState all null,
`beginContentTransfer` was never called for a member's first open, and
`downloadPending` was false for both the member and the owner.
* chore(packaged): inject the vela web origin at build time, not source
This repository is public. Commit 319cb42b3 added a profile -> vela web
origin table to apps/packaged/src/sidecars.ts, which put an internal
environment's hostname into product source and would have published it on
merge. The same hostname had also reached apps/web/src/runtime/amr-guidance.ts
as a new profile row.
Internal AMR environments are not public (one of them fronts a Stripe
sandbox), so their origins now travel the same build-time path POSTHOG_KEY
already uses:
release-beta.yml env (per-profile secret)
-> tools-pack config (OD_VELA_WEB_URL)
-> open-design-config.json (velaWebUrl)
-> packaged config -> daemon spawn env (OD_VELA_WEB_URL)
-> GET /api/integrations/vela/status (consoleOrigin)
-> web runtime console links
The workspace-team gate keeps its meaning and gets stricter: it now needs an
allowlisted AMR profile AND an injected origin. `prod` can never satisfy the
profile half, so a stable build stays dormant however it is configured, and a
feature-test/test build whose secret is unset stays dormant too rather than
pointing the transports at an unknown backend.
A build with no secret configured therefore ships with workspace-team silently
off — the same failure mode as a fork build without POSTHOG_KEY, and the safe
direction for an unreleased feature.
apps/packaged/tests/source-origins.test.ts is a standing guard: every absolute
URL literal under apps/packaged/src must resolve to a publishable host, so the
next backend origin cannot be hardcoded there either.
Secrets required for a workspace-team dogfood build:
VELA_WEB_URL_FEATURE_TEST, VELA_WEB_URL_TEST.
* test(e2e): follow the #5517 plugin-details route in the visual capture
`captures the home plugin use staged surface` has been failing on this
branch since before the main merge. It was written against the pre-#5517
UI: clicking a marketplace card opened a role="dialog" details overlay
with a per-slug `plugin-details-use-<id>` action.
#5517 made plugin details a full-page route — `openCardDetail` calls
navigate({ kind: 'marketplace-detail' }) for plugin records, rendering
`PluginDetailView` at /marketplace/<id> whose Use control is a single
`plugin-detail-use` button. Per the product rule that the accepted
feature-branch UI is the source of truth, the test follows the route
instead of the UI being changed back.
Asserting the route alone was not enough: the detail surface refetches
the record from /api/plugins/<id>, which the visual fixture never mocked
(it mocks the list, */preview and */apply), so the route rendered its
"Failed to load plugin: HTTP 404" branch. configureVisualPage now serves
the single-plugin GET from the same VISUAL_PLUGINS fixture.
The capture asserts the Use button rather than the `plugin-detail` shell
because the shell also renders for the loading and load-failed states.
* fix(packaged): restore masked CLI icons (#6154)
* Sync local workspace-team batch: UI polish, popover simplification, update-reminder progress (#6156)
* UI polish batch: import-dialog cleanup, settings toast placement, tab chrome fixes
- Plugin/skill import dialog: drop per-card icons, left-align card content
- Settings page: center the autosave pill under the top nav
- Avatar model menu: soften the pinned footer divider
- Workspace tabs: pinned entry tab always reads as Home; remove tab-search
button and its popover
- Model picker: wider row gap in the two-pane browse popover
- Community header: unpin (scrolls with content)
- New update-reminder dialog assets and strings
- Keep entry-settings-button as the signed-out settings entry (e2e contract);
align NextStepActions with the navPlugins key rename
* Update reminder: simulate in-place download progress instead of linking out
The strip's confirm now runs a staged progress state ('updating') with an
eased fake download bar, then marks the version updated — replacing the
jump to the GitHub releases page.
* test(web): retire AvatarMenu account-row suites after the popover simplification
The composer popover was reduced to a model picker (2026-07-24): the Open
Design account row — plan badge, balance, wallet fallback, upgrade/console
links — no longer renders there, so the nine suites asserting that surface
(including the workspace-balance and billing-permission gates that landed
upstream meanwhile) can no longer pass. Drop them and recast the remaining
account-row test as the guard for the new invariant: a signed-in AMR status
must render no account UI in the popover.
Also drop the stale onClose prop from the new upstream sign-out-confirm
test; the rail no longer takes one.
* Design-systems toast: anchor to the pane's top edge, pill radius
* Update reminder strip: progress ring on the button, percent face
While updating, the collapsed strip no longer stacks a label + bar above
the rocket button. Progress draws as a brand-green ring hugging the round
button's edge (5px band reserved so the button never shifts), and the
button face swaps the rocket for the bare percent — 'N%' while
downloading, 完成 (updater.done) at 100. The full 正在下载更新 N% label
stays reachable via the hover bubble and the ring's aria-label. Demo
payload version bumps to 1.4.6 so the once-per-version card re-arms.
* fix(web): restore design kit section spacing (#6155)
* fix(web): carry the plugin back to Home when Use runs from the detail route
Product call: using a plugin from the full-page detail route should land on
Home with that plugin already selected and its brief pre-filled, exactly
like the marketplace card's "Try it".
It did not. `PluginDetailView.onUse` applied the plugin, kept the result in
local state, and navigated home. `App` renders that route outside
`EntryShell`, so the navigation unmounted the holder and dropped both the
plugin and the brief — Home came up empty. The "redirected to Home with the
brief pre-filled" note under the button could never be seen either, since
the navigation fired immediately; it is removed rather than left lying.
Reuses the existing channel instead of adding a parallel one: onUse now
publishes the same `createPluginUseHandoff` payload "Try it" produces, and
Home's existing pendingPluginUseHandoff path applies it unchanged. The
handoff is parked in module scope — not on `window` — so it survives the
unmount, and `EntryShell` claims it in a lazy state initializer so it
arrives in the same commit as the mount. Reads are destructive, so a
handoff applies once and does not re-fire on the next visit to Home.
Red first: PluginDetailView.use-handoff.test.tsx fails on the unfixed code
at the handoff assertion (navigation and the failure path already passed),
and goes green with this change. The e2e visual capture that asserts
home-hero-active-plugin should follow without weakening its assertion.
test(e2e): match the settings surface in both #5517 presentations
openSettingsDetailsFromHeader still waited on `.modal-settings[role="dialog"]`.
#5517 routes the entry's settings to /settings, where SettingsDialog renders
in presentation="page" mode with `role="region"`, so that selector never
matched the page presentation: an already-open settings surface looked
absent and the helper fell through to hunting for entry triggers that
surface does not carry, failing 16 captures on one line.
Reuses `settingsSurface` from amr.ts — the existing bare `.modal-settings`
matcher both presentations share — rather than adding a third local
variant. That helper also documents why the `role="dialog"` fallback was
actively wrong: AvatarMenu is a dialog too, so it could resolve to the
account menu.
* test(e2e): open the nav rail before reaching for entry settings
The bare-`.modal-settings` change alone did not move the settings-workspace
lane: all 16 captures still died on `.settings-icon-btn`, because the
failure happens before the surface selector matters. None of the four
triggers was ever visible.
amr.ts already documents why. #5517 moved the entry settings chip into the
nav rail footer, and a collapsed rail is `inert` + `aria-hidden`: the chip
is present but invisible to `getByRole`, and even a programmatic
`element.click()` is a no-op. `openSettingsDialog` expands the rail first
(`ensureEntryRailOpenIfPresent`), clears loading, dismisses the privacy
dialog, matches the surface with the shared bare class, and ends its
trigger chain on the settings aria-label instead of `.settings-icon-btn`,
which the entry surface does not carry. It also clicks
`entry-settings-open-details`, so it is a superset of the local copy.
So `openSettingsDetailsFromHeader` now delegates to it and keeps only its
name, leaving exactly one settings opener instead of a third variant.
* test: realign the suites #6156 outpaced, and reach Settings from a project
#6156 (4e3161751) landed a UI batch whose test fallout was only partly
handled. Four Vitest files and four e2e call sites still describe the surface
as it looked before it, and the Playwright settings opener never had a trigger
that exists once a project is open.
Vitest — the assertions now read the surface that shipped:
- community-view: the type tabs traded their `<small>` count badges for type
icons, so `readFacets().badge` was `NaN`. Counts move to a new
`readFacetCardCounts()` that drives each tab and counts the grid — the same
array the badges were derived from, so the catalogue-fidelity guard
(including "never grids more than the whole catalogue") survives the
badge's removal rather than being dropped.
- home-logo-assets: the rail's signed-out brand header is gone (with no cloud
identity the rail starts at the search box), so it can no longer carry
`od-brand-glyph`. Keeps the guard that still means something: it must never
fall back to the retired raster app icon.
- acceptance-visual-fixes: recvq4iEq1Esno's fix is intact — page mode still
hides only `.settings-chrome-btn`, leaving the autosave pill visible. #6156
re-added the bare `.settings-page-shell .settings-chrome` selector purely to
centre that pill under the top nav, so assert the invariant the row is
about (no page-mode `display: none`) instead of the selector's absence.
- FileViewer: the new `artifact-preview-first-load` cover is a `role="status"`
of its own, so `findByRole('status')` had become ambiguous. Scope the
success assertion to the toast.
e2e — `entry-nav-settings` no longer exists anywhere in `apps/web/src`; the
rail item carries `entry-settings-button`. #6156 renamed it and updated
entry-chrome-flows' two `toHaveCount(0)` assertions but not the four places
that click or await it (critical-smoke, entry-topbar x2, entry-chrome-flows x2).
`openSettingsDialog` also only ever knew entry-shell triggers, all of which
live on Home. From a project every one of them is absent — and
`EntrySettingsMenu` and `AppChromeHeader`'s `SettingsIconButton` are both
unrendered — so the surface never opened and the failure surfaced as a missing
`.modal-settings`. Add the project surface's real entry: the composer model
popover's pinned `avatar-open-execution-settings`, with the topbar switcher's
`inline-model-switcher-open-settings` as a second route.
Not addressed here, deliberately: #6156 also deleted the `settings-general-field`
block that rendered `AppearanceSection`, orphaning the component. That removes
the product's only System/Light/Dark control (the rail's account menu dropped its
theme row *because* Settings owned it), so the 11 `SettingsDialog.execution`
appearance failures are reporting a capability loss, not a stale expectation.
Restoring it is a product call, so those tests are left red rather than weakened.
* test(e2e): finish the popover the settings chain opens on a project surface
`openSettingsDialog`'s trigger chain ends on OPEN_SETTINGS_LABEL, which matches
`Account & settings` — and that is `avatar.title`, the label on AvatarMenu's
own trigger. So on a project surface the chain does find something and clicks
it, but what opens is the composer's model popover, not Settings. The
follow-through step then looked only for `entry-settings-open-details`, which
#5517 left unrendered, so the run gave up with a missing `.modal-settings`
after three identical attempts.
Widen the follow-through to the two rows that actually route to Settings from
a project — AvatarMenu's pinned `avatar-open-execution-settings` and the topbar
switcher's `inline-model-switcher-open-settings` — so whichever popover the
first click opened gets finished.
* test(e2e): pin the AMR selection assertion to the agent card
With Settings actually opening from a project surface again, the surface-wide
`/Open Design/i` in amr-logout-requires-relogin resolves to the sidebar's
"Open Design MCP" `settings-nav-item` — which carries no `aria-pressed` — not
to the AMR agent card's select button. The looseness was invisible while the
surface never opened.
Read the toggle through `settings-agent-card-amr` instead, so the assertion
names the thing the test is about: AMR stays the selected agent.
* test(e2e): prove Settings opened via its section nav, not a heading probe
`prepareVisualSettingsDialog` gated on a heading matching
/Settings|General|Execution mode/. Now that the surface opens from a project it
lands on the execution section, whose heading reads "Models & providers" — and
the surface's own <h2> is consumed as its accessible name via aria-labelledby
anyway, so the probe could not match in either presentation.
Assert `settings-nav-execution` instead, which is what critical-smoke already
uses for the same purpose and holds for both the modal and the routed page.
* test(web): retire the theme/appearance cases the product cut
Product decision (2026-07-28), verbatim: 「主题设置不要了,因为 workspace 功能不
支持暗色主题,要干掉,并且之前用户如果设置了暗色主体,需要强制改成明亮色主题」
So #6156 deleting the `AppearanceSection` render site was the right direction,
and the NON-ALIGNMENT #9 note that argued for keeping the segmented control as
the product's last "follow system" entry point is formally overturned. These
cases are retired because the capability is gone by decision — NOT to turn CI
green. The source-side removal (orphaned `AppearanceSection`, onboarding
light/dark toggle, `settings.appearance*` keys, `trackSettingsAppearanceClick`,
plus forcing stored dark/system back to light for existing users) lands
separately on `fix/remove-theme-force-light`.
Deleted (8) — their whole subject was theme or the accent it carried:
- offers the theme segmented control, and System leaves the document theme unset
- applies the stored default accent color even though the picker is gone
- writes the picked theme to the document and autosaves it
- live previews the configured theme on open, and System leaves no explicit
document theme
- reverts an unsaved appearance preview back to the saved appearance when the
dialog closes
- persists System mode explicitly and preserves accent variables without an
explicit document theme
- keeps a stored non-default accent applied and carries it through an autosave
- localizes the theme controls in Chinese
Nothing was left behind as a loose "still renders something" assertion, and no
live coverage went with them: the close-button/`onClose` path the revert case
also touched is already covered at five other call sites in this file.
Kept (3) — theme was only the vehicle, the subject is still shipping. Each now
rides the notifications completion-sound toggle, seeded on so the single click
is a real state change (the pills no-op when clicked in their current state):
- reconciles the open settings draft when the parent agent CLI env changes —
the only integration proof that `reconcileAmrModelChoice` /
`reconcileAmrProfileEnv` actually run through the real draft on autosave;
its two siblings are pure unit tests and do not cover that path.
- drops a pending autosave when explicit onboarding reset unmounts Settings —
subject is the pending-autosave drop and the draft the reset carries.
- still autosaves an unrelated edit that lands during a silent-update save —
subject is autosave bookkeeping (success must only advance
autosaveLastSavedRef for allowSilentUpdates).
The enclosing block is renamed 'SettingsDialog draft reconciliation' since it no
longer describes appearance, and its document theme/accent teardown is dropped —
nothing in it writes those any more. 144/144 green.
* test(e2e): realign the visual workspace captures with the narrowed surfaces
Three deliberate narrowings shipped without this Playwright lane following, so
eight captures were driving UI that no longer renders. All eight are stale
fixtures; none was a regression.
- `ef9c8cd8b` made the home top-bar `InlineModelSwitcher` `compact`, and
`EntryShell` (its only call site) always passes the flag. In compact mode the
popover is just the active agent's model radio list plus the route to
設定 → 執行; the mode segmented control, agent grid, account block, and both
searchable dropdowns live in the non-compact branches. `68cecac1c` records
that product confirmed this narrower shape, so acceptance #40 ("home 页不能
切换 cli 了") is working as designed.
- `4e3161751` (#6156) removed the Open Design account row from `AvatarMenu`
(plan badge, balance, wallet fallback, upgrade/console links) and retired the
nine equivalent Vitest suites — but not these.
- `56b538aa4` moved the design-system picker out of the staged-context bar, so
`staged-contexts` is no longer unconditionally mounted.
Where a surface still ships, the capture follows it and the assertion got
sharper rather than looser: staged contexts now stages a real attachment and
names the chip; the topbar/avatar captures assert the surviving model picker
plus the *inverse* invariant (no account block, no upgrade button, no balance),
gated on a counted `vela/status` request so a negative cannot pass by racing the
fetch. `:57` additionally pins `inline-model-switcher-mode-daemon` at count 0, so
re-mounting the CLI console on Home fails here again.
`:167` (topbar BYOK model dropdown) is deleted: in compact mode it would
re-capture the identical popover as `:141`, which is a duplicate screenshot
rather than coverage. `:141` absorbed its guard.
`:244` (avatar reasoning readout) turned out never to have passed — not a #6156
casualty. `VISUAL_CLI_AGENTS`' codex entry declares `models` only, while
`AvatarMenu` draws the readout solely for agents reporting `reasoningOptions`, so
it has been red since `68cecac1c` authored it. Fixed by declaring
`reasoningOptions` on a test-local codex fixture, matching what the real daemon
reports (`apps/daemon/src/runtimes/defs/codex.ts:164`).
Nothing was dropped without a verified live home elsewhere in the same CI lane
or in Vitest: the AMR plan/balance/upgrade card at `visual-settings.test.ts:30`,
the searchable dropdowns at `visual-settings.test.ts:93`/`:156`, the upgrade
attribution URLs in `InlineModelSwitcher.test.tsx:585` and
`AvatarMenu.test.tsx:283`, the "no account row" invariant at
`AvatarMenu.test.tsx:528`, and real staged-context chips in
`project-management-flows.test.ts`. The one assertion with no surviving home is
the Open-Design-first agent ordering, whose grid no longer renders anywhere;
that is called out in the test comment rather than silently lost.
* test(e2e): give the AMR upgrade capture a billing-capable workspace
`settings-agent-card-amr-upgrade` stopped rendering for this capture because
`amrCardCanUpgrade` (SettingsDialog.tsx:4651) now also requires
`workspaceContext?.permissions?.canManageBilling` — this branch's
workspace-scoped billing work, i.e. the feature #6142 exists to ship. The gate
is correct; the fixture was not representing anyone entitled to pass it, since
`mockSignedInVelaAccount` establishes a Vela session but no workspace context.
Stub `/api/workspace/context` with a personal-owner context inline in this one
test rather than widening `mockSignedInVelaAccount`, which visual-workspace
shares. The assertion is unchanged — the upgrade entry is a real capability for
billing-capable members, so the fixture represents one instead of the assertion
being relaxed.
Verified the stub does not quietly satisfy the three assertions that already
passed: `planId: null` keeps `resolvePlanTier` falling through to `plus`, and
billing still 404s so the balance stays `$247.51`.
Expect one legitimate baseline diff on `visual-settings-open-design-account`:
the rail now renders signed-in workspace chrome, which is the only state in
which the upgrade entry exists at all.
* test(e2e): leave projects through shipping chrome, and reach /projects reliably
Two dead locators and one under-budgeted wait, all test-side.
`openNewProjectModal` (rail.ts) probed `entry-nav-new-project` then
`entry-nav-projects`, both deleted from EntryNavRail by the #5517 redesign
(`b55f17169`, `f16075f7e`) — `onNewProject` is still destructured there but has
zero call sites. Its body is byte-identical to main, where both testids exist, so
main takes the rail path and never reaches the fallback this branch always hits.
The live affordance is `designs-new-project` / `designs-empty-new-project` in
DesignsTab, inside `entry-view-projects`; `/projects` has no UI entry at all
(entry-chrome-flows:373-382 already documents that and routes directly).
The fallback then failed for a second reason: `entry-view-projects` does ship
(EntryShell.tsx:1579) and `/projects` does route to it, but it was asserted on
the 10s default budget right after `waitUntil: 'domcontentloaded'`. `apps/web`
mounts `src/App` via `dynamic(..., { ssr: false })`, so domcontentloaded fires
while only the boot shell exists — every suite that does this correctly waits out
`Loading Open Design…` with `T.long` first. Now routed via
`history.pushState` + `popstate` (the client router listens) instead of a full
reload, boot shell waited out, and both the view and the create button given
`T.long`.
`ensureRailOpen` was also called unconditionally, but it ends in a hard
`expect(workspace-home-rail-toggle).toBeVisible()` and that testid only renders
for `isPinned && active` (WorkspaceTabsBar.tsx:1474-1510) — from inside a project
it does not exist, so the helper hard-failed on a control this flow never needed.
Now best-effort and gated on `.entry`, kept only so the
`visual-new-project-modal` baseline does not churn.
`real-daemon-run.test.ts:547` hung on `/back to projects/i`, which matches
nothing on a project surface: `AppChromeHeader` owns that aria-label but is no
longer mounted anywhere, and ChatPane's top-left slot resolves
`onCollapse ?? onBack` while ProjectView passes both, so it is always
`chat-collapse-toggle` (deliberate, `884ed1085`). Replaced with a local
`leaveProjectForEntry` that clicks the pinned entry tab — the shipping way out —
so the isolation journey still leaves through real chrome instead of a URL jump.
Dropped `getActionablePoint` with its only consumer. Removing the reload from
~25 call sites across 15 suites was audited against the stub-order-sensitive ones
(settings-media-providers, new-project-ds-picker-*, api-empty-response,
visual-entry); all install routes before their first navigation.
Correcting my own earlier misattribution: `.ws-tab` / `.ws-tab-label` are
FileWorkspace's in-project file strip and are fully intact, `role="tab"` and
file-name labels included. #6156 rewrote WorkspaceTabsBar, which renders
`.workspace-tab` / `.workspace-tab__label` — a different component. Upload→tab is
also intact (`FileWorkspace.uploadFiles` still calls `openFile`). Five of those
six failures merely routed through the broken helper above; the sixth was the
dead back-button. No `.ws-tab` assertion was rewritten.
* fix manual edit across workspace pages (#6160)
* fix(collab): keep process-local presence alive when the collab transport is off (#6170)
`createVelaCliCollabClientFromEnv` returns `null` unless the run opted into
the vela-cli collab transport, but `server.ts` wired the presence routes with
an object literal of arrow functions closing over that client. A literal is
unconditionally truthy, so `registerCollabPresenceRoutes` always saw a `cloud`
dependency: its `deps.cloud ?? null` could never resolve to `null`, the
process-local `presence.present()` fallback became dead code, and every
presence request that reached the cloud branch dereferenced `null` and was
answered `502 collab_presence_unavailable`.
`POST /api/projects/:id/presence/leave` carries no shared-project
precondition, so it failed on any project id in any run without the
transport — every stable/prod packaged build (the packaged workspace-team env
is gated to the `feature-test`/`test` AMR profiles) and every plain
`tools-dev` run. `server.ts` is `@ts-nocheck`, so the null dereference was
invisible to `pnpm typecheck`.
Route the dependency through `createCollabPresenceCloudClient`, which lives in
the checked route module and states the invariant: a `cloud` dependency exists
if and only if a transport exists. Removing its guard is now a typecheck
error, so the bug class cannot come back at the construction site even while
`server.ts` stays unchecked.
* fix(web): scope desktop vibrancy to macOS (#6171)
* fix(web): remove the theme setting and force every install back to light (#6168)
Product removed theme selection: the workspace surfaces shipped for team
workspaces have no dark tokens, so dark mode renders a broken app.
Deleting the picker is not sufficient on its own. Every install that ever
opened it still has `theme: 'dark'` — or `'system'`, which resolves dark on a
dark OS — persisted in localStorage, and a stored value does not move when the
default does. So the theme is now coerced on READ, at all three points a
persisted value can reach the document:
- `loadConfig()` funnels `parsed.theme` through `resolveAppTheme()` and marks
the config migrated so the coerced value is written back once.
- `applyAppearanceToDocument()` stamps `data-theme="light"` unconditionally.
The attribute must be PRESENT, not merely non-dark: every dark CSS rule is
gated on `html:not([data-theme])` / `html:not([data-theme="light"])`, and
every JS theme reader (shiki, ConnectorLogo, SketchEditor, TerminalViewer,
connectorBrandColor, MentionNode) falls back to `prefers-color-scheme` only
when the attribute is absent.
- The pre-hydration inline script in `app/layout.tsx` stamps light before React
mounts, outside its try/catch so a throwing storage read still leaves the
attribute set.
Electron's `themeSource` defaults to `system`, which colours everything the web
layer does not own (macOS vibrancy glass, native menus/dialogs) on a dark-mode
Mac — including the splash, before the renderer's appearance IPC lands. It is
now pinned to light before the first window exists.
Removed surfaces:
- `AppearanceSection` in SettingsDialog, orphaned by #6156 (zero call sites),
together with its `settings-general-block--appearance` styles. Its docblock
claimed the control was "deliberately kept … NON-ALIGNMENT #9"; that decision
is superseded, so the comment goes with the code.
- The onboarding welcome page's sun/moon toggle — the last reachable theme
writer — and the `onThemeChange` prop chain behind it
(App → EntryView/ProjectView → EntryShell → OnboardingView).
- The orphaned theme row in EntrySettingsMenu and its styles.
- i18n keys `settings.appearance`, `settings.appearanceHint`,
`settings.themeSystem`, `settings.themeLight`, `settings.themeDark` across
`types.ts` and all 19 locales.
- Analytics `trackSettingsAppearanceClick`, `SettingsAppearanceClickProps`, and
the `settings_popover` `appearance` element.
`'appearance'` survives only as a legacy settings deep-link token that
`normalizeSettingsSection` folds into General, so an old link is not a type
error.
Red-first: tests/state/force-light-theme.test.ts and
tests/components/theme-settings-removed.test.tsx were written against the
unmodified branch and went red (11 failing) before any source change.
* fix(web): make the update surfaces read real data (#6163)
* fix(web): make the update surfaces read real data
#6156 (4e3161751) shipped two update surfaces whose every field was
invented. `EntryShell` held a private `updateReminderStage` machine keyed on
a literal `'1.4.6'` (the app ships 0.16.1), the rocket's confirm ran a 200ms
`setInterval` easing a fake bar toward 100% and then wrote "updated" to
localStorage, and the cover dialog listed three hardcoded English notes past
i18n over a committed 432 KB JPEG. Nothing in that path made a single
network, IPC or daemon call — a user who clicked "立即更新" watched a
progress ring finish and got no update.
Both surfaces now sit on the real machinery that already existed:
The cover dialog becomes the post-update highlights surface and REPLACES the
bottom-right `WhatsNewPopup` card rather than coexisting with it. Cover art,
release headline, bullets and link all come from the hosted highlights
document via `/api/whats-new`; the title states the running version from
`useAppVersion()` (`/api/version`). It keeps that card's show gate verbatim —
once per highlight `id`, only with content, only on the home surface after
the app returns on a new version — and its existing analytics
(`whats_new_popup` surface-view / click). Its footer is close + open-release;
there is no "cancel / update now", because reporting what already shipped is
all this surface does. Bullets come from a new `whatsNewNotesFromBody`, which
splits the document's `body` per line and strips operator list markers.
The rocket keeps its look and becomes the real updater's ready indicator in
the bottom-left rail footer, replacing that indicator's arrow glyph. It is
gated on the updater's own `shouldShowControl` (installer downloaded and
never opened, desktop only) and still drives `openUpdaterInstaller` →
`quitAfterUpdaterInstallerOpen` through the untouched panel, watchdog and
restart-safety preflight. The progress ring and percent face are gone on
purpose: the indicator only exists once the download has finished, so any
percentage it drew would be invented.
Deleted: `lib/update-reminder.ts`, `UpdateReminderDialog.tsx` and its module
CSS, `public/update-reminder-cover.jpg`, the now-dead
`entry-updater-menu__button/__glyph/__progress` rules, and the unused
`accountNotice` rail slot. The three `updateReminder.*` i18n keys and the
orphaned `whatsNew.dismissAria` are replaced by one `whatsNew.updatedTitle`
across all 19 locales.
A source-level guard (`tests/update-surface-real-data.test.ts`) pins the
invariant that outlives the deletion: no quoted `x.y.z` and no `setInterval`
in the update surfaces, and the placeholder module and cover art stay gone.
* fix(web): name the release-notes destination on the what's-new CTA
Product review of the five open calls closed all five; only the CTA copy
changes. The button opens the release notes, so it now says so —
「查看更新说明」 in Chinese (product's wording, verbatim), with the other 18
locales aligned on "view the release notes" instead of inheriting the old
"see what's new" tease. `whatsNew.cta` had exactly one consumer left (the
dialog) once it replaced the bottom-right card, so the key is repurposed in
place rather than forked.
The other four calls were approved as implemented, and two of them overturn
conventions the old comments still asserted, so those comments are synced:
- `lib/whats-new.ts` documented the toast's deliberate Escape exemption
(Escape hid the card WITHOUT spending the highlight). As a focus-trapping
modal that exemption is retired — every close path marks seen — and the
docblock now says which paths and why.
- `contracts/api/whats-new.ts` described `title` as generic copy, `imageUrl`
as an image "beside the copy", `body` as prose, and `linkUrl` as the "See
what's new" link. The dialog reads them as headline / top-inset cover /
one-bullet-per-line / release-notes link, so each field now documents the
role it actually plays. Comment-only; no shape change.
* fix(web): never state the placeholder version on the what's-new dialog
Review catch (nettee, #6163): `useAppVersion()` reads /api/version at runtime,
so it necessarily boots on `APP_VERSION_PLACEHOLDER` and resolves a round-trip
later. This dialog rendered as soon as /api/whats-new resolved and printed the
hook unconditionally — so a highlights fetch that won that race painted
"Open Design 0.0.0 is here" on first frame. An invented version string, on the
one surface this PR exists to make truthful. The new suite could not see it
because it mocked the hook to an already-resolved value.
Fixed by falling back rather than gating the whole render: gating would delay
the dialog behind a second round-trip on slow networks, while the highlights
document already carries the running version in its `version` field — the
daemon stamps it for display (see contracts/api/whats-new.ts), which is what
fed the old bottom-right card's eyebrow. So `statedAppVersion()` prefers the
resolved hook, falls back to the document, and returns null when neither can
name one; on null the dialog waits, because highlights are worth nothing under
a headline that lies. The same derived value feeds the title AND every
`app_version` prop, so analytics cannot ship the placeholder either.
`APP_VERSION_PLACEHOLDER` moves out of ./provider into a new leaf
analytics/app-version.ts alongside an `isResolvedAppVersion()` predicate, so a
surface that must not PRINT a placeholder can test for it without importing the
analytics client. The provider keeps its own use of the constant.
Regression tests, red before this commit (`.red-evidence-version-race.txt`
reproduces nettee's exact string, `expected 'Open Design 0.0.0 is here…' not to
contain '0.0.0'`): the placeholder never paints while /api/version is in
flight, it never reaches the surface-view analytics, the title switches to the
running version once the hook resolves, and a document with no usable version
waits instead of inventing one. The suite's provider mock is now a mutable
holder so both sides of the race are reachable.
Also caught by this PR's own source guard: quoting the literal in a docblock
tripped it, so the comment names the constant instead.
* test(e2e): unmask the AMR runtime lane and repair the visual settings/workspace captures (#6174)
Three fixture defects, each verified by running the lane rather than reading it.
- amr-run-failure-recovery.test.ts was still `mode: 'serial'`. A second
`describe.configure({ timeout })` cannot clear it — configure only
overwrites the keys it is given — so one failure skipped the eight cases
behind it and the lane reported them as "did not run". e2e/AGENTS.md
forbids serial groups outright.
- configureVisualPage's `**/api/**` catch-all (added by #6126, which is on
main) answered `/conversations` with 404. ProjectView mounts ChatPane only
once a conversation resolves, and both listConversations and
createConversation swallow a non-ok response, so the project opened with no
conversation and no error, ChatPane never mounted, and every capture that
enters the workspace died on `chat-composer` not existing. Supplies the
conversation boundary the catch-all closed without replacing.
- The BYOK tab is named "API providers" since #5971 renamed
`settings.modeApiMeta`; `getByRole('tab', { name: 'BYOK' })` matched
nothing and three captures hung until the test timed out. The topbar BYOK
capture also left `[MOCK_AGENT]` installed, contradicting its own "a BYOK
config has no local agent" premise: an installed agent wins the popover and
renders its model radiogroup instead of the BYOK rows.
Co-authored-by: lefarcen <ontf116@gmail.com>
* Home shell productization batch: message center, What's-new on release data, update-reminder & updater popup restyle (#6162)
* UI polish batch: import-dialog cleanup, settings toast placement, tab chrome fixes
- Plugin/skill import dialog: drop per-card icons, left-align card content
- Settings page: center the autosave pill under the top nav
- Avatar model menu: soften the pinned footer divider
- Workspace tabs: pinned entry tab always reads as Home; remove tab-search
button and its popover
- Model picker: wider row gap in the two-pane browse popover
- Community header: unpin (scrolls with content)
- New update-reminder dialog assets and strings
- Keep entry-settings-button as the signed-out settings entry (e2e contract);
align NextStepActions with the navPlugins key rename
* Update reminder: simulate in-place download progress instead of linking out
The strip's confirm now runs a staged progress state ('updating') with an
eased fake download bar, then marks the version updated — replacing the
jump to the GitHub releases page.
* test(web): retire AvatarMenu account-row suites after the popover simplification
The composer popover was reduced to a model picker (2026-07-24): the Open
Design account row — plan badge, balance, wallet fallback, upgrade/console
links — no longer renders there, so the nine suites asserting that surface
(including the workspace-balance and billing-permission gates that landed
upstream meanwhile) can no longer pass. Drop them and recast the remaining
account-row test as the guard for the new invariant: a signed-in AMR status
must render no account UI in the popover.
Also drop the stale onClose prop from the new upstream sign-out-confirm
test; the rail no longer takes one.
* Design-systems toast: anchor to the pane's top edge, pill radius
* Update reminder strip: progress ring on the button, percent face
While updating, the collapsed strip no longer stacks a label + bar above
the rocket button. Progress draws as a brand-green ring hugging the round
button's edge (5px band reserved so the button never shifts), and the
button face swaps the rocket for the bare percent — 'N%' while
downloading, 完成 (updater.done) at 100. The full 正在下载更新 N% label
stays reachable via the hover bubble and the ring's aria-label. Demo
payload version bumps to 1.4.6 so the once-per-version card re-arms.
* Home shell productization batch: message center in the account menu, What's-new on real release data
- Message center: entry moves into the account menu under 设置 (bell row);
MessageCenter becomes controllable (hideTrigger + open/onOpenChange +
onUnreadCountChange) and mounts persistently in EntryNavRail so unread
polling survives the hover menu; unread shows as a red dot on the account
avatar (and on the menu row); avatars go from squircle clip to 8px rounded
squares; panel filter segmented control and mark-all-read go pill-radius.
- What's-new: the post-update highlights card now wears the shared
release-dialog shell (cover / title / dotted notes / pill footer) while
keeping its REAL /api/whats-new data source, show-once-per-id timing,
soft-hide Escape/backdrop semantics, and analytics. The demo-payload
update-reminder dialog and its simulated download no longer trigger; the
stage machine and progress strip stay for the real updater-feed wiring.
- Rail chrome: brand logo and the in-rail collapse control are gone — the
rail starts at the search box and folding lives in the pinned Home tab's
toggle; the signed-out cloud tip bottom inset now matches its sides.
- Brand picker (modal variant): side-nav split — search + vertical category
nav left, a roomy 2-up borderless gallery right, content flush to the
column line with hover fill/ring bleeding past it; logo tiles lose their
boxed edge; selected template chip and the composer mode pill share the
same selected ring, with the mode glyph optically matched at 13px.
- Tab launcher: rows are icon + label only (descriptions dropped) with a
rotating four-hue icon palette; the 新建空白页面 entry is paused behind
ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT and its PageCreator suites skipIf
on the same switch.
- Move-to-team confirmations: extracted MoveToTeamConfirmDialog, no backdrop
blur (dim only), and the confirm keeps its brand-green label through the
mention-home ink-pill restyle in every interaction state.
- Publish CTA: hover inverts to ink fill with brand-green icon + label.
- Plus assorted in-progress workspace polish (composer, viewer, settings,
model icons) riding the same branch iteration.
* Restyle the updater ready popup to the update-reminder dialog language
Cover art on top (compressed 1296x555 jpg, natural aspect), the orange
accent icon tile removed, secondary action as a soft white pill and the
install CTA as a black pill with brand-green ink, and a stacked footer so
the silent-update checkbox keeps a full-width single line instead of
wrapping beside the widened pills.
* Signed-out rail: message-center entry under 设置
The signed-out rail has no account menu (where the 消息中心 row lives when
signed in), which left the message panel with no opener at all. The rail
item carries the unread dot.
* Composer row: pin chip glyphs against flex shrink
The row-wide min-width: 0 that lets chip labels ellipsize also let SVG
glyphs absorb the squeeze — a hard-narrowed pane rendered the design-system
palette icon tiny. Chips still shrink; their icons keep natural size.
* Bind Community Remix projects to the current workspace
Addresses mrcfps' review thread on apps/web/src/App.tsx:3303.
Product reproduction: 「通过 remix 后的方案, 不出现在我当前 workspace 里的
草稿了」 — stable repro in a team workspace.
`duplicatePluginAsProject` only sends the workspace/member identity headers
when its third `workspaceContext` argument is supplied, and the daemon's
`authorizeCreatedProjectWorkspace` deliberately reads a COMPLETELY headerless
create as a legal legacy/anonymous caller — `bindCreatedProjectToWorkspace` is
a no-op for a null context. Both remix call sites this PR adds (App.tsx's
standalone /community branch and EntryShell's community tab) omitted the
argument, so a remix performed inside a team workspace produced a project
bound to no workspace at all and therefore invisible to every workspace view.
Same enforceWorkspaceProjectMutation / created-project bypass class as
c0bce3b8f, fixed the same way: thread the already-resolved workspace context
that both files already hold, through the repo's existing
`resolvedWorkspaceContextForWrite` seam so an unresolved or unavailable
authority fails closed instead of silently creating an unbound orphan.
The second half of the review note — the follow-up `patchProject` whose `null`
was ignored — was a consequence of the same root cause: with the project
unbound, `enforceWorkspaceResourceMutation` finds no workspace row for the
caller's workspace and 403s the pendingPrompt seed, so the template prompt was
dropped too. Both requests now share one resolved authority, so they can never
disagree about which workspace they are acting in, and a still-null result is
reported instead of swallowed (without creating a second project, which is what
routing it through the existing catch would do).
Red spec first: apps/web/tests/components/community-remix-workspace-binding.test.tsx
drives both call sites in a team workspace against a fetch stub that reproduces
the daemon's create-then-bind contract. Red before this change (the create
carried no workspace headers, the project stayed unbound, and the seed PATCH
403'd), green after.
* Cover the refused-prompt-seed branch of the Community Remix fix
Second half of mrcfps' review ask on the same thread: prove what the flow does
when the follow-up `patchProject` still returns null after the binding fix.
The remaining null shapes are genuine transient refusals (daemon down,
membership revoked mid-flight, workspace locked between the two requests). By
then the copied project is real and bound, so the flow deliberately does NOT
fall into its own catch — that path creates a second, prompt-only project and
would strand the copy. It keeps the user on the remix and reports the dropped
seed. This case pins that: user lands on the remixed project, no second project
is created, and the failure is surfaced rather than swallowed.
* Re-anchor three style specs on the dogfooded UI
`Web workspace tests` went red on this branch because three CSS specs still
described the pre-dogfood layout the restyle replaced. Each one is re-pointed at
what shipped, keeping the invariant it was written to protect:
- `acceptance-visual-fixes` (recvpYDfW12NBu): the preset-thumbnail fix is the
resting `scale(1.15)` framing transform, which is intact. Its hover partner
went away with the family-wide cover-zoom removal (2026-07-27), so the spec
now asserts the framing zoom on both media surfaces and separately locks the
hover rules out — re-adding one would resample the covers soft again.
- `mention-popover`: the filter strip scrolls on one line instead of wrapping to
a second row. The "no clipped labels" half of the contract is unchanged and
still asserted (pills hold natural width, labels stay `nowrap`).
- `settings-polish`: the updater popup footer stacks the silent-update checkbox
above a 50/50 action row. Long en labels must still wrap inside the checkbox
column rather than overflow the panel, which the spec keeps checking.
* Return message-center focus to the rail opener that owns it
`hideTrigger` handed the message-center entry point to the rail but left the
component's internal `triggerRef` unattached, so `closePanel()` restored focus
to nothing. Opening focuses the portaled dialog, so every close — button,
backdrop, Escape, notification-settings — unmounted the focused node and dropped
keyboard focus to the document. Both external openers also failed to advertise
the dialog they own.
`MessageCenter` now takes a `returnFocusRef`: the host that hides the built-in
bell owns the duty that button used to serve. `EntryNavRail` points it at a
control that is still mounted after the close — the account trigger on the
signed-in branch, because the hover menu unmounts the 消息中心 row before the
panel opens, and the rail item itself when signed out. Both openers carry
`aria-haspopup="dialog"` and the live `aria-expanded`.
Red first: `EntryNavRail.message-center-entry.test.tsx` opens from each entry,
closes with Escape and with the close button, and asserts focus lands back on
the host control. All four cases failed before this change.
* Carry the menu popup contract onto the composer quick pills
The 插件 / 设计百宝箱 pills open the standalone `role="menu"` popovers in
ChatComposer, but they arrived without the contract of the ComposerPlusMenu rows
they replaced: no `aria-haspopup` / `aria-expanded`, no Escape handler, and no
focus return. Keyboard and screen-reader users could not tell a popup was open,
and Escape pressed while focus sat in the plugin search did nothing.
The pills live in ChatPane while the popovers live in ChatComposer, so the state
is plumbed both ways rather than duplicated:
- `onStandalonePanelChange` reports which popover is open, and each pill carries
`aria-haspopup="menu"` plus its own `aria-expanded` — the same pair
`ComposerPlusMenu` already puts on its trigger. No `aria-controls`: that
surface does not use one, and this is not the place to invent a second
convention.
- `openDesignToolbox` / `openPluginsPanel` take the opening pill as the
return-focus target. Both popovers move focus inside themselves, so a
dismissal has to hand it back, and the pill is the control the user came from.
- Escape closes through a document-level handler, mirroring ComposerPlusMenu's.
`dismissStandalonePanels` is deliberately only wired to the dismissal paths
(Escape, backdrop). Picking a plugin or an action keeps the plain setters,
because those hand focus to the composer input and pulling it back to the pill
would fight that.
No interaction changes: hover-open, the shared close timer, pill-to-popup
pointer travel and every selection path behave exactly as before.
Red first: `tests/components/ChatPane.quick-pill-popup-contract.test.tsx` runs
both pills through advertise / expand / Escape-from-inside / backdrop-dismiss and
asserts focus lands back on the pill. All six cases failed before this change.
---------
Co-authored-by: lefarcen <935902669@qq.com>
* Converge the visual Design Files prelude instead of clicking once (#6181)
`Playwright visual (settings-workspace)` went red on `[P2] captures the settings
BYOK surface` with a 10s `aria-selected` timeout: the Design Files tab sat at
`false` for all 23 polls with nothing re-clicking it. Seven other captures run
this identical prelude and passed, including the two neighbouring BYOK cases.
The defect is in the oracle, not the product. `prepareVisualWorkspaceFileList`
decided **once** whether to click — one instantaneous `isVisible()` probe of a
file row — and then asserted a state nothing retries. Any interleaving where the
workspace's own tab reconciliation lands around that single click leaves the
assertion permanently unsatisfiable for that attempt, and the lane runs
`OD_PLAYWRIGHT_FULLY_PARALLEL=1` with `retries: 0`, so it reports as a hard
failure rather than a flake.
`activateVisualDesignFilesTab` now converges on the goal state via `toPass`,
driving off `aria-selected` directly rather than through the file-row proxy —
the guard and the assertion were about different things even though
FileWorkspace derives both from one `activeTab === DESIGN_FILES_TAB` expression.
Repeat clicks are safe: the tab's handler is `setPersistedActive(DESIGN_FILES_TAB)`,
which is idempotent.
Why this is not a product regression from #6162: nothing in it touches tab
selection, tab persistence, or row visibility. Its only `design-files.css` change
deletes the hover cover-zoom, which cannot affect visibility, and a diff of
`apps/web/src` for `localStorage|activeTab|openTabs|restoreTab` comes back empty.
The lane's one green run before this failure (`4a6653186`, the #6174 repair of
the unrelated #5971 BYOK rename) is the whole basis for calling the baseline
clean.
Scope is the visual lane only — every caller is in `visual.ts` or
`visual-*.test.ts`, so the UI P0 lanes are untouched.
* fix(web): explain the AMR workspace-scope block instead of a dead send button (#6178)
An Open Design Cloud project run requires a resolved personal/team workspace
authority (21f452ffe). Failing closed there is correct and stays. Failing
closed SILENTLY was the bug: the send button went grey with no reason on
screen and no way out, while Home's equivalent dead end
(checkAmrBalanceGate -> AmrBalanceDialog reason `signed_out`) hands the user
an in-app sign-in. e2e/ui/amr-logout-requires-relogin.test.ts exists to
protect exactly that route, which a disabled composer can never reach.
Adds a classifier (`amrWorkspaceScopeBlock`) that turns "gate closed" into
the remedy that clears it, and a composer-adjacent notice that renders it:
signed_out — the same AmrLoginPill action and `chat.amrBalanceGate.signInCta`
copy Home's balance gate uses, so one identical action clears it from
either surface.
unresolved — a re-read of the project's workspace authority, exposed as
`ProjectWorkspaceScopeState.revalidate`. An account action would be a guess.
The gate itself is untouched: `projectRunWorkspaceScopeReady` never consults
the classifier, and the `disabled: true` invariant stays asserted.
* test(updater): join the installer-reinstall floor across release and client (#6167)
* test(updater): join the installer-reinstall floor across release and client
A payload update never replaces the Electron outer shell, so a release whose
shell changed can only reach an old install through the installer. The
`control.launcher.version.{min,url}` floor is the one mechanism that forces
that, and it spans three owners: tools/release resolves the channel policy,
the feed carries it, and the desktop updater enforces it against the
physically installed outer version. Every side had unit coverage; nothing
joined them, so the only place a break in the chain would surface was a
release publish.
Adds six specs to the already-allowlisted packaged-launcher cross-boundary
suite, driving the real release-side resolver into a real feed and then into a
real packaged update check:
- an outer below the floor takes the installer, not the payload, and the
launcher pointer proves nothing was adopted;
- the floor is judged by the installed outer rather than the payload it is
running (two installs on the same payload, only the stale shell diverted);
- an unreadable installed outer fails closed as `outer-version-unreadable`
with no installedVersion to render;
- a launcher schema beyond this client diverts on the ABI axis alone;
- with no floor configured a below-floor outer still swallows the payload,
which is why a shell-changing release must set the repo vars deliberately;
- a floor above the release version stays refused.
Falsifying `remoteRequiresReinstall` to compare against `config.currentVersion`
-- the semantics the released 0.15.1/0.16.1 shells actually ship -- turns two
of these red, including the unreadable case being mislabelled as below-min.
Also pins the floor's operational shape against real channel version formats:
a bare stable floor sorts above every same-base `-beta.N`/`-preview.N`/
`-prerelease.N`, so inheriting it into those lanes hard-fails their publish.
The specs name that trap and pin the one-explicit-pair-per-lane configuration
that avoids it.
* test(updater): drive the reinstall floor on Windows too, off the real outer
Review (nettee) caught these specs running macOS only: the feed described
`platforms.mac`, the payload writer built a `.app`, and the updater config was
pinned to arm64/darwin. So `resolveInstalledOuterVersion`'s Windows branch —
`dirname(launcherLaunchPath)/resources/open-design-config.json`, a different
lookup from the mac bundle path — and the `installer` artifact shape never ran.
Since the installs most exposed to a shell/payload mismatch are the oldest ones,
leaving Windows uncovered left the half most likely to break unguarded.
The specs now run through `describe.each` over a per-platform target table
carrying the launch-path shape, outer-config location, installer artifact key,
and payload extractor.
They also stop using `OD_UPDATE_INSTALLED_VERSION`. That override returns before
the platform branch it was supposed to exercise, so it was masking the code under
test. Each scenario now materializes a real installed outer package at the real
per-platform location and lets the updater read the version off disk — which
also makes the unreadable-outer case genuinely unreadable rather than simulated.
Falsifying the Windows lookup (collapsing it into the macOS path) fails exactly
the two Windows specs that depend on reading a real installed version, while all
twelve macOS and publication-policy specs stay green.
* fix(web): partition the workspace plan nameplate like the wallet (#6182)
A workspace Vela Web shows as 免费 rendered as 专业版 Plus in the client's
account menu, while the 额度 row in the same card correctly read $0.00.
Money and plan travelled different paths. `GET /api/workspace/billing`
returns three independently scoped things, and only two of them are about
the workspace: `workspaceBalance` / `workspaceSnapshot` are proven for the
exact workspace + member, while `summary` is the caller's VELA ACCOUNT
billing (`workspaceId: null` by contract; the daemon reads it with one
unscoped `fetchBilling()` regardless of `?workspaceId=`). The rail's plan
nameplate read `response.summary` raw, so an account holding a personal
Plus reported `plus` in every workspace — a value that cannot change when
the workspace changes, because it was never about the workspace.
`workspaceBillingSummaryForContext` makes the partition key explicit, the
same `workspaceId` + `workspaceMemberId` pair money already uses:
- personal workspace — the account IS the scope, summary passes through;
- team workspace — plan comes from the context-authorized snapshot, and
the account tier may stand in only when it is itself team-namespaced,
since a personal tier cannot name a team workspace's plan.
That last clause keeps the 飞书 P0 fix intact: B omits planId/billingState
for a non-owner and `workspaceSnapshot` is an additive capability, so a
team-namespaced account tier is the only surviving evidence that a paying
MEMBER's team is subscribed.
`useWorkspaceBilling` is now this projection over the ambient context, and
EntryShell / SettingsDialog consume it instead of the raw summary. No
interaction, layout, or copy changes.
* Revert "fix(web): explain the AMR workspace-scope block instead of a dead send button (#6178)" (#6184)
This reverts commit ea259eb402.
* fix(daemon): resolve an unbound project against the caller's current workspace (#6185)
* fix(daemon): resolve an unbound project against the caller's current workspace
Every project must resolve to a workspace; when the project itself has no
binding, the workspace is the one the caller is currently acting in. There is no
other candidate.
`GET /api/projects/:id/workspace-scope` read only the persisted
`workspace_projects` row and the membership directory — never the request's
`x-od-workspace-*` identity — so a project with no row answered `unbound` for
every caller forever. `unbound` makes `projectWorkspaceScopeAuthorizesAmr` false,
which disabled the chat composer's send button for an Open Design Cloud run on
that project permanently, with nothing the user could do to clear it. #6178 wrote
user-facing copy for that state instead of fixing it (reverted in #6184).
`resolveProjectWorkspaceScopeForCaller` wraps the existing resolver instead of
branching inside it, so `resolveProjectWorkspaceScope` stays byte-identical and
the bound path is provably untouched. The fallback re-enters that resolver with a
synthetic binding naming the caller's own workspace, so the resulting
`workspaceMemberId` comes from the membership directory and never from the
request header: a caller can only select among workspaces their signed-in
identity is genuinely an active member of, and anything the directory cannot
confirm degrades back to `unbound` rather than to `unavailable` on a workspace the
project was never bound to.
Two cases keep today's behavior, both pinned by spec:
unavailable — all three of its return sites. A project pinned to workspace X
read by a member of Y answers X. The scope carries `workspaceMemberId`, the
wallet that pays for the project's runs; resolving to Y would bill Y for X's
project. Reachable because `GET /api/projects/:id` has no workspace gate, so
a deep link or a workspace switch lands there.
no caller identity — signed out, or a plain curl, has no current workspace to
fall back to, and inventing one is worse than answering "none".
The fallback deliberately does not persist a binding.
`reconcileUnboundProjectBeforeMutation` was the preferred shape going in and is
wrong here: its own docblock is explicit that a passive read must not hand out
ownership just because it ran first, and this endpoint is a GET. Writing a row
from it would let whichever workspace opened the project first claim authorship
on no user intent, and would race two clients in different workspaces.
* fix(web): stop pre-emptively blocking an AMR send that has a wallet
An Open Design Cloud run is billed to the CALLER's own wallet, so the only
defensible client-side veto is "there is no billing principal at all". The gate
instead required THIS PROJECT's workspace scope to resolve, and blocked the send
whenever it did not: an unbound project, a membership-directory read that
transiently failed (offline / 504 / timeout all collapse into one `ok: false`
upstream), a workspace that is billing_past_due or locked, or a team the caller
has since left. In every one of those the user is spending their own quota.
It is also not the enforcement point. Real enforcement is server-side — the
daemon's `WORKSPACE_CONTEXT_REQUIRED` 401 plus vela's own billing check. The
client gate could only convert a request the server would have answered into a
dead, unexplained button, which is strictly worse than an honest server error.
It arrived as 21f452ffe, whose entire message is "fail closed on unresolved
workspace authority" with no body and no stated product requirement.
`workspaceIdentityCanBillAmr` names the invariant on the identity read, and the
gate now admits either witness of a billing principal: that identity, or a
project scope that already resolves to an explicit personal/team principal.
Strictly a widening — every state it newly admits was previously blocked, and
nothing previously admitted becomes blocked.
Deliberately NOT treated as "no wallet":
loading — the identity read holds no answer yet. Reporting "signed out" on a
frame that has not heard back is the bug shape this replaces.
failure 'unavailable' — a transient outage taught us nothing about the user.
failure 'unsupported' — an old daemon with no workspace endpoint keeps its
legal pre-workspace behavior.
A genuinely signed-out caller — a settled, authoritative read that came back with
no workspace — stays blocked: there is no wallet, so the run cannot be billed and
cannot succeed.
No UI, no notice, no copy: #6178 wrote copy for this dead end instead of removing
it, and #6184 reverts that. This removes the dead end.
* fix(web): point every console entry at /dashboard, drop the wallet target (#6165)
* fix(web): point every console entry at /dashboard, drop the wallet target
Product ruling: there is no wallet page in the console's information
architecture any more. Balance, manual top-up and the auto-recharge policy were
all rehomed onto the console dashboard (vela #1055), so every entry that used to
open `/wallet` now dropped users on a surface the product no longer navigates
to.
Retargets all of them:
- `AMR_CONSOLE_URL` / `DEFAULT_AMR_RECHARGE_URL` and the per-profile console
map (prod / test / local) now resolve `/dashboard`, keeping the
`?source=open_design` attribution param.
- `teamConsoleUrl`'s `billing` section — the team account menu's 「额度 $0.00」
row — is a plain dashboard visit.
- `teamConsoleUrl`'s `plans` section, `amrPlansUrlForProfile` and
`amrPlansUrlForWorkspace` — the free-tier 「升级」 button and every other
Upgrade affordance — send `billing=plan` instead of the wallet page's
`view=plans`.
- `amrWalletUrlForWorkspace` is renamed `amrConsoleUrlForWorkspace` so the name
matches where it now points.
`billing=plan` is the console's one state-aware upgrade intent: its dashboard
resolves it against the workspace's real subscription state, so a personal owner
lands on the personal plan modal — the dialog the console's own 「升级订阅」 hero
button opens, which is what product asked the free-tier 「升级」 button to reach —
while a team owner still gets checkout or change-plan. The team `upgrade` section
keeps sending its more specific `billing=checkout` hint, but that is now only a
hint: powerformer/vela#1127 makes the console fall back to the dialog that
actually matches, so a stale `hasActivePlan` can no longer strand the user on a
bare Overview page (recvpSQKna0LwR).
Comments carrying earlier live-verification findings are updated wherever this
change invalidated them, rather than left contradicting the code.
No new user-facing copy, so no locale changes: the affected labels are all
action-named (「充值」/「升级套餐」), never destination-named.
* fix(daemon): retire the wallet target in the AMR recharge link too
The daemon builds its own recharge URL for AMR balance failures — it lands in
the failure message and in `actionUrl`, which the client renders as the chat
error card's clickable link — and it still pointed at the console's retired
wallet page. Same product ruling as the client-side retarget: balance and manual
top-up report on the console dashboard now (vela #1055), so this link has to go
there.
Every existing assertion in `vela-errors.test.ts` referenced the constant
symbolically, so all of them would have kept passing while the link sent users
to a surface the product no longer navigates to. Added one that pins the literal,
which is the red spec for this change.
Also retargets the two e2e assertions that pinned the old destination
(`e2e/tests/amr/insufficient-balance.test.ts` on the daemon's emitted URL,
`e2e/ui/visual-workspace.test.ts` on the avatar upgrade link's `view=plans`), and
the web-side fixtures that mimic the daemon's emitted string so they stop
documenting a URL it no longer produces.
* chore(release): bump feat/workspace-team to 0.16.2 (#6188)
The published beta feed is self-inconsistent: metadata.json carries
baseVersion 0.16.1 alongside betaVersion 0.16.2-beta.144, because
0.16.2-beta.144 was published with an explicit release_version override
while every manifest on this branch still said 0.16.1. Every subsequent
beta build now fails its own consistency check:
[release-beta] beta metadata.json baseVersion 0.16.1
does not match betaVersion 0.16.2-beta.144
`force` does not help — it only waives "base must exceed latest stable",
not the baseVersion/betaVersion match.
Raising the branch's base version to 0.16.2 makes the next beta
(0.16.2-beta.145) consistent with its own metadata, and removes the need
to pass force at all. Same 18 manifests the release bot touched in
23f9fcaac; test fixtures that mention 0.16.1 as data are left alone, as
that commit also left them.
Co-authored-by: lefarcen <ontf116@gmail.com>
* fix(collab): keep a project's "last changed" time out of sync's hands (#6189)
A member who opened the client hours later and pulled a shared project saw
「刚刚更新」 on a project nobody had touched, and the home 最近项目 strip kept
rendering the previous workspace's cards for a beat after switching workspaces.
Two independent defects, both reported by the product owner on a packaged
client.
1. `projects.updated_at` (and the `workspace_projects.updated_at` the project
list folds into it with `MAX`) is what the project card shows and what the
list sorts by, so it must answer "when did a person last change this
project's conversations, files, or name". Sync was answering it too:
`materializePulledTeamMirror` carried the origin's timestamp into the
project row but not into the binding it wrote in the same transaction, so
`MAX` surfaced the pull's own clock on EVERY pull. Four more sync writers
had the same shape. `SYNC_KEEPS_UPDATED_AT` names the invariant and the
sync paths now either carry the origin's time or keep the row's existing
answer.
2. A loaded project list describes exactly one workspace identity, so leaving
that workspace invalidates it. The re-list on switch runs in an effect —
after the commit — so the browser painted workspace A's cards under
workspace B's identity first. The list is now dropped during the render
that first observes the new scope, so no frame shows another workspace's
projects. No extra request: the refetch already existed and was already
keyed on the resolved workspace.
Co-authored-by: lefarcen <ontf116@gmail.com>
* fix(web): key every workspace read cache on who is asking (#6198)
`coalesced-get` and `shared-cancellable-get` are CACHES (1s share window), not
just in-flight dedupers, so a key that omits the caller's identity hands the
previous workspace's answer to the next one — and an in-flight entry is joined
with no window at all.
Three reads were keyed on a constant:
- `useTeamMembers` — `'workspace-members'`. `GET /api/workspace/members` answers
for whichever workspace the DAEMON is active in and reads nothing off the
request, so the roster is a per-workspace answer with no per-workspace URL.
A leaked roster renders the previous workspace's teammate name on project
cards (`RecentProjectsStrip.resolveCreator`). Now keyed on the workspace
identity, which also makes the hook re-read on a switch instead of waiting out
its 15-60s poll, and a late read for the identity the user left no longer
overwrites the newer roster.
- `useWorkspaceContext` — `'workspace-context'`. This is the read that
ESTABLISHES the identity, so it cannot be keyed on the identity it fetches and
has no workspace argument to borrow (the switch is a separate
`PUT /api/workspace/active`). It is keyed on the identity GENERATION instead:
one token per deliberate identity change, so a single broadcast heard by a
dozen mounted consumers still collapses to one request, while two switches
inside `forceCoalescedGet`'s 250ms burst window stop being mistaken for one.
- `useProjectWorkspaceScope` — `project-workspace-scope:<id>`. The scope carries
the `workspaceMemberId` that pays for the project's runs, so a shared key
serves one member the wallet of another.
The same read also fetched HEADERLESS, which made #6185's daemon fallback
unreachable: `GET /api/projects/:id/workspace-scope` resolves an unbound project
against the caller's current workspace, but only when the request carries the
caller's `x-od-workspace-*` identity. It now sends `workspaceProjectHeaders`,
the same set every other workspace-aware project read uses — and waits for the
caller's own identity to settle first, so a project open still costs one request
instead of a wrong headerless one followed by a corrected one.
* fix(collab): carry B's workspace name for personal workspaces too (#6199)
B names EVERY workspace, personal included: vela's CurrentWorkspaceContext
requires `workspaceName`, derives "«owner»'s workspace" for an unnamed
personal one (vela #964), and an owner may rename it outright.
The daemon read that field and then threw it away unless the workspace was a
team, because the only name on WorkspaceCollabContext was `teamName`. The
switcher's collapsed label therefore had nothing real to show for a personal
workspace and fell back to a hardcoded English "Personal workspace" until the
user opened the dropdown and the separate workspace-directory read landed.
Adds `workspaceName` to the context contract and carries it for both workspace
types, in the vela mapping and in the directory-item synthesis. `teamName`
stays team-only: it is the team switcher's field and doubles as an "is a team"
signal, so nothing may start reading it for a personal workspace.
The web side consumes the context the shell already holds at startup, so the
label is correct on first paint and NO new request is added — the diff adds
zero fetch call sites. The dev provider parses `workspaceName` too so the
dev/demo lane can drive a personal workspace's label the way B does.
Note this is not what the reported screenshot shows: there, B's real name for
that workspace IS the literal "Personal workspace", because an in-place team
upgrade flips the default workspace's stored type to 'team' and vela's
owner-derived naming then no longer applies. That half is upstream.
Co-authored-by: lefarcen <ontf116@gmail.com>
* fix(web): stop offering 升级 at the top plan tier (team_max) (#6205)
A 团队版 Max workspace is the top of the ladder — nothing is above it — but
both upgrade entry points still showed 「升级」, and clicking it could only
reopen the plan the user already holds.
The two surfaces got there two different ways, and neither shared a rule:
- The account menu's billing card gated on `billingUpgradeUrl &&
canManageBilling` — a destination check and a permission check, with no
TIER check at all. Every tier saw the button.
- The Settings AMR card asked a personal-ladder question
(`canUpgradeVelaPlan`) about `amrCardStatus.account.plan` — vela's
ACCOUNT-scoped login projection, which reports `free` for a user whose
entitlement is held by a team workspace. So a team_max owner measured as
"free" and got the button, while the PlanBadge beside it correctly read
Max off the workspace-resolved tier.
Both now ask one predicate, `canUpgradeFromPlanTier`, about the same
resolved tier each surface already LABELS, so the button can never
contradict the nameplate next to it.
Team and personal tiers are two ladders, so the predicate reads the
team-namespaced id whole instead of stripping the prefix and asking a
personal question. Per the product ruling — 「个人档位都是要显示可升级的,
最顶的就是团队 max」 — personal `max` keeps the affordance: that user can
still move onto a team plan, and vela #1146 deliberately routes their
click to the Team upgrade dialog. Only `team_max` loses it.
Destination logic (`workspaceUpgradeUrl` / `amrPlansUrl`) is untouched;
this only decides whether the affordance renders. No copy, no new i18n
keys.
* fix(web): keep Send alive after using a community template (#6206)
Clicking 「使用」 on a community template seeds the template's own brief into
the composer and binds the template as the run's driver. For a template whose
`od.inputs` are `required: true` with no `default` -- the real
`example-html-ppt-pitch-deck` ships three such fields -- the bind left
`active.inputsValid` false, and `submitDisabled` folded that into the send
button. The user saw a full prompt next to a permanently dead Send whose
tooltip still read "type something to run". Typing did not help: the gate was
never about the prompt.
Those fields have no input surface on that path. #3645 removed the Home
composer's inline plugin-inputs form, and the template's query carries no
`{{...}}` placeholders, so prompt -> inputs write-back cannot reach them
either. A gate on values the user cannot supply anywhere is a dead end, not a
guard.
Narrow it to where it is actionable: `requiredInputsAreUserFillable` keeps the
gate when the media composer renders the fields as controls, or when the
tracked query template actually names the missing field as a `{{placeholder}}`
that editing the prompt writes back. A tracked template alone is not enough --
re-applying a plugin without an explicit template derives one from the
manifest, placeholders or not, which is the shape a reload lands in.
The daemon stays the authority: `validateInputs` still throws
MissingInputError. Since the seeded path no longer pre-empts the send, its
rejection now reports the fields it rejected instead of a generic "Failed to
apply", reusing the existing pre-submit wording.
* fix(collab): give every created project a workspace home (#6201)
* fix(collab): give every created project a workspace home
A project created with no `x-od-workspace-*` headers got no
`workspace_projects` row and still returned 200, so nothing surfaced the
loss. `GET /api/projects/:id/workspace-scope` then answers `unbound`,
which strips the workspace off the run request (`ProjectView`'s
`projectRunWorkspaceContext` -> an Open Design Cloud run nothing can
bill) and blanks the balance/plan area while that project is open
(`AvatarMenu`). The same orphan is also denied its first run outright by
`enforceWorkspaceResourceMutation`.
Headerless creates are not anonymous callers asserting "no workspace" —
they are callers that could not say which workspace they meant:
`od project create` and the MCP `create_project` tool mint no headers at
all, the web duplicate / design-system-copy / plugin-share paths read a
ref that drops the context's `loading` flag, and Orbit + scheduled
routines have no request to read headers from. So a create that names no
workspace now binds to the one this daemon is signed in to, read through
`workspaceContext.lastKnown()` — zero-network, synchronous, no failure
mode. A daemon that has resolved no workspace still binds nothing, which
keeps `unbound` correct for the signed-out single-player user.
Six creation paths were writing no binding at all, not merely skipping it
when headerless: the plugin share-project task, Orbit run projects,
scheduled routine projects, the library capture-as-page exit, the
project-location scan importer, and the `ds-*` project backing a design
system's editing workspace. All six now bind.
Deliberately unchanged: the collab pull's register-on-pull and
shared-project placeholder. Those are a teammate's shared project, whose
correct binding is written by `materializePulledTeamMirror` with
`visibility: 'team'`; claiming them into the reader's ambient workspace
as personal would misattribute someone else's resource.
No creation path gains a new failure mode. `createdProjectWorkspaceHome`
never refuses — a partial or denied header identity degrades to the
ambient workspace rather than turning a working 200 into a 4xx.
On the web side `createPluginShareProject` was the one create in
`state/projects.ts` with no `workspaceContext` parameter at all, so it
sent no workspace identity ever. It now forwards the context when known,
best-effort — deliberately not `resolvedWorkspaceContextForWrite`, which
throws while the identity read is in flight and would add a new block to
a path that works today.
* fix(collab): verify an asserted workspace before persisting it
`createdProjectWorkspaceHome` resolved a created project's workspace with
`resolveCreatedProjectWorkspace`, which only PARSES headers. It trusted
the caller-provided member status and permissions and never checked that
the workspace/member pair exists in the membership directory or agrees
with the daemon's last-known state. `x-od-workspace-*` headers are an
unauthenticated hint, so any local caller — the `od` CLI, a plain curl, a
compromised page — could assert an arbitrary pair and get a project bound
to a workspace it has no membership in, with a fabricated
`createdByWorkspaceMemberId`.
"Do not fail the create" and "trust unverified input" are not the same
constraint. The helper now VERIFIES, then DEGRADES — never rejects, never
trusts:
1. asserted identity that verifies -> bind to it
2. asserted identity that does NOT -> bind to the daemon's ambient
verify, including an unreadable workspace instead, or leave
authority ("cannot confirm") the project unbound
3. nothing asserted -> ambient, as before
Creation still never answers 4xx, so the six previously-never-bound paths
keep binding and a signed-out daemon still creates usable local projects.
An unverifiable claim is simply never written as fact.
Verification is not re-implemented. It is `authorizeCreatedProjectWorkspace`
— the same directory lookup `POST /api/projects` gates on, which also
returns the DIRECTORY's authoritative context rather than the caller's
claimed one — plus `withLastKnownMembership`, the same cross-check the
mutation gates apply (now exported rather than copied). Only the failure
behavior differs: they refuse, this degrades. An absent directory fetcher
remains `authorizeCreatedProjectWorkspace`'s own documented local/dev
compatibility path, not a second weaker definition of verified.
Route modules now take one `CreatedProjectWorkspaceResolver` dep instead
of re-threading the three authorities, so there is a single notion of
"verified" per daemon.
* test(daemon): drive brand routes through the created-project workspace resolver
The brand harness constructed `registerBrandRoutes` without the new
`resolveCreatedProjectHome` seam, so the binding became a no-op and
"binds a freshly extracted brand project into the caller's ACTIVE team
workspace" went red. Inject the same production resolver `server.ts`
builds, in its no-directory (local/dev) configuration, so the test keeps
asserting the product behavior through the real seam instead of a
header-trusting shortcut.
---------
Co-authored-by: lefarcen <ontf116@gmail.com>
* fix(web): move the update-ready rocket above the rail account row (#6208)
* fix(web): move the update-ready rocket onto the rail account row
#5517 removed the entry topbar and parked the updater host in the rail
footer, which put the green rocket on its own line UNDERNEATH the account
row — bottom-left, detached from the identity it belongs to. Product
reported it from a real client: the rocket belongs at the right edge of
the name + plan wordmark line.
Move the mount instead of nudging it with CSS: the rail now owns the
updater host through an `updaterSlot` prop and renders it inside the
account row (`.entry-nav-rail__account-row`), as a SIBLING of the account
trigger so it never steals the account-menu click target. The rail footer
stays as the fallback home for the signed-out shell, which has no account
row to ride, so the rocket's appearance conditions are unchanged.
Same change also un-clips the 「更新已就绪」 prompt. The host is pinned to
the bottom of the rail, so a `top: 0` panel grew downward off the bottom
of the window and the copy was cut off mid-sentence. Anchor its BOTTOM
edge to the host instead, the same way `.entry-help-popover` already does
for the same reason.
Side effect worth having: the rail no longer grows a 52px footer strip
the moment an update lands, so the account row stops jumping.
* fix(web): put the rocket ABOVE the account row, right-aligned
Placement correction. 「放到名字啥的上面的右侧」 was read as "on the name
row, right side"; product clarified 「应该是在名字那个横条的上方,靠右对齐」
— the rocket gets its own right-aligned strip ABOVE the identity row, not a
slot inside it.
The strip (`.entry-nav-rail__account-updater`) is mounted unconditionally so
it stays the account trigger's immediately-preceding sibling, and carries
`:empty { display: none }` so an idle rail reserves no height for it. That
rule is load-bearing: without it every user without a pending update would
get a blank 34px row plus the container's 6px gap pushing the account area
up — a worse regression than the placement it fixes. Measured in a real
browser at 1280x900: the account trigger sits at y 850-884 in BOTH states,
so nothing shifts.
Everything else from the first commit stands: the rail owning the host via
`updaterSlot` instead of an opaque `footerExtra`, the sibling-not-child
relationship to the trigger (now moot in the best way — a strip above the
row cannot be button-in-button), the `bottom: 0` panel anchoring that
un-clips 「更新已就绪」, and the footer fallback for the signed-out shell.
* fix(web): stop the home model list offering picks the chip cannot honor (#6209)
On the Home composer, clicking a model in the chip's compact list did
nothing — the chip kept showing the previous model.
The compact list rendered `currentAgent.models` raw, so a model above the
caller's plan (`enabled: false`, straight from `vela model list --json`)
appeared as an ordinary selectable row. Clicking it wrote the pick to
config, `normalizeAgentModelChoice` reported the plan fallback instead, and
the re-normalization effect in `InlineModelSwitcher` persisted that
fallback — so the chip snapped back with no explanation, and the user's
previous pick was overwritten by the default in the process.
The sibling surface (`AvatarMenu`, the project composer's model list) had
already hand-rolled `agent.id === 'amr' && model.enabled === false` for
this. Copying the rule per surface is what let the newest list drop it, so
this introduces one definition instead: `agentModelIsSelectable`, defined
to be at least as strict as `normalizeAgentModelChoice` (pinned by a test)
so a gated row can never be a pick the config would revert. Both surfaces
now ask it, and every model write inside `InlineModelSwitcher` — the
compact list and the execution-settings picker — goes through a single
`applyAgentModel` sink that asks the same question, leaving no second path
for a future list to bypass.
Locked rows now read as locked: dimmed, `aria-disabled`, a lock glyph, the
existing `settings.amrModelUpgradeHint` reason, sorted below the available
models, and a click routes to the plans page instead of silently doing
nothing.
* fix(collab): verify the asserted workspace before claiming an orphan project (#6213)
* fix(collab): verify the asserted workspace before claiming an orphan project
`reconcileUnboundProjectBeforeMutation` claims a project this daemon has
never bound to ANY workspace into the current mutating request's
workspace, so a true orphan is not denied its first mutation by
`enforceWorkspaceResourceMutation`'s `!row -> false` rule (recvqbhor3pai2).
It resolved that workspace from `workspaceProjectContextFromRequest(req)`
— header PARSING with no authority check — and stamped
`createdByWorkspaceMemberId: ctx.workspaceMemberId` from the same headers.
`x-od-workspace-*` is an unauthenticated hint any local caller can forge,
so a plain curl could claim someone else's orphaned project into a
workspace it has no membership in AND install itself as the author.
Authorship is the dangerous half: `workspaceResourceAccess` derives
`selfCreated` from it, and that bit is what grants a non-privileged member
mutation rights over the row.
Both the workspace and the authorship now come from
`resolveCreatedProjectHome` — the same verify-then-degrade resolver #6201
introduced for created projects:
- asserted identity VERIFIES against the membership directory -> claim
it, attributed to the DIRECTORY's member id, not the header's;
- it does NOT verify (foreign, inactive, permissions disagree,
last-known says removed, or the authority is unreadable so it cannot
be confirmed) -> claim the daemon's own ambient workspace instead,
attributed to that verified session's member id;
- neither available -> write nothing, and let the pre-existing gate
answer.
The `null`/`'missing'` early return is unchanged and load-bearing, which
is why this does not reuse `createdProjectWorkspaceHome`'s own third
branch: the mutation gate's HEADERLESS branch answers 401 as soon as any
row exists for the resource, so claiming on a request that asserts
nothing would convert a working headerless mutation into a 401.
A verified caller is unaffected — `workspace-projects.test.ts`'s
recvqbhor3pai2 regression case still passes unchanged. A caller that
cannot prove membership over a never-claimed project is now refused by
that pre-existing gate instead of being let through on a binding it had
just invented for itself; #6201's e2e case is updated to assert the
persisted binding rather than the mutation's status, since both outcomes
satisfy the property it pins.
* fix(collab): persist only an asserted-and-confirmed workspace on an orphan
Review follow-up (mrcfps). `resolveCreatedProjectHome` is not a pure
verifier: by contract a foreign, inactive, removed, or unconfirmable
assertion degrades to `workspaceContext.lastKnown()`. Reconciliation then
persisted that ambient context onto a PRE-EXISTING orphan before the
mutation gate had rejected the asserted caller — and the
`getWorkspaceProjectByProjectId` guard one line above makes that write
sticky, so the rightful verified workspace could never reconcile it
afterwards. A forged request, or a legitimate one during an authority
outage after the active workspace changed, would permanently mis-file the
project.
Creation and reconciliation are not the same risk, which is the
distinction the original patch missed. Putting a BRAND-NEW project in the
ambient workspace takes nothing from anyone — that is #6201 and it stays.
Putting an EXISTING orphan there may take it from the workspace it
actually belongs to.
So a degraded ambient authority is persisted only when it names the very
pair that was asserted. Verified assertions resolve to the asserted pair
by construction (the directory lookup is keyed on it), so they are
unaffected; outage continuity still works for the ordinary legitimate
caller, whose client took its headers from this same daemon and therefore
agrees with it; a mismatch writes nothing at all.
Not in tension with 「所有 project 都应该能找到 workspace」: the
display-side fallback (#6185) already renders an unbound project inside
the caller's current workspace, so nothing reads as "unknown". The
binding row is a separate, durable fact and stays conservative.
Adds the fixture-matrix case the review asked for — a populated but
DIFFERENT ambient workspace, asserting the orphan stays unbound. The mock
authority's `/current` is now flippable so the fixture can create a true
orphan while ambient is absent and then populate ambient deterministically
through `GET /api/workspace/context`, rather than depending on a poll tick.
---------
Co-authored-by: lefarcen <ontf116@gmail.com>
* fix(web): use neutral Windows app background (#6218)
* fix(collab): let a headerless caller mutate its own daemon's project (#6216)
* fix(collab): let a headerless caller mutate its own daemon's project
`enforceWorkspaceResourceMutation`'s headerless branch answered
401 WORKSPACE_CONTEXT_REQUIRED as soon as ANY `workspace_projects` row
existed for the resource. That was survivable while headerless creates
left projects unbound. #6201 made every created project get a workspace
home, so the two rules combined into a project its own creator cannot
touch:
od project create -> 200, and now writes a binding
od project duplicate -> 401
The `od` CLI never sends `x-od-workspace-*` — only `od workspace …`
builds those headers — so this is not an edge case for it. It is every
CLI mutation of every project the CLI created, which breaks the
embeddability contract `AGENTS.md` makes the CLI responsible for.
Reproduced end to end through the real binary against a daemon-only
process (`od daemon start --headless`, no web, no browser).
INVARIANT, now named in `headerlessMutationAllowed`: every mutation
resolves to a workspace identity. A request that ASSERTS one is judged on
that assertion; a request that asserts NOTHING is the local daemon's own
signed-in user and is judged as that identity. Being unable to name a
workspace is not the same as having no standing in one — the same
"nothing asserted -> ambient" fallback the create path already applies,
so the gate and the creation paths finally agree about what a headerless
caller is.
Verified the ambient identity is available without any UI: a
daemon-only process resolves it from the vela session on disk, and
`GET /api/workspace/context` answers fully in that state. The
`ambientWorkspaceId` in `collab/workspace-hub-subscriptions.ts` is
unrelated — it is SSE subscription bookkeeping fed from the
disk-persisted selection store, not an authorization input.
Deliberately NOT weakened: `authorizeCreatedProjectWorkspace` still
refuses to let ambient stand in for an explicitly CLAIMED pair, and
`resolveProjectWorkspaceScope` still resolves a persisted binding without
consulting ambient. Both govern cases where something WAS asserted;
headerless is the third case.
What stays refused, because the original branch protected something real
(recvqbeDjAsejl / recvqbklNGDqYY, spec 04 §10): a resource bound to a
workspace the daemon is not currently in; a resource the daemon's own
identity may not mutate anyway (the same
`workspaceResourceMutationAllowed` computation runs); and everything when
the daemon has no signed-in identity to resolve. With no ambient wired
the branch is byte-identical to before.
The ambient snapshot type moves down into
`collab/workspace-resource-mutation.ts` so the gate and the creation
paths cannot drift into two notions of "the daemon's own workspace";
`created-project-workspace.ts` re-exports it and now reuses the shared
context builder instead of its own copy.
* test(e2e): pin that signed out, visible equals unbound equals mutable
「未登录也可以用自己 cli 修改未登录态下的那些 project, 这个一定要保证,
不然就是 P0 事故」
A signed-out client can only SEE unbound projects: the no-scope catalog
(`routes/project/index.ts:2432-2443`) reads no `x-od-workspace-*` at all
and joins through `listUnboundProjects`, so a project any workspace has
claimed never leaks to a caller with no identity to check it against
(spec 04 §10). This pins the other half of that set — everything visible
while signed out stays WRITABLE.
The case runs against a daemon with NO vela session at all, not merely a
request without headers, creates its draft in that same signed-out state
(the real user sequence), asserts the draft is listed by the no-scope
catalog, then renames it, duplicates it, and duplicates it again through
the real `od` binary.
It holds only because `headerlessMutationAllowed` short-circuits on "no
row anywhere" BEFORE it asks for an ambient identity, so it is
mutation-tested against that ordering rather than written red-first:
moving the short-circuit after the ambient check turns it red with
`expected 401 to be 200`.
* fix(collab): make the delete side effect use the gate's own context
Review follow-up (mrcfps). Allowing a headerless caller to mutate a
project the daemon's own identity owns made a latent inconsistency
reachable: `DELETE /api/projects/:id` re-derived its context with
`workspaceProjectContextFromRequest(req)`, which is null for a headerless
caller, so `requestTeamVisibility(..., 'personal')` was skipped while
`dbDeleteProject` still ran. The owner's local row and directory
disappeared and the hub kept serving the resource, so teammates went on
seeing a project that no longer existed — a cross-client data-consistency
bug, worse than the 401 this branch set out to fix.
Threading the context, not narrowing the capability:
`effectiveWorkspaceProjectContext` names the identity a mutation actually
acts under — asserted when the request carries one, ambient when it does
not — and its docblock states the invariant that any SIDE EFFECT must read
from it rather than re-deriving from headers, or the effect disagrees with
the gate that permitted it. The delete now uses it.
The no-context case becomes a refusal rather than a silent skip. It is
unreachable while the gate is intact (a team-bound row is admitted only
for an asserted identity or a matching ambient one), but falling through
is what produced a local-only delete of a still-shared project, so a
future gate change must not be able to reintroduce it quietly.
Audited every other capability this branch newly admits for a headerless
caller. Delete was the only one: `/move` and both share/unshare routes
each guard with their own `if (!ctx) return sendMissingWorkspaceContext(res)`
so headerless never reaches their hub work, and the upload route's
`workspaceProjectContextFromRequest(req) !== null` only shapes a 404 for a
missing project. Rename/duplicate/writeFiles/comment have no hub side
effect.
Adds the requested route regression to
`tests/routes/project-delete-unshares-team-share.test.ts`: real route,
real collab runtime, mutable fake hub, no request headers,
`workspaceContext.lastKnown()` populated — asserting the unpublish and
catalog removal happened and the project is gone locally only after. Plus
the other half: with no ambient identity either, the delete is refused and
nothing is destroyed. Mutation-tested — restoring the header re-derivation
turns the first red with "expected 0 to be greater than 0".
---------
Co-authored-by: lefarcen <ontf116@gmail.com>
* fix(web): draw the team wordmark for every tier of a team plan (#6219)
The account row of a team workspace showed the PERSONAL Plus wordmark. Product
ruling: 「团队版的订阅,这里应该都显示 team 的标识」…「产品期望团队从 free 到
max,徽标都显示 team 的那个,个人的还是维持现状不要动」.
The badge names the plan FAMILY, not the tier inside it, and it got that wrong
from both ends. `planBadgeTierForLabel` asked `plus` / `pro` / `max` BEFORE
`team`, and B namespaces team ids by EMBEDDING the personal tier word — so
`team_plus` matched `plus` and the `team` branch was reachable only from a bare
`team` id, the one team tier that happened to work. The free team tier missed
from the other side: B reports an unsubscribed workspace with a null `planId`
and an empty `membershipTier`, an id no different from a personal free account,
so no id can name it.
`planBadgeTierForWorkspace` states the whole rule once, and both surfaces read
it — the rail's account row and the Settings CLI card, which the owner requires
to move together (「设置中的这里应该一样的逻辑」). Team-namespaced ids go through
the shared `isTeamPlanTier`, so the badge cannot drift from the other team
gates; `workspaceType === 'team'` covers the free tier that no id can express.
The plan LABEL is untouched, deliberately: it answers a subscription question,
every user-created workspace in B is team-typed, and reading one as the other
is #146. 免费 beside the `team` wordmark is now the intended pairing — the
family is team, the subscription is not paid.
No interaction, layout, sizing, artwork, or copy changes; no new i18n keys.
* fix(collab): warm the workspace-scoped digest faces on switch and reconnect (#6222)
Every workspace-scoped cache in the daemon keys on the active workspace, so the
instant the user switches, `catalog` and `members` are both a miss and the FIRST
consumer in the new workspace refills them inline on its own request path
(`createPersistentSyncCache` fetches synchronously on a miss). The cost is the
same either way; the only question is who waits for it. A switch is a user
action followed by idle time, so paying it there is free — paying it inside the
first project load or agent run is not.
`PUT /api/workspace/active` now announces a CONFIRMED switch through a new
fire-and-forget `onWorkspaceSwitched` seam, and the daemon warms the two faces
from it. The announcement deliberately does not fire for a rejected or
rolled-back switch: warming there would refill the caches against a workspace
this daemon just refused to move to.
Warming goes through the cache functions, never their underlying fetchers.
`createSwrCache`'s read returns `entry.inflight ?? refresh(key)`, so a warm that
races the very consumer it is protecting joins that consumer's request instead
of issuing a second one. No request is added that would not otherwise be made.
Second half, same root cause: on hub reconnect the gap-closing catch-up read the
team-project and member lists THROUGH those same caches, so an entry that
settled just before (or during) the disconnect still counted as fresh, the diff
concluded "nothing changed", and the one cycle meant to close the gap emitted
nothing — the client stayed stale until an unrelated read happened to miss.
Reconnect now drops those two entries first, and `pollOnce` joins the in-flight
refresh that starts, so the pair still costs one fetch per face.
Co-authored-by: lefarcen <ontf116@gmail.com>
* fix(daemon): split AMR workspace proof failure from proof refusal (#6221)
An AMR run died in preflight with "its persisted workspace <id> is
unavailable for the signed-in member". Two causally unrelated situations
shared that one error and one outcome: a directory that answered and
genuinely listed no active membership, and a directory that could not be
read at all. The `.catch` swallowed a read failure into
`{ ok: false, items: [] }`, so a transient vela blip made a user's own
project unrunnable — and nothing downstream could tell the two apart.
Refusal now fails closed under `ProjectWorkspaceScopeRefusedError`
(`PROJECT_WORKSPACE_SCOPE_REFUSED`); an unreadable directory is retried
with bounded backoff and, if it still cannot be reached, the run proceeds
with no workspace, i.e. on the caller's own account wallet. That is a
product decision recorded in the docblock: a hard failure is worse than a
mis-attributed personal charge. The reverse direction, charging a personal
run to a team wallet, stays impossible — no fallback selects another
workspace, and a proven non-membership still refuses.
The decision was also invisible: a full diagnostics export carried no
directory-fetch line, no run-id in the daemon log, and only a generic
AGENT_EXECUTION_FAILED plus prose. Every branch now reports a closed-union
outcome to the daemon log, the run record (via `ApiError.details`, leaving
the wire error code unchanged) and telemetry.
* fix(comments): preserve rejected mutations
* fix(comments): preserve partial send results
* fix(web): keep workspace-scoped data fresh across account and workspace changes (#6225)
* fix(web): keep workspace-scoped data fresh across account and workspace changes
Three defects in one family — workspace-scoped data in the web client going
stale or being read under the wrong identity.
1. The workspace switcher's module-level directory cache had no invalidation.
`/api/workspace/directory` answers "which workspaces can the SIGNED-IN
ACCOUNT see", but nothing cleared the cache on an account change and
`resetWorkspaceDirectoryCache` had no production caller — so signing in as a
different account kept the previous account's workspace names on screen, and
kept them confidently, since a non-empty cache also suppresses the loading
state. The cache is now served only to an identity that can claim it, by
membership id, which keeps the flash-free reopen it exists for. The
`coalescedGet` key is identity-scoped for the same reason.
2. App.tsx read `/api/skills` without the caller's workspace headers and never
refetched on a workspace switch. The daemon is fail-closed on a missing
`x-od-workspace-id`, so a headerless read is not the unfiltered list — it is
the list with every workspace-claimed skill removed, including the ones
claimed by the workspace the user is in. Skills now get the identity headers
and the same workspace-keyed refresh design systems already had. Launch still
spends exactly one `/api/skills` request: the read moved from the boot pass to
the workspace-keyed effect rather than being added alongside it.
3. `PUT /api/workspace/active` already returns the post-switch context, from the
same producer `GET /api/workspace/context` serves and only after the daemon
agrees the switch happened. The client parsed that body, discarded it, and
made every consumer re-read the same data. The shell is now seeded from the
response; the refresh broadcast still fires, because
`useProjectWorkspaceScope` listens to it and passive tabs cannot be seeded.
Switch path: 2 requests -> 1.
* fix(web): discard workspace-scoped reads that land after the identity moved
Review follow-up on #6225 (PerishCode, two threads — both real).
Keying a read by identity stops the wrong identity being SERVED a cached
answer. It does nothing about the other direction: a read issued for identity A
resolves later, and by then the caller may be identity B. Committing that late
answer restores A's data under B — the same staleness this PR removes, arriving
through the back door. A workspace switch is precisely when one read is in
flight and another starts, so reverse-order completion is reachable, not exotic.
Both sites now use one mechanism, `beginWorkspaceScopedRead`: capture the
identity the read is issued FOR, request with that captured context, and compare
against a ref before any commit. Its docblock states the invariant and the two
rules that make it hold (request with the captured context so request and guard
cannot disagree; compare against a ref, never a closed-over prop, which would
always match and guard nothing). It is the cross-component form of the
`requestEpochRef` ordering guard `useWorkspaceContext` already applies to its
own read.
- `refreshSkills` and `handleSkillsChanged` (App.tsx): a slow read for the
workspace the user left no longer overwrites the current catalog. The
registry-ready gate is intentionally skipped on a discarded response — it is
not an answer about the current identity, and the newer read marks it.
- `loadWorkspaceDirectory` (EntryNavRail.tsx): an in-flight account-A read that
lands after the rail moved to account B no longer repopulates either the
module cache or the visible list. This was the sharper of the two, because it
wrote into module scope and so persisted past the render that caused it.
Request counts are unchanged (startup 4, switch 1): the guard discards
responses, it does not issue requests.
* fix(web): make computing a workspace identity total, and key the skills trigger on it
Fixes the `Web workspace tests` failure 886f11b24 introduced. CI reported all
555 files and 5583 tests PASSING and still exited 1:
Unhandled Rejection
TypeError: Cannot read properties of undefined (reading 'canShareProjects')
❯ workspaceIdentityCacheKey src/collab/useWorkspaceContext.ts:112:32
❯ beginWorkspaceScopedRead src/collab/useWorkspaceContext.ts:159:20
❯ src/App.tsx:1770 ❯ src/App.tsx:1799 ❯ commitHookEffectListMount
originating in tests/components/App.amr-plan-tier.test.tsx
`workspaceIdentityCacheKey` dereferenced `context.permissions` unguarded. That
survived while it was only called during render (a throw there fails loudly, in
the right place) and inside `fetchSkills`'s own try/catch. 886f11b24 put it
behind `beginWorkspaceScopedRead` in an async function launched with
`void refreshSkills()`, so a partial context turned a synchronous programming
error into an unhandled rejection — invisible to every assertion, fatal to the
process.
`permissions` is required on the contract, but API JSON reaches these objects
through an unchecked cast, so a partial context is reachable at runtime and not
only in fixtures. Computing an identity must therefore be TOTAL: a partial
context is its own cache partition, never an exception.
Second, independent defect, found while fixing the first: the skills trigger was
keyed on `workspaceId` while the commit guard compares the full identity digest.
A guard that discards without guaranteeing a successor is worse than the
staleness it replaced — two accounts in one shared team workspace differ only by
membership, so account A's pending read would be discarded for B while the
unchanged workspace id suppressed B's replacement read, leaving the functional
registry loading forever. The trigger now uses the same digest the guard
compares, so every transition that invalidates a response also starts its
successor.
Both are covered by tests that go red without the fix:
- tests/collab/workspace-identity-key-is-total.test.ts (new) — identity is total
for null, partial and complete contexts, including a late commit-time guard.
- tests/components/App.skills-workspace-scope.test.tsx — a member-id-only change
must start AND complete a successor read.
---------
Co-authored-by: lefarcen <ontf116@gmail.com>
* feat(landing): publish current pricing snapshot
* fix: durably deliver enterprise leads
* test(web): update ProjectView comment-status suites to the workspace-scoped signature (#6233)
`feat/workspace-team`'s `Web workspace tests` lane is red at the branch tip: 3
files / 4 tests, all comment-status assertions. Not a flake and not attributable
to any open PR — I had to prove twice while landing #6225 that a red lane was
inherited rather than mine, which is the cost a red base imposes on every
downstream PR.
Cause: `1c15574c2 fix(comments): preserve rejected mutations` gave
`patchPreviewCommentStatus` a fifth argument — the acting workspace context — so
the daemon can authorize the mutation (`routes/project/comments.ts`, same
commit). It updated `FileViewer.test.tsx` and added `registry.test.ts` coverage
but left three `ProjectView.*` suites on the four-argument signature.
The tests were stale, not the code. Checked before editing, because "update the
tests" is the wrong move if the assertions were right:
- Every asserted call still happens, unchanged. The failures are pure arity
mismatches: `api-empty-response` expects `(…, 'failed')` and receives
`(…, 'failed', null)`; likewise `needs_review` in run-cleanup and `applying`
in run-isolation. No status transition changed.
- `1c15574c2` shipped the new contract WITH tests —
`describe('preview comment scoped mutations')` asserting `x-od-workspace-id`
reaches the PATCH. The fifth argument is deliberate and covered.
- The parameter is optional and additive; headers are spread only when a
context is present.
Also restores two assertions that had gone silently vacuous. A four-argument
matcher cannot match a five-argument call, so `run-isolation`'s two
`not.toHaveBeenCalledWith` assertions could never match once the signature grew
and passed unconditionally — coverage actively lost, not merely failing. Both
pass with the correct arity, which independently confirms the new code does not
write `needs_review` after a canceled+done run and does not reset a queued
send's own comment to `open`.
The fifth argument is matched as an explicit `null` rather than
`expect.anything()`, which rejects null/undefined and would have re-broken the
positive assertions and re-vacuated the negative ones.
Tests only — no source file is touched.
Co-authored-by: lefarcen <ontf116@gmail.com>
* fix(collab): make the local pin the only workspace-selection truth (#6230)
* test(collab): pin the one-account-two-clients workspace scope behaviour
Executable evidence for the account-level Active Workspace investigation.
Models vela exactly as its source behaves at origin/main 2155a969f:
- active_workspace_selections is keyed by app_user_id alone
(db/schema/public.hcl:792), so an account has exactly ONE selection.
- GET /api/v1/workspaces/current discards a ?workspaceId= hint
(services/api/src/workspaces/routes.ts:166-176, and vela asserts it by
name in services/api/test/workspaces-personal.test.ts:480).
- GET /api/v1/workspaces is scoped by app_user_id, not by the selection.
What it establishes: OD's local pin DOES hold against a competing client
(resolvePinnedWorkspace), so the switch does not 404. The damage is that
the losing client's context degrades to a directory synthesis - planId
null and a 0/0 seat summary, which derives isSeatFull: true - and its
context read gains a second, fallible round-trip the winning client
never makes.
No behaviour change.
* fix(collab): make the local pin the only workspace-selection truth
Picking a workspace is a local action, but `PUT /api/workspace/active`
gated it on mutating ACCOUNT-scoped state in vela: it PUT the account-level
active workspace first and failed the user's click with 502
`workspace_switch_rejected` when that write did not take, then rolled back
both the local pin and the backend switch if the follow-up context read
disagreed.
That backend row is keyed by app user alone, so it can only ever name ONE
workspace for an account. One account routinely runs several clients in
different workspaces, so every switch yanked the other clients' server-side
scope, and whichever client did not write the row last was answered for the
wrong workspace — forcing OD to synthesize its context from the membership
directory, which carries no billing plane. That client saw planId null and a
0/0 seat summary, and a 0/0 summary derives isSeatFull: true, so a workspace
with free seats read as FULL and blocked inviting. It also turned that
client's context read into two round-trips, the second of which could blip
and leave it with no context at all.
vela #1179 (1ba3eebcb, deployed to test 09:57Z) taught
`GET /api/v1/workspaces/current` to honour `x-vela-workspace-id`, the
per-request workspace scope vela already accepted on its resource plane,
billing scope routes and the Link gateway. So:
- `fetchCurrent` sends that header and drops the `?workspaceId=` query
param, which vela ignores by design and asserts it ignores — it only ever
made this call look scoped.
- `PUT /api/workspace/active` no longer calls vela. The membership
directory it already reads is the authorization; once that confirms an
active membership the switch cannot fail, so the 502 gate and the
rollback are gone along with `selectWorkspace` and `putCurrentWorkspace`.
- An unconfirmed context read is no longer treated as a refusal: the route
answers from the directory entry it already validated instead of
reverting the user's choice, mirroring the invariant
`resolvePinnedWorkspace` already follows in the same module.
Verified against the deployed test backend, GETs only: an explicit
workspace returns that workspace (not the account row), a workspace the
caller is not a member of is refused 404 `workspace_member_required`, and
both an absent and a blank header keep the previous account-level
behaviour. The URL query hint is still ignored.
vela's `active_workspace_selections` table and its write path are left
alone; deprecating it is a separate decision and #1179 kept omitting the
header backward compatible.
Two comments that asserted the now-false invariant are corrected.
* fix(collab): never answer a switch with a workspace the pin no longer holds
Review catch (mrcfps): `workspaceContext.current()` is not a passive read.
The vela provider's `resolvePinnedWorkspace` does its OWN fresh directory
lookup and, when that confirms the pinned workspace is gone, clears the pin
and re-pins a recovery workspace so a removed member is not locked out of
everything. The route's directory read can meanwhile have been served from
its 5s cache and still list that workspace.
The previous branch state read `context.workspaceId !== workspaceId` and
treated the provider's recovery as a merely-unconfirmed read, synthesized the
stale cached entry, and returned 200 claiming the requested workspace while
`activeWorkspace.get()` had already become the fallback. That shipped this
PR's own thesis — the local pin is the only selection truth — as a violable
invariant, and the user-visible symptom is the switch appearing to succeed
and then snapping back on the next context poll.
The pin is now re-read AFTER `current()` resolves. If the provider moved it,
the route reports `404 workspace_no_longer_available` for the workspace the
user asked for, never claims it, and does not warm its caches. The recovery
pin is deliberately left in place: reverting it would restore the very
"signed out of every workspace" state it exists to prevent.
The directory entry that authorizes the switch is also now required to be a
LIVE membership (`memberStatus === 'active'`, lifecycle not deleted) — the
same predicate the provider uses. Matching on id alone let a listed-but-
removed membership authorize a switch AND get synthesized into the response.
Tests wire the real provider against the production cached directory fetcher
over one shared pin store, exactly as server.ts does, so the cached and fresh
reads genuinely disagree. Disabling the new pin re-read turns "never claims a
workspace the pin no longer holds" red and nothing else.
---------
Co-authored-by: lefarcen <ontf116@gmail.com>
* fix landing pricing controls
* fix(daemon): resolve the AMR scope-telemetry app version in its own scope (#6240)
Every AMR run in 0.16.2-beta.148 died at spawn with
`spawn failed: appVersionForCapture is not defined`.
`appVersionForCapture` is a local of
`createFinalizedMessageTelemetryReporter`. #6221's
`amr_workspace_scope_resolved` capture called it from `startChatRun`,
~7,500 lines and one function away, so the name resolved to nothing.
The callback runs while building the agent's launch env, inside the
spawn `try`, so the ReferenceError surfaced as the generic
AGENT_EXECUTION_FAILED spawn failure for 100% of AMR runs.
`design.getAppVersion` is the accessor that scope actually has, and it
is the same one the unreachable helper falls back to, so the reported
value is unchanged.
A scope guard covers the class rather than the instance: it runs the
checker over `server.ts` with `@ts-nocheck` stripped and fails on any
identifier that reaches runtime unbound. It flagged one sibling, fixed
here too — the critique orchestrator's `skill` label read
`effectiveSkillId`, a local of `composeDaemonSystemPrompt`. A `typeof`
guard on an undeclared name does not throw, so instead of crashing that
label has silently been `undefined` for every run since #1485.
`@ts-nocheck` on `server.ts` is the enabling condition for both: an
undefined identifier in this file reaches users without any tool
objecting. Removing it is a separate project — 29 daemon files carry it.
* fix(web): isolate workspace identity snapshots
* test(e2e): move the server.ts identifier-scope guard into the merge gate (#6244)
`apps/daemon/tests/server-identifier-scope.test.ts` landed with the
0.16.2-beta.148 AMR spawn fix (#6240) but protected nothing: `ci.yml`'s
"Daemon workspace tests" step runs exactly one file
(`tests/project-watchers.test.ts`), so no other daemon suite executes in
any workflow. The guard sat outside every merge-gate lane.
Widening the daemon lane is not available as a local fix — that exact
command string is pinned by `workflowRunsOnlyAllowedDaemonTest` in
`scripts/check-certain-exempt-consumption.ts`, which conditions the
`trae-cli.test.ts` docs-consumption exception on the lane running nothing
else. Touching it is a coupled scope-policy change.
So host the guard where a lane already runs and the wiring is itself
guarded:
- Any change under `apps/daemon/src/` matches `certain-daemon-core`
(`scripts/scopes.ts`), whose effects include
`ui_p0_validation_required`; `run_e2e_vitest` is
`isFull || web_tests_required || ui_p0_validation_required`. A
`server.ts`-only change therefore arms `E2E Vitest` even at the merge
queue's `certain` threshold, and an unresolved file list escalates
fail-closed to full.
- The `daemon core boundary` guard (`scripts/lib/guard/scope.ts`) asserts
`ci.yml` still contains both `run_e2e_vitest == 'true'` and
`pnpm --filter @open-design/e2e test`, and runs in the always-on policy
floor — so the premise cannot rot silently.
The check only ever reads `server.ts` as text (no daemon boot, no
fixtures), and reading another app's tree as a repository resource is
what root `AGENTS.md` sends to `e2e/tests/` — the same reason
`e2e/tests/critique-coverage.test.ts` lives there. Behavior is
unchanged, including both self-checks that keep the guard from going
vacuous.
Verified red on the pre-#6240 `server.ts`: exit 1 naming
`appVersionForCapture (line 9261)` and `effectiveSkillId (lines
9657/9658)`; green (exit 0) on the fixed file.
* fix(collab): align unbound project mutation identity (#6231)
* fix(web): name the caller when a run starts before its project scope resolves
Sending the 「水面焦散」 example prompt from Home failed instantly on a team
workspace:
daemon 401: {"error":{"code":"WORKSPACE_CONTEXT_REQUIRED","message":"workspace context is required"}}
with `run_id: n/a`, because the refusal lands before the run is created. That
string is produced in exactly one place in the repo — the `!createResp.ok`
branch of the `POST /api/runs` create fetch in `providers/daemon.ts` — so the
refused call is run creation, gated by `enforceWorkspaceResourceMutation
('project', …)`.
The cause is client-side. Home creates the project WITH the caller's workspace
headers, so #6201 binds it to the team workspace; the follow-on auto-send then
went out with NO headers, because it took its identity from the PROJECT's
workspace scope — an async read still in flight when the auto-send fires (its
gate is `messagesInitialized` + `activeConversationId`, never the scope). Every
other project write in `ProjectView` (`patchProject`, `uploadProjectFiles`, the
comment mutations) already asserts the caller's own context; run creation was
the one write that did not.
INVARIANT, now named in `runWorkspaceIdentity`: a send names its caller while
the project's binding is still UNREAD, and never overrides an answer the daemon
actually gave. Deliberately not a blanket `?? caller` — every answered or failed
state keeps asserting nothing, because for them a headerless request is judged
on daemon-owned state while an asserted one is judged on client-controlled
headers:
- `unbound` — `headerlessMutationAllowed` short-circuits on "no row anywhere"
BEFORE asking for an identity, so asserting would turn a working 200 into a
403 and break 「未登录也可以用自己 cli 修改未登录态下的那些 project」.
- `unavailable` — the daemon's `resolveProjectWorkspaceScope` returns this
both for an unreadable directory AND for a directory that answered with no
active membership, i.e. a proven "you were removed".
- a FAILED scope read — this identity also picks the wallet the AMR pre-run
balance gate prices against, and guessing the caller's team wallet with the
binding unknown blocks a send the daemon would have answered (the dead
button 21f452ffe undid).
Second consequence of the same null, fixed by the same one-liner: with no
resolved scope, `checkAmrBalanceGate` was called with `undefined` and priced the
run against the ACCOUNT wallet instead of the team's. The red spec observes
that directly rather than arguing it.
`ProjectView.run-isolation.test.tsx` mocks `useProjectWorkspaceScope` with a
hand-written factory that enumerates every export, so the new export is added
there too — without it, adding one export breaks all 64 tests in that file with
"not a function" at render.
* fix(web): narrow the run-identity fallback to a project's first unread binding
Review catch (nettee): keying the fallback on `loading` served two opposite
situations, because `useProjectWorkspaceScope`'s trailing transition guard
re-enters `{ loading: true, scope: null }` whenever the resolved caller identity
stops matching — and every workspace switch does that:
- first read, nothing known -> the caller is the only identity available
- revalidating a KNOWN binding -> the caller's current workspace is a guess
that contradicts an answer we already have
So a send issued during a workspace switch asserted the workspace the user had
just moved TO for a project still bound to the one they moved FROM. Both
consumers break, and differently: `POST /api/runs` can 403 because
`enforceWorkspaceResourceMutation` still finds the project row under the old
workspace, and `checkAmrBalanceGate` preflights the new workspace's wallet —
worse than the `undefined` this fix was written to remove, because a wrong
wallet looks like a real answer.
`ProjectWorkspaceScopeState` now carries `initialLoadPending`, reported by the
hook rather than inferred from `loading`: true from mount until the first settled
answer (a scope OR a failure) for the current `projectId`, false for that
project's whole lifetime afterwards, and reset when `projectId` changes because a
different project genuinely has not been read. `runWorkspaceIdentity` keys the
fallback on that bit alone, and its docblock names which of the two loading
reasons it serves so the distinction cannot erode.
The field is required, not optional, so every construction site has to state
which case it is. Two pre-existing assertions in
`useProjectWorkspaceScope.test.tsx` now document the distinction they always
exercised: a project A->B rerender reports pending (B is unread), while a
same-project identity change reports not-pending — the exact window this fixes.
* fix(collab): align unbound project mutation identity
Allow an identified caller to mutate a project that no workspace has claimed, matching the existing headerless and unbound-resource behavior without persisting a new binding. Name the caller on Home auto-send while keeping AMR preflight scope derivation unchanged.
* fix(web): refresh the settled run billing scope
Track the project billing context in both callbacks that read it so an auto-send after scope settlement cannot preflight through a stale unscoped closure. Add a regression whose run identity stays referentially stable while the billing scope changes.
* fix(web): stop asserting disproved run scope
* fix(web): guard workspace-scoped catalog reads (#6249)
* fix(web): guard workspace-scoped catalog reads
* fix(web): guard remaining plugin catalog races
* fix(web): align design catalog guard scope
* fix(landing): show introductory team totals
* fix(daemon): label critique with project skill (#6251)
* fix CI scopes across file renames (#6252)
* fix(ci): preserve scopes across renames
* fix merge queue rename ceiling detection
* test raw merge compare responses
* fix(landing): make contact sales KV canonical
* fix(web): preserve Cloud sign-in during outages (#6274)
* fix(collab): allow shared project viewer comments
* fix(web): keep readonly comment creation available
* feat(web): personal/team project labels + collapse chat for shared non-owners (#6294)
* feat(web): rename sidebar project lists and collapse chat for shared non-owners
Workspace home left rail now labels personal vs team project lists
("Personal projects" / "Team projects", 个人项目 / 团队项目) across all
locales. Opening a confirmed shared team project that the viewer did not
create defaults the chat pane to collapsed so the design is primary;
owners and personal projects keep chat open, and expanding chat stays
sticky for the visit.
* fix(web): use effective ownership for shared non-owner chat default
Default-collapsed chat was latching on raw isOwner, so a catalog-confirmed
owner whose collab status was still missing ownerMemberId looked like a
non-owner and permanently collapsed chat. Expose isEffectiveOwner and
isSharedNonOwner from useProjectCollab (catalog + session-created + status)
and gate the sticky collapse path on the confirmed non-owner signal only.
* fix(web): stop narrow chat error cards stacking titles vertically (#6295)
Move the long Open Design Cloud switch CTA into UserActionCard footer
actions (same shell as run-recovery), add a card-width container query so
head actions reflow when ChatPane is narrow, and soften title wrapping so
CJK copy no longer renders one character per line.
* fix(workspace): make resource authority explicit (#6287)
* fix: make workspace authority explicit across resources
* fix(web): keep new team projects writable during scope load
* fix: close workspace authority review gaps
* fix(web): retry rejected project auto-send
* fix: propagate workspace scope to project consumers
* fix: cache project read workspace authority
* fix(web): stabilize project scope revalidation
* fix: cache project collaboration status authority
* fix: cache collaboration status owner reads
* fix: revalidate media wait tool authority
* fix: rehome member comments on local threads
* fix(web): stabilize project resource authorization
* fix(web): prioritize settled project authority
* fix(web): consume queued home auto-send handoffs
* fix: revalidate dirty comment pulls
* fix: isolate mutation authority reads by session
* fix: cache project presence read hot path
* fix(web): stop unauthorized project background writes
* fix(web): require settled scope for project background work
* fix(web): scope remaining project reads by workspace
* fix(web): scope remaining project resource consumers
* fix(web): partition project caches by full authority
* fix(daemon): pin run workspace billing scope
* fix(daemon): lease workspace read authority
* fix(daemon): keep presence reads on the hot path
* fix(web): refresh deck thumbnails across workspace scope
* fix(daemon): revoke stale unshared team mirrors
* perf(daemon): cache stale mirror read guards
* fix(web): gate deep-link reads on project workspace
* fix(web): keep deep-linked project tab scoped
* fix(web): preserve project routes during onboarding
* fix(web): keep project previews warm across file-list toggles
* perf(daemon): refresh presence without cold reads
* fix(web): keep retained preview iframes connected
* fix(web): reuse exact HTML thumbnail sources
* fix(web): settle read-only empty project chat
* fix(daemon): scope CLI run transports by workspace
* test(web): align preview cache refresh semantics
* fix(web): require positive shared viewer ownership proof
* fix preview auto-fit measurement feedback
* preserve preview fit during annotation freezes
* preserve preview measurement semantics on refresh
* stabilize preview overflow measurement test
* test design systems move exclusively to team
* fix design systems move exclusively to team
* test shared project title scope convergence
* test(daemon): cover project command workspace scope
* fix(daemon): scope project command transports
* fix shared title test project state type
* fix(daemon): preserve shared project catalog title
* fix(daemon): keep foreign mirrors creator-unattributed
* fix(web): recover first shared project conversations
* fix(web): fence conversation recovery generations
* test(web): verify conversation recovery cleanup fence
* UI polish batch: cloud sign-in, onboarding, composer menu (#6281)
* UI polish batch: cloud sign-in, onboarding, composer menu
Sign-in surfaces (rail callout, balance-gate dialog, onboarding) had
drifted from the visual system and lost some redundant chrome; this
batch tightens them up alongside a couple of composer/design-files
reorganizations that were sitting in the same working tree:
- Rail's signed-out callout: reorder copy above the login pill, give
the pill an icon + accent-green label, restore the Escape hatch
the pill had before it lost its close affordance.
- AmrBalanceDialog (insufficient-balance / signed-out gate): add a
full-bleed art banner above the copy, drop the now-redundant icon
badge, tighten spacing/shadows to match.
- Onboarding cloud landing: switch from a single centered column to
a two-column layout (form pane + full-bleed art panel), move the
language switcher into the footer, swap the local-CLI/BYOK links
for icon Buttons, and add a dismiss control to the activation-retry
hint.
- Composer "+" menu: fold 插件/设计百宝箱 back in as hover-expand
submenu rows instead of standalone quick pills above the input.
- Design files panel: move new-document/upload actions out of the
always-visible toolbar into the empty-state actions only.
- Message center: drop the redundant header close button (backdrop
click and Escape still dismiss it) and restyle the list from
bordered cards to a divider-separated list.
- New `key` / `robot` / `translate` / `arrow-right` icons; language
menu switches from the `languages` glyph to `translate`.
- Tab launcher popover reverts to the glass material; language
dropdown gets a max-height + scroll so long lists don't blow out
the popover.
Rebased the composer-quick-pill removal onto this branch's own
accessibility work on those pills (opener-based focus return via
`ComposerStandalonePanel`) rather than reverting it — the "next step"
card still drives the same standalone-panel API, just without a pill
to hand focus back to. Also updated/removed the tests that pinned the
now-removed surfaces (quick-pill popup contract, message-center close
button, onboarding top bar, design-files toolbar upload trigger) so
none of this lands with a newly-red suite.
* perf(web): optimize onboarding cloud artwork
* fix(web): invalidate project file reads after mutations
* fix(web): disable readonly project starter mutations
* fix(web): preserve focus for standalone composer panels
* test(e2e): require explicit workspace scope
* fix(web): invalidate file reads on project events
* test(e2e): seed explicit settings workspace
* test(e2e): align manual edit with current inspector
* fix(web): refresh deployments when share opens
* test(e2e): follow composer design system entry
* fix(web): keep local project creation workspace-independent
* fix(web): preserve scoped auth recovery state
* test(e2e): follow current entry settings flows
* fix(web): preserve AMR retry across settings
* test(e2e): model explicit personal workspace recovery
* test(e2e): stabilize AMR logout settings recovery
* fix(projects): return workspace scope on create
---------
Co-authored-by: lefarcen <935902669@qq.com>
* fix(workspace): preserve scoped projects after main sync (#6320)
* fix(daemon): surface AMR OpenCode stall context (#6040)
* fix(daemon): surface AMR OpenCode stall context
* fix(daemon): accept runtime-omitted OpenCode diagnostics
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix(ci): make static gate runner-independent (#6039)
---------
Co-authored-by: PerishFire <39043006+PerishCode@users.noreply.github.com>
* test(e2e): parallelize restoration group (#6169)
* test(e2e): parallelize restoration group
* test(e2e): identify conversations from persisted prompts
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* test(e2e): wait for conversation persistence
---------
Co-authored-by: Looper <looper@noreply.github.com>
* feat(landing-page): sharpen the Codex agent page for "codex ui" intent (#6200)
The /agents/codex-design/ page ranks #1 for "codex design" but only ~6th
with a 1.3% CTR for "codex ui", a query that grew from 30 to ~90 daily
impressions this month. A live SERP check shows the intent is mostly
"build UI with Codex" (our intent), with a minority looking for the Codex
app's own interface.
- Retitle to "Codex Design: Build UI with OpenAI Codex" (title + H1).
- Lead the frontend section with the query phrase and an answer-shaped
opening paragraph, so the section is extractable for AI answers.
- Add a disambiguation paragraph separating the Codex app's own UI from
the UI you build with Codex, and route the former back to the main line.
- Add two FAQ entries covering both readings; they also feed the FAQPage
schema, which already earns a text snippet on this query.
Applied across all 11 shipped locales (en, zh authored; the other nine
translated with the page's terminology preserved).
Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
* Add Clone Audit plugin (#5687)
* Add Clone Audit plugin
* Update open-design.json
* fix: make clone audit skill reusable
* fix: sanitize clone audit provenance path
* fix(e2e): bound tools-dev runtime lifecycle (#6204)
* test(e2e): refresh full-pool UI fixtures (#6211)
* fix(web): allow skipping required chat questions (#6177)
* fix(web): allow skipping required chat questions
* fix(web): auto-skip unanswered chat questions
---------
Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
* docs(tools-pack): add build cache contract (#6207)
* docs(tools-pack): add build cache contract
Document the tools-pack build-graph cache as a contract: the exact-match
acceptance model, determinant rules, materialization-time parameters, the
signing boundary, and fail-closed confidence grading.
The rules come from an audit of all 11 cache nodes. Two are written down
because nothing currently enforces them: a node key must carry the key of
every upstream node it consumes, and a node key must not restate a list that
already exists as a constant.
Known low-confidence points are declared explicitly so fail-closed grading
stays checkable rather than becoming a verbal convention.
* docs(tools-pack): clarify cache materialization exceptions
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* docs(tools-pack): document NSIS base version scope
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
---------
Co-authored-by: Looper <looper@noreply.github.com>
* docs(landing-page): add 0.16.0 release post, localize and re-cover 0.15.1 (#6236)
* docs(landing-page): localize 0.15.1 post, fix its cover, add 0.16.0 release post
The 0.15.1 blog post shipped English-only and with an off-brand cover: a
Fauvist oil painting overlaid with large display type, which reads nothing
like the warm editorial illustrations every other release post uses. It also
pointed `socialImage` at a 309KB JPEG while a WebP sat unused beside it.
Regenerate the cover in the established house style (cream paper ground,
sage/terracotta watercolour washes, pencil sketch resolving into a calm
product surface), drop the stray JPEG, and add the full ten-locale i18n block
so /zh/, /ja/, /ko/, /de/, /fr/, /ru/, /es/, /pt-br/, /it/ and /tr/ read in
their own language instead of falling back to English.
Also add the missing 0.16.0 "Reliable Delivery" post with a cover plus two
inline figures in the same style, localized across the same ten locales. The
0.16.1 patch is a single preview fix, too thin to carry its own page, so it
lands as a closing section of the 0.16.0 post and the download CTA points
there.
* docs(landing-page): fix doubled apostrophes and locale copy in release posts
Three defects surfaced reviewing the localized bodies.
The French, Italian and Turkish bodies rendered every apostrophe twice —
"aujourd''hui", "all''interno", "0.16.1''i". The `''` escape belongs to
single-quoted YAML scalars, but `bodyHtml` is a block scalar, where content is
literal. 173 occurrences across the two posts.
Korean used Japanese corner brackets and Brazilian Portuguese and Turkish used
French guillemets. All three take curly double quotes.
The Chinese copy read as translation rather than prose. "标签" opens the release
line as a UI label, not a version tag; "抢画框", "编辑策展位" and "安静修复" are
literal renderings that say nothing in Chinese; "画廊" is not the term the
product itself uses for a gallery, which keeps the English word. The tag fix
also lands on 0.10.0 through 0.15.0, which share the phrasing — leaving the
series half-corrected would read worse than either state alone.
* docs(landing-page): drop the leading tag word from release post openings
Every release post opened with the word for a git tag before the version
identifier — "Tag open-design-v0.16.0", "Etiqueta …", "タグ …", and in Chinese
"标签", which reads as a UI label rather than a version. The word carries no
information the identifier does not already carry, and in several languages it
lands as a category noun the sentence never uses again.
Drop it across all nine release posts and every locale, in both the summary and
the opening line, leaving the version identifier to open the dateline. Spanish
and Brazilian Portuguese lose the feminine agreement that referred back to the
removed noun; Turkish loses the trailing "etiketi" apposition for the same
reason.
* docs(landing-page): correct the 0.16.1 interval in the 0.16.0 post
0.16.0 was published 2026-07-22 14:42 UTC and 0.16.1 on 2026-07-23 16:11 UTC —
25.5 hours apart. "Two days later" overstated it, and the same wording had been
carried into all ten localized bodies, so every published route repeated it.
---------
Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
* feat(integrations): add the local Open Design Cloud flow for Codex (#6055)
* feat(daemon): expose Vela login through CLI and MCP
* feat(mcp): add interactive local brief card
* feat(packaged): bootstrap local MCP headlessly
* feat(daemon): secure local BYOK credential profiles
* feat(cli): manage secure BYOK profiles
* feat(web): store BYOK keys in secure profiles
* fix(runs): deduplicate cloud generation requests
* feat(daemon): add end-to-end plugin observability
* fix(pack): include native PTY runtime in desktop packages
Lazy-load node-pty in the daemon so missing native binaries only
disable Terminal and interactive Keychain operations instead of
crashing startup.
Ship and validate target prebuilds in macOS and Windows packages.
Repair the macOS spawn helper before signing, and invalidate stale
Windows packaging caches.
Cover the packaged PTY path in macOS and Windows smoke tests. Require
Vela CLI for every beta desktop build target.
* fix(mcp): localize and stabilize plugin briefs
Keep collect and confirm on one MCP Apps card, report intrinsic size changes,
and remove the fragile widget-state fallback.
Localize brief forms, summaries, status, and public errors from the current
request or Host locale while preserving stable protocol ids and selections.
Keep internal runtime identifiers out of user-facing MCP guidance.
* fix(packaged): register live Studio URLs with daemon
Register the packaged web sidecar loopback origin over namespace-scoped daemon
IPC after dynamic binding.
Invalidate install-info by the live web port and remove the second MCP URL cache
so run and project responses use the current Studio address.
Validate loopback origins and cover registration, rebind, and cache refresh.
* fix(mcp): stabilize brief confirmation lifecycle
Keep confirmed briefs locked after Host publication failures and retry only the cached follow-up message instead of confirming twice.
Replace blank initialization with a compact loading state, accept immediate and delayed tool results, and bump the MCP Apps resource to v3 so Codex does not reuse the stale card.
Add an executable JSDOM Host bridge harness covering double-submit, publication retry, delayed results, and repeated draft notifications. Validated with the full daemon suite, workspace typecheck, and guard.
* fix(mcp): prevent brief app resize feedback loops
Replace the self-observed intrinsic-height loop with deduplicated body measurements so Codex MCP app iframes do not repeatedly resize themselves and fail sandbox startup.
Bump the brief resource URI to v4 to avoid cached v3 cards and cover the regression with the focused brief-app spec. Validated with daemon tests/build, workspace guard/typecheck, a verified beta DMG, and a direct packaged MCP resource smoke.
* fix(mcp): use the final Open Design plugin identity
Make open-design the only accepted external plugin ID across MCP tools, run attribution, artifact version origins, and telemetry. Rename the Brief MCP App resource to v5 and reject the unpublished old ID instead of aliasing it.
Validated with 171 daemon tests, 5 web tests, daemon/web typechecks, and guard.
* chore(pack): bump Vela CLI to 0.0.27
* chore(nix): refresh pnpm dependency hashes
* fix(mcp): honor host and plugin request contracts
Respect independently advertised MCP Apps capabilities and prefer the Codex-native follow-up bridge so confirmed briefs render as readable user turns instead of context-only messages. Bump the brief resource to v6 to invalidate stale cards.\n\nMark Vela login and account probes as open-world interactions, and require stable request IDs for attributed generation so lost responses cannot create a second logical run.
* fix(cli): support keyless BYOK profiles
Add an explicit --no-api-key save mode for local and self-hosted providers while keeping credential-bearing profiles stdin-only. Reject missing or conflicting credential modes and cover both paths in the CLI tests.
* fix(pack): replace headless owner when desktop opens
Advertise headless packaged runtimes as having no visible window, then shut them down before a normal desktop launch continues instead of treating SHOW as a successful focus.
Validated with the focused packaged headless and launcher lifecycle tests (20 passed).
* fix(pack): clean up failed headless startup
Acquire the headless identity, sidecars, and IPC server through one failure-atomic lifecycle so later bootstrap errors close every previously acquired owner in reverse order.
Cover MCP-install and web-identity publication failures; validated with 22 focused tests and the packaged typecheck.
* fix(byok): remove raw provider request contract
Expose only daemon-owned BYOK profile references from shared run types and the web transport, while retaining the daemon's fail-closed rejection for legacy or untyped raw credential payloads.
Validated with 76 web provider tests plus web and contracts typechecks.
* fix(daemon): preserve interrupted run idempotency
Hydrate queued or running durable requests as DAEMON_RESTARTED failures before idempotency reuse, preserving the original request and workflow mappings instead of creating a second cloud operation.
Share the restart transition with boot reconciliation and expose synthetic terminal events to reattaching clients. Validated with 48 focused tests and the daemon typecheck.
* fix(byok): roll back failed credential metadata writes
Snapshot secure-store values before mutation and compensate create, update, and delete operations when profile metadata cannot be persisted, preventing orphaned or lost credentials.
Validated with nine focused credential-service tests and the daemon typecheck.
* fix(web): reconcile selected BYOK profile metadata
Treat the daemon's selected secure profile as authoritative for protocol, endpoint, model, and API version so CLI profile edits cannot leave the browser showing stale execution settings.
Validated with 66 config tests and the web typecheck.
* fix(byok): migrate legacy credentials before cleanup
Import each supported legacy browser credential into a stable daemon-owned secure profile before removing plaintext copies. Keep the original browser record on secure-store or unsupported-provider failures and surface a Settings recovery path.
Cover successful migration, duplicate projections, and injected secure-backend failure while preserving existing Bedrock downgrade behavior in memory.
* fix(byok): secure Windows profiles with DPAPI
Dispatch native Windows BYOK profiles to a CurrentUser DPAPI backend rooted under the resolved daemon data directory. Secrets enter PowerShell only through stdin, encrypted blobs stay separate from non-secret profile metadata, and API responses remain secret-free.
Add platform dispatch coverage, a Windows-only real DPAPI round-trip test, and the packaged Windows smoke path for create, resolve, response-redaction, and delete behavior.
* fix(web): preserve config writes during hydration
* fix(byok): persist secure credentials across entry flows
* test(byok): cover secure onboarding persistence
---------
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
* test: strengthen E2E coverage and fixtures (#6150)
* test: strengthen e2e coverage and fixtures
* test: allow artifact route when starting conversation
* test: keep e2e count assertions stable
Use the shared stable-count helper for no-request and duplicate-event windows so delayed async events cannot escape the assertions.
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix: add e2e assertion import extension
Use the explicit runtime extension required by the e2e NodeNext typecheck.\n\nGenerated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix: preserve design system provenance and settle e2e assertions
Keep disabled design systems available to project recovery and provenance while filtering only runtime injection. Make hover and cancellation checks observe their full settled windows.
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* test(e2e): preserve unique restoration run IDs
Pass the restoration fixture's run ID prefix through routeSuccessfulRuns so each mocked request receives a distinct run identity.
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* test(e2e): stabilize agent and async count fixtures
Route restoration agent requests through the shared JSON and SSE fixture, capture autosave baselines after a quiet period, and observe onboarding cancellation for a bounded stable window.\n\nGenerated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix: honor disabled design systems in daemon runs
Filter disabled persisted project selections at prompt and analytics boundaries, and keep restoration run fixtures unique.\n\nGenerated-By: looper 0.11.2 (runner=fixer, agent=codex)
* test(e2e): shorten cancellation duplicate check
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(daemon): continue stalled post-tool sessions (#6237)
* fix(daemon): continue stalled post-tool sessions
* fix(daemon): separate post-tool continuation budget
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(daemon): preserve retry telemetry budget
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(daemon): align retry failure telemetry
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(tools-pack): carry workspace build key into tarballs (#6235)
* fix(amr): recover late login failures and trace auth stages (#5986)
* fix(amr): recover late login failures and trace auth stages
* fix(amr): preserve scoped cancel attempt IDs
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(amr): defer unsupported Vela auth stages
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(amr): narrow fallback telemetry to shipped Vela
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(amr): preserve live login after stale cancel
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(amr): preserve cancel during login startup
Retain cancellation intent when a provisional cancel races the delayed canonical login response, then cancel the canonical attempt before polling can begin.
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(amr): rejoin newer login after startup cancel
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(amr): preserve cancel across web login starts
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(amr): retain cancel when status refresh fails
Generated-By: looper 0.11.1 (runner=fixer, agent=codex)
* fix(amr): cancel onboarding status preflight
Treat cancellation before an auth attempt exists as a local preflight cancel so a delayed status response cannot start login.\n\nGenerated-By: looper 0.11.1 (runner=fixer, agent=codex)
* ci: constrain Actions cache writes (#6270)
* fix(tools-pack): carry workspace build key into tarballs
* ci: constrain Actions cache writes
* fix(daemon): auto-mint assistantMessageId when clients omit pin (#6107)
* fix(daemon): auto-mint assistantMessageId when API clients omit it
Web always supplies assistantMessageId. Headless/MCP/eval clients that
already send conversationId but omit the pin left lastMessageId null and
skipped multi-turn native session resume (missing_cursor). Mint the id
server-side whenever a conversation is bound and the client did not, and
seed the user message the same way the old MCP-only path did.
* fix(daemon): validate conversation ownership before omit-pin seed
Reorder project-ownership checks before minting assistantMessageId and
seeding the user prompt so cross-project omit-pin requests cannot corrupt
foreign chats. Make ChatRunCreateRequest.assistantMessageId optional and
expose conversationId/assistantMessageId on McpRunCreateRequest with
contract tests for the omit-pin shape.
Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)
* fix(daemon): reject missing conversation before omit-pin mint
Return CONVERSATION_NOT_FOUND when a client supplies a stale or mistyped
conversationId so omit-pin does not mint an unpersisted assistantMessageId
and plugin snapshot resolve does not FK-crash with a 500.
Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)
* fix(daemon): seed latest turn and fallback-bound user prompts
Prefer currentPrompt over full message transcripts when seeding omit-pin
user turns, and still seed after conversationId fallback when the client
already supplied assistantMessageId.
Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)
* fix(daemon): preserve attachments on omit-pin seeded user turns
Copy request attachments and commentAttachments into the server-seeded
user message so omit-pin/headless clients keep chips and annotation
context after conversation reload.
Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)
* fix(daemon): treat empty currentPrompt as authoritative omit-pin seed
Attachments-only ChatRequest turns send currentPrompt as '' while message
is the flattened transcript. Prefer any string currentPrompt, including
empty, so seeded user rows keep empty content and do not duplicate history.
Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)
* fix(daemon): seed turn metadata and bump project activity on omit-pin
Persist sessionMode, runContext, and appliedPluginSnapshot on omit-pin
seeded user turns so reload/retry keep applied context. Call updateProject
after seed so listProjects reorders headless/API activity like PUT /messages.
Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)
* fix(daemon): require projectId for bound conversation ownership
Reject POST /api/runs when conversationId is present but projectId is
missing or non-string, so omit-pin cannot seed messages without owning
project context. Cover both early and post-fallback ownership checks.
Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)
* fix(daemon): preserve slideIndex and BMP kind on omit-pin seeds
Keep deck annotation slideIndex and classify .bmp as image when seeding
user messages for clients that omit assistantMessageId.
Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)
* fix(daemon): seed empty message when omit-pin has attachments
When currentPrompt is unset and message is empty, still seed the user
turn if attachment metadata is present so chips/annotations survive reload.
Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)
* fix(daemon): seed omit-pin chat content from original request
Plugin resolution may rewrite meta.message with a rendered scenario
brief for the run; persist requestBody currentPrompt/message for the
user turn so the internal brief is not shown as chat content.
Generated-By: looper 0.11.1 (runner=fixer, agent=grok-build)
* fix(web): persist browser preview viewport (#4899)
* fix(daemon): honor Kiro ACP turn completion (#6268)
* fix(daemon): isolate plugin-started Local Codex runs (#6273)
Disable Codex plugins only for externally attributed Open Design Plugin runs so Local Codex cannot recursively enter collect_brief or Cloud login. Preserve normal Local Codex plugin loading and the existing operator-wide override.
Validated with the focused runtime args suite, workspace typecheck, guard, and an isolated source smoke that produced a valid artifact without Cloud login.
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
* fix(landing): accept signed Vela team plan leads (#6283)
* fix: durably deliver enterprise leads
* fix(landing): make contact sales KV canonical
* feat(prompts): introduce on-demand discovery in SP v2.0 (#6223)
* feat(prompts): introduce on-demand discovery in SP v2.0
* fix(prompts): prevent nested deck label overlap
* fix(plugins): stabilize od-default task routing
* fix: address prompt and deck audit regressions
* fix(prompts): restore plan mode precedence
* fix(daemon): accept all form answer headers
---------
Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
* fix(web): surface BYOK migration validation errors (#6280)
* fix(web): surface BYOK migration validation errors
Preserve structured daemon errors during legacy credential migration and reserve offline guidance for fetch failures.
Keep legacy browser credentials until migration succeeds, and cover HTTP validation, malformed responses, and network failures.
Validated with focused web tests, web typecheck, workspace guard, and workspace typecheck.
* fix(web): stabilize BYOK persistence error telemetry
Map secure-profile HTTP and network failures to semantic error codes before emitting settings_byok_test_result, while preserving existing fallback behavior for unrelated connection-test errors.
Validated with the complete web test suite, web typecheck, workspace typecheck, guard, and git diff --check.
---------
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
* fix(ui): improve dark-mode contrast for off-state toggles (#5825)
* fix(ui): improve dark-mode contrast for off-state toggles
The default off-state track uses --border-strong (#c9d0da light / #46433c dark).
In dark mode #46433c is only ~5% lighter than the --bg-subtle (#252321) panel
behind it, making the toggle nearly invisible when off and users unsure of the
control state.
Bump the off-state track to #6b6862 with a subtle inset border, and add a
thin outline to the white thumb so it stays visible against the (now lighter)
track. On-state already uses --text (#e8e4dc) and is left unchanged.
Applies to both .toggle-row (used by speaker-notes toggle in the deck tab)
and .compact-toggle (used by SurfaceOptions toggles). Hover lift gives an
additional interactive affordance.
Fixes#5298
* fix(ui): fix two dark-mode toggle blocking issues from review
Two correctness issues called out by mrcfps:
1. System dark theme never gets the contrast fix — the default "system"
theme mode removes [data-theme] and falls back to
@media prefers-color-scheme. Added a parallel block scoped to
html:not([data-theme]) inside @media (prefers-color-scheme: dark).
2. Hover override wins over on-state — .compact-toggle:hover and
.toggle-row:hover have higher specificity than .on
variants, so hovering an ON toggle dropped its track from bright
var(--text) to #75726c. Changed to :not(.on):hover so the hover
lift only applies when off, leaving the on-state always bright.
Both applied to both selector paths (explicit data-theme and system dark).
* fix(web): keep Azure deployment names editable (#6034)
* chore(plugin-previews): refresh baked preview manifest (#6293)
Co-authored-by: open-design-bot <bot@open-design.ai>
* fix(byok): withdraw Windows DPAPI backend (#6308)
* fix(byok): withdraw Windows DPAPI backend
* fix(byok): clean up retired Windows secret blobs
Generated-By: looper 0.11.2 (runner=fixer, agent=codex)
---------
Co-authored-by: Looper <looper@noreply.github.com>
* fix(web): dismiss stale todo after continuation starts (#6307)
* fix(web): preserve shared project update timestamps
* fix(web): reconcile shared timestamps on home
* fix(web): preserve workspace scope in preview assets
* fix(collab): avoid unscoped shared cover requests
* fix(web): avoid double-wrapping preview assets
* fix(web): preserve first prompt across project refresh
* test(web): target the active shared project card
* test(web): keep empty BYOK profile type-safe
* test(e2e): require explicit workspace scope
* fix(web): finish scoped preview hydration
* test(e2e): align manual edit with current inspector
* fix(web): reconcile files after event stream readiness
* fix(web): keep local project creation workspace-independent
* fix(web): preserve scoped auth recovery state
* test(e2e): assert explicit Personal workspace scope
* test(e2e): preserve visual AMR account fixture
* test(web): await scoped cover source
* UI polish batch: cloud sign-in, onboarding, composer menu (#6281)
* UI polish batch: cloud sign-in, onboarding, composer menu
Sign-in surfaces (rail callout, balance-gate dialog, onboarding) had
drifted from the visual system and lost some redundant chrome; this
batch tightens them up alongside a couple of composer/design-files
reorganizations that were sitting in the same working tree:
- Rail's signed-out callout: reorder copy above the login pill, give
the pill an icon + accent-green label, restore the Escape hatch
the pill had before it lost its close affordance.
- AmrBalanceDialog (insufficient-balance / signed-out gate): add a
full-bleed art banner above the copy, drop the now-redundant icon
badge, tighten spacing/shadows to match.
- Onboarding cloud landing: switch from a single centered column to
a two-column layout (form pane + full-bleed art panel), move the
language switcher into the footer, swap the local-CLI/BYOK links
for icon Buttons, and add a dismiss control to the activation-retry
hint.
- Composer "+" menu: fold 插件/设计百宝箱 back in as hover-expand
submenu rows instead of standalone quick pills above the input.
- Design files panel: move new-document/upload actions out of the
always-visible toolbar into the empty-state actions only.
- Message center: drop the redundant header close button (backdrop
click and Escape still dismiss it) and restyle the list from
bordered cards to a divider-separated list.
- New `key` / `robot` / `translate` / `arrow-right` icons; language
menu switches from the `languages` glyph to `translate`.
- Tab launcher popover reverts to the glass material; language
dropdown gets a max-height + scroll so long lists don't blow out
the popover.
Rebased the composer-quick-pill removal onto this branch's own
accessibility work on those pills (opener-based focus return via
`ComposerStandalonePanel`) rather than reverting it — the "next step"
card still drives the same standalone-panel API, just without a pill
to hand focus back to. Also updated/removed the tests that pinned the
now-removed surfaces (quick-pill popup contract, message-center close
button, onboarding top bar, design-files toolbar upload trigger) so
none of this lands with a newly-red suite.
* perf(web): optimize onboarding cloud artwork
* fix(web): invalidate project file reads after mutations
* fix(web): disable readonly project starter mutations
* fix(web): preserve focus for standalone composer panels
* test(e2e): require explicit workspace scope
* fix(web): invalidate file reads on project events
* test(e2e): seed explicit settings workspace
* test(e2e): align manual edit with current inspector
* fix(web): refresh deployments when share opens
* test(e2e): follow composer design system entry
* fix(web): keep local project creation workspace-independent
* fix(web): preserve scoped auth recovery state
* test(e2e): follow current entry settings flows
* fix(web): preserve AMR retry across settings
* test(e2e): model explicit personal workspace recovery
* test(e2e): stabilize AMR logout settings recovery
* fix(projects): return workspace scope on create
---------
Co-authored-by: lefarcen <935902669@qq.com>
* test(e2e): lock design system team partition
* fix(workspace): stabilize scoped project lifecycle
* test(e2e): stabilize retained workspace transitions
* fix(workspace): retain exact scope through transient outages
* test(workspace): lock outage identity boundaries
---------
Co-authored-by: Ray Xi <2667192167@qq.com>
Co-authored-by: PerishFire <39043006+PerishCode@users.noreply.github.com>
Co-authored-by: Looper <looper@noreply.github.com>
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
Co-authored-by: bestthanapon <thanapon.suwannasuk@gmail.com>
Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: Amy <58060647+AmyShang-alt@users.noreply.github.com>
Co-authored-by: Marc Chan <mrc@powerformer.com>
Co-authored-by: _HDCoder <2728073932@qq.com>
Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: Nicholas-Xiong <2482929840@qq.com>
Co-authored-by: mehmet turac <mehmetturac@gmail.com>
Co-authored-by: open-design-release-bot[bot] <295937643+open-design-release-bot[bot]@users.noreply.github.com>
Co-authored-by: open-design-bot <bot@open-design.ai>
Co-authored-by: CHENGLONG WANG <honam884844@gmail.com>
* fix(workspace): seed dashboard workspace input
* test(workspace): cover live dashboard workspace input
* fix(workspace): backfill legacy workspace names
* test(e2e): harden workspace collaboration coverage (#6312)
Harden Workspace collaboration authority, lifecycle cleanup, and browser coverage.\n\nRetry an expired authorized Team-project pull exactly once with a fresh receipt while preserving fail-closed scope, version, cleanup, and final freshness checks. Align the fake Vela receipt lifetime with the real two-second contract and use scalable E2E timeouts.
* fix(workspace): refresh dashboard input on switch
* fix(viewer): avoid inert attribute warning
* fix(chat): preserve user turn ordering
* fix(workspace): stop retracted Team plugins immediately (#6335)
Prevent stale Team-plugin listings, materializations, and delayed positive hub reads from reactivating a resource after retraction. Preserve independent Personal plugins with the same manifest ID and keep Team members able to share their own resources.
Add deterministic regression coverage for cache invalidation, binding lifecycle, and the superseded-positive activation race.
* fix(workspace): harden Vela billing proxy boundaries (#6348)
Reject invalid wallet balances without overwriting valid cache state, validate normalized proxy paths and Workspace scope, strip hop-by-hop headers, and tear down upstream requests on disconnect.
Keep team Workspace scope fail-closed when Connection nominates the protected header.
* fix(web): tolerate unnamed workspace directory rows (#6349)
Preserve a verified explicit Workspace context when legacy directory rows omit the display-only workspaceName field. Keep exact-row name backfill when a non-empty name is present, without inferring authority from active or default Workspace state.
* fix(workspace): enforce exact project-list scope
* fix(workspace): show team design systems in pickers
* fix(web): reserve image cards while thumbnails load
* fix(web): keep home creation types ready during refresh
* test(e2e): align Windows beta smoke with installer
* perf(web): start team catalog from exact directory identity
* fix(web): refresh revoked workspace context
* fix(web): order collaboration presence refreshes
* fix(amr): prefer selected Vela OpenCode companion
* fix(web): isolate collaboration presence sessions
* fix(web): parallelize scoped project route bootstrap
* perf(daemon): cache workspace project list authority
* fix(web): preserve project-open connection budget
* test(web): preserve memory toast exports in app mock
* fix(web): preserve exact project opening scope
* fix(daemon): coalesce scoped comment pulls
* fix(daemon): retry team comment relay delivery
* fix(daemon): atomically queue comment relay writes
* fix(collab): show team comments across local conversations
* fix(daemon): preserve catalog-only projects on unshare
* fix(collab): close comment realtime catch-up gaps
* docs: add workspace team rollout runbook
* fix(collab): retry failed Team project shares
* fix(workspace): isolate personal projects in recent lists
* fix(daemon): isolate personal design systems
* fix(workspace): authorize runs before plugin snapshots
* test(daemon): align workspace retry contracts
* fix: isolate plugins and skills by workspace member
* fix(workspace): scope design systems in run prompts
* fix(workspace): gate runs before plugin resolution
* fix(daemon): namespace team design system bindings
* fix: close plugin and skill scope leaks
* test(collab): cover project-scoped comment actions
* fix(workspace): scope project resource selection
* fix(workspace): preserve personal design system reads
* fix(workspace): authorize scoped run resources first
* fix(workspace): keep project design systems scoped
* fix(types): carry design system member scope
* fix(billing): scope personal balances by workspace
* fix(workspace): preserve unbound local design systems
* fix: resolve team design systems by canonical binding
* fix(workspace): invalidate nested project catalog cache
* fix: keep team design system runs canonical
* perf: batch durable comment relay delivery
* fix: cancel stale local comment relays
* fix(web): preserve scoped project display snapshots
* fix(web): synchronize project rename projections
* perf: reuse request-scoped workspace authority
* test: enforce personal project creator ownership
* fix(web): wait for project workspace authority
* fix(daemon): invalidate team project caches before signaling
* fix(web): order targeted project metadata refreshes
* fix(workspace): preserve team catalog on outages
* fix(web): isolate catalog display across accounts
* fix(web): scope extension marketplace catalogs
* fix(web): order broad team catalog refreshes
* fix(web): scope home plugins to workspace identity
* fix(web): isolate plugin views by workspace identity
* fix(web): polish sign-in and composer focus
* fix(web): preserve scoped project state after team moves
* fix(workspace): enforce scoped design and billing identity
* test(e2e): align workspace flows with scoped bootstrap
* fix(collab): drain newer comment revisions promptly
* test(e2e): keep owner actions after workspace lock
* fix(web): converge project renames across scoped lists
* fix(web): preserve pending projects across view changes
* fix(web): restore preview runtime after activation
* test(e2e): align workspace scope assertions
* test(web): cover hidden stream recovery
* fix(web): update deep-link project titles after rename
* fix(collab): retry project metadata propagation
* fix(web): preserve legacy design system logos
* fix(web): wait for activated preview bridge
* fix(design-systems): isolate team resource storage
* fix(plugins): reject team mirror mutations
* fix(web): partition extension catalogs by workspace
* fix(skills): isolate team mirrors by workspace
* fix(plugins): keep local share projects unscoped
* fix(web): retry preview state capture during activation
* test(e2e): scope project workspace fixtures explicitly
* feat(collab): coordinate team resource invalidations
* fix(collab): emit team resource changes after sync
* fix(web): invalidate project lists after updates
* fix(web): serialize project renames by scope
* fix(collab): preserve team project comment anchors
* fix(web): refresh shared resources after remote changes
* fix(collab): materialize team projects before rename
* fix(web): keep workspace catalogs current
* fix(web): close catalog and credential races
* test(workspace): harden team workflows and packaged deep links (#6358)
* test(workspace): harden team workflows and deep links
* fix(packaged): deliver hot invite deeplinks
Generated-By: looper 0.11.7 (runner=fixer, agent=codex)
* fix(workspace): catch up team resources safely
Generated-By: looper 0.11.7 (runner=fixer, agent=codex)
* fix(workspace): invalidate resource catch-up changes
Generated-By: looper 0.11.7 (runner=fixer, agent=codex)
* fix(workspace): catch up shared resource changes
Generated-By: looper 0.11.7 (runner=fixer, agent=codex)
* fix(packaged): preserve fallback invite deeplinks
Generated-By: looper 0.11.7 (runner=fixer, agent=codex)
* fix(workspace): hide retracted team skills
Generated-By: looper 0.11.7 (runner=fixer, agent=codex)
* test(e2e): align workspace CI fixtures with route bootstrap
* fix(collab): complete design system reconnect adoption
* feat(analytics): track Workspace interactions (#6397)
* feat(analytics): track Workspace interactions
* fix(analytics): dedupe community page views
---------
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
* fix(daemon): preserve team comments across conversations
* fix(collab): keep presence rosters converged
* fix(comments): preserve routed comment anchors
* fix(workspace): reconcile team project renames
* fix(collab): preserve member directory last-good state
* perf(collab): start scoped Team presence before status
* fix(comments): keep relay anchors internal
* perf(collab): warm presence from daemon cache
* perf(collab): cancel obsolete cold status polls
* fix: fence presence sessions against late heartbeats
* perf(collab): share team member roster scheduling
* fix(collab): preserve roster store through strict replay
* perf(collab): retain workspace member directory stores
* fix(collab): resolve presence identities from team directory
* fix(collab): hide unresolved presence identities
* fix(collab): prefer directory presence names
* perf(web): preload workspace member directory
* test(collab): preserve no-leave lifecycle contract
* fix(web): preserve personal workspace presence after share
* fix(web): refresh covers after team materialization
* fix(web): refresh files after team materialization
* fix(web): gate shared file reads on project authority
* fix(web): preserve workspace folder action sizing
* ci(e2e): split project-workspace UI P0 shard into project-workspace + project-collab
project-workspace pins workers=1 by design (deliberate, not incidental —
see e2e/AGENTS.md's UI test stability rules on cross-file worker carry-over),
so its 7 files ran fully serially: ~26min wall time in recent merge-queue
runs, the longest pole in the ui_p0 matrix by a wide margin over its
10-13min siblings.
workspace-multi-client-collab.test.ts alone (spins up two isolated
client/daemon runtimes per case) accounted for ~10 of those minutes.
Carving it and workspace-keyboard-flows.test.ts into a new project-collab
shard — still workers=1, still one file at a time — lets the two halves
run as separate concurrent CI jobs instead of one long serial chain,
without touching the single-worker-per-shard isolation the pin exists for.
Updates the topology guards that pin the exact matrix shape so the split
doesn't drift silently: DAEMON_RUNTIME_DEFINITION_MATRIX_NAMES (scopes.ts),
the full/candidate matrix name guards (guard/scope.ts), and the fixed
expectations in scopes.test.ts / packaged-smoke-workflow.test.ts.
Validated: pnpm guard (UI P0 shadow contract + CI topology checks pass),
pnpm typecheck (all workspaces), and the two topology test files
(107/107 passing).
* fix(collab): revoke unshared team project mirrors
* fix(web): preserve unbound project authority across workspace changes
* fix(web): pair scoped raw queries with headers
* test(e2e): pin writable scope for project actions
* test(web): cover Personal authority recovery after reload
* test(web): await version preview readiness
* fix(workspace): scope brand design system resources
* fix(workspace): materialize owner team design systems
* fix(web): fence design system reads by generation
* fix(web): propagate brand read generations
* fix(web): preserve workspace project visibility
* fix(preview): retain scope for runtime relative assets
* fix(web): explain design system delete denial
* fix(daemon): bound team resource prewarm scopes
* fix(daemon): recheck prewarm lease after queue
* fix(web): stabilize transient presence roster gaps
* chore(pack): pin Vela CLI 0.0.28
* fix(workspace): move design system project with team share
* fix(workspace): gate linked unshare on live authority
* fix(workspace): ensure backing project before design share
* fix(web): scope srcdoc preview assets
* perf(web): mint preview scope only for srcdoc
* perf(web): reuse srcdoc preview scope
* chore(nix): refresh pnpm deps hashes for vela-cli 0.0.28
pnpm-lock.yaml moved @powerformer/vela-cli from 0.0.27-test.7 to 0.0.28, but nix/pnpm-deps.nix was not regenerated, so `nix flake check` failed with a fixed-output hash mismatch for both the daemon and web derivations.
Hashes taken from the Nix-reported expected values in CI run 30933223422 (nix is not available on this machine to run `pnpm nix:update-hash` locally); the merge of origin/main did not touch pnpm-lock.yaml, so those values still describe this tree.
* chore(ci): retrigger workspace-team validation
The previous ci run for this branch sat pending with zero dispatched jobs; cancelling it and pushing an empty commit re-enters the ci-6142 concurrency group from a clean slot.
* perf(web): skip preview mint for authored base
* fix(collab): preserve presence through lease gaps
---------
Co-authored-by: Ray Xi <2667192167@qq.com>
Co-authored-by: CHENGLONG WANG <honam884844@gmail.com>
Co-authored-by: lefarcen <ontf116@gmail.com>
Co-authored-by: PerishFire <39043006+PerishCode@users.noreply.github.com>
Co-authored-by: Looper <looper@noreply.github.com>
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
Co-authored-by: bestthanapon <thanapon.suwannasuk@gmail.com>
Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: Amy <58060647+AmyShang-alt@users.noreply.github.com>
Co-authored-by: Marc Chan <mrc@powerformer.com>
Co-authored-by: _HDCoder <2728073932@qq.com>
Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
Co-authored-by: Nicholas-Xiong <2482929840@qq.com>
Co-authored-by: mehmet turac <mehmetturac@gmail.com>
Co-authored-by: open-design-release-bot[bot] <295937643+open-design-release-bot[bot]@users.noreply.github.com>
Co-authored-by: open-design-bot <bot@open-design.ai>
Co-authored-by: shangxinyu1 <shangxinyu@refly.ai>
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
* feat(ci): promote the certain-exempt docs core to merge-queue trust
Split the exempt surface into a certain-tier core (docs/, apps/landing-page/,
.vscode/, .idea/, .github/ISSUE_TEMPLATE/, LICENSE, CODEOWNERS) and the medium
residue (global markdown regex, workflow exacts, nix). A merge-queue group
confined to the core now drops to the unconditional floor lanes instead of
running everything; replaying the last 398 first-parent merges puts that at
12.3% of queue traffic.
The promotion is backed by a new floor-lane guard check, "certain-exempt
surface consumption": no skippable-lane source may reference a certain-exempt
path (dot-relative literals flagged everywhere; bare repo-relative literals
flagged outside test fixtures). guard.ts gains --list-checks as the machine
registry, and the rule-table invariant test now requires every certain rule's
guard field to resolve to a live check.
* docs(ci): record the scope confidence methodology in specs/current/ci.md
The living framework for confidence-tier changes: medium-tier cheap loop vs
certain-tier deliberate loop, the three-sentence promotion discipline, guard
strength levels and floor placement, evidence recipes (replay, offline plan,
queue trace extraction), tooling graduation criteria, and the open questions
deliberately left undecided. Indexed from AGENTS.md and .github/AGENTS.md
required reading.
* fix(ci): narrow certain-exempt fixture handling
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix(ci): pin the trae-cli consumption exception to the daemon-lane wiring
The review round established that apps/daemon/tests/runtimes/trae-cli.test.ts
genuinely reads docs/agent-adapters.md. Its exemption from the consumption
guard was justified by a wiring fact (the ci.yml daemon lane runs only
project-watchers.test.ts) stated as prose, which would rot silently if the
lane ever widened. Make the exception conditional on that exact ci.yml needle:
when the daemon lane changes, the exemption disappears and the guard reports
the read, forcing reclassification instead of trusting a stale rationale.
* docs(ci): codify exception-precondition binding in the methodology
Backfill the pattern the first review round produced: guard allowlist entries
justified by local definitional facts may stay prose, but entries justified by
remote mutable facts (lane wiring, gate topology) must be conditional on a
checked precondition so the exception dies loudly with its premise. Records
the trae-cli/daemon-lane needle as the worked example.
* fix(ci): fold static paths in exempt consumption guard
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix(ci): require exclusive daemon test wiring
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix(ci): recognize daemon pnpm selector forms
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix(ci): tokenize daemon workflow test commands
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
---------
Co-authored-by: Looper <looper@noreply.github.com>
* chore(scripts): make root scripts/ test-free and purify the guard entrypoint
Root scripts/ is now source-only, enforced by a new "scripts test-free"
guard check (any *.test.{ts,tsx,js,jsx,mjs,cjs} under scripts/ fails
pnpm guard). The root guard script becomes a pure tsx ./scripts/guard.ts
invocation instead of chaining a hand-maintained node --test file list.
Script behavior-contract coverage follows the boundary e2e/AGENTS.md
already declares for e2e/tests/scripts/: the fork PR approval security
tests migrate there (converted to Vitest, 24 tests passing), and the
remaining script unit tests are deleted — five of them (batch-design-
system-test, check-design-system-package-quality, plugin-previews-diff,
plugin-previews-gc, migrate-to-plugins/example) were orphans no runner
ever executed; the guard-chained checker tests covered logic whose
repo-wide enforcement remains in guard.ts checks. Coverage with a real
test claim gets rebuilt in e2e/tests/scripts/ on demand.
* test(ci): pin characterization goldens for scopes.ts scope detection
Golden tests run scripts/scopes.ts as a subprocess exactly the way ci.yml
does, with gh stubbed through the existing OPEN_DESIGN_GH_NODE_SCRIPT seam
(hermetic stub materialized per run), and pin the complete 20-key output
plan for a matrix of changed-file sets across pull_request,
workflow_dispatch (hot/full), and merge_group events.
They deliberately pin current quirks (skills/*.md arming daemon+web without
the ui-critical fallback; e2e/ui/*.md arming ui_p0 with workspace validation
off; dispatch-hot skipping the workspace_validation re-derivation) so the
upcoming rule-table restructuring can prove behavior preservation
byte-for-byte.
* refactor(ci): restructure scopes.ts into a confidence-tiered rule table
Scope detection becomes data plus a pure evaluator: every changed file is
classified by an additive rule table (union of matched effects, minimum
confidence across matched rules), and an evaluation context brings a trust
threshold — files below it, or matching no rule, escalate fail-closed to
the full radius. Today's behavior is exactly the degenerate case of this
structure (all rules medium, certain set empty): pull_request/hot believes
everything, the merge queue believes nothing and stays full. The
characterization goldens pass unchanged, byte-for-byte.
The exempt surface becomes an explicit empty-radius rule instead of a
special case, so exemptions can be split, rated, and promoted per rule.
Promotion to "certain" requires a guard field naming the enforcing check
(enforced by a rule-table invariant test).
New offline entry points for rule iteration: `plan --context
pr|merge-queue|full --files ...` evaluates without any GitHub environment
and prints { plan, trace }; `rules` lists the table. Every evaluation now
returns a decision trace (per-rule hit counts, per-file escalations, and
an ifTrustAll shadow plan that shows what medium-confidence rules would
have allowed — the evidence stream for future certain-tier promotions);
github-output mode logs it and writes a step-summary table without
touching the frozen 20-key output contract.
The legacy dispatch-hot asymmetry (no workspace_validation re-derivation)
is preserved behind an explicit option and pinned by the goldens.
* feat(ci): evaluate merge-group union diffs at the certain threshold with shadow trace
The merge queue now resolves the queued group's union diff
(compare base_sha...head_sha from the merge_group event payload) and
evaluates it through the rule table at the "certain" trust threshold.
While the certain rule set is empty every file escalates and the queue
keeps running everything — the goldens pin this byte-for-byte — but the
evaluation's ifTrustAll shadow column now records, for every queued
group, what medium-confidence rules would have allowed: the evidence
stream that future certain-tier promotions will cite.
Queue lanes derive from the evaluated outputs (ci_mode stays reported as
"full", but lanes are no longer unconditionally forced), so promoting a
rule to "certain" later is a pure rule-table change with no further
structural work.
Fail-safe behavior is explicit and tested: resolution errors and empty
resolutions fall open to the full plan without failing the scopes job
(queue throughput must never depend on this evaluation), while
pull_request resolution failures keep failing the run as before.
Changed-file resolution no longer filters out removed files (manual hot
runs previously did): deleting a runtime source file must trigger the
same validation scopes as editing it. This is the only behavior change,
and it is strictly fail-closed.
* fix(ci): preserve guard contracts and fail closed on compare limits
Restore the eight guard behavior suites in the e2e script lane and make merge-group compare resolution treat GitHub's 300-file ceiling as incomplete.
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix(ci): enforce extension-agnostic script test boundary
Match every suffix after .test. and cover TypeScript, MTS, and non-test filenames in the e2e script suite.
Generated-By: looper 0.11.0 (runner=fixer, agent=codex)
* fix(ci): route script contract tests to e2e
Ensure changes under e2e/tests/scripts arm E2E Vitest with the same daemon and web scope effects as their root script counterparts, and pin the behavior with a PR golden.\n\nGenerated-By: looper 0.11.0 (runner=fixer, agent=codex)
---------
Co-authored-by: Looper <looper@noreply.github.com>