Skip to content

Commit 4f5bdee

Browse files
open-design-crew[bot]xiaoche-hubcursoragent
authored
feat(web): new-user personalized onboarding — Home recommendation + Studio first-gen hint (#5111)
* feat(web): personalized first-run recommendation on Home + Studio handoff After a new user finishes the first-run questionnaire, Home shows one compact, personalized starting point (role/use-case -> product path via a static mapping) instead of the generic empty entry. Clicking it opens Studio with the recommended first request pre-filled in the composer (not auto-sent); the generic quick-start cards are suppressed while the composer carries content. Session-only, no persistence: the recommendation is memory-scoped and gone on refresh or for returning users. Adds the first-generation funnel events (onboarding_first_prompt_sent, onboarding_first_generation_completed) plus the recommendation surface_view / ui_click, all via the v2 analytics convention. i18n keys across all 19 locales. Spec: OD new-user personalized onboarding SS6-7, S11. * feat(web): one-time first-generation hint in Studio When a new user's first previewable artifact appears in Studio, show a single lightweight, dismissible note pointing them at view / edit / export. Shown at most once ever (localStorage), non-modal, in the preview corner so it never stacks with the post-turn next-step card (spec S8.5, one guide at a time). Fires onboarding_first_artifact_hint surface_view / ui_click via the v2 analytics convention. Spec: OD new-user personalized onboarding S8.3. * style(web): use real tokens in onboarding CSS modules --accent-contrast and --shadow don't exist in the token set; the fallbacks were silently in effect. Use the repo's plain-white-on-accent convention and the real --shadow-md elevation token (light/dark adaptive) instead. * feat(web): onboarding_prompt_prefilled event + is_first_session super property Completes the spec S11.1 funnel middle step: fire onboarding_prompt_prefilled once when Studio mounts with the recommended first request actually seeded in the composer (confirming the Home -> Studio handoff), carrying role / use_cases / product_type / recommendation_id through the session-only entry slot. Register is_first_session as a PostHog super property, pinned to the install's first-ever session id in localStorage. * fix(web): recommendation strip hover states + give browse-all a destination The global button:hover:not(:disabled) rule in primitives.css out-specifies plain .class:hover module rules, repainting the primary CTA to --bg-subtle (white label on near-white) and forcing a gray wash on the text actions. Match its specificity per the existing onboarding-cloud pattern: primary keeps its accent fill on hover; the text actions get a soft white wash instead of gray, and the underline is dropped. Browse-all previously just dismissed the strip with no feedback. It now also opens the New-project template picker — the concrete 'all types' catalogue the label promises (spec S7.4 通用选择). * fix(web): unify hover treatment of the two text actions on the recommendation strip 换一个 hovered orange while 浏览全部类型 stayed neutral — as peers with the same pill wash they now share one hover behavior (white wash + text darkens); only their resting text color carries the hierarchy. * fix(web): recommendation strip — card width, placed under the composer Full-page width below the template section read as an auxiliary notice bar at the bottom of Home — the one surface a brand-new user is meant to see first was the hardest to find. HomeHero gains an optional recommendationSlot rendered directly under the composer card (before the template section); the strip is constrained to the composer column (720px, centered) so it reads as a card in the same visual stack. * feat(web): path-scoped starter templates in Studio for recommendation projects The empty conversation above a pre-filled composer hid the quick-start cards entirely, leaving dead space; and the generic three cards are unrelated to what the user just told us they want. A recommendation-started project now shows its own product path's starters (e.g. prototype / component / low-fi for product_ui) as one-click composer replacements — clicking overwrites the draft. Generic projects and the general fallback keep the existing set. Supersedes the hide-when-prefilled gate from spec S8.5: with replace-on-click semantics the cards complement the pre-filled request instead of competing with it, so onDraftEmptyChange plumbing and its regression test are removed. Starter copy moves to a shared module (onboarding/starter-copy.ts) reused by the Home strip. * fix(web): icon-only cycle button on the recommendation strip The text 换一个试试 button crowded the 720px card and pushed 浏览全部类型 onto its own wrapped line. It is now an icon-only refresh button with the label in title/aria-label (zh copy shortened to 换一换); all three actions fit one row. * fix(web): tighten recommendation strip copy across locales Primary CTA drops the Studio jargon (在 Studio 中开始 -> 开始创作 / Start creating) and the fallback shortens to 全部类型 / All types; both updated across all 19 locales. * fix(web): primary CTA sits rightmost on the recommendation strip Action order is now cycle icon, 全部类型, then 开始创作 — the standard primary-on-the-right reading order. * feat(web): attention entrance for the recommendation card The old 200ms fade rode in with the page load and read as furniture. The card now enters 350ms after mount — motion-after-stillness, the only thing moving once Home settles — then a one-time accent glint sweeps it twice, mirroring HomeHero's attention-sheen invitation language. Both animations respect prefers-reduced-motion. * fix(web): recommendation card motion + hierarchy — push-down reveal, CTA glint, tinted primary Entrance is now the repo's canonical accordion reveal: the slot stays collapsed for 600ms, then expands (grid-rows 0fr -> 1fr) and pushes the template section down — displacement as the attention cue, replacing the card-wide sheen that read oddly on an optional surface. A one-time tinted glint then sweeps the CTA (the same click-me-next language home-hero puts on the send button). 开始创作 demotes from a solid accent fill to the tinted accent-secondary (NextStepActions Share vocabulary): the composer's 发送 stays the screen's one filled primary; first-time attention is carried by motion, not fill weight. The shown->clicked funnel can re-promote it later if data says so. * fix(web): drop the hover state on starter-card return glyph The accent-filled circle on hover reads as 'this will send' — the cards only fill the composer. Keep the glyph static; the card hover itself is the affordance. * fix(web): spend the first-artifact hint budget on dismiss, not on show Marking seen at mount meant any parent-gate flicker (files refresh / streaming blip unmounting and remounting the component) burned the once-ever hint before anyone could read it — reproduced live in the desktop app. The budget is now spent by the user's own close action; remounts before dismissal simply show the hint again, matching the spec's 'visible until closed or used'. * fix(web): anchor the first-artifact hint under the preview toolbar Bottom-right corner had zero directionality (and sat on the pet widget) — the hint talks about view/edit/export but pointed at nothing. It now sits directly below the artifact toolbar with an upward caret aimed at the share/download cluster, and slides down from it on entry. Offsets track the stable chrome heights, not preview internals. * feat(web): settle-then-bob entrance for the first-artifact hint 600ms delay lets the freshly-opened preview settle; the slide-down then ends in two decaying bobs (4px, 2px) — a nudge that says 'I appeared' without a glint, sized for a persistent hint rather than a toast. * feat(web): motion-driven drop-and-knock entrance for the first-artifact hint Fully opaque throughout — the 600ms settle window is a delayed mount, not an opacity fade (a see-through frame made the copy unreadable). The card then drops 28px from the toolbar and sets down (easeOut), holds a beat, and gives two equal 4px knocks with a short pause between them (easeInOut segments). Driven by motion/react (already a dependency; ProjectView uses AnimatePresence) with useReducedMotion opting the whole sequence out. * test(web): export useReducedMotion from the motion/react mock FirstArtifactHint is the first component to call it; without the export every jsdom test whose project fixtures include an .html file crashed the whole ProjectView render. * fix(web): first-artifact hint must never occlude or intercept the viewer toolbar The hardcoded top offset landed the card ON the preview's action toolbar in layouts with taller tab chrome, blocking comment/zoom clicks (caught by the Playwright critical suite). Two independent fixes: the card now anchors below the lowest visible .viewer-toolbar, measured imperatively via ref (state-driven repositioning re-rendered mid-flight and froze the motion keyframes) with re-measures while the project chrome settles; and the card is pointer-events: none (dismiss button excepted), so even a transient overlap can never swallow toolbar clicks. * fix(web): first-artifact hint copy leads with editing At the first-artifact moment the loop's next step is modify, not deliver (spec S8.3 orders 查看 -> 修改 -> 导出; export already has the next-step card's Share and the export-ready nudge). Body copy now leads with edit / annotate and closes with export-when-happy, matching the caret that points at the view/edit toolbar. All 19 locales. * fix(web): address onboarding review feedback (PR #5111) - contracts: re-add `amr_auth_result` / `file_version_restore_result` to the `AnalyticsEventName` union (they were present in `AnalyticsEventPayload` but missing from the name union) and add a compile-time drift guard so the two can never diverge again. - studio: gate the one-time first-generation hint on the consumed `onboardingEntryRef` so it only shows for projects started from the Home recommendation, not for returning users opening an existing HTML project. - analytics: make `isFirstSession()` a pure read and defer pinning the first-session marker to the consented analytics-init path (`pinFirstSessionForCapture`), so an install that boots with analytics off and opts in later still records its real first analytics session as first. - onboarding: stash the Home→Studio handoff entry only for a successful create/navigation; clear it when the create fails or is aborted so a later unrelated project mount can't consume the stale slot and mis-attribute itself as recommendation-started. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): address onboarding review follow-ups (PR #5111) - analytics: centralize the first-analytics-session pin in `getAnalyticsClient` (the single consent gate every caller funnels through), so the opt-in-later path — booting with metrics off then enabling them mid-session via `setConsent(true)` — also records the real first analytics session as first. Adds `analytics-first-session-consent` regression coverage. - onboarding: canonicalize `useCases` against questionnaire display order in `resolveProductType` / `buildRecommendation`, so a set of answers resolves to the same product bucket (and echoes the same telemetry) regardless of the chip click order. Adds selection-order-independence tests. - onboarding: create the project FIRST in `handleRecommendationStart` and only dismiss the Home recommendation once it actually opened; a failed/aborted create now keeps the recommendation mounted so the user can retry instead of dropping to a dead-end generic Home. Adds `RecommendedStartRegion.handoff` reject-path coverage. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): report has_prefilled_prompt from real send state (PR #5111) The onboarding_first_prompt_sent funnel event hard-coded has_prefilled_prompt to true for every recommendation-started project, but the user can edit, clear, replace, or starter-swap the prefilled composer before sending. Snapshot the seed prompt at mount and compare it against the actually-sent prompt via a new `sentPrefilledPrompt` invariant helper, so the send-through split reflects whether the suggestion was really used. Adds unit coverage for the unmodified / edited / cleared / no-seed cases. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): pin first analytics session only after client init succeeds pinFirstSessionForCapture() ran right after the config check but before import('posthog-js') / posthog.init(), so a boot where init threw returned null from the catch while the marker was already burned — a later real session would then report is_first_session=false despite never capturing. Move the pin to the point where init is known to have succeeded (after client = posthog, before return), and add a "config enabled but client init fails" regression case. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): key onboarding handoff by project id; gate first-gen on artifact Address two review findings on PR #5111: - The Home→Studio onboarding entry was stashed in a single global sessionStorage slot before create resolved, racing any concurrent project navigation: an unrelated project mounting mid-create could consume the slot and steal the personalized starter/hint/funnel context. Key the handoff by the created project id and move the stash into the create success path (App.handleCreateProject), where the id is finally known. A failed/aborted create now leaves nothing behind, and a concurrent mount reads its own (absent) key. - onboarding_first_generation_completed fired on any succeeded run, even one producing only text or a clarifying question, overstating the funnel and diverging from the first-artifact hint. Gate it on the same artifact-producing condition the hint uses (a produced previewable .html), via a new producedPreviewableArtifact invariant. Tests: keyed per-project isolation (race regression), handoff no longer touches sessionStorage, and producedPreviewableArtifact incl. a succeeded run with no artifact. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): StrictMode-safe onboarding handoff, visible start failure, gate hint impression Address three review findings on PR #5111: - consumeOnboardingEntryForProject is a destructive read called from ProjectView render; under reactStrictMode the throwaway first mount consumed the sessionStorage slot and the committed remount saw null, dropping every recommendation-only behavior in dev. Cache the first parsed entry per project id in the helper so both StrictMode passes (and later remounts) get the value while the slot is still cleared on first read. Adds a double-consume regression test. - EntryShell.handleRecommendationStart swallowed create failures in a catch and RecommendedStartRegion discarded the promise, so a failed "Start creating" was a silent no-op. Let the failure propagate; HomeView now routes it into the same error channel other entry actions use (setError + new home.recommendation.startFailed string in all locales), and RecommendedStartRegion awaits with a pending state that re-enables for retry on failure. - FirstArtifactHint fired its surface_view impression before `settled`, while the card still rendered null, counting mounts removed during the 600ms settle window as impressions the user never saw. Gate the effect on settled. Adds an impression-timing regression test. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): make onboarding funnel guards project-scoped and gate first-prompt after pre-run gates Address two review findings on PR #5111: - The once-per-project funnel guards (first_prompt_sent / first_generation_completed) were mount-local refs. Now that the onboarding entry survives ProjectView remounts via the project-id cache, reopening a project reset the refs while the entry kept returning, so both events could re-fire on a later conversation/run. Move the guards into project-keyed module flags beside the entry cache so they hold across remounts (within the session, which is the entry's lifetime). - onboarding_first_prompt_sent was emitted before the queue-only / busy / AMR balance pre-run gates, which can abort the send without creating a run. A hard AMR gate then showed the sign-in/recharge dialog while the event had already fired and the once-only guard suppressed the real retry. Move the tracking below those gates, to the point the send is committed to creating a run. Adds regression coverage for the project-scoped guards (survive remount, per-id isolation). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): cache onboarding seed prompt with the entry so it survives reopen-before-send Address review on PR #5111: onboardingSeedPromptRef was rebuilt only from project.pendingPrompt, which onClearPendingPrompt wipes on the first mount. Since the onboarding entry now survives ProjectView remounts via the project-id cache, a user who accepts a recommendation, opens Studio, leaves before sending, then reopens the same project in-session kept the entry but lost the seed — so onboarding_first_prompt_sent reported has_prefilled_prompt=false even when they sent the original recommended draft, undercounting the accepted-recommendation path. Cache the seed prompt WITH the entry (new OnboardingEntry.seedPrompt, stashed at create time in App.handleCreateProject). ProjectView prefers the cached seed and falls back to pendingPrompt. Adds a reopen-before-send regression. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 38c20ef commit 4f5bdee

56 files changed

Lines changed: 3361 additions & 14 deletions

Some content is hidden

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

apps/web/src/App.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import {
99
import { deriveUploadCohort } from './analytics/upload-tracking';
1010
import { setPendingDesignSystemCreateEntry } from './analytics/ds-create-entry';
1111
import { detectClientType } from './analytics/identity';
12+
import {
13+
stashOnboardingEntryForProject,
14+
type OnboardingEntry,
15+
} from './onboarding/onboarding-entry';
1216
import {
1317
deriveConfigureGlobals,
1418
projectKindFromMetadataToTracking,
@@ -140,6 +144,7 @@ type AppCreateProjectInput = Omit<CreateInput, 'metadata'> & {
140144
pendingFiles?: File[];
141145
userWorkingDirToken?: string;
142146
linkedDirs?: string[] | null;
147+
onboardingEntry?: OnboardingEntry;
143148
};
144149

145150
const APP_CONFIG_CHANGED_EVENT = 'open-design:app-config-changed';
@@ -1576,6 +1581,23 @@ function AppInner() {
15761581
back to manual send. */
15771582
}
15781583
}
1584+
// Home recommendation handoff: now that the project exists and its id is
1585+
// known, stash the onboarding entry keyed by that id. Studio consumes it
1586+
// by the same id on mount. Keying by id (instead of a single global slot
1587+
// written before create) removes the race where opening an unrelated
1588+
// project mid-create could steal the personalized funnel context, and
1589+
// means a failed/aborted create leaves nothing behind.
1590+
if (input.onboardingEntry) {
1591+
// Cache the prefilled seed prompt WITH the entry so the first-prompt
1592+
// funnel's `has_prefilled_prompt` comparison base survives a
1593+
// reopen-before-send (project.pendingPrompt is wiped on first mount).
1594+
stashOnboardingEntryForProject(result.project.id, {
1595+
...input.onboardingEntry,
1596+
...(derivedPendingPrompt
1597+
? { seedPrompt: derivedPendingPrompt.trim() }
1598+
: {}),
1599+
});
1600+
}
15791601
const project = result.appliedPluginSnapshotId
15801602
? {
15811603
...result.project,

apps/web/src/analytics/client.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,18 @@ import {
1515
clearExceptionTrackingContext,
1616
setExceptionTrackingContext,
1717
} from './error-tracking';
18+
import { pinFirstSessionForCapture } from './identity';
1819

1920
interface AnalyticsContext {
2021
anonymousId: string;
2122
sessionId: string;
2223
clientType: AnalyticsClientType;
2324
locale: string;
2425
appVersion: string;
26+
// Whether this is the install's first analytics session (see
27+
// identity.ts#isFirstSession). Optional so callers that don't care
28+
// (error tracking) can omit it.
29+
isFirstSession?: boolean;
2530
}
2631

2732
let client: PostHog | null = null;
@@ -337,6 +342,10 @@ export async function getAnalyticsClient(
337342
client_type: context.clientType,
338343
locale: context.locale,
339344
session_id: context.sessionId,
345+
// Onboarding-funnel dimension (spec §11.1 common fields).
346+
...(context.isFirstSession !== undefined
347+
? { is_first_session: context.isFirstSession }
348+
: {}),
340349
// v2 rename: was `anonymous_id`. Value is unchanged — the same
341350
// installationId / local-UUID fallback.
342351
device_id: distinctId,
@@ -363,6 +372,15 @@ export async function getAnalyticsClient(
363372
},
364373
});
365374
client = posthog;
375+
// Pin the first-analytics-session marker only now — init returned without
376+
// throwing and capture is live. This is the single consent gate every
377+
// caller (mount, locale effect, track(), and the setConsent opt-in
378+
// re-init) funnels through, so an install that first booted with
379+
// analytics OFF and opts in later records its real first analytics
380+
// session as first. Pinning earlier (before import()/init) would burn the
381+
// marker on a boot where init actually failed and no session was ever
382+
// captured (see identity.ts#isFirstSession). Idempotent + best-effort.
383+
pinFirstSessionForCapture();
366384
flushPersonProperties();
367385
return posthog;
368386
} catch {

apps/web/src/analytics/events.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ import type {
7373
AmrAuthResultProps,
7474
AmrEntryClickProps,
7575
RunFailedToastSurfaceViewProps,
76+
HomeRecommendationClickProps,
77+
HomeRecommendationSurfaceViewProps,
78+
StudioOnboardingHintClickProps,
79+
StudioOnboardingHintSurfaceViewProps,
7680
ChatPanelResourcesPopoverClickProps,
7781
ChatPanelMessageQueueClickProps,
7882
FileManagerClickProps,
@@ -131,6 +135,9 @@ import type {
131135
OnboardingClickProps,
132136
OnboardingRuntimeScanResultProps,
133137
OnboardingCompleteResultProps,
138+
OnboardingPromptPrefilledProps,
139+
OnboardingFirstPromptSentProps,
140+
OnboardingFirstGenerationCompletedProps,
134141
DesignSystemSourceIngestResultProps,
135142
DesignSystemCreateResultProps,
136143
DesignSystemReviewResultProps,
@@ -258,6 +265,36 @@ export function trackRunFailedToastGoAmrClick(
258265
send(track, 'ui_click', props);
259266
}
260267

268+
// Personalized first-run recommendation on Home (spec §7).
269+
export function trackHomeRecommendationSurfaceView(
270+
track: Track,
271+
props: HomeRecommendationSurfaceViewProps,
272+
): void {
273+
send(track, 'surface_view', props);
274+
}
275+
276+
export function trackHomeRecommendationClick(
277+
track: Track,
278+
props: HomeRecommendationClickProps,
279+
): void {
280+
send(track, 'ui_click', props);
281+
}
282+
283+
// First-generation Studio hint (spec §8.3).
284+
export function trackStudioOnboardingHintSurfaceView(
285+
track: Track,
286+
props: StudioOnboardingHintSurfaceViewProps,
287+
): void {
288+
send(track, 'surface_view', props);
289+
}
290+
291+
export function trackStudioOnboardingHintClick(
292+
track: Track,
293+
props: StudioOnboardingHintClickProps,
294+
): void {
295+
send(track, 'ui_click', props);
296+
}
297+
261298
export function trackAmrEntryClick(
262299
track: Track,
263300
props: AmrEntryClickProps,
@@ -1082,6 +1119,28 @@ export function trackOnboardingCompleteResult(
10821119
send(track, 'onboarding_complete_result', props);
10831120
}
10841121

1122+
// First-generation funnel (spec §11.1).
1123+
export function trackOnboardingPromptPrefilled(
1124+
track: Track,
1125+
props: OnboardingPromptPrefilledProps,
1126+
): void {
1127+
send(track, 'onboarding_prompt_prefilled', props);
1128+
}
1129+
1130+
export function trackOnboardingFirstPromptSent(
1131+
track: Track,
1132+
props: OnboardingFirstPromptSentProps,
1133+
): void {
1134+
send(track, 'onboarding_first_prompt_sent', props);
1135+
}
1136+
1137+
export function trackOnboardingFirstGenerationCompleted(
1138+
track: Track,
1139+
props: OnboardingFirstGenerationCompletedProps,
1140+
): void {
1141+
send(track, 'onboarding_first_generation_completed', props);
1142+
}
1143+
10851144
// ---- Design-system lifecycle ---------------------------------------------
10861145
//
10871146
// `trackDesignSystem*Result` cover the five lifecycle moments in the

apps/web/src/analytics/identity.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,51 @@ export function getSessionId(): string {
5656
}
5757
}
5858

59+
const FIRST_SESSION_ID_KEY = 'open-design:analytics.first_session_id';
60+
61+
// Whether the current browser session is this install's FIRST analytics
62+
// session. The first analytics session's `getSessionId()` value is pinned in
63+
// localStorage by `pinFirstSessionForCapture()`; the flag stays true for the
64+
// whole lifetime of that tab session (repeat calls compare against the pin
65+
// rather than re-deriving) and is false for every later session. Registered as
66+
// the `is_first_session` super property so onboarding funnels can split
67+
// first-run behavior from returning visits.
68+
//
69+
// This read is PURE: it never writes the pin. Persisting the pin is deferred to
70+
// `pinFirstSessionForCapture()`, which runs only once capture is actually
71+
// enabled (post-consent). Otherwise an install that first boots with analytics
72+
// OFF and opts in later would have its real first *analytics* session
73+
// mislabeled `is_first_session=false`, systematically skewing the onboarding
74+
// funnel split. Rollout caveat: installs that predate this marker report one
75+
// mislabeled `true` session on their first captured boot. Storage-denied
76+
// contexts report false — we'd rather under-count first sessions than throw.
77+
export function isFirstSession(): boolean {
78+
if (typeof window === 'undefined') return false;
79+
try {
80+
const pinned = window.localStorage.getItem(FIRST_SESSION_ID_KEY);
81+
if (!pinned) return true;
82+
return pinned === getSessionId();
83+
} catch {
84+
return false;
85+
}
86+
}
87+
88+
// Persist the first-analytics-session marker. Call this ONLY from the consented
89+
// analytics-init path (i.e. once `/api/analytics/config` confirms capture is
90+
// enabled), never on unconditional provider mount — see `isFirstSession()` for
91+
// why. No-op once a pin already exists, and never throws in storage-denied
92+
// contexts.
93+
export function pinFirstSessionForCapture(): void {
94+
if (typeof window === 'undefined') return;
95+
try {
96+
if (window.localStorage.getItem(FIRST_SESSION_ID_KEY)) return;
97+
window.localStorage.setItem(FIRST_SESSION_ID_KEY, getSessionId());
98+
} catch {
99+
// Privacy mode / quota — losing the pin only risks a later session being
100+
// mislabeled first; never throw out of an analytics path.
101+
}
102+
}
103+
59104
// Claim the next 0-based run turn index for the current browser analytics
60105
// session and advance the counter. Lives in sessionStorage so it shares the
61106
// exact lifetime of the `session_id` above — both reset together when the tab

apps/web/src/analytics/provider.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
detectClientType,
3434
getAnonymousId,
3535
getSessionId,
36+
isFirstSession,
3637
} from './identity';
3738
import { randomUUID } from '../utils/uuid';
3839

@@ -166,6 +167,8 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
166167
anonymousId: getAnonymousId(),
167168
sessionId: getSessionId(),
168169
clientType: detectClientType(),
170+
// Pinned once per tab session (spec §11.1 common field).
171+
isFirstSession: isFirstSession(),
169172
}),
170173
[],
171174
);
@@ -188,13 +191,15 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
188191
anonymousId: identity.anonymousId,
189192
sessionId: identity.sessionId,
190193
clientType: identity.clientType,
194+
isFirstSession: identity.isFirstSession,
191195
locale,
192196
appVersion: resolvedAppVersion,
193197
});
194198
await getAnalyticsClient({
195199
anonymousId: identity.anonymousId,
196200
sessionId: identity.sessionId,
197201
clientType: identity.clientType,
202+
isFirstSession: identity.isFirstSession,
198203
locale,
199204
appVersion: resolvedAppVersion,
200205
});
@@ -252,6 +257,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
252257
anonymousId: identity.anonymousId,
253258
sessionId: identity.sessionId,
254259
clientType: identity.clientType,
260+
isFirstSession: identity.isFirstSession,
255261
locale: locale,
256262
appVersion: resolvedAppVersion,
257263
});
@@ -310,6 +316,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
310316
anonymousId: identity.anonymousId,
311317
sessionId: identity.sessionId,
312318
clientType: identity.clientType,
319+
isFirstSession: identity.isFirstSession,
313320
locale: locale,
314321
appVersion: resolvedAppVersion,
315322
});
@@ -350,6 +357,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) {
350357
anonymousId: identity.anonymousId,
351358
sessionId: identity.sessionId,
352359
clientType: identity.clientType,
360+
isFirstSession: identity.isFirstSession,
353361
locale,
354362
appVersion: resolvedAppVersion,
355363
});

apps/web/src/components/ChatPane.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { getResolvedDeviceId } from '../analytics/client';
2020
import { trackChatPanelClick, trackMessageQueueClick, trackRunFailedToastSurfaceView } from '../analytics/events';
2121
import { amrHandoffDeviceId, attributedAmrUrl, recordAmrEntry } from '../analytics/amr-attribution';
2222
import { useT } from '../i18n';
23+
import { startersForProduct, type ProductType } from '../onboarding/recommendation';
24+
import { starterCopyFor } from '../onboarding/starter-copy';
2325
import {
2426
FEATURED_DESIGN_TOOLBOX_ACTION_IDS,
2527
findDesignToolboxSkill,
@@ -95,6 +97,7 @@ type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) =>
9597
type StarterPrompt = {
9698
icon: string;
9799
title: string;
100+
// Empty for path-scoped onboarding starters, which have no category tag.
98101
tag: string;
99102
prompt: string;
100103
};
@@ -510,6 +513,10 @@ interface Props {
510513
// time before the full `tool_use` arrives. Never persisted.
511514
liveToolInput?: Record<string, { name: string; text: string; seq?: number }>;
512515
initialDraft?: string;
516+
// Product path of the Home recommendation that started this project. When
517+
// set (and concrete), the empty-conversation starter cards show that path's
518+
// starters — one-click composer replacements — instead of the generic set.
519+
onboardingStarterPath?: ProductType | null;
513520
composerPlaceholder?: string;
514521
// Focus the right-hand Questions tab from the chat banner.
515522
onOpenQuestions?: (request?: QuestionFormOpenRequest) => void;
@@ -796,6 +803,7 @@ export function ChatPane({
796803
forceStreamingMessageIds,
797804
liveToolInput,
798805
initialDraft,
806+
onboardingStarterPath = null,
799807
composerPlaceholder,
800808
onOpenQuestions,
801809
onContinueRemainingTasks,
@@ -1029,6 +1037,19 @@ export function ChatPane({
10291037
})),
10301038
[projectMetadata, t],
10311039
);
1040+
// Empty-conversation starter cards. A recommendation-started project shows
1041+
// its OWN product path's starters — clicking replaces the composer draft, so
1042+
// the pre-filled first request and the cards complement rather than compete.
1043+
// The general fallback path and every other project keep the generic set.
1044+
const starterTemplateCards = useMemo<StarterPrompt[]>(() => {
1045+
if (onboardingStarterPath && onboardingStarterPath !== 'general') {
1046+
return startersForProduct(onboardingStarterPath).map((starter) => {
1047+
const copy = starterCopyFor(starter.id);
1048+
return { icon: '✦', title: t(copy.title), tag: '', prompt: t(copy.firstPrompt) };
1049+
});
1050+
}
1051+
return pickStarters(projectMetadata, t);
1052+
}, [onboardingStarterPath, projectMetadata, t]);
10321053
const followUpComposerScenarios = useMemo<PlaceholderScenario[]>(() => {
10331054
if (nextStepVariant === 'design-system') {
10341055
return DESIGN_SYSTEM_NEXT_STEP_ACTIONS.map((action) => ({
@@ -2209,7 +2230,7 @@ export function ChatPane({
22092230
</span>
22102231
</div>
22112232
<div className="chat-examples" role="list">
2212-
{pickStarters(projectMetadata, t).map((ex, i) => (
2233+
{starterTemplateCards.map((ex, i) => (
22132234
<button
22142235
key={`${ex.title}-${i}`}
22152236
type="button"
@@ -2232,7 +2253,9 @@ export function ChatPane({
22322253
<span className="chat-example-body">
22332254
<span className="chat-example-head">
22342255
<span className="chat-example-title">{ex.title}</span>
2235-
<span className="chat-example-tag">{ex.tag}</span>
2256+
{ex.tag ? (
2257+
<span className="chat-example-tag">{ex.tag}</span>
2258+
) : null}
22362259
</span>
22372260
<span className="chat-example-prompt">{ex.prompt}</span>
22382261
</span>

0 commit comments

Comments
 (0)