From cab26f4c943e66d7f266af8437fc02ccd7d28594 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 7 May 2026 15:09:31 +0200 Subject: [PATCH 1/3] Ensure ai-init-opt-in is recorded as false when users reject, so we can treat absence of flag as true --- code/core/src/shared/utils/ai-checklist-flags.ts | 5 ++++- code/lib/create-storybook/src/initiate.ts | 12 +++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/code/core/src/shared/utils/ai-checklist-flags.ts b/code/core/src/shared/utils/ai-checklist-flags.ts index 733de8041492..bcc2b6bbe4e8 100644 --- a/code/core/src/shared/utils/ai-checklist-flags.ts +++ b/code/core/src/shared/utils/ai-checklist-flags.ts @@ -22,6 +22,8 @@ import { cache } from 'storybook/internal/common'; interface ProjectScopedFlag { timestamp: number; configDir: string; + // only on ai-init-opt-in + answer?: boolean; // only on ai-setup-ran runId?: string; } @@ -49,7 +51,8 @@ async function readProjectScopedFlag( /** Written by `storybook init` when the user accepted the AI feature. */ export async function hasAiInitOptIn(configDir: string): Promise { - return !!(await readProjectScopedFlag('ai-init-opt-in', configDir)); + const flag = await readProjectScopedFlag('ai-init-opt-in', configDir); + return flag?.answer !== false; } /** Written by `storybook ai setup` when the prompt CLI ran in this project. */ diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 229e1075eacb..0aab830729fe 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -154,7 +154,7 @@ export async function doInitiate(options: CommandOptions): Promise< // Step 8: Print final summary const hasAiFeature = selectedFeatures.has(Feature.AI); - if (hasAiFeature && configDir) { + if (configDir) { // Persist the init-time AI opt-in so the dev server can gate AI-related UI // (checklist item, copy-prompt button) on the user's actual choice — not on // a telemetry-event side effect. Scoped to the project's configDir so a @@ -162,10 +162,16 @@ export async function doInitiate(options: CommandOptions): Promise< // sibling Storybook projects. This is a tiny local file with no PII, so it // is written even when telemetry is disabled. await cache - .set('ai-init-opt-in', { timestamp: Date.now(), configDir: resolve(configDir) }) + .set('ai-init-opt-in', { + timestamp: Date.now(), + configDir: resolve(configDir), + answer: hasAiFeature, + }) .catch(() => {}); // Telemetry event remains for analytics. UI logic does not depend on it. - await telemetry('ai-init-opt-in', {}).catch(() => {}); + await telemetry('ai-init-opt-in', { + answer: hasAiFeature, + }).catch(() => {}); } await executeFinalization({ showAgentFollowUp: !!options.agent && hasAiFeature, From 5bca0e89499dae3ac7030b0343ae39281dd04290 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 7 May 2026 15:20:18 +0200 Subject: [PATCH 2/3] Use ai-setup run evidence for checklist telemetry, not ai-init-opt-in --- .../src/core-server/utils/checklist.test.ts | 25 +++++++++++++++++++ code/core/src/core-server/utils/checklist.ts | 9 ++++--- .../sidebar/ChecklistWidget.stories.tsx | 1 + .../manager/settings/GuidePage.stories.tsx | 3 +++ code/core/src/shared/checklist-store/index.ts | 2 ++ 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/code/core/src/core-server/utils/checklist.test.ts b/code/core/src/core-server/utils/checklist.test.ts index f85466ee36b6..bfa3b25837cb 100644 --- a/code/core/src/core-server/utils/checklist.test.ts +++ b/code/core/src/core-server/utils/checklist.test.ts @@ -241,6 +241,31 @@ describe('initializeChecklist', () => { }); }); + describe('aiSetupRun flag', () => { + it('flips aiSetupRun=true when the regular fs cache has it (telemetry-disabled path)', async () => { + const { get: getEventCacheEntry } = await import('../../telemetry/event-cache.ts'); + vi.mocked(getEventCacheEntry).mockResolvedValue(undefined); + await setAiFlags({ optedIn: true }); + + const { initializeChecklist } = await import('./checklist.ts'); + await initializeChecklist(undefined, undefined, '/p'); + await vi.advanceTimersByTimeAsync(0); + + expect(mockStore.getState().aiSetupRun).toBe(true); + }); + + it('keeps aiSetupRun=false when cache does not have the flag', async () => { + const { get: getEventCacheEntry } = await import('../../telemetry/event-cache.ts'); + vi.mocked(getEventCacheEntry).mockResolvedValue(undefined); + + const { initializeChecklist } = await import('./checklist.ts'); + await initializeChecklist(); + await vi.advanceTimersByTimeAsync(0); + + expect(mockStore.getState().aiSetupRun).toBeFalsy(); + }); + }); + describe('debounced analytics and ghost stories', () => { function createMockChannel() { const listeners: Record = {}; diff --git a/code/core/src/core-server/utils/checklist.ts b/code/core/src/core-server/utils/checklist.ts index da6a37ff8f83..faf0586f0045 100644 --- a/code/core/src/core-server/utils/checklist.ts +++ b/code/core/src/core-server/utils/checklist.ts @@ -78,14 +78,14 @@ export async function initializeChecklist( }) satisfies StoreState ); - // AI opt-in flag (set in `init` when user accepted the AI feature). + // AI setup run flag (set in `init` when user ran the AI setup). // Read from the regular fs cache — NOT from the telemetry event cache // so the copy-prompt button appears for users who disabled telemetry. // Fire-and-forget so the store is never blocked waiting for this check. - hasAiInitOptIn(configDir!) + hasAiSetupRun(configDir!) .then((hasOptedIn) => { if (hasOptedIn) { - store.setState((state) => ({ ...state, aiOptIn: true })); + store.setState((state) => ({ ...state, aiSetupRun: true })); } }) .catch(() => {}); @@ -170,7 +170,8 @@ export async function initializeChecklist( throttledSyncAiSetupStatus(); clearTimeout(analyticsTimer); analyticsTimer = setTimeout(async () => { - if (!store.getState().aiOptIn) { + // If the CLI command never ran, don't emit analytics or ghost stories. + if (!store.getState().aiSetupRun) { return; } // Agents often run `npx vitest` for many minutes. If a recent diff --git a/code/core/src/manager/components/sidebar/ChecklistWidget.stories.tsx b/code/core/src/manager/components/sidebar/ChecklistWidget.stories.tsx index d05556a1d4ef..c87ed1fb6be4 100644 --- a/code/core/src/manager/components/sidebar/ChecklistWidget.stories.tsx +++ b/code/core/src/manager/components/sidebar/ChecklistWidget.stories.tsx @@ -98,6 +98,7 @@ export const Narrow = meta.story({ const withAiSetupState = { loaded: true, aiOptIn: true, + aiSetupRun: true, widget: {}, items: { ...initialState.items, diff --git a/code/core/src/manager/settings/GuidePage.stories.tsx b/code/core/src/manager/settings/GuidePage.stories.tsx index 6f6fa477dee3..e5a55b13d55b 100644 --- a/code/core/src/manager/settings/GuidePage.stories.tsx +++ b/code/core/src/manager/settings/GuidePage.stories.tsx @@ -54,6 +54,7 @@ export const Default = meta.story({}); const aiCtaOpenState = { loaded: true, aiOptIn: true, + aiSetupRun: true, widget: {}, items: { ...initialState.items, @@ -75,6 +76,7 @@ export const AiCtaSkipped = meta.story({ mockStore.setState({ loaded: true, aiOptIn: true, + aiSetupRun: true, widget: {}, items: { ...initialState.items, @@ -92,6 +94,7 @@ export const AiCtaDone = meta.story({ mockStore.setState({ loaded: true, aiOptIn: true, + aiSetupRun: true, widget: {}, items: { ...initialState.items, diff --git a/code/core/src/shared/checklist-store/index.ts b/code/core/src/shared/checklist-store/index.ts index da6b32e39a58..0fe76f9237c2 100644 --- a/code/core/src/shared/checklist-store/index.ts +++ b/code/core/src/shared/checklist-store/index.ts @@ -14,6 +14,8 @@ export type StoreState = Required> & { loaded?: boolean; /** True when the user opted into AI during `storybook init`. Set by the server from the event cache. */ aiOptIn?: boolean; + /** True when the user ran the AI setup at some point in the past. */ + aiSetupRun?: boolean; }; export type ItemId = keyof StoreState['items']; From a4cae099aade667bb87b577862d83a4586242825 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Thu, 7 May 2026 17:34:39 +0200 Subject: [PATCH 3/3] Address PR feedback --- .../src/core-server/utils/checklist.test.ts | 17 +++++----- code/core/src/core-server/utils/checklist.ts | 12 +++++-- code/core/src/shared/checklist-store/index.ts | 4 +-- .../shared/utils/ai-checklist-flags.test.ts | 31 +++++++++++++------ .../src/shared/utils/ai-checklist-flags.ts | 2 +- code/lib/create-storybook/src/initiate.ts | 4 +-- 6 files changed, 45 insertions(+), 25 deletions(-) diff --git a/code/core/src/core-server/utils/checklist.test.ts b/code/core/src/core-server/utils/checklist.test.ts index bfa3b25837cb..c5b7f53b572c 100644 --- a/code/core/src/core-server/utils/checklist.test.ts +++ b/code/core/src/core-server/utils/checklist.test.ts @@ -217,7 +217,7 @@ describe('initializeChecklist', () => { }); describe('aiOptIn flag', () => { - it('flips aiOptIn=true when the regular fs cache has it (telemetry-disabled path)', async () => { + it('flips aiOptIn to match the value found in the regular fs cache (true case)', async () => { const { get: getEventCacheEntry } = await import('../../telemetry/event-cache.ts'); vi.mocked(getEventCacheEntry).mockResolvedValue(undefined); await setAiFlags({ optedIn: true }); @@ -229,15 +229,16 @@ describe('initializeChecklist', () => { expect(mockStore.getState().aiOptIn).toBe(true); }); - it('keeps aiOptIn=false when cache does not have the flag', async () => { + it('flips aiOptIn to match the value found in the regular fs cache (false case)', async () => { const { get: getEventCacheEntry } = await import('../../telemetry/event-cache.ts'); vi.mocked(getEventCacheEntry).mockResolvedValue(undefined); + await setAiFlags({ optedIn: false }); const { initializeChecklist } = await import('./checklist.ts'); - await initializeChecklist(); + await initializeChecklist(undefined, undefined, '/p'); await vi.advanceTimersByTimeAsync(0); - expect(mockStore.getState().aiOptIn).toBeFalsy(); + expect(mockStore.getState().aiOptIn).toBe(false); }); }); @@ -245,7 +246,7 @@ describe('initializeChecklist', () => { it('flips aiSetupRun=true when the regular fs cache has it (telemetry-disabled path)', async () => { const { get: getEventCacheEntry } = await import('../../telemetry/event-cache.ts'); vi.mocked(getEventCacheEntry).mockResolvedValue(undefined); - await setAiFlags({ optedIn: true }); + await setAiFlags({ setupRan: true }); const { initializeChecklist } = await import('./checklist.ts'); await initializeChecklist(undefined, undefined, '/p'); @@ -382,7 +383,7 @@ describe('initializeChecklist', () => { expect(channel.emit).toHaveBeenCalledWith(AI_SETUP_ANALYTICS_REQUEST); }); - it('does not emit if user did not opt into AI', async () => { + it('emits even if user did not opt into AI', async () => { const { AI_SETUP_ANALYTICS_REQUEST, GHOST_STORIES_REQUEST, STORY_INDEX_INVALIDATED } = await import('storybook/internal/core-events'); const { get: getEventCacheEntry } = await import('../../telemetry/event-cache.ts'); @@ -399,8 +400,8 @@ describe('initializeChecklist', () => { listeners[STORY_INDEX_INVALIDATED]?.forEach((fn) => fn()); await vi.advanceTimersByTimeAsync(AI_IDLE_DELAY_MS); - expect(channel.emit).not.toHaveBeenCalledWith(AI_SETUP_ANALYTICS_REQUEST); - expect(channel.emit).not.toHaveBeenCalledWith(GHOST_STORIES_REQUEST); + expect(channel.emit).toHaveBeenCalledWith(AI_SETUP_ANALYTICS_REQUEST); + expect(channel.emit).toHaveBeenCalledWith(GHOST_STORIES_REQUEST); }); it('only emits once even after multiple idle cycles', async () => { diff --git a/code/core/src/core-server/utils/checklist.ts b/code/core/src/core-server/utils/checklist.ts index faf0586f0045..f40959d3c9a6 100644 --- a/code/core/src/core-server/utils/checklist.ts +++ b/code/core/src/core-server/utils/checklist.ts @@ -78,18 +78,24 @@ export async function initializeChecklist( }) satisfies StoreState ); - // AI setup run flag (set in `init` when user ran the AI setup). + // AI setup run and AI optin flags (set in `ai setup` and `init`respectively). // Read from the regular fs cache — NOT from the telemetry event cache // so the copy-prompt button appears for users who disabled telemetry. // Fire-and-forget so the store is never blocked waiting for this check. hasAiSetupRun(configDir!) - .then((hasOptedIn) => { - if (hasOptedIn) { + .then((hasSetupRun) => { + if (hasSetupRun) { store.setState((state) => ({ ...state, aiSetupRun: true })); } }) .catch(() => {}); + hasAiInitOptIn(configDir!) + .then((hasOptedIn) => { + store.setState((state) => ({ ...state, aiOptIn: hasOptedIn })); + }) + .catch(() => {}); + /** * "Has the agent actually produced something?" Running `storybook ai setup` * only generates the prompt; it doesn't mean the agent that received the diff --git a/code/core/src/shared/checklist-store/index.ts b/code/core/src/shared/checklist-store/index.ts index 0fe76f9237c2..fad18f962770 100644 --- a/code/core/src/shared/checklist-store/index.ts +++ b/code/core/src/shared/checklist-store/index.ts @@ -12,9 +12,9 @@ export type ChecklistState = NonNullable< export type StoreState = Required> & { items: NonNullable>; loaded?: boolean; - /** True when the user opted into AI during `storybook init`. Set by the server from the event cache. */ + /** True unless the user opted out from AI during `storybook init`. Set by the server from the event cache. Treat empty values as true.*/ aiOptIn?: boolean; - /** True when the user ran the AI setup at some point in the past. */ + /** True when the user ran the AI setup at some point in the past. Treat empty values as false.*/ aiSetupRun?: boolean; }; diff --git a/code/core/src/shared/utils/ai-checklist-flags.test.ts b/code/core/src/shared/utils/ai-checklist-flags.test.ts index 41f4282f4b30..f90191ab1369 100644 --- a/code/core/src/shared/utils/ai-checklist-flags.test.ts +++ b/code/core/src/shared/utils/ai-checklist-flags.test.ts @@ -29,35 +29,48 @@ describe('ai-checklist-flags', () => { }); describe('hasAiInitOptIn', () => { - it('returns false when nothing is cached', async () => { + it('returns true when nothing is cached', async () => { const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts'); - expect(await hasAiInitOptIn('/some/project/.storybook')).toBe(false); + expect(await hasAiInitOptIn('/some/project/.storybook')).toBe(true); }); - it('returns true when the cached configDir matches the resolved input', async () => { + it('returns true when the cached configDir is for a different project', async () => { mockCacheStore.set('ai-init-opt-in', { timestamp: Date.now(), configDir: resolve('/repo/apps/web/.storybook'), }); const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts'); - expect(await hasAiInitOptIn('/repo/apps/web/.storybook')).toBe(true); + expect(await hasAiInitOptIn('/repo/packages/ui/.storybook')).toBe(true); + }); + + it('returns true when the cached entry lacks a configDir field', async () => { + // Defensive — should never happen in practice because the CLI always + // writes configDir, but a corrupted cache shouldn't unlock this flag. + mockCacheStore.set('ai-init-opt-in', { timestamp: Date.now() }); + const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts'); + expect(await hasAiInitOptIn('/any/project/.storybook')).toBe(true); }); - it('returns false when the cached configDir is for a different project', async () => { + it('returns true when the cached configDir matches the resolved input', async () => { mockCacheStore.set('ai-init-opt-in', { timestamp: Date.now(), configDir: resolve('/repo/apps/web/.storybook'), + answer: true, }); const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts'); - expect(await hasAiInitOptIn('/repo/packages/ui/.storybook')).toBe(false); + expect(await hasAiInitOptIn('/repo/apps/web/.storybook')).toBe(true); }); - it('returns false when the cached entry lacks a configDir field', async () => { + it('returns false when the cached entry is for this project and indicates user opt-out', async () => { // Defensive — should never happen in practice because the CLI always // writes configDir, but a corrupted cache shouldn't unlock this flag. - mockCacheStore.set('ai-init-opt-in', { timestamp: Date.now() }); + mockCacheStore.set('ai-init-opt-in', { + timestamp: Date.now(), + configDir: resolve('/repo/apps/web/.storybook'), + answer: false, + }); const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts'); - expect(await hasAiInitOptIn('/any/project/.storybook')).toBe(false); + expect(await hasAiInitOptIn('/repo/apps/web/.storybook')).toBe(false); }); }); diff --git a/code/core/src/shared/utils/ai-checklist-flags.ts b/code/core/src/shared/utils/ai-checklist-flags.ts index bcc2b6bbe4e8..7a1c9ed5b126 100644 --- a/code/core/src/shared/utils/ai-checklist-flags.ts +++ b/code/core/src/shared/utils/ai-checklist-flags.ts @@ -49,7 +49,7 @@ async function readProjectScopedFlag( } catch {} } -/** Written by `storybook init` when the user accepted the AI feature. */ +/** Written by `storybook init` when the user accepted the AI feature and in legacy inits where the question was not asked. */ export async function hasAiInitOptIn(configDir: string): Promise { const flag = await readProjectScopedFlag('ai-init-opt-in', configDir); return flag?.answer !== false; diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 0aab830729fe..225605d1456d 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -154,8 +154,8 @@ export async function doInitiate(options: CommandOptions): Promise< // Step 8: Print final summary const hasAiFeature = selectedFeatures.has(Feature.AI); - if (configDir) { - // Persist the init-time AI opt-in so the dev server can gate AI-related UI + if (configDir && isAiSetupAvailable) { + // Persist init-time AI opt-in/opt-out so the dev server can gate AI-related UI // (checklist item, copy-prompt button) on the user's actual choice — not on // a telemetry-event side effect. Scoped to the project's configDir so a // monorepo with hoisted `node_modules/.cache` doesn't leak the flag across