diff --git a/apps/daemon/src/run-artifacts.ts b/apps/daemon/src/run-artifacts.ts new file mode 100644 index 0000000000..76ca35e181 --- /dev/null +++ b/apps/daemon/src/run-artifacts.ts @@ -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 = 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(); + 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(); + 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; +} diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index dd274f6cd4..be293de17d 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -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 } : {}), diff --git a/apps/daemon/tests/run-artifacts.test.ts b/apps/daemon/tests/run-artifacts.test.ts new file mode 100644 index 0000000000..80ccb728ee --- /dev/null +++ b/apps/daemon/tests/run-artifacts.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 881a95174c..eb6a2889b5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1414,7 +1414,7 @@ export function App() { onRenameProject={handleRenameProject} onChangeDefaultDesignSystem={handleChangeDefaultDesignSystem} onCreateDesignSystem={() => navigate({ kind: 'design-system-create' })} - renderDesignSystemCreation={(onBack) => ( + renderDesignSystemCreation={(onBack, hooks) => ( openSettings('composio')} + {...(hooks?.onBeforeGenerate ? { onBeforeGenerate: hooks.onBeforeGenerate } : {})} + {...(hooks?.onGenerateSettled ? { onGenerateSettled: hooks.onGenerateSettled } : {})} /> )} onOpenDesignSystem={(id: string) => navigate({ kind: 'design-system-detail', designSystemId: id })} diff --git a/apps/web/src/analytics/events.ts b/apps/web/src/analytics/events.ts index c9aebc5d3e..fff9102da4 100644 --- a/apps/web/src/analytics/events.ts +++ b/apps/web/src/analytics/events.ts @@ -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); +} diff --git a/apps/web/src/components/DesignSystemFlow.tsx b/apps/web/src/components/DesignSystemFlow.tsx index bc2b89aeb3..a1b109c2d1 100644 --- a/apps/web/src/components/DesignSystemFlow.tsx +++ b/apps/web/src/components/DesignSystemFlow.tsx @@ -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('setup'); const [state, setState] = useState(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); } diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index 385c49a763..34223eda46 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -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; onPersistComposioKey: (composio: AppConfig['composio']) => Promise | void; @@ -729,7 +751,18 @@ function OnboardingView({ onApiModelChange: (model: string) => void; onConfigPersist: (cfg: AppConfig) => Promise | void; onRefreshAgents: () => Promise | 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(''); 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(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[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 })); + }} /> { 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 })); + }} /> @@ -1308,11 +1573,65 @@ function OnboardingView({ {t('settings.onboardingDesignIntroReuseBody')} - - {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; + }, + })} ) : null} @@ -1342,7 +1661,7 @@ function OnboardingView({ diff --git a/apps/web/src/components/EntryView.tsx b/apps/web/src/components/EntryView.tsx index 9c08acf8d1..b6c931a4d9 100644 --- a/apps/web/src/components/EntryView.tsx +++ b/apps/web/src/components/EntryView.tsx @@ -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; onPersistComposioKey: (composio: AppConfig['composio']) => Promise | void; diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index c442302a28..084b8b0cee 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -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([]); const [activeConversationId, setActiveConversationId] = useState( diff --git a/apps/web/src/components/UpdaterPopup.tsx b/apps/web/src/components/UpdaterPopup.tsx index 4a45c975f4..52067ae2b9 100644 --- a/apps/web/src/components/UpdaterPopup.tsx +++ b/apps/web/src/components/UpdaterPopup.tsx @@ -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; @@ -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(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); diff --git a/packages/contracts/src/analytics/events.ts b/packages/contracts/src/analytics/events.ts index 864a40563f..269ea67c37 100644 --- a/packages/contracts/src/analytics/events.ts +++ b/packages/contracts/src/analytics/events.ts @@ -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) -----------------------