mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
356c8c364f
* 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. Commit319cb42b3added 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 asc0bce3b8f, 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 commitea259eb402. * 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 as21f452ffe, 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` failure886f11b24introduced. 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.886f11b24put 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 button21f452ffeundid). 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>
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { planLostAnchorWriteBacks, type CommentAnchorResolution } from '../src/comments.js';
|
|
import { persistCommentAnchor, persistCommentAnchors } from '../src/collab/comment-anchor-client.js';
|
|
import type { PreviewComment } from '../src/types.js';
|
|
import type { WorkspaceCollabContext } from '@open-design/contracts';
|
|
|
|
const POS = { x: 10, y: 20, width: 30, height: 40 };
|
|
|
|
function comment(overrides: Partial<PreviewComment> = {}): PreviewComment {
|
|
return {
|
|
id: 'c1',
|
|
filePath: 'index.html',
|
|
elementId: 'pin-1',
|
|
text: 'note',
|
|
position: POS,
|
|
createdAt: 1,
|
|
...overrides,
|
|
} as PreviewComment;
|
|
}
|
|
|
|
function lost(snapshotPos: { x: number; y: number; width: number; height: number } | null): CommentAnchorResolution {
|
|
return {
|
|
state: 'lost',
|
|
snapshot: snapshotPos
|
|
? { filePath: 'index.html', elementId: 'pin-1', selector: '', label: '', htmlHint: '', text: 'note', position: snapshotPos }
|
|
: null,
|
|
};
|
|
}
|
|
|
|
describe('planLostAnchorWriteBacks', () => {
|
|
it('captures last-good position the first time a comment is lost', () => {
|
|
const plan = planLostAnchorWriteBacks([{ comment: comment(), resolution: lost(POS) }]);
|
|
expect(plan).toEqual([{ commentId: 'c1', anchorState: 'lost', lastGoodPosition: POS }]);
|
|
});
|
|
|
|
it('is idempotent once a lastGoodPosition is already stored', () => {
|
|
const stored = comment({ lastGoodPosition: POS });
|
|
const plan = planLostAnchorWriteBacks([{ comment: stored, resolution: lost(POS) }]);
|
|
expect(plan).toEqual([]);
|
|
});
|
|
|
|
it('does not write back anchored / reanchored / stale states', () => {
|
|
const anchored: CommentAnchorResolution = {
|
|
state: 'anchored',
|
|
snapshot: { filePath: 'index.html', elementId: 'pin-1', selector: '', label: '', htmlHint: '', text: 'note', position: POS },
|
|
};
|
|
const stale: CommentAnchorResolution = { ...anchored, state: 'stale' };
|
|
const plan = planLostAnchorWriteBacks([
|
|
{ comment: comment(), resolution: anchored },
|
|
{ comment: comment({ id: 'c2' }), resolution: stale },
|
|
]);
|
|
expect(plan).toEqual([]);
|
|
});
|
|
|
|
it('skips a lost comment that has no ghost position at all', () => {
|
|
const plan = planLostAnchorWriteBacks([{ comment: comment({ position: undefined }), resolution: lost(null) }]);
|
|
expect(plan).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('persistCommentAnchor', () => {
|
|
it('PATCHes the anchor route with the anchor state + last-good position', async () => {
|
|
const fetchMock = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => ({ ok: true, status: 200 }) as unknown as Response);
|
|
await persistCommentAnchor({
|
|
projectId: 'p 1',
|
|
conversationId: 'conv',
|
|
writeBack: { commentId: 'c1', anchorState: 'lost', lastGoodPosition: POS },
|
|
fetch: fetchMock as unknown as typeof fetch,
|
|
workspaceContext: {
|
|
workspaceId: 'workspace-a',
|
|
workspaceMemberId: 'member-a',
|
|
workspaceType: 'team',
|
|
role: 'member',
|
|
memberStatus: 'active',
|
|
lifecycleState: 'active',
|
|
permissions: {
|
|
canShareProjects: true,
|
|
canWriteSyncedFiles: true,
|
|
},
|
|
} as WorkspaceCollabContext,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe('/api/projects/p%201/conversations/conv/comments/c1/anchor');
|
|
expect(init?.method).toBe('PATCH');
|
|
const headers = new Headers(init?.headers);
|
|
expect(headers.get('x-od-workspace-id')).toBe('workspace-a');
|
|
expect(headers.get('x-od-workspace-member-id')).toBe('member-a');
|
|
expect(JSON.parse(String(init?.body))).toEqual({ anchorState: 'lost', lastGoodPosition: POS });
|
|
});
|
|
|
|
it('persistCommentAnchors reports failures without aborting the batch', async () => {
|
|
const fetchMock = vi.fn(async (url: RequestInfo | URL) => {
|
|
const ok = !String(url).includes('/c2/');
|
|
return { ok, status: ok ? 200 : 500 } as unknown as Response;
|
|
});
|
|
const errors: string[] = [];
|
|
await persistCommentAnchors({
|
|
projectId: 'p1',
|
|
conversationId: 'conv',
|
|
writeBacks: [
|
|
{ commentId: 'c1', anchorState: 'lost', lastGoodPosition: POS },
|
|
{ commentId: 'c2', anchorState: 'lost', lastGoodPosition: POS },
|
|
{ commentId: 'c3', anchorState: 'lost', lastGoodPosition: POS },
|
|
],
|
|
fetch: fetchMock as unknown as typeof fetch,
|
|
onError: (_error, wb) => errors.push(wb.commentId),
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
expect(errors).toEqual(['c2']);
|
|
});
|
|
});
|