Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions code/core/src/core-server/utils/checklist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -229,15 +229,41 @@ 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(undefined, undefined, '/p');
await vi.advanceTimersByTimeAsync(0);

expect(mockStore.getState().aiOptIn).toBe(false);
});
});

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({ setupRan: true });

const { initializeChecklist } = await import('./checklist.ts');
await initializeChecklist(undefined, undefined, '/p');
await vi.advanceTimersByTimeAsync(0);

expect(mockStore.getState().aiSetupRun).toBe(true);
});
Comment thread
Sidnioulz marked this conversation as resolved.
Comment thread
Sidnioulz marked this conversation as resolved.

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().aiOptIn).toBeFalsy();
expect(mockStore.getState().aiSetupRun).toBeFalsy();
});
});

Expand Down Expand Up @@ -357,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');
Expand All @@ -374,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 () => {
Expand Down
17 changes: 12 additions & 5 deletions code/core/src/core-server/utils/checklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,21 @@ export async function initializeChecklist(
}) satisfies StoreState
);

// AI opt-in flag (set in `init` when user accepted the AI feature).
// 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((hasSetupRun) => {
if (hasSetupRun) {
store.setState((state) => ({ ...state, aiSetupRun: true }));
}
})
.catch(() => {});
Comment thread
Sidnioulz marked this conversation as resolved.

hasAiInitOptIn(configDir!)
.then((hasOptedIn) => {
if (hasOptedIn) {
store.setState((state) => ({ ...state, aiOptIn: true }));
}
store.setState((state) => ({ ...state, aiOptIn: hasOptedIn }));
})
.catch(() => {});

Expand Down Expand Up @@ -170,7 +176,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export const Narrow = meta.story({
const withAiSetupState = {
loaded: true,
aiOptIn: true,
aiSetupRun: true,
widget: {},
items: {
...initialState.items,
Expand Down
3 changes: 3 additions & 0 deletions code/core/src/manager/settings/GuidePage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const Default = meta.story({});
const aiCtaOpenState = {
loaded: true,
aiOptIn: true,
aiSetupRun: true,
widget: {},
items: {
...initialState.items,
Expand All @@ -75,6 +76,7 @@ export const AiCtaSkipped = meta.story({
mockStore.setState({
loaded: true,
aiOptIn: true,
aiSetupRun: true,
widget: {},
items: {
...initialState.items,
Expand All @@ -92,6 +94,7 @@ export const AiCtaDone = meta.story({
mockStore.setState({
loaded: true,
aiOptIn: true,
aiSetupRun: true,
widget: {},
items: {
...initialState.items,
Expand Down
4 changes: 3 additions & 1 deletion code/core/src/shared/checklist-store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ export type ChecklistState = NonNullable<
export type StoreState = Required<Omit<ChecklistState, 'items'>> & {
items: NonNullable<Required<ChecklistState['items']>>;
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. Treat empty values as false.*/
aiSetupRun?: boolean;
};

export type ItemId = keyof StoreState['items'];
Expand Down
31 changes: 22 additions & 9 deletions code/core/src/shared/utils/ai-checklist-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
7 changes: 5 additions & 2 deletions code/core/src/shared/utils/ai-checklist-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -47,9 +49,10 @@ 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<boolean> {
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. */
Expand Down
14 changes: 10 additions & 4 deletions code/lib/create-storybook/src/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,18 +154,24 @@ export async function doInitiate(options: CommandOptions): Promise<

// Step 8: Print final summary
const hasAiFeature = selectedFeatures.has(Feature.AI);
if (hasAiFeature && 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
// sibling Storybook projects. This is a tiny local file with no PII, so it
// is written even when telemetry is disabled.
await cache
Comment thread
Sidnioulz marked this conversation as resolved.
.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,
Expand Down
Loading