mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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 (commit5b5a7ed5— 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 commit9e9a0019). `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 commit2cd05f09). 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:
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) -----------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user