Skip to content

Commit 356c8c3

Browse files
lefarcenSiri-Raywangchenglong0001lefarcenPerishCode
authored
feat(workspace-team): team workspaces, shared resources, workspace-scoped billing, and the #5517 redesign (#6142)
* fix(web): scope plugin URL imports to workspace * test(web): cover billing interest remount lifecycle * feat(packaged): enable the workspace-team transport for test-profile builds The vela test backend now serves the workspace-team API, so a packaged build baked with the test AMR profile should drive team projects / collab / resource sharing there instead of leaving them dormant. Replace the single feature-test equality check with a profile -> vela web origin map so adding a backend is one entry, and keep every unlisted profile (prod above all) off. * test(daemon): cover billing catch-up after SSE reconnect * fix(collab): surface and start first materialization when opening a shared project QA P0: a brand-new member on a fresh install joins someone's workspace, opens a project from it, and sees no loading state and no files — content only appears on a SECOND open, much later. Two defects, both on the first `/collab/status` request that opening a shared project makes. 1. No pull is started. The daemon's self-materialization block was gated on `callerIsOwner`, so a non-owner member got the placeholder record registered and nothing else. The web's auto-pull is gated on `publishedVersion` advancing past its cursor, and a fresh daemon's first status response cannot carry a published head: `collab.publishedVersion()` reads an in-process map that has never been written, and the real hub head is fetched fire-and-forget into `headEnrichmentCache` for a LATER poll to consume. Materialization therefore arrived only whenever a proactive lane (hub push / reconnect catch-up / the 30s recovery floor) next fired — the "second open" the report describes. 2. No download state is shown. `useProjectCollab.downloadPending` — which picks DesignFilesPanel's "Syncing files from the team…" over its empty state and create-a-file CTAs — reads only `publishedVersion > cursor`, `contentTransferState`, and an in-flight pull. All three are blank on that first response, so the member was shown an empty project inviting them to start creating over content that was still on its way. The fix makes the placeholder stamp the load-bearing signal on both sides: - `materializePlaceholderOnOpen` (routes/collab-sync.ts) replaces the owner-only block: any viewer opening a shared project whose only local record is an unmaterialized placeholder starts its pull on that same request, through the same coalesced flow. The retracted-share heal in its catch stays owner-only — a member who hits the hub tombstone has merely lost access and must not unshare anyone's project. - `/collab/status` reports `awaitingFirstMaterialization`, a purely local, synchronous fact that needs no hub round-trip and is therefore available on the very first response. `od collab status` prints it too. - `localFilesAreNotTheContentYet` (useProjectCollab.ts) makes it always download-pending, bypassing the member-only `shouldAutoPull` gate: whether the local files are the content is a fact about the record, not about who may pull it, so the owner of an unmaterialized placeholder (the reinstall case, recvqzaDvUU6B3) stops seeing the same empty state too. Red evidence: all six new specs fail on origin/feat/workspace-team @ 793d7b92c — the first status response reported `awaitingFirstMaterialization: undefined` with publishedVersion/materializedVersion/contentTransferState all null, `beginContentTransfer` was never called for a member's first open, and `downloadPending` was false for both the member and the owner. * chore(packaged): inject the vela web origin at build time, not source This repository is public. Commit 319cb42b3 added a profile -> vela web origin table to apps/packaged/src/sidecars.ts, which put an internal environment's hostname into product source and would have published it on merge. The same hostname had also reached apps/web/src/runtime/amr-guidance.ts as a new profile row. Internal AMR environments are not public (one of them fronts a Stripe sandbox), so their origins now travel the same build-time path POSTHOG_KEY already uses: release-beta.yml env (per-profile secret) -> tools-pack config (OD_VELA_WEB_URL) -> open-design-config.json (velaWebUrl) -> packaged config -> daemon spawn env (OD_VELA_WEB_URL) -> GET /api/integrations/vela/status (consoleOrigin) -> web runtime console links The workspace-team gate keeps its meaning and gets stricter: it now needs an allowlisted AMR profile AND an injected origin. `prod` can never satisfy the profile half, so a stable build stays dormant however it is configured, and a feature-test/test build whose secret is unset stays dormant too rather than pointing the transports at an unknown backend. A build with no secret configured therefore ships with workspace-team silently off — the same failure mode as a fork build without POSTHOG_KEY, and the safe direction for an unreleased feature. apps/packaged/tests/source-origins.test.ts is a standing guard: every absolute URL literal under apps/packaged/src must resolve to a publishable host, so the next backend origin cannot be hardcoded there either. Secrets required for a workspace-team dogfood build: VELA_WEB_URL_FEATURE_TEST, VELA_WEB_URL_TEST. * test(e2e): follow the #5517 plugin-details route in the visual capture `captures the home plugin use staged surface` has been failing on this branch since before the main merge. It was written against the pre-#5517 UI: clicking a marketplace card opened a role="dialog" details overlay with a per-slug `plugin-details-use-<id>` action. #5517 made plugin details a full-page route — `openCardDetail` calls navigate({ kind: 'marketplace-detail' }) for plugin records, rendering `PluginDetailView` at /marketplace/<id> whose Use control is a single `plugin-detail-use` button. Per the product rule that the accepted feature-branch UI is the source of truth, the test follows the route instead of the UI being changed back. Asserting the route alone was not enough: the detail surface refetches the record from /api/plugins/<id>, which the visual fixture never mocked (it mocks the list, */preview and */apply), so the route rendered its "Failed to load plugin: HTTP 404" branch. configureVisualPage now serves the single-plugin GET from the same VISUAL_PLUGINS fixture. The capture asserts the Use button rather than the `plugin-detail` shell because the shell also renders for the loading and load-failed states. * fix(packaged): restore masked CLI icons (#6154) * Sync local workspace-team batch: UI polish, popover simplification, update-reminder progress (#6156) * UI polish batch: import-dialog cleanup, settings toast placement, tab chrome fixes - Plugin/skill import dialog: drop per-card icons, left-align card content - Settings page: center the autosave pill under the top nav - Avatar model menu: soften the pinned footer divider - Workspace tabs: pinned entry tab always reads as Home; remove tab-search button and its popover - Model picker: wider row gap in the two-pane browse popover - Community header: unpin (scrolls with content) - New update-reminder dialog assets and strings - Keep entry-settings-button as the signed-out settings entry (e2e contract); align NextStepActions with the navPlugins key rename * Update reminder: simulate in-place download progress instead of linking out The strip's confirm now runs a staged progress state ('updating') with an eased fake download bar, then marks the version updated — replacing the jump to the GitHub releases page. * test(web): retire AvatarMenu account-row suites after the popover simplification The composer popover was reduced to a model picker (2026-07-24): the Open Design account row — plan badge, balance, wallet fallback, upgrade/console links — no longer renders there, so the nine suites asserting that surface (including the workspace-balance and billing-permission gates that landed upstream meanwhile) can no longer pass. Drop them and recast the remaining account-row test as the guard for the new invariant: a signed-in AMR status must render no account UI in the popover. Also drop the stale onClose prop from the new upstream sign-out-confirm test; the rail no longer takes one. * Design-systems toast: anchor to the pane's top edge, pill radius * Update reminder strip: progress ring on the button, percent face While updating, the collapsed strip no longer stacks a label + bar above the rocket button. Progress draws as a brand-green ring hugging the round button's edge (5px band reserved so the button never shifts), and the button face swaps the rocket for the bare percent — 'N%' while downloading, 完成 (updater.done) at 100. The full 正在下载更新 N% label stays reachable via the hover bubble and the ring's aria-label. Demo payload version bumps to 1.4.6 so the once-per-version card re-arms. * fix(web): restore design kit section spacing (#6155) * fix(web): carry the plugin back to Home when Use runs from the detail route Product call: using a plugin from the full-page detail route should land on Home with that plugin already selected and its brief pre-filled, exactly like the marketplace card's "Try it". It did not. `PluginDetailView.onUse` applied the plugin, kept the result in local state, and navigated home. `App` renders that route outside `EntryShell`, so the navigation unmounted the holder and dropped both the plugin and the brief — Home came up empty. The "redirected to Home with the brief pre-filled" note under the button could never be seen either, since the navigation fired immediately; it is removed rather than left lying. Reuses the existing channel instead of adding a parallel one: onUse now publishes the same `createPluginUseHandoff` payload "Try it" produces, and Home's existing pendingPluginUseHandoff path applies it unchanged. The handoff is parked in module scope — not on `window` — so it survives the unmount, and `EntryShell` claims it in a lazy state initializer so it arrives in the same commit as the mount. Reads are destructive, so a handoff applies once and does not re-fire on the next visit to Home. Red first: PluginDetailView.use-handoff.test.tsx fails on the unfixed code at the handoff assertion (navigation and the failure path already passed), and goes green with this change. The e2e visual capture that asserts home-hero-active-plugin should follow without weakening its assertion. test(e2e): match the settings surface in both #5517 presentations openSettingsDetailsFromHeader still waited on `.modal-settings[role="dialog"]`. #5517 routes the entry's settings to /settings, where SettingsDialog renders in presentation="page" mode with `role="region"`, so that selector never matched the page presentation: an already-open settings surface looked absent and the helper fell through to hunting for entry triggers that surface does not carry, failing 16 captures on one line. Reuses `settingsSurface` from amr.ts — the existing bare `.modal-settings` matcher both presentations share — rather than adding a third local variant. That helper also documents why the `role="dialog"` fallback was actively wrong: AvatarMenu is a dialog too, so it could resolve to the account menu. * test(e2e): open the nav rail before reaching for entry settings The bare-`.modal-settings` change alone did not move the settings-workspace lane: all 16 captures still died on `.settings-icon-btn`, because the failure happens before the surface selector matters. None of the four triggers was ever visible. amr.ts already documents why. #5517 moved the entry settings chip into the nav rail footer, and a collapsed rail is `inert` + `aria-hidden`: the chip is present but invisible to `getByRole`, and even a programmatic `element.click()` is a no-op. `openSettingsDialog` expands the rail first (`ensureEntryRailOpenIfPresent`), clears loading, dismisses the privacy dialog, matches the surface with the shared bare class, and ends its trigger chain on the settings aria-label instead of `.settings-icon-btn`, which the entry surface does not carry. It also clicks `entry-settings-open-details`, so it is a superset of the local copy. So `openSettingsDetailsFromHeader` now delegates to it and keeps only its name, leaving exactly one settings opener instead of a third variant. * test: realign the suites #6156 outpaced, and reach Settings from a project #6156 (4e3161751) landed a UI batch whose test fallout was only partly handled. Four Vitest files and four e2e call sites still describe the surface as it looked before it, and the Playwright settings opener never had a trigger that exists once a project is open. Vitest — the assertions now read the surface that shipped: - community-view: the type tabs traded their `<small>` count badges for type icons, so `readFacets().badge` was `NaN`. Counts move to a new `readFacetCardCounts()` that drives each tab and counts the grid — the same array the badges were derived from, so the catalogue-fidelity guard (including "never grids more than the whole catalogue") survives the badge's removal rather than being dropped. - home-logo-assets: the rail's signed-out brand header is gone (with no cloud identity the rail starts at the search box), so it can no longer carry `od-brand-glyph`. Keeps the guard that still means something: it must never fall back to the retired raster app icon. - acceptance-visual-fixes: recvq4iEq1Esno's fix is intact — page mode still hides only `.settings-chrome-btn`, leaving the autosave pill visible. #6156 re-added the bare `.settings-page-shell .settings-chrome` selector purely to centre that pill under the top nav, so assert the invariant the row is about (no page-mode `display: none`) instead of the selector's absence. - FileViewer: the new `artifact-preview-first-load` cover is a `role="status"` of its own, so `findByRole('status')` had become ambiguous. Scope the success assertion to the toast. e2e — `entry-nav-settings` no longer exists anywhere in `apps/web/src`; the rail item carries `entry-settings-button`. #6156 renamed it and updated entry-chrome-flows' two `toHaveCount(0)` assertions but not the four places that click or await it (critical-smoke, entry-topbar x2, entry-chrome-flows x2). `openSettingsDialog` also only ever knew entry-shell triggers, all of which live on Home. From a project every one of them is absent — and `EntrySettingsMenu` and `AppChromeHeader`'s `SettingsIconButton` are both unrendered — so the surface never opened and the failure surfaced as a missing `.modal-settings`. Add the project surface's real entry: the composer model popover's pinned `avatar-open-execution-settings`, with the topbar switcher's `inline-model-switcher-open-settings` as a second route. Not addressed here, deliberately: #6156 also deleted the `settings-general-field` block that rendered `AppearanceSection`, orphaning the component. That removes the product's only System/Light/Dark control (the rail's account menu dropped its theme row *because* Settings owned it), so the 11 `SettingsDialog.execution` appearance failures are reporting a capability loss, not a stale expectation. Restoring it is a product call, so those tests are left red rather than weakened. * test(e2e): finish the popover the settings chain opens on a project surface `openSettingsDialog`'s trigger chain ends on OPEN_SETTINGS_LABEL, which matches `Account & settings` — and that is `avatar.title`, the label on AvatarMenu's own trigger. So on a project surface the chain does find something and clicks it, but what opens is the composer's model popover, not Settings. The follow-through step then looked only for `entry-settings-open-details`, which #5517 left unrendered, so the run gave up with a missing `.modal-settings` after three identical attempts. Widen the follow-through to the two rows that actually route to Settings from a project — AvatarMenu's pinned `avatar-open-execution-settings` and the topbar switcher's `inline-model-switcher-open-settings` — so whichever popover the first click opened gets finished. * test(e2e): pin the AMR selection assertion to the agent card With Settings actually opening from a project surface again, the surface-wide `/Open Design/i` in amr-logout-requires-relogin resolves to the sidebar's "Open Design MCP" `settings-nav-item` — which carries no `aria-pressed` — not to the AMR agent card's select button. The looseness was invisible while the surface never opened. Read the toggle through `settings-agent-card-amr` instead, so the assertion names the thing the test is about: AMR stays the selected agent. * test(e2e): prove Settings opened via its section nav, not a heading probe `prepareVisualSettingsDialog` gated on a heading matching /Settings|General|Execution mode/. Now that the surface opens from a project it lands on the execution section, whose heading reads "Models & providers" — and the surface's own <h2> is consumed as its accessible name via aria-labelledby anyway, so the probe could not match in either presentation. Assert `settings-nav-execution` instead, which is what critical-smoke already uses for the same purpose and holds for both the modal and the routed page. * test(web): retire the theme/appearance cases the product cut Product decision (2026-07-28), verbatim: 「主题设置不要了,因为 workspace 功能不 支持暗色主题,要干掉,并且之前用户如果设置了暗色主体,需要强制改成明亮色主题」 So #6156 deleting the `AppearanceSection` render site was the right direction, and the NON-ALIGNMENT #9 note that argued for keeping the segmented control as the product's last "follow system" entry point is formally overturned. These cases are retired because the capability is gone by decision — NOT to turn CI green. The source-side removal (orphaned `AppearanceSection`, onboarding light/dark toggle, `settings.appearance*` keys, `trackSettingsAppearanceClick`, plus forcing stored dark/system back to light for existing users) lands separately on `fix/remove-theme-force-light`. Deleted (8) — their whole subject was theme or the accent it carried: - offers the theme segmented control, and System leaves the document theme unset - applies the stored default accent color even though the picker is gone - writes the picked theme to the document and autosaves it - live previews the configured theme on open, and System leaves no explicit document theme - reverts an unsaved appearance preview back to the saved appearance when the dialog closes - persists System mode explicitly and preserves accent variables without an explicit document theme - keeps a stored non-default accent applied and carries it through an autosave - localizes the theme controls in Chinese Nothing was left behind as a loose "still renders something" assertion, and no live coverage went with them: the close-button/`onClose` path the revert case also touched is already covered at five other call sites in this file. Kept (3) — theme was only the vehicle, the subject is still shipping. Each now rides the notifications completion-sound toggle, seeded on so the single click is a real state change (the pills no-op when clicked in their current state): - reconciles the open settings draft when the parent agent CLI env changes — the only integration proof that `reconcileAmrModelChoice` / `reconcileAmrProfileEnv` actually run through the real draft on autosave; its two siblings are pure unit tests and do not cover that path. - drops a pending autosave when explicit onboarding reset unmounts Settings — subject is the pending-autosave drop and the draft the reset carries. - still autosaves an unrelated edit that lands during a silent-update save — subject is autosave bookkeeping (success must only advance autosaveLastSavedRef for allowSilentUpdates). The enclosing block is renamed 'SettingsDialog draft reconciliation' since it no longer describes appearance, and its document theme/accent teardown is dropped — nothing in it writes those any more. 144/144 green. * test(e2e): realign the visual workspace captures with the narrowed surfaces Three deliberate narrowings shipped without this Playwright lane following, so eight captures were driving UI that no longer renders. All eight are stale fixtures; none was a regression. - `ef9c8cd8b` made the home top-bar `InlineModelSwitcher` `compact`, and `EntryShell` (its only call site) always passes the flag. In compact mode the popover is just the active agent's model radio list plus the route to 設定 → 執行; the mode segmented control, agent grid, account block, and both searchable dropdowns live in the non-compact branches. `68cecac1c` records that product confirmed this narrower shape, so acceptance #40 ("home 页不能 切换 cli 了") is working as designed. - `4e3161751` (#6156) removed the Open Design account row from `AvatarMenu` (plan badge, balance, wallet fallback, upgrade/console links) and retired the nine equivalent Vitest suites — but not these. - `56b538aa4` moved the design-system picker out of the staged-context bar, so `staged-contexts` is no longer unconditionally mounted. Where a surface still ships, the capture follows it and the assertion got sharper rather than looser: staged contexts now stages a real attachment and names the chip; the topbar/avatar captures assert the surviving model picker plus the *inverse* invariant (no account block, no upgrade button, no balance), gated on a counted `vela/status` request so a negative cannot pass by racing the fetch. `:57` additionally pins `inline-model-switcher-mode-daemon` at count 0, so re-mounting the CLI console on Home fails here again. `:167` (topbar BYOK model dropdown) is deleted: in compact mode it would re-capture the identical popover as `:141`, which is a duplicate screenshot rather than coverage. `:141` absorbed its guard. `:244` (avatar reasoning readout) turned out never to have passed — not a #6156 casualty. `VISUAL_CLI_AGENTS`' codex entry declares `models` only, while `AvatarMenu` draws the readout solely for agents reporting `reasoningOptions`, so it has been red since `68cecac1c` authored it. Fixed by declaring `reasoningOptions` on a test-local codex fixture, matching what the real daemon reports (`apps/daemon/src/runtimes/defs/codex.ts:164`). Nothing was dropped without a verified live home elsewhere in the same CI lane or in Vitest: the AMR plan/balance/upgrade card at `visual-settings.test.ts:30`, the searchable dropdowns at `visual-settings.test.ts:93`/`:156`, the upgrade attribution URLs in `InlineModelSwitcher.test.tsx:585` and `AvatarMenu.test.tsx:283`, the "no account row" invariant at `AvatarMenu.test.tsx:528`, and real staged-context chips in `project-management-flows.test.ts`. The one assertion with no surviving home is the Open-Design-first agent ordering, whose grid no longer renders anywhere; that is called out in the test comment rather than silently lost. * test(e2e): give the AMR upgrade capture a billing-capable workspace `settings-agent-card-amr-upgrade` stopped rendering for this capture because `amrCardCanUpgrade` (SettingsDialog.tsx:4651) now also requires `workspaceContext?.permissions?.canManageBilling` — this branch's workspace-scoped billing work, i.e. the feature #6142 exists to ship. The gate is correct; the fixture was not representing anyone entitled to pass it, since `mockSignedInVelaAccount` establishes a Vela session but no workspace context. Stub `/api/workspace/context` with a personal-owner context inline in this one test rather than widening `mockSignedInVelaAccount`, which visual-workspace shares. The assertion is unchanged — the upgrade entry is a real capability for billing-capable members, so the fixture represents one instead of the assertion being relaxed. Verified the stub does not quietly satisfy the three assertions that already passed: `planId: null` keeps `resolvePlanTier` falling through to `plus`, and billing still 404s so the balance stays `$247.51`. Expect one legitimate baseline diff on `visual-settings-open-design-account`: the rail now renders signed-in workspace chrome, which is the only state in which the upgrade entry exists at all. * test(e2e): leave projects through shipping chrome, and reach /projects reliably Two dead locators and one under-budgeted wait, all test-side. `openNewProjectModal` (rail.ts) probed `entry-nav-new-project` then `entry-nav-projects`, both deleted from EntryNavRail by the #5517 redesign (`b55f17169`, `f16075f7e`) — `onNewProject` is still destructured there but has zero call sites. Its body is byte-identical to main, where both testids exist, so main takes the rail path and never reaches the fallback this branch always hits. The live affordance is `designs-new-project` / `designs-empty-new-project` in DesignsTab, inside `entry-view-projects`; `/projects` has no UI entry at all (entry-chrome-flows:373-382 already documents that and routes directly). The fallback then failed for a second reason: `entry-view-projects` does ship (EntryShell.tsx:1579) and `/projects` does route to it, but it was asserted on the 10s default budget right after `waitUntil: 'domcontentloaded'`. `apps/web` mounts `src/App` via `dynamic(..., { ssr: false })`, so domcontentloaded fires while only the boot shell exists — every suite that does this correctly waits out `Loading Open Design…` with `T.long` first. Now routed via `history.pushState` + `popstate` (the client router listens) instead of a full reload, boot shell waited out, and both the view and the create button given `T.long`. `ensureRailOpen` was also called unconditionally, but it ends in a hard `expect(workspace-home-rail-toggle).toBeVisible()` and that testid only renders for `isPinned && active` (WorkspaceTabsBar.tsx:1474-1510) — from inside a project it does not exist, so the helper hard-failed on a control this flow never needed. Now best-effort and gated on `.entry`, kept only so the `visual-new-project-modal` baseline does not churn. `real-daemon-run.test.ts:547` hung on `/back to projects/i`, which matches nothing on a project surface: `AppChromeHeader` owns that aria-label but is no longer mounted anywhere, and ChatPane's top-left slot resolves `onCollapse ?? onBack` while ProjectView passes both, so it is always `chat-collapse-toggle` (deliberate, `884ed1085`). Replaced with a local `leaveProjectForEntry` that clicks the pinned entry tab — the shipping way out — so the isolation journey still leaves through real chrome instead of a URL jump. Dropped `getActionablePoint` with its only consumer. Removing the reload from ~25 call sites across 15 suites was audited against the stub-order-sensitive ones (settings-media-providers, new-project-ds-picker-*, api-empty-response, visual-entry); all install routes before their first navigation. Correcting my own earlier misattribution: `.ws-tab` / `.ws-tab-label` are FileWorkspace's in-project file strip and are fully intact, `role="tab"` and file-name labels included. #6156 rewrote WorkspaceTabsBar, which renders `.workspace-tab` / `.workspace-tab__label` — a different component. Upload→tab is also intact (`FileWorkspace.uploadFiles` still calls `openFile`). Five of those six failures merely routed through the broken helper above; the sixth was the dead back-button. No `.ws-tab` assertion was rewritten. * fix manual edit across workspace pages (#6160) * fix(collab): keep process-local presence alive when the collab transport is off (#6170) `createVelaCliCollabClientFromEnv` returns `null` unless the run opted into the vela-cli collab transport, but `server.ts` wired the presence routes with an object literal of arrow functions closing over that client. A literal is unconditionally truthy, so `registerCollabPresenceRoutes` always saw a `cloud` dependency: its `deps.cloud ?? null` could never resolve to `null`, the process-local `presence.present()` fallback became dead code, and every presence request that reached the cloud branch dereferenced `null` and was answered `502 collab_presence_unavailable`. `POST /api/projects/:id/presence/leave` carries no shared-project precondition, so it failed on any project id in any run without the transport — every stable/prod packaged build (the packaged workspace-team env is gated to the `feature-test`/`test` AMR profiles) and every plain `tools-dev` run. `server.ts` is `@ts-nocheck`, so the null dereference was invisible to `pnpm typecheck`. Route the dependency through `createCollabPresenceCloudClient`, which lives in the checked route module and states the invariant: a `cloud` dependency exists if and only if a transport exists. Removing its guard is now a typecheck error, so the bug class cannot come back at the construction site even while `server.ts` stays unchecked. * fix(web): scope desktop vibrancy to macOS (#6171) * fix(web): remove the theme setting and force every install back to light (#6168) Product removed theme selection: the workspace surfaces shipped for team workspaces have no dark tokens, so dark mode renders a broken app. Deleting the picker is not sufficient on its own. Every install that ever opened it still has `theme: 'dark'` — or `'system'`, which resolves dark on a dark OS — persisted in localStorage, and a stored value does not move when the default does. So the theme is now coerced on READ, at all three points a persisted value can reach the document: - `loadConfig()` funnels `parsed.theme` through `resolveAppTheme()` and marks the config migrated so the coerced value is written back once. - `applyAppearanceToDocument()` stamps `data-theme="light"` unconditionally. The attribute must be PRESENT, not merely non-dark: every dark CSS rule is gated on `html:not([data-theme])` / `html:not([data-theme="light"])`, and every JS theme reader (shiki, ConnectorLogo, SketchEditor, TerminalViewer, connectorBrandColor, MentionNode) falls back to `prefers-color-scheme` only when the attribute is absent. - The pre-hydration inline script in `app/layout.tsx` stamps light before React mounts, outside its try/catch so a throwing storage read still leaves the attribute set. Electron's `themeSource` defaults to `system`, which colours everything the web layer does not own (macOS vibrancy glass, native menus/dialogs) on a dark-mode Mac — including the splash, before the renderer's appearance IPC lands. It is now pinned to light before the first window exists. Removed surfaces: - `AppearanceSection` in SettingsDialog, orphaned by #6156 (zero call sites), together with its `settings-general-block--appearance` styles. Its docblock claimed the control was "deliberately kept … NON-ALIGNMENT #9"; that decision is superseded, so the comment goes with the code. - The onboarding welcome page's sun/moon toggle — the last reachable theme writer — and the `onThemeChange` prop chain behind it (App → EntryView/ProjectView → EntryShell → OnboardingView). - The orphaned theme row in EntrySettingsMenu and its styles. - i18n keys `settings.appearance`, `settings.appearanceHint`, `settings.themeSystem`, `settings.themeLight`, `settings.themeDark` across `types.ts` and all 19 locales. - Analytics `trackSettingsAppearanceClick`, `SettingsAppearanceClickProps`, and the `settings_popover` `appearance` element. `'appearance'` survives only as a legacy settings deep-link token that `normalizeSettingsSection` folds into General, so an old link is not a type error. Red-first: tests/state/force-light-theme.test.ts and tests/components/theme-settings-removed.test.tsx were written against the unmodified branch and went red (11 failing) before any source change. * fix(web): make the update surfaces read real data (#6163) * fix(web): make the update surfaces read real data #6156 (4e3161751) shipped two update surfaces whose every field was invented. `EntryShell` held a private `updateReminderStage` machine keyed on a literal `'1.4.6'` (the app ships 0.16.1), the rocket's confirm ran a 200ms `setInterval` easing a fake bar toward 100% and then wrote "updated" to localStorage, and the cover dialog listed three hardcoded English notes past i18n over a committed 432 KB JPEG. Nothing in that path made a single network, IPC or daemon call — a user who clicked "立即更新" watched a progress ring finish and got no update. Both surfaces now sit on the real machinery that already existed: The cover dialog becomes the post-update highlights surface and REPLACES the bottom-right `WhatsNewPopup` card rather than coexisting with it. Cover art, release headline, bullets and link all come from the hosted highlights document via `/api/whats-new`; the title states the running version from `useAppVersion()` (`/api/version`). It keeps that card's show gate verbatim — once per highlight `id`, only with content, only on the home surface after the app returns on a new version — and its existing analytics (`whats_new_popup` surface-view / click). Its footer is close + open-release; there is no "cancel / update now", because reporting what already shipped is all this surface does. Bullets come from a new `whatsNewNotesFromBody`, which splits the document's `body` per line and strips operator list markers. The rocket keeps its look and becomes the real updater's ready indicator in the bottom-left rail footer, replacing that indicator's arrow glyph. It is gated on the updater's own `shouldShowControl` (installer downloaded and never opened, desktop only) and still drives `openUpdaterInstaller` → `quitAfterUpdaterInstallerOpen` through the untouched panel, watchdog and restart-safety preflight. The progress ring and percent face are gone on purpose: the indicator only exists once the download has finished, so any percentage it drew would be invented. Deleted: `lib/update-reminder.ts`, `UpdateReminderDialog.tsx` and its module CSS, `public/update-reminder-cover.jpg`, the now-dead `entry-updater-menu__button/__glyph/__progress` rules, and the unused `accountNotice` rail slot. The three `updateReminder.*` i18n keys and the orphaned `whatsNew.dismissAria` are replaced by one `whatsNew.updatedTitle` across all 19 locales. A source-level guard (`tests/update-surface-real-data.test.ts`) pins the invariant that outlives the deletion: no quoted `x.y.z` and no `setInterval` in the update surfaces, and the placeholder module and cover art stay gone. * fix(web): name the release-notes destination on the what's-new CTA Product review of the five open calls closed all five; only the CTA copy changes. The button opens the release notes, so it now says so — 「查看更新说明」 in Chinese (product's wording, verbatim), with the other 18 locales aligned on "view the release notes" instead of inheriting the old "see what's new" tease. `whatsNew.cta` had exactly one consumer left (the dialog) once it replaced the bottom-right card, so the key is repurposed in place rather than forked. The other four calls were approved as implemented, and two of them overturn conventions the old comments still asserted, so those comments are synced: - `lib/whats-new.ts` documented the toast's deliberate Escape exemption (Escape hid the card WITHOUT spending the highlight). As a focus-trapping modal that exemption is retired — every close path marks seen — and the docblock now says which paths and why. - `contracts/api/whats-new.ts` described `title` as generic copy, `imageUrl` as an image "beside the copy", `body` as prose, and `linkUrl` as the "See what's new" link. The dialog reads them as headline / top-inset cover / one-bullet-per-line / release-notes link, so each field now documents the role it actually plays. Comment-only; no shape change. * fix(web): never state the placeholder version on the what's-new dialog Review catch (nettee, #6163): `useAppVersion()` reads /api/version at runtime, so it necessarily boots on `APP_VERSION_PLACEHOLDER` and resolves a round-trip later. This dialog rendered as soon as /api/whats-new resolved and printed the hook unconditionally — so a highlights fetch that won that race painted "Open Design 0.0.0 is here" on first frame. An invented version string, on the one surface this PR exists to make truthful. The new suite could not see it because it mocked the hook to an already-resolved value. Fixed by falling back rather than gating the whole render: gating would delay the dialog behind a second round-trip on slow networks, while the highlights document already carries the running version in its `version` field — the daemon stamps it for display (see contracts/api/whats-new.ts), which is what fed the old bottom-right card's eyebrow. So `statedAppVersion()` prefers the resolved hook, falls back to the document, and returns null when neither can name one; on null the dialog waits, because highlights are worth nothing under a headline that lies. The same derived value feeds the title AND every `app_version` prop, so analytics cannot ship the placeholder either. `APP_VERSION_PLACEHOLDER` moves out of ./provider into a new leaf analytics/app-version.ts alongside an `isResolvedAppVersion()` predicate, so a surface that must not PRINT a placeholder can test for it without importing the analytics client. The provider keeps its own use of the constant. Regression tests, red before this commit (`.red-evidence-version-race.txt` reproduces nettee's exact string, `expected 'Open Design 0.0.0 is here…' not to contain '0.0.0'`): the placeholder never paints while /api/version is in flight, it never reaches the surface-view analytics, the title switches to the running version once the hook resolves, and a document with no usable version waits instead of inventing one. The suite's provider mock is now a mutable holder so both sides of the race are reachable. Also caught by this PR's own source guard: quoting the literal in a docblock tripped it, so the comment names the constant instead. * test(e2e): unmask the AMR runtime lane and repair the visual settings/workspace captures (#6174) Three fixture defects, each verified by running the lane rather than reading it. - amr-run-failure-recovery.test.ts was still `mode: 'serial'`. A second `describe.configure({ timeout })` cannot clear it — configure only overwrites the keys it is given — so one failure skipped the eight cases behind it and the lane reported them as "did not run". e2e/AGENTS.md forbids serial groups outright. - configureVisualPage's `**/api/**` catch-all (added by #6126, which is on main) answered `/conversations` with 404. ProjectView mounts ChatPane only once a conversation resolves, and both listConversations and createConversation swallow a non-ok response, so the project opened with no conversation and no error, ChatPane never mounted, and every capture that enters the workspace died on `chat-composer` not existing. Supplies the conversation boundary the catch-all closed without replacing. - The BYOK tab is named "API providers" since #5971 renamed `settings.modeApiMeta`; `getByRole('tab', { name: 'BYOK' })` matched nothing and three captures hung until the test timed out. The topbar BYOK capture also left `[MOCK_AGENT]` installed, contradicting its own "a BYOK config has no local agent" premise: an installed agent wins the popover and renders its model radiogroup instead of the BYOK rows. Co-authored-by: lefarcen <ontf116@gmail.com> * Home shell productization batch: message center, What's-new on release data, update-reminder & updater popup restyle (#6162) * UI polish batch: import-dialog cleanup, settings toast placement, tab chrome fixes - Plugin/skill import dialog: drop per-card icons, left-align card content - Settings page: center the autosave pill under the top nav - Avatar model menu: soften the pinned footer divider - Workspace tabs: pinned entry tab always reads as Home; remove tab-search button and its popover - Model picker: wider row gap in the two-pane browse popover - Community header: unpin (scrolls with content) - New update-reminder dialog assets and strings - Keep entry-settings-button as the signed-out settings entry (e2e contract); align NextStepActions with the navPlugins key rename * Update reminder: simulate in-place download progress instead of linking out The strip's confirm now runs a staged progress state ('updating') with an eased fake download bar, then marks the version updated — replacing the jump to the GitHub releases page. * test(web): retire AvatarMenu account-row suites after the popover simplification The composer popover was reduced to a model picker (2026-07-24): the Open Design account row — plan badge, balance, wallet fallback, upgrade/console links — no longer renders there, so the nine suites asserting that surface (including the workspace-balance and billing-permission gates that landed upstream meanwhile) can no longer pass. Drop them and recast the remaining account-row test as the guard for the new invariant: a signed-in AMR status must render no account UI in the popover. Also drop the stale onClose prop from the new upstream sign-out-confirm test; the rail no longer takes one. * Design-systems toast: anchor to the pane's top edge, pill radius * Update reminder strip: progress ring on the button, percent face While updating, the collapsed strip no longer stacks a label + bar above the rocket button. Progress draws as a brand-green ring hugging the round button's edge (5px band reserved so the button never shifts), and the button face swaps the rocket for the bare percent — 'N%' while downloading, 完成 (updater.done) at 100. The full 正在下载更新 N% label stays reachable via the hover bubble and the ring's aria-label. Demo payload version bumps to 1.4.6 so the once-per-version card re-arms. * Home shell productization batch: message center in the account menu, What's-new on real release data - Message center: entry moves into the account menu under 设置 (bell row); MessageCenter becomes controllable (hideTrigger + open/onOpenChange + onUnreadCountChange) and mounts persistently in EntryNavRail so unread polling survives the hover menu; unread shows as a red dot on the account avatar (and on the menu row); avatars go from squircle clip to 8px rounded squares; panel filter segmented control and mark-all-read go pill-radius. - What's-new: the post-update highlights card now wears the shared release-dialog shell (cover / title / dotted notes / pill footer) while keeping its REAL /api/whats-new data source, show-once-per-id timing, soft-hide Escape/backdrop semantics, and analytics. The demo-payload update-reminder dialog and its simulated download no longer trigger; the stage machine and progress strip stay for the real updater-feed wiring. - Rail chrome: brand logo and the in-rail collapse control are gone — the rail starts at the search box and folding lives in the pinned Home tab's toggle; the signed-out cloud tip bottom inset now matches its sides. - Brand picker (modal variant): side-nav split — search + vertical category nav left, a roomy 2-up borderless gallery right, content flush to the column line with hover fill/ring bleeding past it; logo tiles lose their boxed edge; selected template chip and the composer mode pill share the same selected ring, with the mode glyph optically matched at 13px. - Tab launcher: rows are icon + label only (descriptions dropped) with a rotating four-hue icon palette; the 新建空白页面 entry is paused behind ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT and its PageCreator suites skipIf on the same switch. - Move-to-team confirmations: extracted MoveToTeamConfirmDialog, no backdrop blur (dim only), and the confirm keeps its brand-green label through the mention-home ink-pill restyle in every interaction state. - Publish CTA: hover inverts to ink fill with brand-green icon + label. - Plus assorted in-progress workspace polish (composer, viewer, settings, model icons) riding the same branch iteration. * Restyle the updater ready popup to the update-reminder dialog language Cover art on top (compressed 1296x555 jpg, natural aspect), the orange accent icon tile removed, secondary action as a soft white pill and the install CTA as a black pill with brand-green ink, and a stacked footer so the silent-update checkbox keeps a full-width single line instead of wrapping beside the widened pills. * Signed-out rail: message-center entry under 设置 The signed-out rail has no account menu (where the 消息中心 row lives when signed in), which left the message panel with no opener at all. The rail item carries the unread dot. * Composer row: pin chip glyphs against flex shrink The row-wide min-width: 0 that lets chip labels ellipsize also let SVG glyphs absorb the squeeze — a hard-narrowed pane rendered the design-system palette icon tiny. Chips still shrink; their icons keep natural size. * Bind Community Remix projects to the current workspace Addresses mrcfps' review thread on apps/web/src/App.tsx:3303. Product reproduction: 「通过 remix 后的方案, 不出现在我当前 workspace 里的 草稿了」 — stable repro in a team workspace. `duplicatePluginAsProject` only sends the workspace/member identity headers when its third `workspaceContext` argument is supplied, and the daemon's `authorizeCreatedProjectWorkspace` deliberately reads a COMPLETELY headerless create as a legal legacy/anonymous caller — `bindCreatedProjectToWorkspace` is a no-op for a null context. Both remix call sites this PR adds (App.tsx's standalone /community branch and EntryShell's community tab) omitted the argument, so a remix performed inside a team workspace produced a project bound to no workspace at all and therefore invisible to every workspace view. Same enforceWorkspaceProjectMutation / created-project bypass class as c0bce3b8f, fixed the same way: thread the already-resolved workspace context that both files already hold, through the repo's existing `resolvedWorkspaceContextForWrite` seam so an unresolved or unavailable authority fails closed instead of silently creating an unbound orphan. The second half of the review note — the follow-up `patchProject` whose `null` was ignored — was a consequence of the same root cause: with the project unbound, `enforceWorkspaceResourceMutation` finds no workspace row for the caller's workspace and 403s the pendingPrompt seed, so the template prompt was dropped too. Both requests now share one resolved authority, so they can never disagree about which workspace they are acting in, and a still-null result is reported instead of swallowed (without creating a second project, which is what routing it through the existing catch would do). Red spec first: apps/web/tests/components/community-remix-workspace-binding.test.tsx drives both call sites in a team workspace against a fetch stub that reproduces the daemon's create-then-bind contract. Red before this change (the create carried no workspace headers, the project stayed unbound, and the seed PATCH 403'd), green after. * Cover the refused-prompt-seed branch of the Community Remix fix Second half of mrcfps' review ask on the same thread: prove what the flow does when the follow-up `patchProject` still returns null after the binding fix. The remaining null shapes are genuine transient refusals (daemon down, membership revoked mid-flight, workspace locked between the two requests). By then the copied project is real and bound, so the flow deliberately does NOT fall into its own catch — that path creates a second, prompt-only project and would strand the copy. It keeps the user on the remix and reports the dropped seed. This case pins that: user lands on the remixed project, no second project is created, and the failure is surfaced rather than swallowed. * Re-anchor three style specs on the dogfooded UI `Web workspace tests` went red on this branch because three CSS specs still described the pre-dogfood layout the restyle replaced. Each one is re-pointed at what shipped, keeping the invariant it was written to protect: - `acceptance-visual-fixes` (recvpYDfW12NBu): the preset-thumbnail fix is the resting `scale(1.15)` framing transform, which is intact. Its hover partner went away with the family-wide cover-zoom removal (2026-07-27), so the spec now asserts the framing zoom on both media surfaces and separately locks the hover rules out — re-adding one would resample the covers soft again. - `mention-popover`: the filter strip scrolls on one line instead of wrapping to a second row. The "no clipped labels" half of the contract is unchanged and still asserted (pills hold natural width, labels stay `nowrap`). - `settings-polish`: the updater popup footer stacks the silent-update checkbox above a 50/50 action row. Long en labels must still wrap inside the checkbox column rather than overflow the panel, which the spec keeps checking. * Return message-center focus to the rail opener that owns it `hideTrigger` handed the message-center entry point to the rail but left the component's internal `triggerRef` unattached, so `closePanel()` restored focus to nothing. Opening focuses the portaled dialog, so every close — button, backdrop, Escape, notification-settings — unmounted the focused node and dropped keyboard focus to the document. Both external openers also failed to advertise the dialog they own. `MessageCenter` now takes a `returnFocusRef`: the host that hides the built-in bell owns the duty that button used to serve. `EntryNavRail` points it at a control that is still mounted after the close — the account trigger on the signed-in branch, because the hover menu unmounts the 消息中心 row before the panel opens, and the rail item itself when signed out. Both openers carry `aria-haspopup="dialog"` and the live `aria-expanded`. Red first: `EntryNavRail.message-center-entry.test.tsx` opens from each entry, closes with Escape and with the close button, and asserts focus lands back on the host control. All four cases failed before this change. * Carry the menu popup contract onto the composer quick pills The 插件 / 设计百宝箱 pills open the standalone `role="menu"` popovers in ChatComposer, but they arrived without the contract of the ComposerPlusMenu rows they replaced: no `aria-haspopup` / `aria-expanded`, no Escape handler, and no focus return. Keyboard and screen-reader users could not tell a popup was open, and Escape pressed while focus sat in the plugin search did nothing. The pills live in ChatPane while the popovers live in ChatComposer, so the state is plumbed both ways rather than duplicated: - `onStandalonePanelChange` reports which popover is open, and each pill carries `aria-haspopup="menu"` plus its own `aria-expanded` — the same pair `ComposerPlusMenu` already puts on its trigger. No `aria-controls`: that surface does not use one, and this is not the place to invent a second convention. - `openDesignToolbox` / `openPluginsPanel` take the opening pill as the return-focus target. Both popovers move focus inside themselves, so a dismissal has to hand it back, and the pill is the control the user came from. - Escape closes through a document-level handler, mirroring ComposerPlusMenu's. `dismissStandalonePanels` is deliberately only wired to the dismissal paths (Escape, backdrop). Picking a plugin or an action keeps the plain setters, because those hand focus to the composer input and pulling it back to the pill would fight that. No interaction changes: hover-open, the shared close timer, pill-to-popup pointer travel and every selection path behave exactly as before. Red first: `tests/components/ChatPane.quick-pill-popup-contract.test.tsx` runs both pills through advertise / expand / Escape-from-inside / backdrop-dismiss and asserts focus lands back on the pill. All six cases failed before this change. --------- Co-authored-by: lefarcen <935902669@qq.com> * Converge the visual Design Files prelude instead of clicking once (#6181) `Playwright visual (settings-workspace)` went red on `[P2] captures the settings BYOK surface` with a 10s `aria-selected` timeout: the Design Files tab sat at `false` for all 23 polls with nothing re-clicking it. Seven other captures run this identical prelude and passed, including the two neighbouring BYOK cases. The defect is in the oracle, not the product. `prepareVisualWorkspaceFileList` decided **once** whether to click — one instantaneous `isVisible()` probe of a file row — and then asserted a state nothing retries. Any interleaving where the workspace's own tab reconciliation lands around that single click leaves the assertion permanently unsatisfiable for that attempt, and the lane runs `OD_PLAYWRIGHT_FULLY_PARALLEL=1` with `retries: 0`, so it reports as a hard failure rather than a flake. `activateVisualDesignFilesTab` now converges on the goal state via `toPass`, driving off `aria-selected` directly rather than through the file-row proxy — the guard and the assertion were about different things even though FileWorkspace derives both from one `activeTab === DESIGN_FILES_TAB` expression. Repeat clicks are safe: the tab's handler is `setPersistedActive(DESIGN_FILES_TAB)`, which is idempotent. Why this is not a product regression from #6162: nothing in it touches tab selection, tab persistence, or row visibility. Its only `design-files.css` change deletes the hover cover-zoom, which cannot affect visibility, and a diff of `apps/web/src` for `localStorage|activeTab|openTabs|restoreTab` comes back empty. The lane's one green run before this failure (`4a6653186`, the #6174 repair of the unrelated #5971 BYOK rename) is the whole basis for calling the baseline clean. Scope is the visual lane only — every caller is in `visual.ts` or `visual-*.test.ts`, so the UI P0 lanes are untouched. * fix(web): explain the AMR workspace-scope block instead of a dead send button (#6178) An Open Design Cloud project run requires a resolved personal/team workspace authority (21f452ffe). Failing closed there is correct and stays. Failing closed SILENTLY was the bug: the send button went grey with no reason on screen and no way out, while Home's equivalent dead end (checkAmrBalanceGate -> AmrBalanceDialog reason `signed_out`) hands the user an in-app sign-in. e2e/ui/amr-logout-requires-relogin.test.ts exists to protect exactly that route, which a disabled composer can never reach. Adds a classifier (`amrWorkspaceScopeBlock`) that turns "gate closed" into the remedy that clears it, and a composer-adjacent notice that renders it: signed_out — the same AmrLoginPill action and `chat.amrBalanceGate.signInCta` copy Home's balance gate uses, so one identical action clears it from either surface. unresolved — a re-read of the project's workspace authority, exposed as `ProjectWorkspaceScopeState.revalidate`. An account action would be a guess. The gate itself is untouched: `projectRunWorkspaceScopeReady` never consults the classifier, and the `disabled: true` invariant stays asserted. * test(updater): join the installer-reinstall floor across release and client (#6167) * test(updater): join the installer-reinstall floor across release and client A payload update never replaces the Electron outer shell, so a release whose shell changed can only reach an old install through the installer. The `control.launcher.version.{min,url}` floor is the one mechanism that forces that, and it spans three owners: tools/release resolves the channel policy, the feed carries it, and the desktop updater enforces it against the physically installed outer version. Every side had unit coverage; nothing joined them, so the only place a break in the chain would surface was a release publish. Adds six specs to the already-allowlisted packaged-launcher cross-boundary suite, driving the real release-side resolver into a real feed and then into a real packaged update check: - an outer below the floor takes the installer, not the payload, and the launcher pointer proves nothing was adopted; - the floor is judged by the installed outer rather than the payload it is running (two installs on the same payload, only the stale shell diverted); - an unreadable installed outer fails closed as `outer-version-unreadable` with no installedVersion to render; - a launcher schema beyond this client diverts on the ABI axis alone; - with no floor configured a below-floor outer still swallows the payload, which is why a shell-changing release must set the repo vars deliberately; - a floor above the release version stays refused. Falsifying `remoteRequiresReinstall` to compare against `config.currentVersion` -- the semantics the released 0.15.1/0.16.1 shells actually ship -- turns two of these red, including the unreadable case being mislabelled as below-min. Also pins the floor's operational shape against real channel version formats: a bare stable floor sorts above every same-base `-beta.N`/`-preview.N`/ `-prerelease.N`, so inheriting it into those lanes hard-fails their publish. The specs name that trap and pin the one-explicit-pair-per-lane configuration that avoids it. * test(updater): drive the reinstall floor on Windows too, off the real outer Review (nettee) caught these specs running macOS only: the feed described `platforms.mac`, the payload writer built a `.app`, and the updater config was pinned to arm64/darwin. So `resolveInstalledOuterVersion`'s Windows branch — `dirname(launcherLaunchPath)/resources/open-design-config.json`, a different lookup from the mac bundle path — and the `installer` artifact shape never ran. Since the installs most exposed to a shell/payload mismatch are the oldest ones, leaving Windows uncovered left the half most likely to break unguarded. The specs now run through `describe.each` over a per-platform target table carrying the launch-path shape, outer-config location, installer artifact key, and payload extractor. They also stop using `OD_UPDATE_INSTALLED_VERSION`. That override returns before the platform branch it was supposed to exercise, so it was masking the code under test. Each scenario now materializes a real installed outer package at the real per-platform location and lets the updater read the version off disk — which also makes the unreadable-outer case genuinely unreadable rather than simulated. Falsifying the Windows lookup (collapsing it into the macOS path) fails exactly the two Windows specs that depend on reading a real installed version, while all twelve macOS and publication-policy specs stay green. * fix(web): partition the workspace plan nameplate like the wallet (#6182) A workspace Vela Web shows as 免费 rendered as 专业版 Plus in the client's account menu, while the 额度 row in the same card correctly read $0.00. Money and plan travelled different paths. `GET /api/workspace/billing` returns three independently scoped things, and only two of them are about the workspace: `workspaceBalance` / `workspaceSnapshot` are proven for the exact workspace + member, while `summary` is the caller's VELA ACCOUNT billing (`workspaceId: null` by contract; the daemon reads it with one unscoped `fetchBilling()` regardless of `?workspaceId=`). The rail's plan nameplate read `response.summary` raw, so an account holding a personal Plus reported `plus` in every workspace — a value that cannot change when the workspace changes, because it was never about the workspace. `workspaceBillingSummaryForContext` makes the partition key explicit, the same `workspaceId` + `workspaceMemberId` pair money already uses: - personal workspace — the account IS the scope, summary passes through; - team workspace — plan comes from the context-authorized snapshot, and the account tier may stand in only when it is itself team-namespaced, since a personal tier cannot name a team workspace's plan. That last clause keeps the 飞书 P0 fix intact: B omits planId/billingState for a non-owner and `workspaceSnapshot` is an additive capability, so a team-namespaced account tier is the only surviving evidence that a paying MEMBER's team is subscribed. `useWorkspaceBilling` is now this projection over the ambient context, and EntryShell / SettingsDialog consume it instead of the raw summary. No interaction, layout, or copy changes. * Revert "fix(web): explain the AMR workspace-scope block instead of a dead send button (#6178)" (#6184) This reverts commit ea259eb402fa8d35300e6d0c33c09a40b27ab6e7. * fix(daemon): resolve an unbound project against the caller's current workspace (#6185) * fix(daemon): resolve an unbound project against the caller's current workspace Every project must resolve to a workspace; when the project itself has no binding, the workspace is the one the caller is currently acting in. There is no other candidate. `GET /api/projects/:id/workspace-scope` read only the persisted `workspace_projects` row and the membership directory — never the request's `x-od-workspace-*` identity — so a project with no row answered `unbound` for every caller forever. `unbound` makes `projectWorkspaceScopeAuthorizesAmr` false, which disabled the chat composer's send button for an Open Design Cloud run on that project permanently, with nothing the user could do to clear it. #6178 wrote user-facing copy for that state instead of fixing it (reverted in #6184). `resolveProjectWorkspaceScopeForCaller` wraps the existing resolver instead of branching inside it, so `resolveProjectWorkspaceScope` stays byte-identical and the bound path is provably untouched. The fallback re-enters that resolver with a synthetic binding naming the caller's own workspace, so the resulting `workspaceMemberId` comes from the membership directory and never from the request header: a caller can only select among workspaces their signed-in identity is genuinely an active member of, and anything the directory cannot confirm degrades back to `unbound` rather than to `unavailable` on a workspace the project was never bound to. Two cases keep today's behavior, both pinned by spec: unavailable — all three of its return sites. A project pinned to workspace X read by a member of Y answers X. The scope carries `workspaceMemberId`, the wallet that pays for the project's runs; resolving to Y would bill Y for X's project. Reachable because `GET /api/projects/:id` has no workspace gate, so a deep link or a workspace switch lands there. no caller identity — signed out, or a plain curl, has no current workspace to fall back to, and inventing one is worse than answering "none". The fallback deliberately does not persist a binding. `reconcileUnboundProjectBeforeMutation` was the preferred shape going in and is wrong here: its own docblock is explicit that a passive read must not hand out ownership just because it ran first, and this endpoint is a GET. Writing a row from it would let whichever workspace opened the project first claim authorship on no user intent, and would race two clients in different workspaces. * fix(web): stop pre-emptively blocking an AMR send that has a wallet An Open Design Cloud run is billed to the CALLER's own wallet, so the only defensible client-side veto is "there is no billing principal at all". The gate instead required THIS PROJECT's workspace scope to resolve, and blocked the send whenever it did not: an unbound project, a membership-directory read that transiently failed (offline / 504 / timeout all collapse into one `ok: false` upstream), a workspace that is billing_past_due or locked, or a team the caller has since left. In every one of those the user is spending their own quota. It is also not the enforcement point. Real enforcement is server-side — the daemon's `WORKSPACE_CONTEXT_REQUIRED` 401 plus vela's own billing check. The client gate could only convert a request the server would have answered into a dead, unexplained button, which is strictly worse than an honest server error. It arrived as 21f452ffe, whose entire message is "fail closed on unresolved workspace authority" with no body and no stated product requirement. `workspaceIdentityCanBillAmr` names the invariant on the identity read, and the gate now admits either witness of a billing principal: that identity, or a project scope that already resolves to an explicit personal/team principal. Strictly a widening — every state it newly admits was previously blocked, and nothing previously admitted becomes blocked. Deliberately NOT treated as "no wallet": loading — the identity read holds no answer yet. Reporting "signed out" on a frame that has not heard back is the bug shape this replaces. failure 'unavailable' — a transient outage taught us nothing about the user. failure 'unsupported' — an old daemon with no workspace endpoint keeps its legal pre-workspace behavior. A genuinely signed-out caller — a settled, authoritative read that came back with no workspace — stays blocked: there is no wallet, so the run cannot be billed and cannot succeed. No UI, no notice, no copy: #61…
1 parent d87ba8e commit 356c8c3

1,268 files changed

Lines changed: 279853 additions & 32338 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,8 @@ jobs:
753753
handoff_dir="$(python3 .github/scripts/handoff.py dir comment "$handoff_id" --root "$handoff_root")"
754754
mkdir -p "$handoff_dir"
755755
marker="<!-- merge-queue-needs-validation -->"
756+
# Markdown code spans are intentionally literal in these single-quoted strings.
757+
# shellcheck disable=SC2016
756758
{
757759
printf '%s\n' "$marker"
758760
# Markdown code spans are intentional literal text.

.github/workflows/release-beta.yml

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ on:
5555
required: false
5656
type: string
5757
default: ""
58+
amr_profile:
59+
description: "Optional AMR profile baked into the build (e.g. feature-test to point the packaged app at that profile's AMR backend and enable the workspace-team transport). Empty leaves the default (prod)."
60+
required: false
61+
type: string
62+
default: ""
5863
mac_arm64_sign_mode:
5964
description: "macOS arm64 signing mode."
6065
required: true
@@ -205,6 +210,10 @@ on:
205210
required: false
206211
type: string
207212
default: ""
213+
amr_profile:
214+
required: false
215+
type: string
216+
default: ""
208217
mac_arm64_sign_mode:
209218
required: false
210219
type: string
@@ -307,6 +316,20 @@ env:
307316
POSTHOG_HOST: ${{ inputs.publish && vars.POSTHOG_HOST || '' }}
308317
POSTHOG_CLI_API_KEY: ${{ inputs.publish && secrets.POSTHOG_CLI_API_KEY || '' }}
309318
POSTHOG_CLI_PROJECT_ID: ${{ inputs.publish && vars.POSTHOG_CLI_PROJECT_ID || '' }}
319+
# Vela web console origin for the AMR profile this build targets. Held in a
320+
# per-profile repository secret instead of the source tree: the non-prod AMR
321+
# environments are internal deployments and this repository is public, so
322+
# neither the workflow nor the product code may name their hostnames.
323+
#
324+
# tools-pack bakes it into open-design-config.json and the packaged runtime
325+
# forwards it to the daemon as OD_VELA_WEB_URL, which is the second half of the
326+
# workspace-team gate (the first being the AMR profile allowlist). A profile
327+
# whose secret is not configured therefore ships with workspace-team dormant
328+
# rather than pointed at an unknown backend. `prod` never gets an origin — the
329+
# packaged gate rejects it regardless.
330+
#
331+
# Secrets read here: VELA_WEB_URL_FEATURE_TEST, VELA_WEB_URL_TEST.
332+
OD_VELA_WEB_URL: ${{ inputs.amr_profile == 'feature-test' && secrets.VELA_WEB_URL_FEATURE_TEST || inputs.amr_profile == 'test' && secrets.VELA_WEB_URL_TEST || '' }}
310333
RELEASE_BRANCH: ${{ github.ref_name }}
311334
RELEASE_REPOSITORY: ${{ github.repository }}
312335
RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }}
@@ -475,6 +498,13 @@ jobs:
475498
APPLE_ID: ${{ secrets.APPLE_ID }}
476499
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
477500
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
501+
# Bake the AMR profile (tools-pack reads OPEN_DESIGN_AMR_PROFILE) so a
502+
# feature-test dispatch produces a packaged app that targets that
503+
# profile's AMR backend and enables the workspace-team transport. The
504+
# matching console origin arrives via the workflow-level
505+
# OD_VELA_WEB_URL. Empty = default (prod) — the normal beta pipeline is
506+
# unaffected.
507+
OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }}
478508
run: |
479509
set -euo pipefail
480510
mkdir -p "$RUNNER_TEMP/release-build/mac_arm64"
@@ -508,6 +538,11 @@ jobs:
508538
509539
- name: Retry beta mac_arm64 without restored cache
510540
if: ${{ steps.mac_arm64_tools_pack_build.outcome == 'failure' }}
541+
env:
542+
APPLE_ID: ${{ secrets.APPLE_ID }}
543+
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
544+
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
545+
OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }}
511546
run: |
512547
set -euo pipefail
513548
rm -rf "$RUNNER_TEMP/tools-pack-cache"
@@ -540,6 +575,42 @@ jobs:
540575
exit "$build_status"
541576
fi
542577
578+
- name: Upload mac_arm64 DMG for manual distribution
579+
# Only when NOT publishing to the release feed: this is the downloadable
580+
# artifact for a manual dispatch (e.g. a signed feature-test build handed
581+
# to teammates). The normal publish=true release pipeline is unchanged.
582+
if: ${{ !cancelled() && !inputs.publish }}
583+
uses: actions/upload-artifact@v7
584+
with:
585+
name: open-design-beta-mac-arm64-dmg
586+
path: ${{ runner.temp }}/tools-pack/out/mac/namespaces/release-beta/**/*.dmg
587+
if-no-files-found: warn
588+
retention-days: 14
589+
590+
- name: Upload mac_arm64 dogfood build to release storage
591+
# Companion to the artifact step above, for publish=false only. A GitHub
592+
# artifact needs a logged-in teammate and a zip round-trip; this puts the
593+
# same DMG in R2 under dogfood/<version>/<build>/ so the link can be
594+
# handed over directly. It writes no channel metadata and no latest
595+
# pointer (enforced by the guard in tools/release/src/storage/dogfood.ts),
596+
# so it cannot change what an installed client sees as an available
597+
# update. The publish=true pipeline is untouched.
598+
if: ${{ !cancelled() && !inputs.publish }}
599+
continue-on-error: true
600+
env:
601+
DOGFOOD_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
602+
DOGFOOD_BUILD_JSON_KEYS: dmgPath
603+
DOGFOOD_BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.json
604+
DOGFOOD_LABEL: macOS arm64 beta ${{ needs.metadata.outputs.beta_version }}
605+
DOGFOOD_VERSION: ${{ needs.metadata.outputs.beta_version }}
606+
RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin != '' && inputs.release_public_origin || vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
607+
RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
608+
RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
609+
RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
610+
RELEASE_STORAGE_REGION: auto
611+
RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
612+
run: pnpm exec tools-release publish-dogfood
613+
543614
- name: Delete failed mac_arm64 tools-pack cache
544615
if: ${{ steps.mac_arm64_tools_pack_build.outcome == 'failure' && steps.mac_arm64_tools_pack_cache_restore.outputs.cache-matched-key != '' }}
545616
continue-on-error: true
@@ -1054,6 +1125,14 @@ jobs:
10541125
OD_BETA_WINDOWS_SIGN_CERT_SHA1: ${{ steps.sign_probe.outputs.thumbprint }}
10551126
OD_PACKAGED_E2E_WIN_UPDATE_METADATA_URL: ${{ inputs.win_x64_update_metadata_url }}
10561127
OD_PACKAGED_E2E_WIN_UPDATE_VERSION: ${{ inputs.win_x64_update_target_version }}
1128+
# Bake the AMR profile (tools-pack reads OPEN_DESIGN_AMR_PROFILE) so a
1129+
# feature-test dispatch produces a packaged app that targets that
1130+
# profile's AMR backend and enables the workspace-team transport. Empty
1131+
# = default (prod) — the normal beta pipeline is unaffected. Mirrors the
1132+
# mac_arm64 build step above; this was missing here, so every win_x64
1133+
# dogfood build silently fell back to prod and could not sign in to a
1134+
# feature-test AMR environment.
1135+
OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }}
10571136
run: |
10581137
$ErrorActionPreference = "Stop"
10591138
New-Item -ItemType Directory -Force -Path "${{ runner.temp }}\release-build\win_x64" | Out-Null
@@ -1070,6 +1149,8 @@ jobs:
10701149
id: win_tools_pack_build_retry
10711150
if: ${{ steps.win_tools_pack_build.outcome == 'failure' }}
10721151
shell: pwsh
1152+
env:
1153+
OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }}
10731154
run: |
10741155
$ErrorActionPreference = "Stop"
10751156
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "${{ runner.temp }}\tools-pack-cache"
@@ -1083,6 +1164,45 @@ jobs:
10831164
$build = $buildOutput | ConvertFrom-Json
10841165
pnpm.cmd exec tools-pack win validate-payload --namespace release-beta-win --payload-path $build.payloadPath --expected-version "${{ needs.metadata.outputs.beta_version }}" --json
10851166
1167+
- name: Upload win_x64 installer for manual distribution
1168+
# Only when NOT publishing to the release feed, mirroring the mac DMG
1169+
# step. Before this existed a publish=false dispatch left the Windows job
1170+
# with nothing retrievable at all, so a QA/dogfood build could not be
1171+
# handed to a teammate without also publishing to the public feed. The
1172+
# glob covers every win_x64_target: nsis produces the setup exe, zip the
1173+
# portable zip, all both. The normal publish=true pipeline is unchanged.
1174+
if: ${{ !cancelled() && !inputs.publish }}
1175+
uses: actions/upload-artifact@v7
1176+
with:
1177+
name: open-design-beta-win-x64-installer
1178+
path: |
1179+
${{ runner.temp }}\tools-pack\out\win\namespaces\release-beta-win\builder\*-setup.exe
1180+
${{ runner.temp }}\tools-pack\out\win\namespaces\release-beta-win\builder\*-portable.zip
1181+
if-no-files-found: warn
1182+
retention-days: 14
1183+
1184+
- name: Upload win_x64 dogfood build to release storage
1185+
# publish=false only. Artifact paths come from the build's own --json
1186+
# output rather than a hardcoded layout, so whichever of installerPath /
1187+
# portableZipPath the selected win_x64_target actually produced is what
1188+
# gets uploaded. Writes under dogfood/<version>/<build>/ and nowhere
1189+
# else; see the guard in tools/release/src/storage/dogfood.ts.
1190+
if: ${{ !cancelled() && !inputs.publish }}
1191+
continue-on-error: true
1192+
env:
1193+
DOGFOOD_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
1194+
DOGFOOD_BUILD_JSON_KEYS: installerPath,portableZipPath
1195+
DOGFOOD_BUILD_JSON_PATH: ${{ runner.temp }}\release-build\win_x64\build.json
1196+
DOGFOOD_LABEL: Windows x64 beta ${{ needs.metadata.outputs.beta_version }}
1197+
DOGFOOD_VERSION: ${{ needs.metadata.outputs.beta_version }}
1198+
RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin != '' && inputs.release_public_origin || vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
1199+
RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
1200+
RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
1201+
RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
1202+
RELEASE_STORAGE_REGION: auto
1203+
RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
1204+
run: pnpm exec tools-release publish-dogfood
1205+
10861206
- name: Delete failed Windows tools-pack cache
10871207
if: ${{ steps.win_tools_pack_build.outcome == 'failure' && steps.win_tools_pack_cache_restore.outputs.cache-matched-key != '' }}
10881208
shell: pwsh

.github/workflows/release-branch-direct-pr-guard.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ jobs:
5151
body="$(printf '%s\n' \
5252
'🚫 Direct PRs into release branches are not accepted.' \
5353
'' \
54-
'Changes reach a release branch through the **backport flow**, not by targeting `release/*` directly:' \
54+
"Changes reach a release branch through the **backport flow**, not by targeting \`release/*\` directly:" \
5555
'' \
56-
'1. Open your PR against **`main`** and get it merged there.' \
57-
'2. Add the **`backport release/vX.Y.Z`** label to that main PR — the release bot cherry-picks it onto the release branch automatically (resolving conflicts in a draft if needed).' \
56+
"1. Open your PR against **\`main\`** and get it merged there." \
57+
"2. Add the **\`backport release/vX.Y.Z\`** label to that main PR — the release bot cherry-picks it onto the release branch automatically (resolving conflicts in a draft if needed)." \
5858
'' \
59-
'Closing this PR. If this is a genuine release-only fix that cannot go through `main`, ask a maintainer to handle it directly.')"
59+
"Closing this PR. If this is a genuine release-only fix that cannot go through \`main\`, ask a maintainer to handle it directly.")"
6060
gh pr comment "$PR" --repo "$REPO" --body "$body"
6161
gh pr close "$PR" --repo "$REPO"

apps/daemon/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ The daemon is not a shared library for the web app. Do not import daemon private
2525
- `src/runtimes/` owns agent runtime definitions, spawning, parser integration, executable discovery, and runtime environment shaping. Agent argument definitions belong in `src/runtimes/defs/`.
2626
- `src/prompts/` owns daemon-side prompt construction. Keep mirrored BYOK/API wording in `packages/contracts/src/prompts/` when the same text is exposed outside the daemon.
2727
- `src/plugins/`, `src/connectors/`, `src/registry/`, `src/research/`, `src/media-adapters/`, `src/live-artifacts/`, `src/storage/`, and `src/critique/` own their named domains. Prefer adding code inside the existing domain folder before creating a new top-level folder.
28+
- Team resource storage is Vela-owned. Daemon adapters under `src/collab/vela-cli-*` must invoke Vela through `src/integrations/vela-command.ts`, which shares the login/agent binary resolver and environment. Do not add Resource Hub tokens, direct HTTP clients, or a second content-addressed drive implementation to Open Design. `od resource` is only a thin Vela CLI compatibility entry point.
2829
- `tests/` contains daemon tests. Keep test paths roughly parallel to `src/` when useful.
2930

3031
Do not edit generated `dist/` output.

apps/daemon/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@open-design/daemon",
3-
"version": "0.16.1",
3+
"version": "0.16.2",
44
"private": true,
55
"type": "module",
66
"main": "./dist/cli.js",

apps/daemon/src/agent-protocol/acp/json.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,16 @@ export function extractAcpUpdateText(update: JsonObject): string | null {
132132
}
133133
return null;
134134
}
135+
136+
/**
137+
* Pull a short human-readable status detail (`message` / `detail`) off an ACP
138+
* `session/update`, so a status event can carry e.g. a "compacting context"
139+
* reason. Returns `undefined` when neither field is a non-empty string.
140+
*/
141+
export function extractAcpStatusDetail(update: JsonObject): string | undefined {
142+
for (const key of ['message', 'detail']) {
143+
const value = update[key];
144+
if (typeof value === 'string' && value.trim()) return value;
145+
}
146+
return undefined;
147+
}

apps/daemon/src/agent-protocol/acp/rpc.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,27 @@ export function rpcErrorRetryable(data: unknown): boolean | undefined {
8787
const details = asObject(data);
8888
return typeof details?.retryable === 'boolean' ? details.retryable : undefined;
8989
}
90+
/**
91+
* Fallback retryability inference from the error message/details text, used when
92+
* the runtime does not set an explicit `retryable` field. `request_too_large`
93+
* (the prompt must shrink) is non-retryable; upstream transport blips
94+
* (`stream idle timeout`, `overloaded`, gateway/service outages) are retryable.
95+
* Returns `undefined` when nothing matches so callers keep their own default.
96+
*/
97+
export function inferRpcErrorRetryable(message: string, data: unknown): boolean | undefined {
98+
const details = asObject(data);
99+
const text = [
100+
message,
101+
details ? JSON.stringify(details) : '',
102+
].join('\n');
103+
if (/\b(request_too_large|request body exceeds configured limit)\b/i.test(text)) {
104+
return false;
105+
}
106+
if (/\b(upstream_error|stream idle timeout|no data received within configured window|temporarily unavailable|overloaded|gateway timeout|service unavailable)\b/i.test(text)) {
107+
return true;
108+
}
109+
return undefined;
110+
}
90111
/**
91112
* Promotes an opencode `ROLE_MARKER_HALLUCINATION` error embedded in an ACP
92113
* JSON-RPC `error.data` payload into a canonical Open Design error object.

apps/daemon/src/agent-protocol/acp/session.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@ import {
2222
ACP_RAW_EVENT_SHAPE_DIAGNOSTIC_LIMIT,
2323
AMR_STDERR_RETRY_TAIL_LIMIT,
2424
} from './constants.js';
25-
import { errorMessage, asObject, extractAcpUpdateText } from './json.js';
25+
import { errorMessage, asObject, extractAcpUpdateText, extractAcpStatusDetail } from './json.js';
2626
import {
2727
sendRpc,
2828
sendRpcResult,
2929
isJsonRpcId,
3030
rpcErrorMessage,
3131
rpcErrorData,
3232
rpcErrorRetryable,
33+
inferRpcErrorRetryable,
3334
promotedOpenCodeSessionErrorPayload,
3435
formatUsage,
3536
choosePermissionOutcome,
@@ -92,9 +93,11 @@ export interface AttachAcpSessionOptions {
9293
// `onCliReady` fires once on the first well-formed ACP JSON-RPC message
9394
// (the CLI is up and speaking the protocol); `onSessionInit` fires once when
9495
// the `session/new` handshake is acknowledged (a session id is established).
95-
// Both are best-effort and the caller dedupes, so extra calls are harmless.
96+
// `onPromptComplete` fires once when a clean `session/prompt` result is
97+
// accepted. Error paths never invoke it.
9698
onCliReady?: () => void;
9799
onSessionInit?: () => void;
100+
onPromptComplete?: () => void;
98101
}
99102
/**
100103
* Attaches an ACP protocol session to an already-spawned child process and
@@ -139,6 +142,7 @@ export function attachAcpSession({
139142
resumeSessionId,
140143
onCliReady,
141144
onSessionInit,
145+
onPromptComplete,
142146
}: AttachAcpSessionOptions) {
143147
const runStartedAt = Date.now();
144148
const effectiveCwd = path.resolve(cwd || process.cwd());
@@ -511,6 +515,10 @@ export function attachAcpSession({
511515

512516
const finishCleanPrompt = (usageSource?: unknown) => {
513517
if (finished) return;
518+
// Mark the prompt finished before notifying observers so duplicate results
519+
// and callback re-entry cannot report clean completion more than once.
520+
finished = true;
521+
onPromptComplete?.();
514522
// Flush any tools still open when the prompt completes so traces stay
515523
// complete (one tool_use + tool_result per id).
516524
flushOpenAcpTools();
@@ -524,7 +532,6 @@ export function attachAcpSession({
524532
emitToolCallTextSuppressionSummary();
525533
emitArtifactTextSuppressionSummary();
526534
emitUsageIfPresent(usageSource);
527-
finished = true;
528535
clearStageTimer();
529536
stdin.end();
530537
// Some ACP agents keep the child process alive after stdin closes,
@@ -607,7 +614,7 @@ export function attachAcpSession({
607614
failWithPayload(promotedPayload);
608615
return;
609616
}
610-
const retryable = rpcErrorRetryable(details);
617+
const retryable = rpcErrorRetryable(details) ?? inferRpcErrorRetryable(rpcErr, details);
611618
fail(rpcErr, {
612619
details,
613620
...(retryable === undefined ? {} : { retryable }),
@@ -628,9 +635,11 @@ export function attachAcpSession({
628635
}
629636
}
630637
if (update.sessionUpdate !== 'agent_message_chunk' && update.sessionUpdate !== 'agent_thought_chunk') {
638+
const detail = extractAcpStatusDetail(update);
631639
send('agent', {
632640
type: 'status',
633641
label: String(update.sessionUpdate || 'session_update'),
642+
...(detail ? { detail } : {}),
634643
elapsedMs: Date.now() - runStartedAt,
635644
});
636645
emitAcpRawShapeDiagnostic(update);

apps/daemon/src/analytics.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,13 +234,20 @@ export interface AnalyticsService {
234234
properties?: Record<string, unknown>;
235235
insertId?: string;
236236
}): Promise<void>;
237+
identifyGroup(args: {
238+
context: AnalyticsContext;
239+
groupType: 'workspace';
240+
groupKey: string;
241+
properties: Record<string, unknown>;
242+
}): Promise<void>;
237243
shutdown(): Promise<void>;
238244
}
239245

240246
const NOOP_SERVICE: AnalyticsService = {
241247
capture: async () => undefined,
242248
captureSafety: async () => undefined,
243249
mergeAnonymousPerson: async () => undefined,
250+
identifyGroup: async () => undefined,
244251
shutdown: async () => undefined,
245252
};
246253

@@ -414,6 +421,22 @@ export function createAnalyticsService(args: {
414421
// Attribution merge failures must not block app startup or consent.
415422
}
416423
},
424+
identifyGroup: async ({ context, groupType, groupKey, properties }) => {
425+
try {
426+
const appCfg = await readAppConfig(args.dataDir);
427+
if (appCfg.telemetry?.metrics !== true) return;
428+
const cleanProperties = cleanPosthogPersonProperties(properties);
429+
if (!groupKey || Object.keys(cleanProperties).length === 0) return;
430+
client.groupIdentify({
431+
groupType,
432+
groupKey,
433+
distinctId: context.deviceId,
434+
properties: cleanProperties,
435+
});
436+
} catch {
437+
// Group updates are best-effort and must never affect product reads.
438+
}
439+
},
417440
shutdown: async () => {
418441
try {
419442
await client.shutdown();

0 commit comments

Comments
 (0)