Skip to content

release: Open Design 0.8.0 β€” Everything is a plugin. Headless. Plugins create plugins.#2461

Draft
lefarcen wants to merge 45 commits into
mainfrom
release/v0.8.0
Draft

release: Open Design 0.8.0 β€” Everything is a plugin. Headless. Plugins create plugins.#2461
lefarcen wants to merge 45 commits into
mainfrom
release/v0.8.0

Conversation

@lefarcen
Copy link
Copy Markdown
Contributor

Note for reviewers: every @user mention below is wrapped in backticks so creating this PR does not ping contributors. Strip the backticks before publishing the GitHub release if you want attribution to notify people.

✊ 305 PRs Β· 75 contributors Β· 7 days β€” This isn't a release. It's a starting gun. We rebuilt Open Design's core. Then we ported the old world into it.

Three things are different underneath now:

  • 🧩 Everything is a plugin. The engine stays small and boring. Design systems, slices, prototypes, exports, and every legacy workflow β€” including Figma itself β€” all live in plugins. You write a plugin; you migrate a piece of the industry.
  • 🌐 Headless by default. The desktop app is a thin wrapper around the CLI. The same engine runs in Claude Code, OpenClaw, Hermes Agent, or straight inside a Lark / Discord / Slack chat. Design stops being a place you go and becomes a capability your agents have.
  • ♻️ Plugins create plugins. OD CLI wraps GitHub CLI, so an agent can clone the repo, scaffold a plugin, and open a PR β€” for you, or for itself. The engine grows itself, in public, with you in the loop.

Two movements behind the rebuild:

  • πŸ₯Š #DeFigma β€” kill the canvas, keep the craft. Figma isn't broken. It's just the wrong unit of work in an agent-native world. A design system shouldn't be a file you maintain. A prototype shouldn't be 200 frames you nudge by hand. The canvas was a workaround for not having a real engine. We have an engine now.
  • πŸ€– Agent-Native Design β€” the designer becomes the director. Agent-driven design is no longer a demo. It's shipping production-grade design systems, world-class UI, decks, prototypes β€” for people who cannot draw a rectangle. The 10×–100Γ— productivity jump people kept promising for AI tooling? It's here. The human gives intent, not coordinates.

This is the first release where both movements are real in the same engine. Full announcement and discussion thread β†’

πŸ”₯ Highlights

πŸ“₯ Download: Tag open-design-v0.8.0. All assets below are live on GitHub Releases and releases.open-design.ai.

Platform Architecture Asset
macOS Apple Silicon (arm64) open-design-0.8.0-mac-arm64.dmg
macOS Intel (x64) open-design-0.8.0-mac-x64.dmg
macOS (auto-update feed) Apple Silicon (arm64) open-design-0.8.0-mac-arm64.zip
Windows x64 (unsigned, auto-update πŸ†•) open-design-0.8.0-win-x64-setup.exe

✨ What's New

🧩 Plugin engine, registry & publishing

🎭 Critique Theater (Phases 9 – 16)

🎨 Design systems & tokens

πŸ€– Agents, providers & media

πŸ–₯️ Web UI

🍎πŸͺŸ Desktop & packaging

🌍 Internationalization

πŸ“Š Analytics, observability & infra

πŸ“¦ Templates, landing & tutorials

πŸ› Selected fixes

Web

Desktop & packaging

Daemon, runtime & connectivity

πŸ“š Documentation

πŸ”¨ For Developers

Click to expand

βœ… System requirements

  • 🍎 macOS β€” Apple Silicon (arm64) and Intel (x64), macOS 11 Big Sur or newer.
  • πŸͺŸ Windows β€” x64, Windows 10 / 11. Installer is unsigned but now ships packaged auto-update.
  • 🐧 Linux β€” headless lifecycle (install / start / stop from CLI). Packaged GUI artifact is still deferred while the release lane is hardened.
  • ❄️ Nix β€” official flake at flake.nix with home-manager and NixOS support.
  • πŸ§‘β€πŸ’» From source β€” Node.js 24.x and pnpm 10.33.2+ (per engines in package.json).

⚠️ Known issues

πŸ™ Thanks to everyone who shipped 0.8.0

305 PRs by 75 contributors. The "everything is a plugin" thesis only works because so many of you came at it from so many different angles. Thank you to:

@Abepena, @Derrick-xn, @Fl0rencess720, @GHX5T-SOL, @Hetsavani, @Jeshua09090, @Lucky19112002, @MJ-thunder, @MetaAlms, @MrRockySL, @Nagendhra-web, @PerishCode, @Priyanshudotdev, @Romantin, @Sid-Qin, @Siri-Ray, @Tuola-waj, @YUHAO-corn, @ZZXX-bit, @aaronjmars, @abhid-007, @alchemistklk, @ashleyashli, @asim48-ctrl, @bankielewicz, @bluto447, @bulai0408, @davezfr, @delamoer, @digitalcr8tive, @enaktes9-hub, @epicsagas, @fancyboi999, @feliciaZH, @fuyizheng3120, @hahaplus, @heylakatos, @hobostay, @jeongjin0, @jiannanya, @laihenyi, @leessju, @lefarcen, @mar2181, @mrcfps, @mrzhangkris, @mturac, @mutoe, @mzl163, @mzl2233, @neogenix, @nettee, @ngoduybien, @nmsn, @nombreregular, @ojhendershot, @orbisai0security, @pftom, @portseif, @prantikmedhi, @quangdo126, @sakshyasinha, @samay-hash, @sasha1107, @shangxinyu1, @slamj1, @spurnout, @sukumarp2022, @toby-bridges, @whoughton, @xxiaoxiong, @yuhaoyuan, @zellux, @zhangdongming0607, @zoeforfun.

A movement doesn't ship from one team's laptops; it ships from the people who showed up early and built the missing pieces. We see you. 🫑


πŸš€ Releasing this PR

This PR ships:

  1. πŸ“¦ All workspace package.json files at 0.7.0 bumped to 0.8.0 (root, apps/{web,daemon,desktop,landing-page}, packages/{contracts,host,platform,sidecar,sidecar-proto}, tools/{dev,pack,pr}, e2e). apps/packaged was already 0.8.0 from the preview lane. Independently versioned packages (packages/agui-adapter, packages/diagnostics, packages/plugin-runtime, packages/registry-protocol, apps/telemetry-worker, tools/serve) stay on their own tracks.
  2. πŸ“ CHANGELOG.md β€” new [0.8.0] - 2026-05-20 entry that crystallises the 305 PRs into a digestible release history.
  3. βž• Carries forward 10 release-branch-only stabilization commits (Windows packaged auto-update fixes, packaged identity for nightly/preview, mac Dock-icon fix, updater UI polish, visual refresh) that landed directly on release/v0.8.0.

The standard release path is:

  1. Merge this PR into main (release-branch merge-back, as with 0.7.0).
  2. Dispatch release-stable workflow with channel=nightly to validate, then channel=stable, nightly_version=... to promote.
  3. release-stable creates the open-design-v0.8.0 tag, builds + signs mac (arm64 + Intel) artefacts, builds Windows NSIS, and publishes the GitHub Release.

Full Changelog: open-design-v0.7.0...release/v0.8.0

PerishCode and others added 13 commits May 20, 2026 15:36
…ens (#2413)

The desktop-pet companion window calls setVisibleOnAllWorkspaces so it
floats across every Space. By default Electron's macOS implementation
transforms the whole process type between UIElementApplication and
ForegroundApplication while applying that call. On Electron 41 / macOS 26
that round-trip races during the launch burst β€” the pet window is
created alongside the main window β€” and the process can stay stuck as an
accessory app: no Dock icon, no menu bar, even though the windows render
fine.

Pass skipTransformProcessType: true so the pet window's all-Spaces
behavior never touches the app's process type. The desktop pet is a
cosmetic companion window; the main window keeps the app a regular Dock
app. The pet still floats on every Space via its alwaysOnTop floating
level.

Fixes #2394
* Fix Windows nightly release smoke identity

* Fix mac nightly release smoke identity
Conflicts resolved by taking origin/main on all six points:

- apps/web/src/components/HomeHero.tsx:479-487  brand div removed
  (main dropped the .home-hero__brand wrapper; the release-side visual
  refresh still had it).
- apps/web/src/components/HomeHero.tsx:894-898  attach Icon size
  18 (main's update) replaces 20 from release.
- apps/web/src/components/HomeHero.tsx:913-927  submit button uses
  <Icon name="arrow-up" size={22} /> (main's component refactor)
  instead of the release-side inline SVG.
- apps/web/src/components/EntryShell.tsx:578-582  Discord Icon size
  14 (main) instead of 16 (release).
- apps/web/src/styles/home/home-hero.css  drop .home-hero__brand /
  __brand-mark / __brand-name rules β€” main removed both the component
  div and these CSS rules together; keeping the CSS would be dead code.
- apps/web/src/styles/home/entry-layout.css  Discord badge icon color
  #5865f2 (main, the brand color introduced by PR #2386) instead of
  release's neutral var(--text-strong).
Bumps 14 workspace package.json files from 0.7.0 to 0.8.0:
- root, apps/{web,daemon,desktop,landing-page}
- packages/{contracts,host,platform,sidecar,sidecar-proto}
- tools/{dev,pack,pr}, e2e

apps/packaged was already at 0.8.0 from the preview lane.
Independently versioned packages keep their own tracks.

Adds CHANGELOG [0.8.0] - 2026-05-20 entry covering the
305 PRs merged since 0.7.0 by 75 contributors:

- Plugin engine rebuild + Plugin Registry surface
- Headless by default (desktop is thin wrapper around CLI)
- Critique Theater Phases 9 through 16
- 149 design systems with structured tokens.css
- Italian locale + CJK font fallback
- Leonardo.ai, ElevenLabs, SenseAudio providers
- Windows packaged auto-update
- Visual refresh + Quick-brief discovery overhaul
- PostHog v2 analytics
- Manual edit UX overhaul
* Fix Windows silent reinstall detection

* Remove duplicate entry plugins nav
Conflicts resolved by taking origin/main on both files. Root cause:
main's PR #2460 (fix(landing): align logo.webp with brand icon) changed
HomeHero.tsx's .home-hero__brand-mark to render <img src=/app-icon.svg>
instead of an inlined <HeroBrandIcon /> SVG, and bundled the matching
CSS (26px round badge with bg-panel + border + padding 2px) plus a
gap/font-size tune. The release-side visual-refresh CSS still targeted
the SVG layout (38px square, transparent, inset SVG selector). Keeping
release's CSS would leave main's <img> unstyled.

- apps/web/src/styles/home/home-hero.css  three blocks, all taken from
  main: .home-hero__brand gap 8px, .home-hero__brand-mark redesigned for
  <img> child, .home-hero__brand-name font-size 16px.
- apps/web/src/index.css  two blocks, both taken from main: workspace
  tab close column 22px and .workspace-tab__close 18x18 (paired
  tune-down of tab UI spacing).
@lefarcen lefarcen requested a review from Siri-Ray May 20, 2026 16:21
@lefarcen lefarcen added size/XXL PR changes 1500+ lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/chore CI / build / config / tooling labels May 20, 2026
lefarcen and others added 12 commits May 21, 2026 10:52
Conflict resolved by taking origin/main:

- apps/web/src/components/EntryNavRail.tsx  design-systems rail
  button icon name palette-filled (release-side) -> blocks (main);
  main's icon swap is part of the more recent design-systems rail
  pass.
…ds (#2508)

* i18n: add translations for media provider coming soon section (#2415)

* i18n: add translations for media provider coming soon section

- Add 'settings.mediaProviderComingSoonHint' key to all 19 locales
- Replace hardcoded English strings in SettingsDialog.tsx with i18n keys
- Reuse existing 'tasks.comingSoon' and 'settings.agentInstall.docs' keys
- Resolves TODO(i18n) comment at line 5091

* fix: escape single quotes in translation strings

* fix: escape all single quotes in English translation string

* feat(release): upload browser sourcemaps to PostHog for packaged builds

Next.js was emitting minified JS with no browser sourcemaps, so PostHog
Error Tracking surfaces frames like fO / fz / s4 / tD instead of real
file:line locations. This wires up the full pipeline:

- apps/web/next.config.ts: enable productionBrowserSourceMaps so next build
  emits .js.map alongside each chunk.
- tools/pack/src/web-sourcemaps.ts: new helper that runs after next build
  and before any packaging step copies the web output into the Electron
  resources. Uses @posthog/cli to inject chunk IDs and upload sourcemaps
  to PostHog, then ALWAYS strips every .map under .next/static so source
  never ships inside an installer (saves ~14 MB per packaged image too).
- tools/pack/src/{mac/workspace,win/app,linux}.ts: call processWebSourcemaps
  immediately after the @open-design/web build step.
- tools/pack/src/config.ts: read POSTHOG_CLI_API_KEY + POSTHOG_CLI_PROJECT_ID
  (with POSTHOG_PERSONAL_API_KEY / POSTHOG_PROJECT_ID aliases) and expose
  them on ToolPackConfig with the same shape as the existing posthogKey /
  posthogHost fields.
- .github/workflows/release-{beta,preview,stable}.yml: pass the new secrets
  through so all three release channels symbolicate stacks.

When the API key is missing (PR builds, forks, local contributor builds),
the helper logs and skips the upload β€” but still strips .map files. The
strip step is unconditional because shipping a sourcemap is equivalent to
shipping the source.

Adds tools/pack/tests/web-sourcemaps.test.ts covering: missing chunks dir
silently noop, no-map noop, strip-only path when credentials are absent,
recursive walker for nested subdirectories. CLI happy path is left to the
release workflow itself.

Required follow-up (cannot push from code): add a repo secret named
POSTHOG_CLI_API_KEY (the phx_ personal API key) and a repo var named
POSTHOG_CLI_PROJECT_ID (the numeric project id, 420348 for our project)
in nexu-io/open-design settings before merging.

* fix(web-sourcemaps): use management host for CLI, not ingest host

POSTHOG_HOST is the ingest URL (us.i.posthog.com) used by the runtime SDK
to POST events to /capture/. The @posthog/cli sourcemap upload talks to
the **management** API (us.posthog.com) and gets a 404 on the ingest
host. The two are not interchangeable.

Adds a separate `posthogCliHost` field on ToolPackConfig sourced from
POSTHOG_CLI_HOST (with no fallback to POSTHOG_HOST). When the env is
unset the @posthog/cli defaults to the US Cloud app host on its own,
which is correct for our project β€” so this PR doesn't need a new repo
variable for it.

---------

Co-authored-by: Nicholas-Xiong <[email protected]>
…#2521)

PostHog Error tracking was missing the vast majority of real exceptions:

  1. posthog-js's capture_exceptions: true is silenced by opt_out_capturing,
     so every opted-out user vanished from the error feed even though we
     could perfectly safely keep collecting their stacks (the consent
     toggle's user copy gates analytics, not safety telemetry).
  2. posthog-js is dynamically imported only after /api/analytics/config
     resolves AND the user has consented. Errors thrown during the first
     1-2 seconds (React hydration, early effects) had no listener to
     catch them.

Net effect: 14d $exception count was 54 events / 10 users across ~5k DAU,
producing the misleading 99.93% crash-free curve in PostHog's dashboard.

This PR makes exception capture independent of both gates:

  - apps/web/src/analytics/error-tracking.ts (new): own window.error +
    unhandledrejection handlers, in-memory buffer (capped at 50 entries),
    direct fetch to https://<host>/i/v0/e/ with the public phc_ key. Same
    scrub layer as the posthog-js path so file paths still get redacted.
  - apps/web/app/[[...slug]]/client-app.tsx: installErrorHandlers() at
    module-load, before React or any feature code can throw.
  - apps/web/src/analytics/provider.tsx: bootstrapExceptionTracking() in
    the identity useEffect, parallel to getAnalyticsClient() β€” runs
    regardless of consent state, fetches /api/analytics/config, hands the
    phc_ key + host + distinctId to the error tracker so buffered events
    can flush.
  - apps/web/src/analytics/client.ts: capture_exceptions: false so
    posthog-js stops also emitting $exception (would have produced
    duplicate events server-side); also re-bridges the error-tracking
    context inside the loaded() callback so future events inherit the
    fully-resolved appVersion / sessionId.
  - apps/daemon/src/server.ts + packages/contracts: /api/analytics/config
    now returns key + host even when consent=false. enabled still reflects
    only the analytics consent toggle (posthog-js full autocapture stays
    off when enabled=false), but the always-on error tracker can read key
    directly. Forks without POSTHOG_KEY still get key=null and the whole
    pipeline becomes a no-op β€” fork-safe by construction.
  - apps/web/src/analytics/scrub.ts: regex fix so packaged-mac paths like
    /Applications/Open Design.app/Contents/Resources/apps/web/... (which
    contain a space) get fully rewritten to app://apps/web/...; previously
    the [^\s] guard stopped at 'Open' and leaked the install dir.

Validation:

  - pnpm --filter @open-design/web typecheck: pass
  - pnpm --filter @open-design/web test: 199 files / 1823 tests pass
    (includes 8 new error-tracking.test.ts cases for buffer cap, hook
    install, scrub, and direct dispatch)
  - pnpm --filter @open-design/daemon test: 250 files / 2971 tests pass
  - pnpm guard: pass

After release/v0.8.0 ships and rolls out, expect the crash-free curve to
drop from the artificial 99.93% to a realistic 95-98% β€” that's not a
regression, it's the first time we're measuring it.
…cles (#2525)

* fix(web): decouple privacy banner from onboarding and Settings lifecycles

The first-run privacy banner used to be tightly bound to two unrelated
surfaces: it was hidden whenever Settings was open, and the onboarding
panel only navigated in after the user had resolved the banner. The
coupling existed because the banner's z-index sat below modal backdrops,
so showing both at once collided visually, and the banner+onboarding
were linearized to avoid a "two unfinished things on screen" feel.

This change makes the three surfaces independent:

- Lift `.privacy-consent-banner` z-index above the modal-backdrop layer
  so the banner stays visible (and clickable) when Settings is open. The
  banner is already `pointer-events: none` with opt-in on its actionable
  children, so it does not steal clicks from the layer below.
- Drop the `!settingsOpen` guard from `showPrivacyConsent`.
- Drop the `privacyDecisionAt != null` guard from the bootstrap
  onboarding route; first-run users land on `/onboarding` purely based
  on `!onboardingCompleted`, and the banner sits on top in parallel.
- Drop the `navigate(... onboarding)` side effect from the banner's
  `onAccept` β€” the banner only persists the privacy decision now.

Bootstrap also had to be reshaped: the merged config is now computed
outside the `setConfig` updater so navigation can happen synchronously
after the state update. Calling `navigate` inside the updater triggered
a React "setState while rendering" warning, and reading a captured flag
after `setConfig` was unreliable because React 18+ batches the updater
to the next render β€” the navigate condition was never observed.

Existing test that asserted the old coupling ("banner unmounts while
Settings is open") is inverted to lock in the new contract.

* fix(web): defer privacy banner until onboarding is done and user lands on home

Product feedback on the previous lifecycle change: the banner should not
appear during the welcome panel. It should surface only:

  - immediately after the user Skips onboarding (lands directly on home), or
  - after the user finishes the design-system step and later returns to a
    home view from the project view they were dropped into.

To capture both paths with a single rule, the banner now requires:

  1. Daemon config hydrated (unchanged).
  2. No privacy decision recorded yet (unchanged).
  3. onboardingCompleted === true.
  4. The current route is a home route (route.kind === 'home').

The Skip path already routes through finishOnboarding, which calls
onCompleteOnboarding() + changeView('home') β€” that satisfies all four
gates the moment Skip is clicked.

The finish path (step 2: create design system) previously navigated to a
project view without marking onboardingCompleted. This commit mirrors the
Skip path by calling handleCompleteOnboarding() from the App-level
renderDesignSystemCreation onCreated callback (the onboarding-specific use
of DesignSystemCreationFlow). The shared DesignSystemFlow component is left
untouched so the create-from-Settings entry point keeps its existing
semantics.

The route gate keeps the banner suppressed while the user is reading their
just-created design system project. As soon as they navigate back to the
entry shell (home route), the banner appears.

Tests:
  - "withholds the privacy banner until onboarding completes" β€” covers
    gate 3 (onboardingCompleted=false while still on onboarding/home).
  - "withholds the privacy banner outside the home route" β€” covers gate 4
    (user is on a project route, onboardingCompleted=true).
  - Existing "keeps the first-run privacy banner mounted while settings is
    open" still passes; the Settings/banner z-index relationship is
    independent of these gates.

* fix(web): allow privacy banner to surface on non-home routes after onboarding

Follow-up to the previous lifecycle change. After exercising the design-
system finish path end-to-end, product wants the banner to appear in the
project view the user is dropped into β€” the first generation is running
in the background and the user is already waiting, so the disclosure can
be acknowledged inline rather than being held back until they navigate
back to a home view.

The Skip path is unchanged: Skip routes the user to home and the banner
appears there.

This drops the `route.kind === 'home'` guard and the matching test, and
adds a contract test that locks in banner visibility on a project route
when `onboardingCompleted=true` and no privacy decision has been made.
…migration (#2527)

* feat(observability): web lifecycle telemetry + stable installationId migration

Two intertwined safety-telemetry additions for the 0.8.0 release.

Web lifecycle observability
---------------------------
New `apps/web/src/observability/` module installed at module load via
client-app.tsx β€” alongside the existing error-tracking exception hooks
from #2521. Reuses error-tracking's direct-fetch transport (the same
consent-bypass + early-buffer guarantees) so every event flows even when
the user has opted out of general analytics:

  - client_long_task         PerformanceObserver longtask >100ms (real
                             "feels janky" signal, FPS proxy)
  - client_white_screen      app fails to mount after 5s; MutationObserver
                             cancels the timer the moment the React root
                             renders so a normal boot is zero events
  - client_resource_error    capture-phase window.error catches failed
                             <script>/<link>/<img>/<iframe> loads
                             (chunk-load failures, broken artifact refs)
  - client_boot_timing       navigationStart β†’ load timings via
                             Navigation Timing v2
  - client_visibility_change visibilitychange + page lifetime
  - client_session_summary   real foreground duration emitted on pagehide
  - client_run_stuck         5min watchdog on SSE runs that don't progress
                             (#2464 / #2405 / #1451 in data form)
  - client_iframe_error      FileViewer iframe load failures (iframe
                             errors don't bubble to window, so the global
                             resource-error observer can't see them)
  - desktop_renderer_crash   Electron main observes render-process-gone
                             and forwards to daemon /api/observability/event
  - daemon_uncaught_exception
    daemon_unhandled_rejection
                             process-level handlers on the daemon

error-tracking.ts is generalised: `reportSafetyEvent(name, props)` now
exposes the same buffer + direct-fetch transport that `reportHandledException`
used, with identical $exception wire shape preserved for the existing
exception path.

Daemon cross-process bridge
---------------------------
New `AnalyticsService.captureSafety()` skips the consent re-check and
posts via posthog-node with installationId as distinct_id. Wired into:

  - `POST /api/observability/event` for desktop main and any future
    helper process that needs to ship a safety event (no consent check β€”
    same contract as web's direct-fetch path)
  - `process.on('uncaughtException')` / `unhandledRejection` on the
    daemon itself

Stable installationId across reinstalls (critical for 0.8.0 rollout)
--------------------------------------------------------------------
installationId previously lived in `<namespace>/data/app-config.json`,
so a packaged reinstall that churned the namespace token (or any future
namespace-scoped data wipe) rotated the id and the user showed up as a
brand-new PostHog person. This is the immediate trigger: when 0.8.0
ships, every 0.7.x user upgrading would silently double the user count.

New module `apps/daemon/src/installation.ts` reads/writes
`<installationDir>/installation.json` at the channel root. The daemon
gets the path from `OD_INSTALLATION_DIR`, set by
`apps/packaged/src/sidecars.ts` to `paths.installationRoot`
(one level above `namespaces/` β€” e.g.
`~/Library/Application Support/Open Design Nightly/` on mac).

`readAppConfig` transparently merges: if installation.json has an id it
wins; if only app-config.json has one (the 0.7.x state), it gets mirrored
to installation.json on the next read. `writeAppConfig` mirrors any
explicit installationId write, including the null-clear path used by
Settings β†’ "Delete my data". 7 call sites of readAppConfig keep their
signatures unchanged.

Survives:
  - same-channel reinstall (DMG drag-replace, NSIS reinstall)
  - namespace churn between packaged builds
  - per-namespace data reset (future installer that clears `<ns>/data/`)

Still rotates (intentionally):
  - explicit "Delete my data"
  - manual `rm -rf "~/Library/Application Support/Open Design <Channel>/"`
  - different channel (Stable vs Nightly stay distinct because userData
    paths differ; that's the existing channel-isolation contract)

What this changes for posthog-js
--------------------------------
client.ts had `capture_exceptions: false` from #2521; nothing else
changes. autocapture / $pageview / $autocapture / track() / daemon
analyticsService.capture() β€” all unchanged. New events are additive.

Validation
----------
  - pnpm guard                              pass
  - pnpm typecheck                          whole repo pass
  - pnpm --filter @open-design/web test     200 files / 1824 tests
  - pnpm --filter @open-design/daemon test  251 files / 2981 tests
    (includes 10 new tests in installation.test.ts pinning the 0.7.x β†’
    0.8.0 migration, namespace-wipe survival, delete-my-data clear, and
    fresh-id rotation)
  - pnpm --filter @open-design/packaged test 9 files / 89 tests
  - Pre-existing baseline: apps/desktop/src/main/updater.ts has typecheck
    references to RELEASE_CHANNEL_NAMES.PREVIEW/NIGHTLY on release/v0.8.0;
    unrelated to this PR.

* fix(observability): preserve fatal exit on uncaught + skip loading shell in white-screen check

Addresses codex review on PR #2527 (Siri-Ray).

1) Daemon process handlers must keep Node fatal semantics

Installing an uncaughtException listener silences Node's default
crash/exit; Node 15+ does the same for unhandledRejection when a
listener is present. The previous handlers logged telemetry and let
control return to the event loop, leaving a corrupted daemon serving
requests instead of letting the supervisor restart it cleanly.

triggerFatalShutdown() now:
  - dispatches captureSafety once (guarded against re-entry from
    cascading faults)
  - races posthog-node's shutdown against a 1s bounded timeout so a
    slow flush can't keep the process alive
  - calls process.exit(1) after the race resolves
Both uncaughtException and unhandledRejection route through it.

apps/daemon/tests/uncaught-fatal-shutdown.test.ts pins:
  - captureSafety is invoked exactly once even on repeated faults
  - exit(1) fires on the happy path
  - exit(1) still fires when shutdown hangs past the timeout
  - exit(1) still fires when captureSafety itself throws

2) White-screen detector treated the loading shell as a successful mount

apps/web/app/[[...slug]]/client-app.tsx renders the dynamic-import
fallback as <div class="od-loading-shell">Loading Open Design…</div>
whose visible text (19 chars) exceeded the previous 10-char floor.
monitorMount() would therefore cancel the 5s timer the instant Next
swapped the loading shell in, completely missing the white-screen
signal the observer is meant to add.

isAppMounted() now:
  - primary signal: <html data-od-app-mounted="1"> set by App.tsx's
    first useEffect β€” authoritative because once App has mounted at
    least once, any later tree crash is an $exception story, not a
    white-screen story
  - fallback: only counts children of the root container whose
    classList does NOT include known loading-shell markers
    (od-loading-shell). Their visible text drives the > MIN_VISIBLE_TEXT
    check, so the loading sentinel can never be mistaken for a mount.

apps/web/tests/observability/white-screen.test.ts pins:
  - fires client_white_screen when only the loading shell is present
    after the timeout
  - does NOT fire when data-od-app-mounted is set before the timeout
  - cancels the timer the moment a real workspace-shell child appears
    alongside the loading shell
  - still fires when only sub-MIN_VISIBLE_TEXT non-shell content is
    present (effectively blank)

Validation:
  - pnpm guard pass
  - pnpm typecheck pass
  - pnpm --filter @open-design/daemon test  252 files / 2985 tests
  - pnpm --filter @open-design/web test     201 files / 1828 tests

* fix(observability): await captureSafety enqueue before fatal shutdown flush

Addresses second-pass codex review on PR #2527 (Siri-Ray, 3279268246).

The previous fatal-shutdown path called `analyticsService.captureSafety()`
synchronously and immediately raced `analyticsService.shutdown()` against
the bounded timeout. captureSafety in apps/daemon/src/analytics.ts does
its real `client.capture()` call only inside an async IIFE after
`await readInstallationIdSafe()` β€” so shutdown could win the race,
drain an empty posthog-node queue, and let `process.exit(1)` run BEFORE
the daemon crash event ever got enqueued. We'd then preserve the
process-lifecycle contract but lose the exact signal this PR is adding.

Changes:

  - AnalyticsService.captureSafety now returns Promise<void>. The async
    IIFE is gone; the body awaits readInstallationIdSafe directly so the
    returned promise resolves only AFTER client.capture() has been
    invoked (which is when posthog-node's local buffer contains the
    event).
  - server.ts triggerFatalShutdown awaits captureSafety, then calls
    shutdown, and races that whole sequence against the 1s bounded
    timeout. Capture failures still don't block exit (try/catch around
    the await).
  - NOOP_SERVICE.captureSafety becomes `async () => undefined` to
    match the new signature.
  - Fire-and-forget callers (/api/observability/event) are unaffected;
    voiding the returned promise keeps them non-blocking.

apps/daemon/tests/uncaught-fatal-shutdown.test.ts adds the reviewer-
requested fixture:

  - 'waits for the captureSafety promise to settle before invoking
    shutdown' β€” gives capture a 50ms delay and shutdown a separate 50ms
    delay so the intermediate "capture done / shutdown not yet" state
    is observable.
  - 'still aborts and exits if captureSafety hangs past the bounded
    timeout' β€” captureSafety never resolves; the outer 1s timeout still
    forces process.exit(1).

Validation:
  - pnpm guard                                pass
  - pnpm typecheck                            whole repo pass
  - pnpm --filter @open-design/daemon test    252 files / 2987 tests
) (#2532)

* fix(web): show feedback prompt on every successful assistant turn

Previously the thumbs-up/down widget only appeared when the turn produced
an `<artifact>`, wrote a file via Write/Edit, or emitted a live_artifact
event. That left whole categories of completed turns β€” image/video
generation via `generate_image` / `generate_video`, MCP tool runs, plain
text answers β€” without any way for the user to rate them.

Drop the `hasArtifactWork` gate from `isFeedbackEligible` and remove the
helper functions that fed it. The remaining filters (streaming in
progress, empty response, unfinished todos, runStatus !== "succeeded")
still suppress the widget for genuinely incomplete turns.

The PostHog `has_produced_files` field is still emitted so the analytics
side can keep slicing feedback by whether the turn produced artifacts.

* test(web): flip artifact-only feedback assertion in AssistantMessage.test.tsx

Companion test file to `chat-feedback.test.tsx` β€” the same artifact-only
visibility rule was duplicated here and asserted the text-only success
case stays hidden. Flip that case to assert the widget now appears, and
update the file-level comment so the new rule reads cleanly. The
streaming / failed-run / empty-response cases keep their existing
"hidden" assertions; those are still excluded under the new rule.
* fix(web): preserve automation ingest select chevron

* fix(web): isolate automation ingest select chevron

* fix(web): make dark select chevrons non-repeating
qiongyu1999 and others added 20 commits May 21, 2026 17:25
* fix: tighten packaged updater flow

* test: prune noisy extended ui coverage

* fix: hide unpublished release artifacts

* test: validate release updater channels

* fix: align prerelease release namespaces
…2544 into release/v0.8.0) (#2560)

* feat(desktop): follow OS language in packaged builds

Packaged Electron currently shows Open Design in en-US regardless of
the OS language setting, because the renderer's i18n picks its locale
from `navigator.language` and Chromium hard-codes that to en-US unless
the host process intervenes. Browser users and `tools-dev` users are
unaffected because their `navigator.language` already reflects the
OS / browser preference.

This change:

- Adds `applyOsLocaleSwitch(app)` in `@open-design/desktop/main`. It
  reads `app.getPreferredSystemLanguages()[0]` and (when called before
  Electron's `ready` event) points Chromium's `--lang` flag at it, so
  the renderer's `navigator.language` follows the OS. Safe to call
  more than once: `appendSwitch` is a no-op once `app.isReady()`.
- Calls the helper from both Electron entries: `apps/packaged` before
  its own `whenReady`, and `runDesktopMain` for tools-dev parity.
- Forwards the resolved locale through
  `BrowserWindow.webPreferences.additionalArguments` as
  `--od-os-locale=<bcp-47>`, parsed by the preload and exposed at
  `window.__od__.client.osLocale`. The host bridge type
  (`OpenDesignHostClient.osLocale`) is extended accordingly.
- Updates `detectInitialLocale` in `apps/web/src/i18n/index.tsx` to
  read that field as a new step between the existing localStorage and
  navigator fallbacks. Browser/web continues to fall through to
  `navigator.languages` unchanged.

The explicit `osLocale` channel exists in addition to `--lang` because
some `app.getPreferredSystemLanguages()` strings (e.g. `zh-Hant-TW`,
`pt-PT`) need to round-trip through `resolveSystemLocale` to land on
the right supported locale, which Chromium's `navigator.language`
cannot do on its own.

* fix(web): route OS locale read through getOpenDesignHost

The first cut of detectInitialLocale read `window.__od__.client.osLocale`
directly, which trips `tests/host-boundary.test.ts` β€” that guard test
keeps web source from referencing preload globals by name so the
boundary stays single-source. Switch to `getOpenDesignHost()` from
`@open-design/host`, and rewrite the i18n test to install the host via
`installMockOpenDesignHost` instead of poking the global directly.

* fix(tools-pack): unblock mac packaged build on pnpm workspaces

Two independent issues prevented `pnpm tools-pack mac build --to all`
from completing on a clean macOS workspace, both unrelated to the
desktop OS-locale change in this PR but bundled here because verifying
that change end-to-end required the packaged pipeline to actually
finish.

1. `apps/web/.next/standalone/node_modules/.pnpm/node_modules/<pkg>`
   contained dangling symlinks left by Next's nft trace (e.g. a
   `semver -> ../[email protected]/node_modules/semver` link to a
   `.pnpm/<pkg>@<ver>` directory pnpm never created). The downstream
   `cp { dereference: true }` aborted the whole packaged pipeline
   with ENOENT. Walk every artifact tree before copy and unlink
   symlinks whose target doesn't resolve. Targets that *do* resolve
   stay untouched.

2. Next 16's standalone build under pnpm workspaces does not hoist
   peer-dep packages (react, react-dom, styled-jsx) into
   `<standalone>/apps/web/node_modules`. The downstream
   `web-standalone-after-pack.cjs` audit then does
   `createRequire(server.js).resolve('react/package.json')`, whose
   module walk falls out of the standalone tree and aborts the
   electron-builder phase. Add a `hoistStandaloneNextPeerDeps` step
   for the web standalone artifact only: it locates the
   `<pkg>@<version>` (not peer-resolved sibling) directory under
   `.pnpm` and symlinks it into `apps/web/node_modules/<pkg>`. The
   subsequent `cp { dereference: true }` then writes the real
   directory into the cache so the packaged tree stays self-contained.

Verified by `pnpm tools-pack mac build --to all` succeeding end-to-end
(zip + dmg + app), then `pnpm tools-pack mac install` and
`pnpm exec tools-pack mac inspect --expr` reading the desired
`__od__.client.osLocale` from the packaged renderer.

* feat(desktop): fold encodeURIComponent + manual locale source + pet window from #2554

Three defensive improvements lifted from @Eli-tangerine's parallel
implementation on #2554, kept consistent with the OS-locale chain
already on this branch:

- The argv value crossing main β†’ preload is now wrapped with
  encodeURIComponent / decodeURIComponent so a locale string with `;`,
  `=`, or any other Chromium argv special char round-trips cleanly.
  BCP-47 region tags don't carry those today, but the renderer parser
  no longer has to assume it.
- `setLocale` now also writes `open-design:locale-source = "manual"`
  to localStorage, and `detectInitialLocale` only treats the stored
  locale as winning when that marker is present. An untagged value
  (left over from a future auto-write path, or a stale install) no
  longer pins the app to an old language once the host injects a
  fresh OS locale. Today `setLocale` is the only writer so the marker
  has no behaviour difference yet β€” this is a defensive net.
- `createDesktopPetWindow` now receives `osLocale` and forwards the
  same `additionalArguments` as the main `BrowserWindow`, so the
  pet renderer's `__od__.client.osLocale` is consistent with the main
  window's instead of being silently undefined.

Co-authored idea credit: changes mirror the locale-piece of
@Eli-tangerine on #2554 β€” that PR is closing in favour of this one.

Tests: detect-initial-locale gets a new "untagged localStorage value
loses to host locale" case. desktop 62/62, host 13/13, web i18n +
host-boundary 15/15 stay green.

* feat(web): fold onboarding view styles from #2554

Pulls the 747-line addition to `apps/web/src/styles/home/entry-layout.css`
from @Eli-tangerine's #2554 β€” the visual layer for the global onboarding
flow (`/onboarding` view, Connect / About-you / Design-system steps).
The view itself was already plumbed through `EntryShell.tsx`; this adds
the styling that makes it shippable on v0.8.0.

#2554 is closing in favour of this branch, so the CSS lands here so the
onboarding work doesn't get dropped on the floor.

Co-authored idea credit: @Eli-tangerine β€” original styling on #2554.

* fix(tools-pack): make hoistStandaloneNextPeerDeps idempotent across builds

Addresses non-blocking review by @PerishCode on #2560: the previous
`if (await pathExists(linkPath)) continue;` guard uses `access()`,
which follows symlinks. A stale symlink from a previous build whose
`.pnpm/<pkg>@<version>` target moved (e.g. after a react/react-dom
version bump that invalidates the workspace-build cache key and forces
a re-run) reports as missing through `pathExists`, then `symlink()`
rejects with EEXIST and the unhandled rejection aborts the packaged
build.

Switch to `lstat` (which does not follow the link) so we can tell
"genuinely empty slot", "real directory left by Next" and "stale
symlink" apart, then unlink stale entries before re-creating. Also
move `stripBrokenSymlinks` ahead of `hoistStandaloneNextPeerDeps` in
`copyWorkspaceBuildArtifactsToCache` so any leftover dangling links
that survived a previous run are cleared before hoist tries to write.
#2459)

* feat(analytics): emit file_upload_result from all three upload entries

`file_upload_result` was wired only on the Design Files Upload button in
FileWorkspace. The chat composer paperclip (project page) and the home
hero composer paperclip uploaded files silently β€” PostHog dashboards
saw upload activity from one of three real entry points, so per-surface
funnels were invisible and totals undercounted.

Three problems are fixed together:

1. `FileUploadResultProps` hard-coded `page_name: 'file_manager' /
   area: 'file_manager'`, which prevented the other two surfaces from
   type-checking. Widened to a discriminated union over the three v2
   doc surfaces (`file_manager` / `chat_panel` chat_composer /
   `home` chat_composer).

2. `HomeChatComposerClickProps.element` was missing `'attachment'`, so
   the home composer paperclip had no usable click value even if we
   wanted to instrument it. Added the literal, mirroring the
   chat_panel composer.

3. Three call sites for `file_upload_result` would duplicate the
   per-file mime + total-bytes cohort math. Extracted to
   `apps/web/src/analytics/upload-tracking.ts#deriveUploadCohort` so
   FileWorkspace, ChatComposer, and the App.tsx Home submit path all
   compute the same `file_count` / `file_type` / `file_size_bucket`
   triplet. FileWorkspace's inline math is replaced with the shared
   helper to prevent drift.

Call-site wiring:

- HomeHero attach button: `ui_click` (`element='attachment'`) at
  click time. The actual upload is deferred to submit, so the
  `file_upload_result` for this surface fires from App.tsx after
  `uploadProjectFiles` resolves.
- ChatComposer.uploadFiles: `file_upload_result` on success / failed /
  throw branches; existing `ui_click` (`element='attachment'`) at the
  paperclip stays as-is.
- FileWorkspace.uploadFiles: refactored to use `deriveUploadCohort`;
  behavior unchanged.

* test(analytics): cover deriveUploadCohort matrix

Reviewer flagged that deriveUploadCohort silently fans out to three
upload entry points (file_manager / chat_panel / home) but has no
focused coverage, so a regression in zip detection, mixed-type
collapsing, or the 1/10/100 MB thresholds would skew analytics
without breaking any visible UI behavior.

Adds homogeneous-image, zip-by-mime, zip-by-extension, mixed-type,
empty-batch, bucket-boundary (1/10/100 MB), and defensive
empty-mime cases.
* fix: tighten packaged updater flow

* test: prune noisy extended ui coverage

* fix: hide unpublished release artifacts

* test: validate release updater channels

* fix: align prerelease release namespaces

* fix: align packaged updater validation
* Fix plugin publish and PR workflow UX

* Update plugin workflow test expectations

* Fix fake gh repo view verification path

* Fix plugin publish headless tests and preserve PATH in shell wrappers.

The publish-repo flow needs real git commits and fake gh auth output that
matches gh auth status parsing. Login shells no longer drop PATH so test
fakes and agent wrappers stay visible to nested gh/git calls.

Co-authored-by: Cursor <[email protected]>

* Restore plugin action card when share-task startup fails.

If startGeneratedPluginShareTask rejects before a task is created, clear
hiddenAssistantPluginActionPaths so the assistant action card reappears.

Co-authored-by: Cursor <[email protected]>

* Make daemon vitest self-contained for publish-github CLI shell-outs.

Build dist/cli.js in tests/setup.ts when missing and set OD_DAEMON_CLI_PATH
before server.ts resolves OD_BIN, so headless plugin tests pass from a clean
checkout without a prior manual daemon build.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
…dback (#1558)

* feat(analytics): PostHog + Langfuse instrumentation for assistant feedback

Re-bases the original three-commit PR onto release/v0.8.0. The web-side
feedback UI instrumentation (surface_view / ui_click / feedback_submit_result)
landed on main while this branch was open, so on this rebase that wiring
is taken from main; the remaining net additions are:

- Contracts: TrackingFeedback* enums and the four dedicated
  assistant_feedback_* event payload types (click, reason_view,
  reason_click, reason_submit), plus normalizeCustomReason helper.
  The new event-name variants are added to TrackingEventName and the
  AnalyticsEventPayload discriminated union next to the existing
  surface_view/ui_click variants β€” both wire formats coexist.
- POST /api/runs/:id/feedback in apps/daemon/src/chat-routes.ts:
  thin route that validates rating, allowlists reasonCodes through a
  simple string filter, and fire-and-forgets into the daemon's
  reportFeedback hook.
- apps/daemon/src/langfuse-bridge.ts reportRunFeedbackFromDaemon
  forwards the rating + reasonCodes into Langfuse as user_rating
  (NUMERIC Β±1) + user_rating_reason (CATEGORICAL, one per code)
  score-create entries. Gates on telemetry.metrics + telemetry.content.
- apps/web/src/providers/daemon.ts reportChatRunFeedback (fire-and-forget
  fetch) and apps/web/src/components/ProjectView.tsx wiring so each
  thumbs-up/down + reason submission posts the side-channel.

Conflicts resolved (release/v0.8.0 vs the branch's old base):
- packages/contracts/src/analytics/events.ts: keep main's
  file_upload_result / feedback_submit_result / settings_* event
  variants alongside the new assistant_feedback_* additions.
- apps/daemon/src/server.ts: keep DNS-aware validateExternalApiBaseUrl,
  add reportFeedback closure wired into registerChatRoutes telemetry.
- apps/daemon/src/chat-routes.ts: keep both /tool-result and the new
  /feedback routes; merge RegisterChatRoutesDeps to include both
  'paths' and 'telemetry'. Drop PR's chat-routes-local
  reconcileAssistantMessageOnRunEnd helper (main has the equivalent in
  server.ts).
- apps/web/src/components/ChatPane.tsx & AssistantMessage.tsx & ProjectView.tsx:
  keep main's projectKindForTracking prop name and its existing
  emission of surface_view / ui_click / feedback_submit_result; the
  PR's analyticsCtx-based reason_view/click/submit emission is dropped
  in this rebase since it would duplicate the existing wire format.
- apps/web/tests/components/*: rename projectKind β†’ projectKindForTracking
  to match ChatPane's current prop name.

Outstanding review feedback (from the pre-rebase round, will be
addressed in a follow-up commit):
- AssistantMessage tests not yet passing the new feedback context to
  the direct render path.
- ProjectView clear-feedback path skips reportChatRunFeedback, leaving
  stale Langfuse user_rating scores.
- buildFeedbackPayload has no deletion path for previously-submitted
  user_rating_reason scores when the user switches thumbs.
- POST /api/runs/:id/feedback always returns {status:'accepted'} even
  when consent is off; needs to surface skipped_consent / skipped_no_sink.
- reasonCodes are filtered to string[] but not allowlisted against
  ChatMessageFeedbackReasonCode or deduped.

* fix(analytics): address review on assistant feedback rebase

Picks up the in-scope correctness items from the prior review round
and the rebase residue without rewriting history:

- chat-routes.ts: `/feedback` now awaits the daemon's preflight
  outcome and echoes it as the response. The contract was already
  shaped as `accepted | skipped_consent | skipped_no_sink`, but the
  previous handler always returned `accepted` because the network
  send was fire-and-forget. The consent + sink decision is local
  (a small file read and an env-var lookup); the actual Langfuse
  upload still runs as a detached promise.
- chat-routes.ts: reasonCodes are now allowlisted against the
  contract's reason-code union and deduplicated before reaching
  Langfuse, so a stale or replayed client can't poison the
  Langfuse score table with unknown categorical values or
  duplicate stable ids in the same batch.
- langfuse-bridge.ts: split the consent + sink resolution from the
  fire-and-forget network send so the route can claim `accepted`
  honestly. The legacy `skipped_no_sink` return on app-config read
  failure is preserved.

Contracts + comment hygiene:
- TrackingFeedbackReasonCode in packages/contracts/src/analytics/events.ts
  drifted from ChatMessageFeedbackReasonCode in packages/contracts/src/api/chat.ts;
  add `followed_design_system` and `missed_design_system` so the
  analytics wire format stays aligned with the persistence shape.
- langfuse-trace.ts buildFeedbackPayload: the docblock claimed the
  raw custom-reason text is bucketed before send. Product reversed
  that on 2026-05-13 (raw text now ships, consent-gated). Replace
  the stale comment with the real semantics + a note that there is
  no tombstone path for reason codes the user removes in a
  follow-up submission (left as scope for a later PR).
- AssistantMessage.tsx: remove the now-unused
  `AssistantFeedbackAnalyticsCtx` interface and a stray blank-line
  delete from the rebase; restore the analytics-context comment
  above the feedback hook.

Left as follow-up (intentional, documented in code):
- Sending a tombstone score when the user clears their rating β€”
  ProjectView still skips reportChatRunFeedback on `change===null`,
  so Langfuse retains the previous rating until the user re-submits.
  The PostHog event captures the clear separately.
- Removing reason-code scores when the user re-submits with a
  smaller set β€” buildFeedbackPayload only overwrites the codes
  present in the current payload.

* feat(analytics): wire PR's dedicated assistant_feedback_* events

The four dedicated event types (`assistant_feedback_click` /
`_reason_view` / `_reason_click` / `_reason_submit`) the PR added to
contracts were sitting unused after the rebase because main's
umbrella `surface_view` / `ui_click` / `feedback_submit_result`
emissions covered the same user gestures. Wire the dedicated events
alongside the umbrella ones so both wire formats fire on every
feedback action β€” dashboards / evals can pick whichever schema they
were built against without losing signal.

Each dedicated event has stricter typing than its umbrella sibling
(`project_id` / `project_kind` / `conversation_id` are non-null), so
the new emissions are guarded behind a presence check and skipped on
test renders that mount AssistantMessage without project context. The
umbrella emissions retain their nullable fallbacks unchanged.

Pairing:
- surface_view (feedback reason panel) ↔ assistant_feedback_reason_view
- ui_click (feedback button)           ↔ assistant_feedback_click
- ui_click (reason submit button)      ↔ assistant_feedback_reason_click
- feedback_submit_result               ↔ assistant_feedback_reason_submit

Reason click + submit share the existing `requestId` so PostHog can
stitch click→result across both schemas, matching the spec.
* fix: tighten packaged updater flow

* test: prune noisy extended ui coverage

* fix: hide unpublished release artifacts

* test: validate release updater channels

* fix: align prerelease release namespaces

* fix: align packaged updater validation

* test: seed Windows release smoke config in app data
…ver surface_view (#2590)

* feat(analytics): onboarding ui_click + lifecycle + update_popover surface_view

Spec rows 1-3 of the Onboarding family (ui_click,
onboarding_runtime_scan_result, onboarding_complete_result) and the
home `update_popover` surface_view were all listed as P0 in the v2
doc but unwired β€” PostHog showed 0 events for every onboarding
ui_click, 0 for the scan/complete result events, and 0 for the
update-popover exposure.

Contract (`packages/contracts/src/analytics/events.ts`):
- Adds event names `onboarding_runtime_scan_result` /
  `onboarding_complete_result` and wires them into
  `AnalyticsEventPayload`.
- Adds `OnboardingClickProps` (page_name=onboarding, area/element/
  action discriminators + optional runtime/about_you/source rider
  fields) and threads it into `UiClickProps`.
- Adds `OnboardingRuntimeScanResultProps` and
  `OnboardingCompleteResultProps` with the doc's full field set β€”
  enums for runtime_type / scan result / completion result /
  completion_type, plus the lifecycle context (has_about_you,
  has_design_system_request, source_count, exit_step_name).
- Extends `TrackingFileUploadSurface` with an `onboarding /
  design_system_source` shape so the design-system-step source ingest
  can ride the same `file_upload_result` event the file_manager /
  chat composer already use. `source_type` is required on this shape
  so the dashboard can split by `local_code|fig|assets` without
  inspecting `file_type`.
- Adds `UpdatePopoverSurfaceViewProps` for the home toolbar's
  "Update ready" panel.

Onboarding wiring (`apps/web/src/components/EntryShell.tsx`):
- Centralises step/runtime-context derivation in `emitOnboardingClick`
  + `emitOnboardingComplete` helpers; every interactive control inside
  OnboardingView now fires through one of them so a future spec tweak
  changes one place.
- Click rows for runtime cards (local_coding_agent / byok), design-
  source cards (github_repo / local_code / fig_upload), about_you
  selects (organization_size / use_case / hear_about_us), and the
  Continue / Back / Skip navigation buttons. Multi-select use_case
  emits one row per added value, not per render.
- `scanCliAgents` now emits `onboarding_runtime_scan_result` with
  detected/available counts on every terminal state β€” success when
  any CLI is available, failed when scan returned zero or threw.
  `duration_ms` measures wall-clock from start to terminal.
- `onboarding_complete_result` fires from the Skip / last-step
  Continue / Generate paths with the right `completion_type`. The
  Generate path uses a new `DesignSystemCreationFlow.onBeforeGenerate`
  callback so the embedded flow can expose its local source-count
  state to the wrapper.

DS creation flow (`apps/web/src/components/DesignSystemFlow.tsx`):
- New `onBeforeGenerate(snapshot)` prop with a typed
  `DesignSystemGenerateSnapshot` shape. Fired right before the async
  generate() work; OnboardingView consumes it for both the `generate`
  ui_click (with source_type derived from which-counts-equal-total)
  and the completion lifecycle event.
- `renderDesignSystemCreation` in `EntryView` / `EntryShell` / `App`
  grows a second `hooks` arg that plumbs `onBeforeGenerate` through.

Update popover (`apps/web/src/components/UpdaterPopup.tsx`):
- Fires `surface_view page_name=home area=update_popover` once per
  panel-open transition, deduped by `app_version_before ->
  app_version_after` so a re-render of the same offer doesn't
  inflate the count.

Validation:
- `pnpm guard` βœ…
- `pnpm --filter @open-design/web typecheck` βœ…
- `pnpm --filter @open-design/web test` βœ… 203 files / 1828 tests
- `pnpm --filter @open-design/daemon test` βœ… 249 files / 2977 tests

* fix(analytics): generation_progress fires from chat_panel + complete_result uses snapshot

E2E (2026-05-21, distinct_id=e2e-onboarding-test-001) drove the full
welcome flow and exposed two issues in the previous commit:

1. `page_view page_name=onboarding area=generation_progress` (step 4)
   never fired. PR #2590's commit wired this from
   `DesignSystemDetailView`, but the Generate path actually navigates
   to ProjectView (`page_name=chat_panel`), not to the DS detail
   surface. PostHog showed `chat_panel` and `file_manager` page_views
   landing right after the Generate click but no
   `area=generation_progress` row.

   Fix: fire `area=generation_progress` from `ProjectView` right
   alongside its `chat_panel` page_view when an onboarding session
   id is still in sessionStorage. Clear the session id immediately
   after so a later unrelated project visit doesn't inherit the
   onboarding attribution. The `DesignSystemDetailView` site can
   stay as a defense-in-depth β€” same dedup guard, no double-fire.

2. `onboarding_complete_result` from the Generate path shipped with
   `has_design_system_request: false` and `source_count: 0`. The
   `emitOnboardingComplete` helper read `designSource` (the click
   state on the three source-type cards), but E2E showed users
   click Generate without clicking those cards β€” they type a brand
   description and add a GitHub URL directly in the embedded form,
   so `designSource` stays null even when a request is clearly in
   flight.

   Fix: thread `DesignSystemGenerateSnapshot` from the
   `onBeforeGenerate` callback into `emitOnboardingComplete` via a
   new `extra.sourceSnapshot` option. When present, derive
   `has_design_system_request` from `sourceCount > 0 ||
   hasBrandDescription` and `source_count` from the snapshot's
   `sourceCount`. Skip / last-step Continue paths still fall back
   to the `designSource` heuristic since no snapshot exists there.

* fix(analytics): emit artifact_count from new-html count + remove unmount session-id clear

Cherry-picked from the orphaned `fix/analytics-app-version-zero` HEAD
(commit 5b5a7ed β€” pushed after PR #2453 had already squash-merged,
never made it into release/v0.8.0). Two P0 data bugs:

1. `run_finished.artifact_count` was hard-coded `0` at
   `server.ts:11061` (now `:11394`). Every run on PostHog reported
   zero artifacts, breaking the "generation success β†’ artifact
   produced" funnel.

   Fix: count incremental `.html` paths the run wrote or edited,
   deduped per path so a Write-then-Edit cycle on the same file
   counts as one artifact. Pure helper in
   `apps/daemon/src/run-artifacts.ts` with 10 unit tests covering
   empty / no-html runs, single Write, dedup across Write+Edit+
   MultiEdit, distinct paths, Codex aliases (create_file,
   str_replace_edit), both `file_path` and `path` input shapes,
   case-insensitive extension, non-agent / malformed payloads, and
   Read/Grep/Bash always ignored. Wired into server.ts's
   `run_finished` properties block.

2. `OnboardingView` cleared `onboardingSessionId` on unmount. The
   Generate path unmounts OnboardingView *before* the post-Generate
   page_view fires elsewhere, so an unmount-clear consistently
   wiped the id before the 4th-step emission could read it.
   PostHog showed zero `area=generation_progress` events.

   Fix: drop the unmount cleanup effect entirely. Skip / Back /
   last-step Continue paths clear inline in their respective
   handlers (already in place from this PR's earlier instrumentation
   commit). The Generate path's clear now lives in `ProjectView`
   right after the `chat_panel` page_view (and the
   `generation_progress` page_view that rides with it). Abandoned
   sessions clear on sessionStorage tab close.

* fix(analytics): emit onboarding complete after generate settles + text source_type

Two review fixes on PR #2590 from mrcfps (2026-05-21 14:11):

1. `onboarding_complete_result` was emitted from `onBeforeGenerate`,
   which fires synchronously BEFORE
   `DesignSystemCreationFlow.generate()` runs the async draft-create
   / workspace-open work. Both of those have failure branches that
   bounce the user back to the setup form with an error. In that
   case the lifecycle row would have shipped as
   `result=completed` / `completion_type=completed_with_design_system`
   even though no design system was actually generated.

   Fix: add a new `onGenerateSettled(snapshot, outcome)` callback to
   `DesignSystemCreationFlow` and fire it from each branch of the
   `generate()` function (success after `onCreated` / failed on
   draft-create returning null / failed on workspace-open returning
   null / failed on catch). OnboardingView keeps the `onBeforeGenerate`
   hook for the intent-only `generate` ui_click row, and moves the
   lifecycle complete emit into `onGenerateSettled`. Failed outcomes
   ship as `result=failed` + `completion_type=completed_without_design_system`
   + the daemon's error code, and clear the onboarding session id
   since the user stays in the wrapper.

2. The `source_type` ternary in OnboardingView's `generate` ui_click
   mapped `sourceCount === 0` to `'none'` unconditionally, so a
   prompt-only generate ("user only typed a brand description, no
   GitHub / local / fig / assets sources") was indistinguishable on
   PostHog from "no input at all". The v2 contract reserves the
   `'text'` literal precisely for that prompt-only path.

   Fix: extract a `deriveOnboardingSourceType(snapshot)` helper that
   returns `'text'` when `sourceCount === 0 && hasBrandDescription`,
   `'none'` only when both are absent, single-source literal when one
   kind dominates, `'mixed'` otherwise. Single source of truth for
   the mapping so the ui_click and any future complete-row tagging
   stay consistent.

* fix(analytics): countNewHtmlArtifacts skips failed tool ops

Review fix on PR #2590 from mrcfps (2026-05-21 14:30, on commit
9e9a001). `countNewHtmlArtifacts` counted every `Write` / `Edit`
tool_use on a `.html` path regardless of whether the matching
`tool_result` came back with `isError: true`. A permission denied
`Write index.html`, a path-outside-cwd refusal, or a
parent-missing failure all still bumped `run_finished.artifact_count`
to 1 β€” which is exactly the corruption pattern this helper was
introduced to fix (hard-coded zero β†’ spuriously > 0 is the same
class of broken funnel signal).

Fix: mirror the web-side `apps/web/src/runtime/file-ops.ts` pattern.
Build a `resultByToolUseId` map in a first pass, then in the
second pass only count a tool_use whose paired result exists AND
`isError !== true`. A tool_use with no matching result is treated
as "still in flight" and not counted; the dashboard would rather
under-count attempts than promise artifacts we can't confirm
landed.

Tests grow 3 β†’ 13:
- successful Write pair counts (canonical path)
- isError=true result does NOT count
- unpaired tool_use does NOT count
- Write-success-then-Edit-fail on same path still counts (artifact
  is on disk; later edit failure doesn't unmake it)
- existing dedup / distinct-paths / alias / case / malformed /
  read-skip cases all updated to use the new pair() helper

* fix(analytics): re-arm onboarding lifecycle on generate failure for retry

Review fix on PR #2590 from mrcfps (2026-05-21 14:45, on commit
2cd05f0). The previous `onGenerateSettled` failure branch did two
things that together broke the retry path:

1. Flipped `lifecycleReportedRef.current` to `true` (via
   `emitOnboardingComplete`), which the same guard then uses to
   short-circuit every subsequent complete emit.
2. Called `clearOnboardingSessionId()`, wiping the sessionStorage id
   that downstream surfaces (ProjectView's `generation_progress`
   page_view, subsequent ui_click rows) need to attribute under the
   same funnel session.

But `DesignSystemCreationFlow.generate()` doesn't bail out on
failure β€” it `setStep('setup')` and leaves the user in the same
embedded form to try again. So the retry sequence used to look
like:

  click Generate β†’ fails β†’ complete(failed) β†’ flag locked + id cleared
  user fixes input β†’ click Generate again
    ui_click `generate` row β†’ fires under the STALE in-memory ref
      (sessionStorage was cleared but `onboardingSessionIdRef.current`
       still holds the old uuid)
    generate succeeds β†’ onGenerateSettled(success)
      β†’ emitOnboardingComplete β†’ lifecycleReportedRef guard returns
        early β†’ second complete row never lands
    navigate to ProjectView β†’ peekOnboardingSessionId() = null
      β†’ step-4 `area=generation_progress` row never lands

Fix: the failure handler keeps the session id intact and just
re-arms `lifecycleReportedRef.current = false`. A retry then
emits a fresh complete row under the same `onboarding_session_id`
(useful for "N retries until success" analysis) and an eventual
success can still hand off through ProjectView with the id available
for the step-4 emission. The Skip / last-step Continue paths still
clear via the inline `clearOnboardingSessionId()` next to their
`onFinish()` because those terminate the flow explicitly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/XXL PR changes 1500+ lines type/chore CI / build / config / tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Only the most recently started run completes correctly when multiple Routine runs execute in parallel

8 participants