feat(analytics): onboarding ui_click + lifecycle events + update_popover 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 5b5a7ed5 — 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
9e9a0019). `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
2cd05f09). 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.
This commit is contained in:
lefarcen
2026-05-21 22:50:46 +08:00
committed by GitHub
parent ec96585a8f
commit e1818f2677
11 changed files with 1055 additions and 30 deletions
+127
View File
@@ -0,0 +1,127 @@
// Daemon-side helper that counts how many distinct `.html` files this
// run produced or modified. Fed into v2 `run_finished.artifact_count`.
//
// Semantics (per product spec, 2026-05-21):
// - Count is incremental for THIS run only, not cumulative across the
// project. If the run touched no `.html` files, the count is 0.
// - A file written multiple times within the same run counts once
// (dedup by path) so a Write-then-Edit cycle on the same file
// reports one artifact, not two.
// - Both Write (create_file) and Edit / MultiEdit count, because the
// agent often writes a skeleton then edits to fill it in; both end
// in a new file state at run end.
// - Read-only ops never count.
// - Failed ops never count. A `tool_use` whose matching `tool_result`
// reports `isError: true` (permission denied, path outside cwd,
// parent missing, etc.) does NOT produce an artifact even though
// the tool name and path were correct. Earlier the helper skipped
// this join, so a "Write index.html" that errored still bumped
// `artifact_count` to 1 and corrupted the
// "generation_success → artifact_produced" funnel (mrcfps review
// on PR #2590).
//
// Earlier `server.ts` hard-coded `artifact_count: 0`, which produced
// uniform zero on PostHog and made the same funnel useless from the
// other direction.
// Tool names cover Claude-style, Codex-style, and the ACP/MCP shapes
// the daemon proxies. Keep aligned with the web-side `WRITE_NAMES` /
// `EDIT_NAMES` sets in `apps/web/src/runtime/file-ops.ts`.
const WRITE_OR_EDIT_TOOL_NAMES: ReadonlySet<string> = new Set([
'Write',
'create_file',
'Edit',
'str_replace_edit',
'MultiEdit',
'multi_edit',
]);
function extractToolFilePath(input: unknown): string | null {
if (!input || typeof input !== 'object') return null;
const obj = input as { file_path?: unknown; path?: unknown };
if (typeof obj.file_path === 'string' && obj.file_path) return obj.file_path;
if (typeof obj.path === 'string' && obj.path) return obj.path;
return null;
}
function isHtmlPath(path: string): boolean {
return path.toLowerCase().endsWith('.html');
}
export interface RunEventLike {
event?: string;
data?: unknown;
}
// Join key the daemon-stream uses for tool_use → tool_result pairing.
// Claude / Codex / ACP all stamp the same `id` onto the `tool_use`
// event and reference it via `toolUseId` on the subsequent
// `tool_result`; see `apps/daemon/src/langfuse-bridge.ts#collectToolCalls`
// for the canonical implementation.
function readToolUseId(data: unknown): string | null {
if (!data || typeof data !== 'object') return null;
const obj = data as { id?: unknown };
return typeof obj.id === 'string' && obj.id ? obj.id : null;
}
function readToolResultId(data: unknown): string | null {
if (!data || typeof data !== 'object') return null;
const obj = data as { toolUseId?: unknown };
return typeof obj.toolUseId === 'string' && obj.toolUseId
? obj.toolUseId
: null;
}
function readToolResultIsError(data: unknown): boolean {
if (!data || typeof data !== 'object') return false;
return (data as { isError?: unknown }).isError === true;
}
export function countNewHtmlArtifacts(events: readonly RunEventLike[]): number {
if (!events || events.length === 0) return 0;
// First pass: collect tool_result records keyed by toolUseId so the
// second pass can resolve each tool_use's outcome without quadratic
// scanning. Default outcome for a tool_use with no matching result
// (run still streaming when this is called, or adapter swallowed
// the result) is "still in flight" — we treat that as NOT counting,
// since we can't confirm the artifact actually landed. The web-side
// `deriveFileOps` makes the same choice (status='running' for
// unmatched tool_use); aligning here keeps both pipelines in sync.
const resultByToolUseId = new Map<string, { isError: boolean }>();
for (const rec of events) {
if (rec?.event !== 'agent') continue;
const data = rec.data as { type?: string } | null | undefined;
if (data?.type !== 'tool_result') continue;
const id = readToolResultId(rec.data);
if (!id) continue;
resultByToolUseId.set(id, { isError: readToolResultIsError(rec.data) });
}
const writtenPaths = new Set<string>();
for (const rec of events) {
if (rec?.event !== 'agent') continue;
const data = rec.data as
| { type?: string; name?: unknown; input?: unknown }
| null
| undefined;
if (data?.type !== 'tool_use') continue;
if (typeof data.name !== 'string') continue;
if (!WRITE_OR_EDIT_TOOL_NAMES.has(data.name)) continue;
const path = extractToolFilePath(data.input);
if (!path) continue;
if (!isHtmlPath(path)) continue;
const toolUseId = readToolUseId(rec.data);
// No id: legacy / synthetic stream shapes that don't pair. Be
// conservative and skip; the dashboard would rather under-count
// than count attempts that may have failed silently.
if (!toolUseId) continue;
const outcome = resultByToolUseId.get(toolUseId);
// No matching result: tool still in flight at snapshot time, OR
// adapter never emitted one. Don't count either way.
if (!outcome) continue;
if (outcome.isError) continue;
writtenPaths.add(path);
}
return writtenPaths.size;
}
+8 -1
View File
@@ -184,6 +184,7 @@ import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
import { createChatRunService } from './runs.js';
import { deriveRunErrorCode, runResultFromStatus } from './run-result.js';
import { countNewHtmlArtifacts } from './run-artifacts.js';
import {
reportRunCompletedFromDaemon,
reportRunFeedbackFromDaemon,
@@ -11384,7 +11385,13 @@ export async function startServer({
...baseProps,
area: 'chat_panel',
result,
artifact_count: 0,
// Incremental count of `.html` paths the run produced or
// modified, deduped per file. Replaces the hard-coded `0`
// that masked the "did this run actually generate an
// artifact?" funnel on PostHog. See `run-artifacts.ts`
// for the dedup semantics; tested in
// `tests/run-artifacts.test.ts`.
artifact_count: countNewHtmlArtifacts(run.events),
total_duration_ms: Date.now() - runStartedAt,
...(errorCode ? { error_code: errorCode } : {}),
...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}),
+204
View File
@@ -0,0 +1,204 @@
// Unit coverage for `countNewHtmlArtifacts`. Pins the v2
// `run_finished.artifact_count` invariant: incremental count of
// distinct `.html` paths the run produced or modified, deduped by
// path, with Read ops never counted and FAILED ops never counted
// (mrcfps review on PR #2590 — earlier version counted every
// matching `tool_use` regardless of whether the matching
// `tool_result` landed `isError: true`, so a permission-denied
// `Write index.html` still bumped `artifact_count` to 1 and
// corrupted the same funnel this helper is trying to repair).
//
// `server.ts` previously emitted `artifact_count: 0` literally, which
// suppressed every dashboard tile that breaks "generation success" by
// whether an artifact landed. These tests keep the new helper honest
// for the shapes the daemon actually sees on the wire (claude-stream,
// codex, ACP/MCP proxies).
import { describe, expect, it } from 'vitest';
import { countNewHtmlArtifacts } from '../src/run-artifacts.js';
let nextId = 0;
function freshId(prefix = 'tool'): string {
nextId += 1;
return `${prefix}-${nextId}`;
}
// Helper: emit a tool_use+tool_result pair. `isError` defaults to
// false so the common "successful Write on .html" case stays one
// line at the call site.
function pair(
name: string,
filePath: string,
isError = false,
id = freshId(),
) {
return [
{
event: 'agent',
data: {
type: 'tool_use',
id,
name,
input: { file_path: filePath },
},
},
{
event: 'agent',
data: {
type: 'tool_result',
toolUseId: id,
isError,
},
},
];
}
// Helper: emit a tool_use with no matching tool_result. Used to pin
// the "tool still in flight" / "adapter swallowed result" behavior.
function unfinished(name: string, filePath: string, id = freshId()) {
return [
{
event: 'agent',
data: {
type: 'tool_use',
id,
name,
input: { file_path: filePath },
},
},
];
}
describe('countNewHtmlArtifacts', () => {
it('returns 0 when the run produced no events', () => {
expect(countNewHtmlArtifacts([])).toBe(0);
});
it('returns 0 when no tool_use targets a .html file', () => {
expect(
countNewHtmlArtifacts([
...pair('Write', '/proj/notes.md'),
...pair('Edit', '/proj/styles.css'),
...pair('Read', '/proj/index.html'),
]),
).toBe(0);
});
it('counts a single successful Write on a .html path', () => {
expect(
countNewHtmlArtifacts(pair('Write', '/proj/index.html')),
).toBe(1);
});
it('does NOT count a tool_use whose tool_result reports isError=true', () => {
// Permission denied, path outside cwd, parent missing — all bounce
// the same way through the tool_result.isError channel and must
// not increment artifact_count.
expect(
countNewHtmlArtifacts(pair('Write', '/proj/index.html', true)),
).toBe(0);
});
it('does NOT count a tool_use that has no matching tool_result yet', () => {
// Run was sampled while a Write was still in flight; the
// safe-default is to undercount rather than promise an
// artifact we can't confirm landed.
expect(
countNewHtmlArtifacts(unfinished('Write', '/proj/index.html')),
).toBe(0);
});
it('dedupes multiple successful Write/Edit ops on the same path', () => {
expect(
countNewHtmlArtifacts([
...pair('Write', '/proj/index.html'),
...pair('Edit', '/proj/index.html'),
...pair('MultiEdit', '/proj/index.html'),
]),
).toBe(1);
});
it('keeps the path counted when a later edit on the same path fails', () => {
// First Write succeeded — the artifact exists. A subsequent Edit
// that errors doesn't take that fact away; the file is still on
// disk. So this still reports 1.
expect(
countNewHtmlArtifacts([
...pair('Write', '/proj/index.html'),
...pair('Edit', '/proj/index.html', true),
]),
).toBe(1);
});
it('counts distinct .html paths separately', () => {
expect(
countNewHtmlArtifacts([
...pair('Write', '/proj/index.html'),
...pair('Write', '/proj/about.html'),
...pair('Write', '/proj/contact.html'),
]),
).toBe(3);
});
it('handles the Codex `create_file` / `str_replace_edit` aliases', () => {
expect(
countNewHtmlArtifacts([
...pair('create_file', '/proj/a.html'),
...pair('str_replace_edit', '/proj/b.html'),
]),
).toBe(2);
});
it('accepts both `file_path` and `path` input shapes', () => {
const id = freshId();
expect(
countNewHtmlArtifacts([
{
event: 'agent',
data: {
type: 'tool_use',
id,
name: 'Write',
input: { path: '/proj/page.html' },
},
},
{
event: 'agent',
data: { type: 'tool_result', toolUseId: id, isError: false },
},
]),
).toBe(1);
});
it('treats .HTML / .Html case-insensitively', () => {
expect(
countNewHtmlArtifacts([
...pair('Write', '/proj/Page.HTML'),
...pair('Write', '/proj/Other.Html'),
]),
).toBe(2);
});
it('ignores non-agent events and malformed payloads', () => {
expect(
countNewHtmlArtifacts([
{ event: 'start', data: { runId: 'r1' } },
{ event: 'stderr', data: { chunk: 'log' } },
{ event: 'agent', data: null },
{ event: 'agent', data: { type: 'text_delta', text: 'hi' } },
...pair('Write', '/proj/index.html'),
]),
).toBe(1);
});
it('ignores Read / Grep / Bash even when their input names a .html file', () => {
expect(
countNewHtmlArtifacts([
...pair('Read', '/proj/index.html'),
...pair('Grep', '/proj/index.html'),
...pair('Bash', '/proj/index.html'),
]),
).toBe(0);
});
});
+3 -1
View File
@@ -1414,7 +1414,7 @@ export function App() {
onRenameProject={handleRenameProject}
onChangeDefaultDesignSystem={handleChangeDefaultDesignSystem}
onCreateDesignSystem={() => navigate({ kind: 'design-system-create' })}
renderDesignSystemCreation={(onBack) => (
renderDesignSystemCreation={(onBack, hooks) => (
<DesignSystemCreationFlow
chrome="embedded"
onBack={onBack}
@@ -1443,6 +1443,8 @@ export function App() {
onSystemsRefresh={refreshDesignSystems}
config={config}
onOpenConnectorsTab={() => openSettings('composio')}
{...(hooks?.onBeforeGenerate ? { onBeforeGenerate: hooks.onBeforeGenerate } : {})}
{...(hooks?.onGenerateSettled ? { onGenerateSettled: hooks.onGenerateSettled } : {})}
/>
)}
onOpenDesignSystem={(id: string) => navigate({ kind: 'design-system-detail', designSystemId: id })}
+44
View File
@@ -82,6 +82,10 @@ import type {
SettingsCliTestResultProps,
SettingsByokTestResultProps,
SettingsConnectorAuthResultProps,
OnboardingClickProps,
OnboardingRuntimeScanResultProps,
OnboardingCompleteResultProps,
UpdatePopoverSurfaceViewProps,
} from '@open-design/contracts/analytics';
type TrackOptions = { requestId?: string; insertId?: string };
@@ -664,3 +668,43 @@ export function trackAssistantFeedbackReasonSubmit(
options,
);
}
// ---- Onboarding ---------------------------------------------------------
//
// `trackOnboardingClick` is the catch-all for the welcome flow's
// runtime-pick / about-you / design-system source / continue / skip /
// back / generate buttons; the discriminator combo (area + element +
// action) narrows the row down inside PostHog so the dashboard can
// split each step's funnel cleanly without a separate event name per
// button. Lifecycle events that don't fit a click — CLI scan finishing,
// onboarding wrapping up — get their own `onboarding_*_result` shape.
export function trackOnboardingClick(
track: Track,
props: OnboardingClickProps,
): void {
send(track, 'ui_click', props);
}
export function trackOnboardingRuntimeScanResult(
track: Track,
props: OnboardingRuntimeScanResultProps,
): void {
send(track, 'onboarding_runtime_scan_result', props);
}
export function trackOnboardingCompleteResult(
track: Track,
props: OnboardingCompleteResultProps,
): void {
send(track, 'onboarding_complete_result', props);
}
// ---- Update popover surface_view ----------------------------------------
export function trackUpdatePopoverSurfaceView(
track: Track,
props: UpdatePopoverSurfaceViewProps,
): void {
send(track, 'surface_view', props);
}
@@ -69,6 +69,21 @@ import type {
TrackingDesignSystemsEntryFrom,
} from '@open-design/contracts/analytics';
// Source counts the embedded DS creation flow can report back to its
// wrapper at Generate-click time. OnboardingView uses this to emit the
// `generate` ui_click + `onboarding_complete_result` events with the
// runtime/about-you context that only it knows; without this hook the
// onboarding wrapper would have no way to see the user-pinned source
// material because the form state lives inside `DesignSystemCreationFlow`.
export interface DesignSystemGenerateSnapshot {
sourceCount: number;
hasBrandDescription: boolean;
githubRepoCount: number;
localFolderCount: number;
figFileCount: number;
assetFileCount: number;
}
interface CreationProps {
onBack: () => void;
onCreated: (projectId: string, project?: Project) => void;
@@ -77,6 +92,21 @@ interface CreationProps {
config?: AppConfig;
onOpenConnectorsTab?: () => void;
chrome?: 'standalone' | 'embedded';
// Intent signal: user clicked Generate. Fires before any async work,
// so a wrapper (OnboardingView) can emit the `generate` ui_click row
// even when generation later fails.
onBeforeGenerate?: (snapshot: DesignSystemGenerateSnapshot) => void;
// Outcome signal: generation either kicked off successfully (workspace
// opened, project handed off) or hit a failure branch. Wrappers use
// this to emit lifecycle completion events with the right result so
// a draft-create error or workspace-open error doesn't ship as
// `completed_with_design_system`. `error_code` is the daemon's
// generic failure code; the exact message stays in the local error
// toast.
onGenerateSettled?: (
snapshot: DesignSystemGenerateSnapshot,
outcome: { result: 'success' } | { result: 'failed'; errorCode: string },
) => void;
}
interface DetailProps {
@@ -199,6 +229,8 @@ export function DesignSystemCreationFlow({
config,
onOpenConnectorsTab,
chrome = 'standalone',
onBeforeGenerate,
onGenerateSettled,
}: CreationProps) {
const [step, setStep] = useState<SetupStep>('setup');
const [state, setState] = useState<SetupState>(EMPTY_SETUP);
@@ -377,6 +409,27 @@ export function DesignSystemCreationFlow({
async function generate() {
if (generationStarting) return;
// Snapshot the user-pinned source state up front. Used for the
// pre-async ui_click intent signal AND the post-async lifecycle
// outcome — both rides need the same numbers so the
// dashboard can correlate "user attempted generate with N
// sources" → "generate eventually succeeded / failed with the
// same N". Computed here because OnboardingView can't peek into
// this flow's setup form.
const githubRepoCount = state.githubUrls?.length ?? 0;
const localFolderCount = state.codeFolders?.length ?? 0;
const figFileCount = state.figFiles?.length ?? 0;
const assetFileCount = state.assetFiles?.length ?? 0;
const snapshot = {
sourceCount:
githubRepoCount + localFolderCount + figFileCount + assetFileCount,
hasBrandDescription: Boolean(state.company?.trim()),
githubRepoCount,
localFolderCount,
figFileCount,
assetFileCount,
};
onBeforeGenerate?.(snapshot);
setGenerationStarting(true);
setError(null);
try {
@@ -394,18 +447,27 @@ export function DesignSystemCreationFlow({
if (!created) {
setError('Could not generate this design system.');
setStep('setup');
onGenerateSettled?.(snapshot, {
result: 'failed',
errorCode: 'DS_DRAFT_CREATE_FAILED',
});
return;
}
const workspace = await ensureDesignSystemWorkspace(created.id);
if (!workspace) {
setError('Could not open the design system workspace.');
setStep('setup');
onGenerateSettled?.(snapshot, {
result: 'failed',
errorCode: 'DS_WORKSPACE_OPEN_FAILED',
});
return;
}
const project = workspace.project;
const setupState = state;
const connector = githubConnector;
onCreated(project.id, project);
onGenerateSettled?.(snapshot, { result: 'success' });
scheduleAfterProjectHandoff(() => {
void prepareCreatedDesignSystemProject({
project,
@@ -419,6 +481,12 @@ export function DesignSystemCreationFlow({
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not prepare the design system project.');
setStep('setup');
onGenerateSettled?.(snapshot, {
result: 'failed',
errorCode: err instanceof Error
? `DS_GENERATE_THREW:${err.message.slice(0, 80)}`
: 'DS_GENERATE_THREW',
});
} finally {
setGenerationStarting(false);
}
+338 -19
View File
@@ -15,10 +15,14 @@ import {
type InstalledPluginRecord,
} from '@open-design/contracts';
import type { OpenDesignHostProjectImportSuccess } from '@open-design/host';
import type { DesignSystemGenerateSnapshot } from './DesignSystemFlow';
import { useAnalytics } from '../analytics/provider';
import {
trackHomeNavClick,
trackHomeToolbarClick,
trackOnboardingClick,
trackOnboardingCompleteResult,
trackOnboardingRuntimeScanResult,
trackPageView,
} from '../analytics/events';
import {
@@ -29,7 +33,14 @@ import type {
TrackingOnboardingArea,
TrackingOnboardingStepIndex,
TrackingOnboardingStepName,
TrackingOnboardingClickElement,
TrackingOnboardingClickAction,
TrackingOnboardingRuntimeType,
TrackingOnboardingCompletionResult,
TrackingOnboardingCompletionType,
TrackingCliProviderId,
} from '@open-design/contracts/analytics';
import { agentIdToTracking } from '@open-design/contracts/analytics';
import { useT } from '../i18n';
import { navigate, useRoute } from '../router';
import type {
@@ -238,7 +249,18 @@ interface Props {
onRenameProject: (id: string, name: string) => void;
onChangeDefaultDesignSystem: (id: string) => void;
onCreateDesignSystem?: () => void;
renderDesignSystemCreation?: (onBack: () => void) => ReactNode;
renderDesignSystemCreation?: (
onBack: () => void,
hooks?: {
onBeforeGenerate?: (snapshot: DesignSystemGenerateSnapshot) => void;
onGenerateSettled?: (
snapshot: DesignSystemGenerateSnapshot,
outcome:
| { result: 'success' }
| { result: 'failed'; errorCode: string },
) => void;
},
) => ReactNode;
onOpenDesignSystem?: (id: string) => void;
onDesignSystemsRefresh?: () => Promise<void> | void;
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
@@ -729,7 +751,18 @@ function OnboardingView({
onApiModelChange: (model: string) => void;
onConfigPersist: (cfg: AppConfig) => Promise<void> | void;
onRefreshAgents: () => Promise<AgentInfo[]> | AgentInfo[];
renderDesignSystemCreation?: (onBack: () => void) => ReactNode;
renderDesignSystemCreation?: (
onBack: () => void,
hooks?: {
onBeforeGenerate?: (snapshot: DesignSystemGenerateSnapshot) => void;
onGenerateSettled?: (
snapshot: DesignSystemGenerateSnapshot,
outcome:
| { result: 'success' }
| { result: 'failed'; errorCode: string },
) => void;
},
) => ReactNode;
onFinish: () => void;
}) {
const t = useT();
@@ -817,18 +850,23 @@ function OnboardingView({
// exposure. The fourth step (`generation`) lives in
// `DesignSystemDetailView` because the user navigates out of this
// component once the design system project opens; that emission
// reads the same `onboarding_session_id` from sessionStorage.
// `clearOnboardingSessionId` runs on `onFinish` / unmount so a
// later DS visit unrelated to onboarding doesn't inherit the id.
// reads the same `onboarding_session_id` from sessionStorage and
// clears it once it fires.
//
// We do NOT clear on unmount: the Generate path unmounts
// OnboardingView *before* the post-Generate chat_panel page_view
// mounts in ProjectView, so an unmount-clear would race the 4th-step
// emission and consistently wipe the id before it could be read
// (observed on PostHog 2026-05-21 — `area=generation_progress` had
// zero events with the unmount-clear in place). Skip / Back / last-
// step Continue clear inline in their respective handlers below; the
// Generate path clears from `ProjectView` after the
// generation_progress page_view lands; abandoned sessions clear on
// sessionStorage tab close.
const onboardingSessionIdRef = useRef<string>('');
if (!onboardingSessionIdRef.current) {
onboardingSessionIdRef.current = getOrCreateOnboardingSessionId();
}
useEffect(() => {
return () => {
clearOnboardingSessionId();
};
}, []);
useEffect(() => {
const onboardingSessionId = onboardingSessionIdRef.current;
if (!onboardingSessionId) return;
@@ -857,6 +895,115 @@ function OnboardingView({
});
}, [analytics.track, step]);
// Onboarding analytics helpers. Wall-clock start so the lifecycle
// result event can carry `duration_ms`; `runtime` state is the user's
// current pick at click time so `runtime_type` rides along on every
// click. The `_lifecycleReportedRef` guards against double-firing the
// completion event when the user fires both Skip and unmount in the
// same tick (the unmount path also clears the session id; see the
// PR #2453 follow-up).
const onboardingStartedAtRef = useRef<number>(Date.now());
const lifecycleReportedRef = useRef(false);
function currentRuntimeType(): TrackingOnboardingRuntimeType {
if (runtime === 'local') return 'local_cli';
if (runtime === 'byok') return 'byok';
return 'none';
}
function stepInfo(stepIdx: number): {
area: TrackingOnboardingArea;
stepIndex: TrackingOnboardingStepIndex;
stepName: TrackingOnboardingStepName;
} {
if (stepIdx === 0) return { area: 'runtime', stepIndex: '1', stepName: 'connect' };
if (stepIdx === 1) return { area: 'about_you', stepIndex: '2', stepName: 'about_you' };
return { area: 'design_system', stepIndex: '3', stepName: 'design_system' };
}
// Pure mapping from `DesignSystemGenerateSnapshot` to the v2
// `TrackingOnboardingSourceType` enum. Single-source batches collapse
// to that source's literal; mixed batches go to `'mixed'`; the empty
// batch falls back to `'text'` when the user typed a brand
// description (prompt-only path, which the v2 contract reserves the
// `'text'` literal for) and `'none'` otherwise. The pre-fix version
// shipped `'none'` for prompt-only too, losing the prompt-only vs
// truly-empty distinction the dashboard needs.
function deriveOnboardingSourceType(
snapshot: DesignSystemGenerateSnapshot,
): import('@open-design/contracts/analytics').TrackingOnboardingSourceType {
if (snapshot.sourceCount === 0) {
return snapshot.hasBrandDescription ? 'text' : 'none';
}
if (snapshot.githubRepoCount === snapshot.sourceCount) return 'github_repo';
if (snapshot.localFolderCount === snapshot.sourceCount) return 'local_code';
if (snapshot.figFileCount === snapshot.sourceCount) return 'fig';
if (snapshot.assetFileCount === snapshot.sourceCount) return 'assets';
return 'mixed';
}
function emitOnboardingClick(
element: TrackingOnboardingClickElement,
action: TrackingOnboardingClickAction,
extra: Partial<Omit<
Parameters<typeof trackOnboardingClick>[1],
'page_name' | 'area' | 'element' | 'action' | 'step_index' | 'step_name' | 'onboarding_session_id'
>> = {},
): void {
const onboardingSessionId = onboardingSessionIdRef.current;
if (!onboardingSessionId) return;
const info = stepInfo(step);
trackOnboardingClick(analytics.track, {
page_name: 'onboarding',
area: info.area,
element,
action,
step_index: info.stepIndex,
step_name: info.stepName,
onboarding_session_id: onboardingSessionId,
...extra,
});
}
function emitOnboardingComplete(
result: TrackingOnboardingCompletionResult,
completionType: TrackingOnboardingCompletionType,
extra: {
errorCode?: string;
// Generate-path callers pass the embedded DS creation flow's
// snapshot so the wire row reflects the actual source-count
// and brand-description the user typed, not the (always-null)
// `designSource` card-pick state. E2E (2026-05-21) showed the
// user can click Generate without first clicking one of the
// three source-type cards — they go straight to typing a
// brand prompt — so reading `designSource` alone yielded
// `has_design_system_request: false` despite a real request.
sourceSnapshot?: DesignSystemGenerateSnapshot;
} = {},
): void {
if (lifecycleReportedRef.current) return;
const onboardingSessionId = onboardingSessionIdRef.current;
if (!onboardingSessionId) return;
lifecycleReportedRef.current = true;
const info = stepInfo(step);
const snapshot = extra.sourceSnapshot;
const hasRequest = snapshot
? snapshot.sourceCount > 0 || snapshot.hasBrandDescription
: Boolean(designSource);
const sourceCount = snapshot ? snapshot.sourceCount : 0;
trackOnboardingCompleteResult(analytics.track, {
page_name: 'onboarding',
area: 'onboarding',
result,
exit_step_name: info.stepName,
completion_type: completionType,
runtime_type: currentRuntimeType(),
has_about_you: Boolean(
profile.role || profile.orgSize || profile.useCase.length > 0 || profile.source,
),
has_design_system_request: hasRequest,
source_count: sourceCount,
...(extra.errorCode ? { error_code: extra.errorCode } : {}),
duration_ms: Math.max(0, Date.now() - onboardingStartedAtRef.current),
onboarding_session_id: onboardingSessionId,
});
}
const steps = [
t('settings.onboardingStepConnect'),
t('settings.onboardingStepProfile'),
@@ -877,6 +1024,9 @@ function OnboardingView({
title: t('settings.onboardingLocalTitle'),
body: t('settings.onboardingLocalBody'),
onSelect: () => {
emitOnboardingClick('local_coding_agent', 'select_runtime', {
runtime_type: 'local_cli',
});
void scanCliAgents();
},
},
@@ -886,6 +1036,7 @@ function OnboardingView({
title: t('settings.onboardingByokTitle'),
body: t('settings.onboardingByokBody'),
onSelect: () => {
emitOnboardingClick('byok', 'select_runtime', { runtime_type: 'byok' });
setRuntime('byok');
onModeChange('api');
},
@@ -904,21 +1055,36 @@ function OnboardingView({
icon: 'github',
title: t('settings.onboardingGithubTitle'),
body: t('settings.onboardingGithubBody'),
onSelect: () => setDesignSource('github'),
onSelect: () => {
emitOnboardingClick('github_repo', 'add_source', {
source_type: 'github_repo',
});
setDesignSource('github');
},
},
{
id: 'upload',
icon: 'upload',
title: t('settings.onboardingUploadTitle'),
body: t('settings.onboardingUploadBody'),
onSelect: () => setDesignSource('upload'),
onSelect: () => {
emitOnboardingClick('local_code', 'upload_source', {
source_type: 'local_code',
});
setDesignSource('upload');
},
},
{
id: 'prompt',
icon: 'sparkles',
title: t('settings.onboardingPromptTitle'),
body: t('settings.onboardingPromptBody'),
onSelect: () => setDesignSource('prompt'),
onSelect: () => {
emitOnboardingClick('fig_upload', 'upload_source', {
source_type: 'fig',
});
setDesignSource('prompt');
},
},
];
const roleOptions = [
@@ -1021,11 +1187,36 @@ function OnboardingView({
agentRevealTimersRef.current = [];
}
function handleSkipWithTracking(): void {
emitOnboardingClick('skip', 'skip');
emitOnboardingComplete('skipped', 'skipped');
clearOnboardingSessionId();
onFinish();
}
function handleBackWithTracking(): void {
if (step === 0) {
// Step 0 "Back" semantically maps to Skip — there's nowhere
// earlier to go. Match the Skip telemetry shape rather than
// emit a back-without-prior-step row.
handleSkipWithTracking();
return;
}
emitOnboardingClick('back', 'back');
setStep((current) => current - 1);
}
function handlePrimaryAction() {
if (isLastStep) {
emitOnboardingClick('continue', 'continue');
// Last-step Continue without a DS generation = "completed
// without design system". The Generate path inside the
// embedded DesignSystemCreationFlow takes a different route
// (navigation to project) and emits its own completion.
emitOnboardingComplete('completed', 'completed_without_design_system');
clearOnboardingSessionId();
onFinish();
return;
}
emitOnboardingClick('continue', 'continue');
setStep((current) => current + 1);
}
@@ -1037,14 +1228,58 @@ function OnboardingView({
onModeChange('daemon');
setCliScanStatus('scanning');
setVisibleAgentIds([]);
const scanStartedAt = Date.now();
const onboardingSessionId = onboardingSessionIdRef.current;
const emitScanResult = (
args: {
result: 'success' | 'failed';
detected: number;
available: number;
selectedCliId?: TrackingCliProviderId;
errorCode?: string;
},
): void => {
if (!onboardingSessionId) return;
trackOnboardingRuntimeScanResult(analytics.track, {
page_name: 'onboarding',
area: 'runtime',
runtime_type: 'local_cli',
result: args.result,
detected_cli_count: args.detected,
available_cli_count: args.available,
...(args.selectedCliId ? { selected_cli_id: args.selectedCliId } : {}),
...(args.errorCode ? { error_code: args.errorCode } : {}),
duration_ms: Math.max(0, Date.now() - scanStartedAt),
onboarding_session_id: onboardingSessionId,
});
};
try {
const nextAgents = await onRefreshAgents();
if (cliScanTokenRef.current !== scanToken) return;
const availableAgents = nextAgents.filter((agent) => agent.available);
// Scan-result semantics: zero available CLIs is a `failed` outcome
// because the user's runtime path is blocked, even though the
// detect call itself returned successfully. `detected_cli_count`
// separately reports the raw catalog so the dashboard can split
// "user has no CLI installed" from "detect crashed".
if (availableAgents.length === 0) {
setCliScanStatus('done');
emitScanResult({
result: 'failed',
detected: nextAgents.length,
available: 0,
errorCode: 'NO_AVAILABLE_CLI',
});
return;
}
emitScanResult({
result: 'success',
detected: nextAgents.length,
available: availableAgents.length,
...(availableAgents[0]
? { selectedCliId: agentIdToTracking(availableAgents[0].id) }
: {}),
});
availableAgents.forEach((agent, index) => {
const timer = setTimeout(() => {
if (cliScanTokenRef.current !== scanToken) return;
@@ -1057,9 +1292,15 @@ function OnboardingView({
}, 110 * (index + 1));
agentRevealTimersRef.current.push(timer);
});
} catch {
} catch (err) {
if (cliScanTokenRef.current === scanToken) {
setCliScanStatus('done');
emitScanResult({
result: 'failed',
detected: 0,
available: 0,
errorCode: err instanceof Error ? err.message : 'AGENT_REFRESH_THREW',
});
}
}
}
@@ -1267,7 +1508,14 @@ function OnboardingView({
placeholder={t('settings.onboardingSelectPlaceholder')}
value={profile.orgSize}
options={orgSizeOptions}
onChange={(value) => setProfile((current) => ({ ...current, orgSize: value }))}
onChange={(value) => {
if (typeof value === 'string' && value) {
emitOnboardingClick('organization_size', 'select_option', {
organization_size: value,
});
}
setProfile((current) => ({ ...current, orgSize: value }));
}}
/>
<OnboardingDropdown
label={t('settings.onboardingUseCaseLabel')}
@@ -1277,6 +1525,16 @@ function OnboardingView({
multiple
onChange={(value) => {
if (!Array.isArray(value)) return;
// Multi-select: emit one click per newly added
// value (delta), not per render of the whole
// selection. The dashboard then sees one row per
// use_case chosen.
const previous = new Set(profile.useCase);
for (const v of value) {
if (!previous.has(v)) {
emitOnboardingClick('use_case', 'select_option', { use_case: v });
}
}
setProfile((current) => ({ ...current, useCase: value }));
}}
/>
@@ -1285,7 +1543,14 @@ function OnboardingView({
placeholder={t('settings.onboardingSelectPlaceholder')}
value={profile.source}
options={sourceOptions}
onChange={(value) => setProfile((current) => ({ ...current, source: value }))}
onChange={(value) => {
if (typeof value === 'string' && value) {
emitOnboardingClick('hear_about_us', 'select_option', {
discovery_source: value,
});
}
setProfile((current) => ({ ...current, source: value }));
}}
/>
</div>
</div>
@@ -1308,11 +1573,65 @@ function OnboardingView({
<span>{t('settings.onboardingDesignIntroReuseBody')}</span>
</div>
</div>
<button type="button" className="onboarding-view__ds-skip" onClick={onFinish}>
<button type="button" className="onboarding-view__ds-skip" onClick={handleSkipWithTracking}>
{t('settings.onboardingSkip')}
</button>
</div>
{renderDesignSystemCreation(() => setStep(1))}
{renderDesignSystemCreation(() => setStep(1), {
onBeforeGenerate: (snapshot) => {
// INTENT signal — fires before async DS-draft create
// / workspace-open work runs. Use it ONLY for the
// `generate` click row so the dashboard captures
// user intent even when generation later errors.
// The lifecycle `onboarding_complete_result` row
// moved to `onGenerateSettled` below so a draft
// create failure no longer ships as
// `completion_type=completed_with_design_system`.
emitOnboardingClick('generate', 'generate', {
source_type: deriveOnboardingSourceType(snapshot),
source_count: snapshot.sourceCount,
has_brand_description: snapshot.hasBrandDescription,
});
},
onGenerateSettled: (snapshot, outcome) => {
// OUTCOME signal — fires from `DesignSystemCreationFlow`
// *after* the create/workspace branch settles.
// Success → emit lifecycle complete row with
// `completion_type=completed_with_design_system`.
// Generation hand-off navigates away from this
// tab; the post-Generate `chat_panel` page_view
// in ProjectView fires the 4th-step
// `area=generation_progress` row and clears the
// session id. Don't clear here.
// Failure → emit lifecycle complete with
// `result=failed`, the daemon's failure code, and
// `completed_without_design_system` so we don't
// overstate completed-with-DS funnel. Then re-arm
// the lifecycle guard (don't clear the session
// id) so the user's retry attempt — which
// DesignSystemCreationFlow leaves them in by
// bouncing back to its setup step — emits a
// second complete row under the SAME
// onboarding_session_id, and any eventual
// success can still navigate to ProjectView with
// the id intact for step 4. Tracked by mrcfps
// review on PR #2590 (2026-05-21 14:45).
if (outcome.result === 'success') {
emitOnboardingComplete(
'completed',
'completed_with_design_system',
{ sourceSnapshot: snapshot },
);
return;
}
emitOnboardingComplete(
'failed',
'completed_without_design_system',
{ sourceSnapshot: snapshot, errorCode: outcome.errorCode },
);
lifecycleReportedRef.current = false;
},
})}
</div>
) : null}
@@ -1342,7 +1661,7 @@ function OnboardingView({
<button
type="button"
className="onboarding-view__secondary"
onClick={() => (step === 0 ? onFinish() : setStep((current) => current - 1))}
onClick={handleBackWithTracking}
>
{step === 0 ? t('settings.onboardingSkip') : t('settings.onboardingBack')}
</button>
+13 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import type { DesignSystemGenerateSnapshot } from './DesignSystemFlow';
import type {
ConnectorDetail,
ConnectorStatusResponse,
@@ -109,7 +110,18 @@ interface Props {
onRenameProject: (id: string, name: string) => void;
onChangeDefaultDesignSystem: (id: string) => void;
onCreateDesignSystem?: () => void;
renderDesignSystemCreation?: (onBack: () => void) => ReactNode;
renderDesignSystemCreation?: (
onBack: () => void,
hooks?: {
onBeforeGenerate?: (snapshot: DesignSystemGenerateSnapshot) => void;
onGenerateSettled?: (
snapshot: DesignSystemGenerateSnapshot,
outcome:
| { result: 'success' }
| { result: 'failed'; errorCode: string },
) => void;
},
) => ReactNode;
onOpenDesignSystem?: (id: string) => void;
onDesignSystemsRefresh?: () => Promise<void> | void;
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | void;
+25
View File
@@ -48,6 +48,10 @@ import {
import { projectKindToTracking } from '@open-design/contracts/analytics';
import { useAnalytics } from '../analytics/provider';
import { trackPageView } from '../analytics/events';
import {
clearOnboardingSessionId,
peekOnboardingSessionId,
} from '../analytics/onboarding-session';
import { navigate } from '../router';
import { agentDisplayName, agentModelDisplayName } from '../utils/agentLabels';
import { isMacPlatform } from '../utils/platform';
@@ -479,6 +483,27 @@ export function ProjectView({
if (chatPanelPageViewFiredRef.current === project.id) return;
chatPanelPageViewFiredRef.current = project.id;
trackPageView(analytics.track, { page_name: 'chat_panel' });
// Onboarding's 4th step ("生成进度页") fires here, not in
// `DesignSystemDetailView`: the Generate path navigates
// straight to the project's chat_panel, not to the design
// system detail surface. If an onboarding session id is still
// in sessionStorage we stamp the funnel's last row here and
// clear so any later DS visit doesn't inherit the attribution.
// E2E (2026-05-21) confirmed this is the only path users
// actually take — observed: page_view chat_panel fires, but
// page_view design_system_project never did because that
// route isn't visited from the embedded onboarding generate.
const onboardingSessionId = peekOnboardingSessionId();
if (onboardingSessionId) {
trackPageView(analytics.track, {
page_name: 'onboarding',
area: 'generation_progress',
step_index: 'progress',
step_name: 'generation',
onboarding_session_id: onboardingSessionId,
});
clearOnboardingSessionId();
}
}, [analytics.track, project.id]);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConversationId, setActiveConversationId] = useState<string | null>(
+29
View File
@@ -14,6 +14,8 @@ import {
} from '../lib/updater';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { useAnalytics, useAppVersion } from '../analytics/provider';
import { trackUpdatePopoverSurfaceView } from '../analytics/events';
type InstallState = 'idle' | 'opening' | 'opened' | 'quitting';
type Translator = (key: keyof Dict, vars?: Record<string, string | number>) => string;
@@ -90,6 +92,33 @@ export function UpdaterPopup() {
return model.promptKey !== dismissedPromptKey;
}, [actionError, dismissedPromptKey, model.promptKey, model.shouldPrompt, panelOpen]);
// `surface_view page_name=home area=update_popover` — fires once per
// panel-open transition so the doc's "升级浮窗曝光" funnel actually
// sees data. `app_version_before` is the running daemon version,
// `app_version_after` is the available version the updater wants to
// install. Both optional so a popup that opens without a complete
// model still contributes a row.
const analytics = useAnalytics();
const appVersionBefore = useAppVersion();
const lastReportedKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!isPanelOpen) {
lastReportedKeyRef.current = null;
return;
}
const key = `${appVersionBefore}->${model.availableVersion ?? 'unknown'}`;
if (lastReportedKeyRef.current === key) return;
lastReportedKeyRef.current = key;
trackUpdatePopoverSurfaceView(analytics.track, {
page_name: 'home',
area: 'update_popover',
...(appVersionBefore ? { app_version_before: appVersionBefore } : {}),
...(model.availableVersion
? { app_version_after: model.availableVersion }
: {}),
});
}, [analytics.track, appVersionBefore, isPanelOpen, model.availableVersion]);
const close = useCallback(() => {
if (installState === 'opening' || installState === 'quitting') return;
if (model.promptKey != null) setDismissedPromptKey(model.promptKey);
+196 -8
View File
@@ -41,7 +41,13 @@ export type AnalyticsEventName =
| 'settings_view'
| 'settings_cli_test_result'
| 'settings_byok_test_result'
| 'settings_connector_auth_result';
| 'settings_connector_auth_result'
// Onboarding-only result events. UI clicks + page_views inside the
// onboarding flow reuse the generic `ui_click` / `page_view` shapes
// with `page_name=onboarding`; the three `onboarding_*` names below
// capture lifecycle moments that don't fit a click or a view.
| 'onboarding_runtime_scan_result'
| 'onboarding_complete_result';
// ---- Pages ---------------------------------------------------------------
@@ -257,6 +263,62 @@ export type TrackingOnboardingStepName =
| 'design_system'
| 'generation';
// How the user chose to connect to a model provider. `amr_cloud` is the
// hosted offering the doc references; today the UI ships only
// `local_cli` (Local Coding Agent) and `byok` (own model key). `none`
// stamps the click events fired before any runtime was picked.
export type TrackingOnboardingRuntimeType =
| 'amr_cloud'
| 'local_cli'
| 'byok'
| 'none';
// What kind of source material the user pinned in the design-system
// step. `text` covers the brand description textarea; `mixed` is
// reserved for batches that combined more than one type.
export type TrackingOnboardingSourceType =
| 'text'
| 'github_repo'
| 'local_code'
| 'fig'
| 'assets'
| 'mixed'
| 'none';
// `completed`: user clicked through every step (with or without a DS).
// `skipped`: user clicked Skip from any step. `cancelled`: user closed
// the onboarding tab / navigated away without finishing.
// `failed`: terminal error before completion.
export type TrackingOnboardingCompletionResult =
| 'completed'
| 'skipped'
| 'cancelled'
| 'failed';
export type TrackingOnboardingCompletionType =
| 'completed_with_design_system'
| 'completed_without_design_system'
| 'skipped';
// CLI scan terminal state. `success`: at least one CLI was detected;
// `failed`: scan errored or detected nothing; `timeout`: scan didn't
// settle inside the budget. Daemon doesn't currently surface timeouts
// for this scan; left in the type so a future watchdog can use it
// without a contract change.
export type TrackingOnboardingScanResult = 'success' | 'failed' | 'timeout';
// About-you step values. Surfaces from `onboardingRole*` /
// `onboardingOrg*` / `onboardingUse*` / `onboardingSource*` i18n
// keys; this enum is the wire-format shape the dashboard groups on.
// Kept as `string` rather than literal union because the product
// catalogue extends (e.g. add a new role) more often than the wire
// shape: a stricter union would force a contract bump for every new
// option. `unknown` covers the "user didn't pick / picked Other"
// path explicitly.
export type TrackingOnboardingOrganizationSize = string;
export type TrackingOnboardingUseCase = string;
export type TrackingOnboardingDiscoverySource = string;
export interface OnboardingPageViewProps {
page_name: 'onboarding';
area: TrackingOnboardingArea;
@@ -265,6 +327,100 @@ export interface OnboardingPageViewProps {
onboarding_session_id: string;
}
// ---- Onboarding ui_click ------------------------------------------------
//
// One interface so every onboarding click site can write
// `trackOnboardingClick(...)`, regardless of which sub-surface. The doc
// pre-allocates a large element / action enum because the funnel needs
// to discriminate Skip from Back from Continue, runtime choice from
// source selection, etc.
export type TrackingOnboardingClickElement =
// Runtime / connect step
| 'amr_cloud'
| 'local_coding_agent'
| 'byok'
// Action buttons
| 'continue'
| 'back'
| 'skip'
| 'generate'
// About you fields
| 'organization_size'
| 'use_case'
| 'hear_about_us'
// Design system source options
| 'github_repo'
| 'local_code'
| 'fig_upload'
| 'assets_upload'
| 'show_access_methods';
export type TrackingOnboardingClickAction =
| 'select_runtime'
| 'continue'
| 'back'
| 'skip'
| 'generate'
| 'select_option'
| 'add_source'
| 'upload_source'
| 'show_access_methods';
// All optional except the discriminators (area/element/action/step/
// session id). `organization_size`/`use_case`/`discovery_source` only
// ride along on About-you clicks. `source_type`/`has_brand_description`/
// `source_count` only on Design-system source clicks. `runtime_type`/
// `is_recommended` only on Connect clicks. Doc explicitly forbids
// brand text, GitHub URL, file name, or path values — all enum + bool
// + count, no free-text.
export interface OnboardingClickProps {
page_name: 'onboarding';
area: TrackingOnboardingArea;
element: TrackingOnboardingClickElement;
action: TrackingOnboardingClickAction;
step_index: TrackingOnboardingStepIndex;
step_name: TrackingOnboardingStepName;
onboarding_session_id: string;
runtime_type?: TrackingOnboardingRuntimeType;
is_recommended?: boolean;
organization_size?: TrackingOnboardingOrganizationSize;
use_case?: TrackingOnboardingUseCase;
discovery_source?: TrackingOnboardingDiscoverySource;
source_type?: TrackingOnboardingSourceType;
has_brand_description?: boolean;
source_count?: number;
}
// ---- Onboarding lifecycle result events ---------------------------------
export interface OnboardingRuntimeScanResultProps {
page_name: 'onboarding';
area: 'runtime';
runtime_type: 'local_cli';
result: TrackingOnboardingScanResult;
detected_cli_count: number;
available_cli_count: number;
selected_cli_id?: TrackingCliProviderId;
error_code?: string;
duration_ms: number;
onboarding_session_id: string;
}
export interface OnboardingCompleteResultProps {
page_name: 'onboarding';
area: 'onboarding';
result: TrackingOnboardingCompletionResult;
exit_step_name: TrackingOnboardingStepName;
completion_type: TrackingOnboardingCompletionType;
runtime_type: TrackingOnboardingRuntimeType;
has_about_you: boolean;
has_design_system_request: boolean;
source_count: number;
error_code?: string;
duration_ms: number;
onboarding_session_id: string;
}
// --- Design systems page_view (multi-surface) ---
//
// Single shape covering the dedicated DS list / create / preview pages plus
@@ -1046,7 +1202,8 @@ export type UiClickProps =
| SettingsAppearanceClickProps
| SettingsNotificationsClickProps
| SettingsPetsClickProps
| SettingsPrivacyClickProps;
| SettingsPrivacyClickProps
| OnboardingClickProps;
// ---- surface_view --------------------------------------------------------
@@ -1086,12 +1243,24 @@ export interface AssistantFeedbackReasonPanelSurfaceViewProps {
rating: 'positive' | 'negative';
}
// Home toolbar "Update ready" popover. Fires once per render of the
// upgrade-available card (see top-right of `EntryShell` /
// `WorkspaceTabsBar`). `app_version_before` / `app_version_after` ride
// along so the dashboard can compare adoption rate by version delta.
export interface UpdatePopoverSurfaceViewProps {
page_name: 'home';
area: 'update_popover';
app_version_before?: string;
app_version_after?: string;
}
export type SurfaceViewProps =
| HelpPopoverSurfaceViewProps
| NewProjectModalSurfaceViewProps
| PluginReplacementModalSurfaceViewProps
| DesignSystemsTemplatesModalSurfaceViewProps
| AssistantFeedbackReasonPanelSurfaceViewProps;
| AssistantFeedbackReasonPanelSurfaceViewProps
| UpdatePopoverSurfaceViewProps;
// ---- Result events -------------------------------------------------------
@@ -1203,18 +1372,35 @@ export interface UpdateApplyObservedProps {
elapsed_bucket: TrackingUpdateApplyElapsedBucket;
}
// Discriminated union over the four surfaces that fire
// `file_upload_result`. The `file_manager` shape is the original (Files
// panel Upload button). `home` / `chat_panel` were added in PR #2459 so
// the home + chat composer paperclip uploads stop being silent. The
// `onboarding` shape covers the Design-system step's source ingest:
// `source_type` is required so the dashboard can split the funnel by
// source kind without inspecting `file_type`.
export type TrackingFileUploadSurface =
| { page_name: 'file_manager'; area: 'file_manager' }
| { page_name: 'chat_panel'; area: 'chat_composer' }
| { page_name: 'home'; area: 'chat_composer' };
| { page_name: 'file_manager'; area: 'file_manager'; project_id: string }
| { page_name: 'chat_panel'; area: 'chat_composer'; project_id: string }
| { page_name: 'home'; area: 'chat_composer'; project_id: string }
| {
page_name: 'onboarding';
area: 'design_system_source';
source_type: 'local_code' | 'fig' | 'assets';
onboarding_session_id: string;
// Onboarding uploads happen BEFORE a project exists, so
// `project_id` is optional and present only when the upload was
// re-issued after a project landed (rare in the onboarding flow).
project_id?: string;
};
export type FileUploadResultProps = TrackingFileUploadSurface & {
project_id: string;
file_count: number;
file_type: TrackingFileType;
file_size_bucket: TrackingFileSizeBucket;
result: TrackingRunResult;
error_code?: string;
duration_ms?: number;
};
export interface ArtifactExportResultProps {
@@ -1372,7 +1558,9 @@ export type AnalyticsEventPayload =
| { event: 'settings_view'; props: SettingsViewProps }
| { event: 'settings_cli_test_result'; props: SettingsCliTestResultProps }
| { event: 'settings_byok_test_result'; props: SettingsByokTestResultProps }
| { event: 'settings_connector_auth_result'; props: SettingsConnectorAuthResultProps };
| { event: 'settings_connector_auth_result'; props: SettingsConnectorAuthResultProps }
| { event: 'onboarding_runtime_scan_result'; props: OnboardingRuntimeScanResultProps }
| { event: 'onboarding_complete_result'; props: OnboardingCompleteResultProps };
// ---- Enum mapping helpers (code ↔ CSV wire format) -----------------------