diff --git a/.github/scripts/agent-scan-check-org-membership.mjs b/.github/scripts/agent-scan-check-org-membership.mjs new file mode 100644 index 000000000000..7a9da73799e5 --- /dev/null +++ b/.github/scripts/agent-scan-check-org-membership.mjs @@ -0,0 +1,37 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; + +async function main() { + const token = core.getInput('token', { required: true }); + const org = core.getInput('org', { required: true }); + const username = core.getInput('username', { required: true }); + + const octokit = github.getOctokit(token); + + let isOrgMember = false; + + try { + await octokit.rest.orgs.checkMembershipForUser({ + org, + username, + }); + + isOrgMember = true; + } catch (error) { + if (error.status === 404) { + } else if (error.status === 302 || error.status === 403) { + core.warning( + `Unable to verify org membership for ${username}; GitHub API returned ${error.status}. Falling back to scanning this fork PR.` + ); + } else { + throw error; + } + } + + core.setOutput('is-org-member', String(isOrgMember)); + core.setOutput('should-scan', String(!isOrgMember)); +} + +main().catch((error) => { + core.setFailed(error.message); +}); diff --git a/.github/workflows/agent-scan.yml b/.github/workflows/agent-scan.yml index 52891e575a1a..badbb0da6a48 100644 --- a/.github/workflows/agent-scan.yml +++ b/.github/workflows/agent-scan.yml @@ -17,10 +17,8 @@ jobs: agentscan: if: | github.repository_owner == 'storybookjs' && + github.event.pull_request.head.repo.full_name != github.repository && !contains( - fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), - github.event.pull_request.author_association - ) && !contains( fromJSON('["dependabot[bot]", "github-actions[bot]","storybook-bot"]'), github.event.pull_request.user.login ) @@ -31,13 +29,22 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Install script dependencies run: npm install --prefix .github/scripts + - name: Check author org membership + id: membership + env: + INPUT_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_ORG: ${{ github.repository_owner }} + INPUT_USERNAME: ${{ github.event.pull_request.user.login }} + run: node .github/scripts/agent-scan-check-org-membership.mjs - name: Cache AgentScan analysis + if: steps.membership.outputs.should-scan == 'true' uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 with: path: .agentscan-cache key: agentscan-cache-${{ github.actor }} restore-keys: agentscan-cache- - name: AgentScan + if: steps.membership.outputs.should-scan == 'true' id: agentscan uses: MatteoGabriele/agentscan-action@a584774dd15cabe6df4c6ab45fc43514a3b56b2d with: @@ -45,7 +52,7 @@ jobs: agent-scan-comment: false cache-path: .agentscan-cache - name: Label PR with classification - if: steps.agentscan.outputs + if: steps.membership.outputs.should-scan == 'true' && steps.agentscan.outputs.classification env: INPUT_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_CLASSIFICATION: ${{ steps.agentscan.outputs.classification }} diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index ddfd7ab7314e..0b0cc2349d60 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,17 @@ +## 10.4.0-alpha.19 + +- Agentic Setup: Add --extensive for an extra prompt - [#34730](https://github.com/storybookjs/storybook/pull/34730), thanks @Sidnioulz! +- Agentic Setup: Rework ai-init-opt-in logic - [#34739](https://github.com/storybookjs/storybook/pull/34739), thanks @Sidnioulz! +- CLI: Improve package incompatibility detection and warning - [#34559](https://github.com/storybookjs/storybook/pull/34559), thanks @copilot-swe-agent! +- CLI: Remove extensive prompt option - [#34740](https://github.com/storybookjs/storybook/pull/34740), thanks @yannbf! +- Cli: Set ai prompt to yes if yes flag for react-vite to tanstack migration - [#34743](https://github.com/storybookjs/storybook/pull/34743), thanks @huang-julien! +- Core: Fix "Open In Editor" support for VSCode - [#34747](https://github.com/storybookjs/storybook/pull/34747), thanks @JReinhold! +- Core: Quiet change-detection regex warning and swap clear icon - [#34758](https://github.com/storybookjs/storybook/pull/34758), thanks @valentinpalkovic! +- ReactNative: AppRegistry component name in template - [#34742](https://github.com/storybookjs/storybook/pull/34742), thanks @ndelangen! +- Sidebar: Fix clear filter button not refreshing story list - [#34737](https://github.com/storybookjs/storybook/pull/34737), thanks @valentinpalkovic! +- Sidebar: Show same status icon at story and group level - [#34702](https://github.com/storybookjs/storybook/pull/34702), thanks @valentinpalkovic! +- Tanstack: Treeshake top-level unused functions - [#34760](https://github.com/storybookjs/storybook/pull/34760), thanks @huang-julien! + ## 10.4.0-alpha.18 - Agentic Setup: Allow failed stories to persist - [#34717](https://github.com/storybookjs/storybook/pull/34717), thanks @Sidnioulz! diff --git a/code/addons/vitest/src/vitest-plugin/agent-telemetry-reporter.ts b/code/addons/vitest/src/vitest-plugin/agent-telemetry-reporter.ts index 49041b5e4344..b8092e16ff91 100644 --- a/code/addons/vitest/src/vitest-plugin/agent-telemetry-reporter.ts +++ b/code/addons/vitest/src/vitest-plugin/agent-telemetry-reporter.ts @@ -10,6 +10,7 @@ import { isExampleStoryId, telemetry } from 'storybook/internal/telemetry'; import type { AgentInfo } from 'storybook/internal/telemetry'; import { mergeAndWriteStoryHistory } from './agent-story-history-cache.ts'; +import { getAiSetupRunId } from '../../../../core/src/shared/utils/ai-checklist-flags.ts'; interface AgentTelemetryReporterOptions { configDir: string; @@ -75,6 +76,8 @@ export class AgentTelemetryReporter implements Reporter { const testModulesErrors = testModules.flatMap((t) => t.errors()); const unhandledErrorCount = unhandledErrors.length + testModulesErrors.length; + const runId = await getAiSetupRunId(this.configDir); + // Fire and forget — same pattern as the existing test-run telemetry telemetry( 'ai-setup-self-healing-scoring', @@ -84,6 +87,7 @@ export class AgentTelemetryReporter implements Reporter { unhandledErrorCount, duration, watch: this.ctx.config.watch, + runId, }, { configDir: this.configDir, stripMetadata: true } ); diff --git a/code/core/package.json b/code/core/package.json index 944ec99966e8..6040034c6db3 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -336,7 +336,7 @@ "jiti": "^2.6.1", "js-yaml": "^4.1.0", "jsdoc-type-pratt-parser": "^4.0.0", - "launch-editor": "^2.11.1", + "launch-editor": "^2.13.2", "lazy-universal-dotenv": "^4.0.0", "leven": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", diff --git a/code/core/src/core-server/change-detection/dependency-graph/ResolverFactory.ts b/code/core/src/core-server/change-detection/dependency-graph/ResolverFactory.ts index 2f34f817e406..3b4b33cc99b4 100644 --- a/code/core/src/core-server/change-detection/dependency-graph/ResolverFactory.ts +++ b/code/core/src/core-server/change-detection/dependency-graph/ResolverFactory.ts @@ -56,7 +56,7 @@ class AliasNormalizer { for (const p of newPatterns) { this.warnedRegexAliases.add(p); } - logger.warn( + logger.debug( `Change detection: ignored ${skippedRegex.length} regex alias(es); related modules tracked as opaque-leaf.` ); logger.debug( diff --git a/code/core/src/core-server/server-channel/ai-setup-channel.test.ts b/code/core/src/core-server/server-channel/ai-setup-channel.test.ts new file mode 100644 index 000000000000..e6b866e93027 --- /dev/null +++ b/code/core/src/core-server/server-channel/ai-setup-channel.test.ts @@ -0,0 +1,349 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ChannelTransport } from 'storybook/internal/channels'; +import { Channel } from 'storybook/internal/channels'; +import { + AI_SETUP_ANALYTICS_REQUEST, + AI_SETUP_ANALYTICS_RESPONSE, +} from 'storybook/internal/core-events'; +import type { Options } from 'storybook/internal/types'; + +import { initAIAnalyticsChannel } from './ai-setup-channel.ts'; + +vi.mock('storybook/internal/telemetry', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getLastEvents: vi.fn(), + getStorybookMetadata: vi.fn(), + isStoryCreatedByAISetup: vi.fn(), + telemetry: vi.fn(), + }; +}); + +vi.mock('../../shared/utils/ai-checklist-flags.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAiSetupRunId: vi.fn(), + }; +}); + +vi.mock('../utils/ghost-stories/run-story-tests.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runStoryTests: vi.fn(), + }; +}); + +vi.mock('../utils/wait-for-idle-vitest.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + waitForIdleVitest: vi.fn(), + }; +}); + +const mockTelemetry = await import('storybook/internal/telemetry'); +const mockAiChecklistFlags = await import('../../shared/utils/ai-checklist-flags.ts'); +const mockRunStoryTests = await import('../utils/ghost-stories/run-story-tests.ts'); +const mockWaitForIdleVitest = await import('../utils/wait-for-idle-vitest.ts'); + +describe('initAIAnalyticsChannel', () => { + const transport = { setHandler: vi.fn(), send: vi.fn() } satisfies ChannelTransport; + const mockChannel = new Channel({ transport }); + const analyticsResponseListener = vi.fn(); + // to avoid noise in the test output + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + transport.setHandler.mockClear(); + transport.send.mockClear(); + analyticsResponseListener.mockClear(); + consoleErrorSpy.mockClear(); + consoleLogSpy.mockClear(); + + mockChannel.removeAllListeners(); + + vi.mocked(mockTelemetry.getLastEvents).mockReset(); + vi.mocked(mockTelemetry.getStorybookMetadata).mockReset(); + vi.mocked(mockTelemetry.isStoryCreatedByAISetup).mockReset(); + vi.mocked(mockTelemetry.telemetry).mockReset(); + vi.mocked(mockAiChecklistFlags.getAiSetupRunId).mockReset().mockResolvedValue(undefined); + vi.mocked(mockRunStoryTests.runStoryTests).mockReset(); + vi.mocked(mockWaitForIdleVitest.waitForIdleVitest).mockReset().mockResolvedValue(true); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + describe('no-op conditions', () => { + it('should skip scoring when there is no lastAISetup event', async () => { + mockChannel.addListener(AI_SETUP_ANALYTICS_RESPONSE, analyticsResponseListener); + + vi.mocked(mockTelemetry.getLastEvents).mockResolvedValue({ + init: { body: { sessionId: 'test-session' } }, + } as any); + + initAIAnalyticsChannel(mockChannel, {} as Options); + mockChannel.emit(AI_SETUP_ANALYTICS_REQUEST); + + await vi.waitFor(() => { + expect(analyticsResponseListener).toHaveBeenCalled(); + }); + + expect(mockTelemetry.telemetry).not.toHaveBeenCalled(); + expect(mockTelemetry.getStorybookMetadata).not.toHaveBeenCalled(); + }); + + it('should skip scoring when lastSetupStoryScoringRun.runId matches lastAISetup.runId (same session)', async () => { + mockChannel.addListener(AI_SETUP_ANALYTICS_RESPONSE, analyticsResponseListener); + + vi.mocked(mockTelemetry.getLastEvents).mockResolvedValue({ + 'ai-setup': { body: { payload: { runId: 'session-A' } } }, + 'ai-setup-final-scoring': { body: { payload: { runId: 'session-A' } } }, + } as any); + + initAIAnalyticsChannel(mockChannel, {} as Options); + mockChannel.emit(AI_SETUP_ANALYTICS_REQUEST); + + await vi.waitFor(() => { + expect(analyticsResponseListener).toHaveBeenCalled(); + }); + + expect(mockTelemetry.telemetry).not.toHaveBeenCalled(); + expect(mockTelemetry.getStorybookMetadata).not.toHaveBeenCalled(); + }); + }); + + describe('run conditions', () => { + it('should run scoring when there is no lastSetupStoryScoringRun (first time)', async () => { + mockChannel.addListener(AI_SETUP_ANALYTICS_RESPONSE, analyticsResponseListener); + + vi.mocked(mockTelemetry.getLastEvents).mockResolvedValue({ + 'ai-setup': { body: { payload: { runId: 'session-A' } } }, + } as any); + vi.mocked(mockTelemetry.getStorybookMetadata).mockResolvedValue({ + renderer: '@storybook/react', + addons: { '@storybook/addon-vitest': {} }, + } as any); + vi.mocked(mockTelemetry.isStoryCreatedByAISetup).mockReturnValue(false); + + const mockGenerator = { + getIndexAndStats: vi.fn().mockResolvedValue({ + storyIndex: { entries: {} }, + }), + }; + + initAIAnalyticsChannel(mockChannel, {} as Options, () => + Promise.resolve(mockGenerator as any) + ); + mockChannel.emit(AI_SETUP_ANALYTICS_REQUEST); + + await vi.waitFor(() => { + expect(analyticsResponseListener).toHaveBeenCalled(); + }); + + expect(mockTelemetry.getStorybookMetadata).toHaveBeenCalled(); + expect(mockTelemetry.telemetry).toHaveBeenCalledWith( + 'ai-setup-final-scoring', + expect.objectContaining({ + stats: expect.objectContaining({ fileCount: 0, storyCount: 0 }), + }) + ); + }); + + it('should run scoring when lastSetupStoryScoringRun.runId differs from lastAISetup.runId (new ai-setup session)', async () => { + mockChannel.addListener(AI_SETUP_ANALYTICS_RESPONSE, analyticsResponseListener); + + vi.mocked(mockTelemetry.getLastEvents).mockResolvedValue({ + 'ai-setup': { body: { payload: { runId: 'session-B' } } }, + 'ai-setup-final-scoring': { body: { payload: { runId: 'session-A' } } }, + } as any); + vi.mocked(mockTelemetry.getStorybookMetadata).mockResolvedValue({ + renderer: '@storybook/react', + addons: { '@storybook/addon-vitest': {} }, + } as any); + vi.mocked(mockTelemetry.isStoryCreatedByAISetup).mockReturnValue(false); + + const mockGenerator = { + getIndexAndStats: vi.fn().mockResolvedValue({ + storyIndex: { entries: {} }, + }), + }; + + initAIAnalyticsChannel(mockChannel, {} as Options, () => + Promise.resolve(mockGenerator as any) + ); + mockChannel.emit(AI_SETUP_ANALYTICS_REQUEST); + + await vi.waitFor(() => { + expect(analyticsResponseListener).toHaveBeenCalled(); + }); + + expect(mockTelemetry.getStorybookMetadata).toHaveBeenCalled(); + expect(mockTelemetry.telemetry).toHaveBeenCalledWith( + 'ai-setup-final-scoring', + expect.objectContaining({ + stats: expect.objectContaining({ fileCount: 0, storyCount: 0 }), + }) + ); + }); + + it('should run scoring for AI-generated story files when they are found', async () => { + mockChannel.addListener(AI_SETUP_ANALYTICS_RESPONSE, analyticsResponseListener); + + vi.mocked(mockTelemetry.getLastEvents).mockResolvedValue({ + 'ai-setup': { body: { payload: { runId: 'session-B' } } }, + 'ai-setup-final-scoring': { body: { payload: { runId: 'session-A' } } }, + } as any); + vi.mocked(mockTelemetry.getStorybookMetadata).mockResolvedValue({ + renderer: '@storybook/react', + addons: { '@storybook/addon-vitest': {} }, + } as any); + vi.mocked(mockTelemetry.isStoryCreatedByAISetup).mockReturnValue(true); + vi.mocked(mockRunStoryTests.runStoryTests).mockResolvedValue({ + duration: 1234, + summary: { + runTotal: 2, + runPassed: 2, + runSuccessRate: 1, + runSuccessRateWithoutEmptyRender: 1, + runCategorizedErrors: {}, + runCssCheck: 'not-run', + runUniqueErrorCount: 0, + runPassedButEmptyRender: 0, + }, + } as any); + vi.mocked(mockAiChecklistFlags.getAiSetupRunId).mockResolvedValue('session-B'); + + const mockGenerator = { + getIndexAndStats: vi.fn().mockResolvedValue({ + storyIndex: { + entries: { + 'story-1': { importPath: './Button.stories.tsx', type: 'story' }, + }, + }, + }), + }; + + initAIAnalyticsChannel(mockChannel, {} as Options, () => + Promise.resolve(mockGenerator as any) + ); + mockChannel.emit(AI_SETUP_ANALYTICS_REQUEST); + + await vi.waitFor(() => { + expect(analyticsResponseListener).toHaveBeenCalled(); + }); + + expect(mockRunStoryTests.runStoryTests).toHaveBeenCalledWith(['./Button.stories.tsx']); + expect(mockTelemetry.telemetry).toHaveBeenCalledWith( + 'ai-setup-final-scoring', + expect.objectContaining({ + stats: expect.objectContaining({ + fileCount: 1, + storyCount: 1, + testRunDuration: 1234, + }), + runId: 'session-B', + results: expect.objectContaining({ runTotal: 2, runPassed: 2 }), + }) + ); + }); + }); + + describe('gating conditions', () => { + it('should skip scoring when renderer is not React', async () => { + mockChannel.addListener(AI_SETUP_ANALYTICS_RESPONSE, analyticsResponseListener); + + vi.mocked(mockTelemetry.getLastEvents).mockResolvedValue({ + 'ai-setup': { body: { payload: { runId: 'session-A' } } }, + } as any); + vi.mocked(mockTelemetry.getStorybookMetadata).mockResolvedValue({ + renderer: '@storybook/vue', + addons: { '@storybook/addon-vitest': {} }, + } as any); + + initAIAnalyticsChannel(mockChannel, {} as Options); + mockChannel.emit(AI_SETUP_ANALYTICS_REQUEST); + + await vi.waitFor(() => { + expect(analyticsResponseListener).toHaveBeenCalled(); + }); + + expect(mockTelemetry.getStorybookMetadata).toHaveBeenCalled(); + expect(mockTelemetry.telemetry).not.toHaveBeenCalled(); + }); + + it('should skip scoring when vitest addon is not present', async () => { + mockChannel.addListener(AI_SETUP_ANALYTICS_RESPONSE, analyticsResponseListener); + + vi.mocked(mockTelemetry.getLastEvents).mockResolvedValue({ + 'ai-setup': { body: { payload: { runId: 'session-A' } } }, + } as any); + vi.mocked(mockTelemetry.getStorybookMetadata).mockResolvedValue({ + renderer: '@storybook/react', + addons: {}, + } as any); + + initAIAnalyticsChannel(mockChannel, {} as Options); + mockChannel.emit(AI_SETUP_ANALYTICS_REQUEST); + + await vi.waitFor(() => { + expect(analyticsResponseListener).toHaveBeenCalled(); + }); + + expect(mockTelemetry.getStorybookMetadata).toHaveBeenCalled(); + expect(mockTelemetry.telemetry).not.toHaveBeenCalled(); + }); + + it('should skip scoring when vitest is not idle', async () => { + mockChannel.addListener(AI_SETUP_ANALYTICS_RESPONSE, analyticsResponseListener); + + vi.mocked(mockTelemetry.getLastEvents).mockResolvedValue({ + 'ai-setup': { body: { payload: { runId: 'session-A' } } }, + } as any); + vi.mocked(mockTelemetry.getStorybookMetadata).mockResolvedValue({ + renderer: '@storybook/react', + addons: { '@storybook/addon-vitest': {} }, + } as any); + vi.mocked(mockWaitForIdleVitest.waitForIdleVitest).mockResolvedValue(false); + + initAIAnalyticsChannel(mockChannel, {} as Options); + mockChannel.emit(AI_SETUP_ANALYTICS_REQUEST); + + await vi.waitFor(() => { + expect(analyticsResponseListener).toHaveBeenCalled(); + }); + + expect(mockRunStoryTests.runStoryTests).not.toHaveBeenCalled(); + expect(mockTelemetry.telemetry).not.toHaveBeenCalled(); + }); + }); + + describe('error conditions', () => { + it('should call telemetry with runError when an unexpected error occurs', async () => { + mockChannel.addListener(AI_SETUP_ANALYTICS_RESPONSE, analyticsResponseListener); + + vi.mocked(mockTelemetry.getLastEvents).mockRejectedValue(new Error('Cache error')); + + initAIAnalyticsChannel(mockChannel, {} as Options); + mockChannel.emit(AI_SETUP_ANALYTICS_REQUEST); + + await vi.waitFor(() => { + expect(analyticsResponseListener).toHaveBeenCalled(); + }); + + expect(mockTelemetry.telemetry).toHaveBeenCalledWith( + 'ai-setup-final-scoring', + expect.objectContaining({ + runError: 'Unknown error during AI story scoring', + }) + ); + }); + }); +}); diff --git a/code/core/src/core-server/server-channel/ai-setup-channel.ts b/code/core/src/core-server/server-channel/ai-setup-channel.ts index 758229aba8d3..5cd124828883 100644 --- a/code/core/src/core-server/server-channel/ai-setup-channel.ts +++ b/code/core/src/core-server/server-channel/ai-setup-channel.ts @@ -15,6 +15,7 @@ import { logger } from 'storybook/internal/node-logger'; import { runStoryTests } from '../utils/ghost-stories/run-story-tests.ts'; import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import { waitForIdleVitest } from '../utils/wait-for-idle-vitest.ts'; +import { getAiSetupRunId } from '../../shared/utils/ai-checklist-flags.ts'; export function initAIAnalyticsChannel( channel: Channel, @@ -29,18 +30,24 @@ export function initAIAnalyticsChannel( testRunDuration?: number; } = {}; + let runId: string | undefined; + try { const lastEvents = await getLastEvents(); const lastAISetup = lastEvents?.['ai-setup']; const lastSetupStoryScoringRun = lastEvents?.['ai-setup-final-scoring']; + runId = await getAiSetupRunId(options.configDir); // Only run if sb ai setup has been called if (!lastAISetup) { return; } - // Already ran once for this project — never run again - if (lastSetupStoryScoringRun) { + // Already ran once for this project and `ai setup` session — never run again + if ( + lastSetupStoryScoringRun && + lastSetupStoryScoringRun.body.payload.runId === lastAISetup.body.payload.runId + ) { return; } @@ -98,6 +105,7 @@ export function initAIAnalyticsChannel( storyCount: aiStoryCount, testRunDuration: aiTestRunResult.duration, }, + runId, results: aiTestRunResult.summary, ...(aiTestRunResult.runError ? { runError: aiTestRunResult.runError } : {}), }); @@ -108,12 +116,14 @@ export function initAIAnalyticsChannel( storyCount: 0, testRunDuration: 0, }, + runId, runError: 'No stories found that were generated by ai setup', }); } } catch { telemetry('ai-setup-final-scoring', { stats, + runId, runError: 'Unknown error during AI story scoring', }); } finally { diff --git a/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts b/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts index dbda1da19d6b..2b5b597bbea6 100644 --- a/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts +++ b/code/core/src/core-server/server-channel/ghost-stories-channel.test.ts @@ -370,6 +370,63 @@ describe('ghostStoriesChannel', () => { expect(mockStoryGeneration.getComponentCandidates).not.toHaveBeenCalled(); }); + it('should skip discovery run when ghost stories ran and ai-setup scoring runId matches current ai-setup session', async () => { + mockChannel.addListener(GHOST_STORIES_RESPONSE, ghostStoriesEventListener); + vi.mocked(mockTelemetry.getLastEvents).mockResolvedValue({ + 'ghost-stories': { timestamp: Date.now(), body: {} }, + 'ai-setup': { body: { payload: { runId: 'session-A' } } }, + 'ai-setup-final-scoring': { body: { payload: { runId: 'session-A' } } }, + init: { body: { sessionId: 'test-session' } }, + } as any); + + initGhostStoriesChannel(mockChannel, {} as Options); + + mockChannel.emit(GHOST_STORIES_REQUEST); + + await vi.waitFor(() => { + expect(ghostStoriesEventListener).toHaveBeenCalled(); + }); + + expect(mockTelemetry.getStorybookMetadata).not.toHaveBeenCalled(); + expect(mockStoryGeneration.getComponentCandidates).not.toHaveBeenCalled(); + }); + + it('should run discovery again when ghost stories ran but ai-setup scoring runId is from an older session', async () => { + mockChannel.addListener(GHOST_STORIES_RESPONSE, ghostStoriesEventListener); + // Ghost stories has run before, but a new `ai setup` session has started + // (scoring runId is from session-A, ai-setup runId is now session-B) + vi.mocked(mockTelemetry.getLastEvents).mockResolvedValue({ + 'ghost-stories': { timestamp: Date.now(), body: {} }, + 'ai-setup': { body: { payload: { runId: 'session-B' } } }, + 'ai-setup-final-scoring': { body: { payload: { runId: 'session-A' } } }, + init: { body: { sessionId: 'test-session' } }, + } as any); + + vi.mocked(mockTelemetry.getStorybookMetadata).mockResolvedValue({ + renderer: '@storybook/react', + addons: { '@storybook/addon-vitest': {} }, + } as any); + + vi.mocked(mockStoryGeneration.getComponentCandidates).mockResolvedValue({ + candidates: [], + globMatchCount: 0, + analyzedCount: 0, + avgComplexity: 0, + }); + + initGhostStoriesChannel(mockChannel, {} as Options); + + mockChannel.emit(GHOST_STORIES_REQUEST); + + await vi.waitFor(() => { + expect(ghostStoriesEventListener).toHaveBeenCalled(); + }); + + // Should have proceeded past the skip condition + expect(mockTelemetry.getStorybookMetadata).toHaveBeenCalled(); + expect(mockStoryGeneration.getComponentCandidates).toHaveBeenCalled(); + }); + it('should skip discovery run when not in a React + Vitest project', async () => { mockChannel.addListener(GHOST_STORIES_RESPONSE, ghostStoriesEventListener); // Has not run yet (no ghost stories event and session matches) diff --git a/code/core/src/core-server/server-channel/ghost-stories-channel.ts b/code/core/src/core-server/server-channel/ghost-stories-channel.ts index b92766da0dc9..41c7df147feb 100644 --- a/code/core/src/core-server/server-channel/ghost-stories-channel.ts +++ b/code/core/src/core-server/server-channel/ghost-stories-channel.ts @@ -6,6 +6,7 @@ import type { Options } from 'storybook/internal/types'; import { getComponentCandidates } from '../utils/ghost-stories/get-candidates.ts'; import { runStoryTests } from '../utils/ghost-stories/run-story-tests.ts'; import { waitForIdleVitest } from '../utils/wait-for-idle-vitest.ts'; +import { getAiSetupRunId } from '../../shared/utils/ai-checklist-flags.ts'; class SkipGhostStoriesTelemetry extends Error {} @@ -18,10 +19,15 @@ export function initGhostStoriesChannel(channel: Channel, options: Options) { totalRunDuration?: number; analyzedCount?: number; avgComplexity?: number; + candidateCount?: number; testRunDuration?: number; } = {}; + // Initialize contextual data; if ghost stories are triggered to assess the + // quality of an ai setup workflow, inject the runId for the ai setup session. + const aiSetupRunId = await getAiSetupRunId(options.configDir); + try { await telemetry('ghost-stories', async () => { try { @@ -29,6 +35,7 @@ export function initGhostStoriesChannel(channel: Channel, options: Options) { const lastEvents = await getLastEvents(); const lastInit = lastEvents?.init; const lastAISetup = lastEvents?.['ai-setup']; + const lastSetupStoryScoringRun = lastEvents?.['ai-setup-final-scoring']; const lastGhostStoriesRun = lastEvents?.['ghost-stories']; // We only want to run ghost stories immediately after init or ai setup. @@ -37,8 +44,12 @@ export function initGhostStoriesChannel(channel: Channel, options: Options) { throw new SkipGhostStoriesTelemetry(); } - // Already ran once for this project — never run again - if (lastGhostStoriesRun) { + // Already ran once for this project — re-run it only when we need fresh + // data for a new instance of `ai setup`. + if ( + lastGhostStoriesRun && + lastSetupStoryScoringRun.body.payload.runId === lastAISetup.body.payload.runId + ) { throw new SkipGhostStoriesTelemetry(); } @@ -63,7 +74,11 @@ export function initGhostStoriesChannel(channel: Channel, options: Options) { // disturb end user activities. const isIdle = await waitForIdleVitest(); if (!isIdle) { - return; + return { + stats, + aiSetupRunId, + runError: "Vitest busy, couldn't run ghost stories", + }; } // Phase 1: find candidates from components @@ -79,6 +94,7 @@ export function initGhostStoriesChannel(channel: Channel, options: Options) { stats.totalRunDuration = Date.now() - ghostRunStart; return { stats, + aiSetupRunId, runError: candidatesResult.error, }; } @@ -87,6 +103,7 @@ export function initGhostStoriesChannel(channel: Channel, options: Options) { stats.totalRunDuration = Date.now() - ghostRunStart; return { stats, + aiSetupRunId, runError: 'No candidates found', }; } @@ -101,12 +118,14 @@ export function initGhostStoriesChannel(channel: Channel, options: Options) { if (testRunResult.runError) { return { stats, + aiSetupRunId, runError: testRunResult.runError, }; } return { stats, + aiSetupRunId, results: testRunResult.summary, }; } catch (error) { @@ -116,6 +135,7 @@ export function initGhostStoriesChannel(channel: Channel, options: Options) { return { stats, + aiSetupRunId, runError: 'Unknown error during ghost run', }; } diff --git a/code/core/src/core-server/utils/checklist.test.ts b/code/core/src/core-server/utils/checklist.test.ts index fed492f0702b..c5b7f53b572c 100644 --- a/code/core/src/core-server/utils/checklist.test.ts +++ b/code/core/src/core-server/utils/checklist.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CacheEntry } from '../../telemetry/event-cache.ts'; -import type { TelemetryEvent } from '../../telemetry/types.ts'; import { MockUniversalStore } from '../../shared/universal-store/mock.ts'; import { type StoreEvent, @@ -20,7 +19,7 @@ vi.mock('storybook/internal/common', () => ({ // The two AI-related flags read from the regular fs cache. Mocking the small // helper module lets each test set the flags directly without having to drive // the underlying cache through vitest's module resolution. -vi.mock('./ai-checklist-flags.ts', () => ({ +vi.mock('../../shared/utils/ai-checklist-flags.ts', () => ({ hasAiInitOptIn: vi.fn().mockResolvedValue(false), hasAiSetupRun: vi.fn().mockResolvedValue(false), })); @@ -70,11 +69,6 @@ vi.mock('../../telemetry/event-cache.ts', () => ({ const AI_IDLE_DELAY_MS = 4 * 60 * 1000; -const aiInitOptInCacheEntry = { - timestamp: Date.now(), - body: { eventType: 'ai-init-opt-in' } as TelemetryEvent, -} satisfies CacheEntry; - /** Mock getEventCacheEntry to return specific entries by event type. */ function mockEventCache(events: Record) { return async (eventType: string) => events[eventType]; @@ -106,7 +100,7 @@ async function setAiFlags({ optedIn?: boolean; setupRan?: boolean; }) { - const flags = await import('./ai-checklist-flags.ts'); + const flags = await import('../../shared/utils/ai-checklist-flags.ts'); vi.mocked(flags.hasAiInitOptIn).mockResolvedValue(optedIn); vi.mocked(flags.hasAiSetupRun).mockResolvedValue(setupRan); } @@ -142,7 +136,7 @@ describe('initializeChecklist', () => { it('sets loaded immediately, even before the AI checks resolve', async () => { const { get: getEventCacheEntry } = await import('../../telemetry/event-cache.ts'); vi.mocked(getEventCacheEntry).mockReturnValue(new Promise(() => {})); - const flags = await import('./ai-checklist-flags.ts'); + const flags = await import('../../shared/utils/ai-checklist-flags.ts'); vi.mocked(flags.hasAiInitOptIn).mockReturnValue(new Promise(() => {})); vi.mocked(flags.hasAiSetupRun).mockReturnValue(new Promise(() => {})); @@ -193,7 +187,7 @@ describe('initializeChecklist', () => { it('still initializes when reading the AI cache fails', async () => { const { get: getEventCacheEntry } = await import('../../telemetry/event-cache.ts'); vi.mocked(getEventCacheEntry).mockRejectedValue(new Error('cache read failed')); - const flags = await import('./ai-checklist-flags.ts'); + const flags = await import('../../shared/utils/ai-checklist-flags.ts'); vi.mocked(flags.hasAiInitOptIn).mockRejectedValueOnce(new Error('cache read failed')); const { initializeChecklist } = await import('./checklist.ts'); @@ -223,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 }); @@ -235,7 +229,33 @@ 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); + }); + + 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); @@ -243,7 +263,7 @@ describe('initializeChecklist', () => { await initializeChecklist(); await vi.advanceTimersByTimeAsync(0); - expect(mockStore.getState().aiOptIn).toBeFalsy(); + expect(mockStore.getState().aiSetupRun).toBeFalsy(); }); }); @@ -363,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'); @@ -380,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 213763fda6a5..f40959d3c9a6 100644 --- a/code/core/src/core-server/utils/checklist.ts +++ b/code/core/src/core-server/utils/checklist.ts @@ -16,7 +16,7 @@ import { globalSettings } from '../../cli/index.ts'; import { universalTestProviderStore } from '../stores/test-provider.ts'; import { get as getEventCacheEntry } from '../../telemetry/event-cache.ts'; import { isStoryCreatedByAISetup } from '../../telemetry/ai-setup-utils.ts'; -import { hasAiInitOptIn, hasAiSetupRun } from './ai-checklist-flags.ts'; +import { hasAiInitOptIn, hasAiSetupRun } from '../../shared/utils/ai-checklist-flags.ts'; import { type ChecklistState, type StoreEvent, @@ -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(() => {}); + hasAiInitOptIn(configDir!) .then((hasOptedIn) => { - if (hasOptedIn) { - store.setState((state) => ({ ...state, aiOptIn: true })); - } + store.setState((state) => ({ ...state, aiOptIn: hasOptedIn })); }) .catch(() => {}); @@ -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 diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 4be0305d98f0..ff35677c8049 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -325,14 +325,14 @@ export interface SubAPI { experimental_setFilter: (addonId: string, filterFunction: API_FilterFunction) => Promise; /** Resets tag filters in the sidebar to the default filters. */ - resetTagFilters(): void; + resetTagFilters(): Promise; /** * Replaces all tag filters in the sidebar with the provided included and excluded lists. * * @param included The tags to include in the filtered stories list * @param excluded The tags to filter out (exclude) from the stories list */ - setAllTagFilters(included: Tag[], excluded: Tag[]): void; + setAllTagFilters(included: Tag[], excluded: Tag[]): Promise; /** * Adds tag filters to the included or excluded filter lists. Included filters are included in the * stories list, whereas excluded filters are filtered out. @@ -340,36 +340,36 @@ export interface SubAPI { * @param tags The tags to add as filters. * @param excluded Whether to add the tags to the include or exclude filter list. */ - addTagFilters(tags: Tag[], excluded: boolean): void; + addTagFilters(tags: Tag[], excluded: boolean): Promise; /** * Removes tag filters from both the included and excluded filter lists. * * @param tags The tags to remove from filters. */ - removeTagFilters(tags: Tag[]): void; + removeTagFilters(tags: Tag[]): Promise; /** Resets status filters in the sidebar (clears both included and excluded). */ - resetStatusFilters(): void; + resetStatusFilters(): Promise; /** * Replaces all status filters in the sidebar with the provided included and excluded lists. * * @param included The status values to include in the filtered stories list * @param excluded The status values to filter out (exclude) from the stories list */ - setAllStatusFilters(included: StatusValue[], excluded: StatusValue[]): void; + setAllStatusFilters(included: StatusValue[], excluded: StatusValue[]): Promise; /** * Adds status filters to the included or excluded filter lists. * * @param statuses The status values to add as filters. * @param excluded Whether to add to the include or exclude filter list. */ - addStatusFilters(statuses: StatusValue[], excluded: boolean): void; + addStatusFilters(statuses: StatusValue[], excluded: boolean): Promise; /** * Removes status filters from both the included and excluded filter lists. * * @param statuses The status values to remove from filters. */ - removeStatusFilters(statuses: StatusValue[]): void; + removeStatusFilters(statuses: StatusValue[]): Promise; } const removedOptions = ['enableShortcuts', 'theme', 'showRoots']; @@ -920,18 +920,18 @@ export const init: ModuleFn = ({ excludedTagFilters: s.defaultExcludedTagFilters, })); - recomputeTagsFilter(); + await recomputeTagsFilter(); }, setAllTagFilters: async (included: Tag[], excluded: Tag[]) => { await persistFilters({ includedTagFilters: included, excludedTagFilters: excluded }); - recomputeTagsFilter(); + await recomputeTagsFilter(); }, addTagFilters: async (tags: Tag[], excluded: boolean) => { await addFilters('tag', tags, excluded); - recomputeTagsFilter(); + await recomputeTagsFilter(); if (tags.length === 1 && BUILT_IN_TAG_IDS.has(tags[0])) { emitFilterTelemetry('interaction', { filterType: 'tag', @@ -943,7 +943,7 @@ export const init: ModuleFn = ({ removeTagFilters: async (tags: Tag[]) => { await removeFilters('tag', tags); - recomputeTagsFilter(); + await recomputeTagsFilter(); if (tags.length === 1 && BUILT_IN_TAG_IDS.has(tags[0])) { emitFilterTelemetry('interaction', { filterType: 'tag', @@ -955,7 +955,7 @@ export const init: ModuleFn = ({ resetStatusFilters: async () => { await persistFilters({ includedStatusFilters: [], excludedStatusFilters: [] }); - recomputeStatusFilter(); + await recomputeStatusFilter(); }, setAllStatusFilters: async (included: StatusValue[], excluded: StatusValue[]) => { @@ -966,7 +966,7 @@ export const init: ModuleFn = ({ const nextExcluded = new Set(excluded); await persistFilters({ includedStatusFilters: included, excludedStatusFilters: excluded }); - recomputeStatusFilter(); + await recomputeStatusFilter(); const changedIds = new Set([ ...prevIncluded, @@ -1000,7 +1000,7 @@ export const init: ModuleFn = ({ addStatusFilters: async (statuses: StatusValue[], excluded: boolean) => { await addFilters('status', statuses, excluded); - recomputeStatusFilter(); + await recomputeStatusFilter(); if (statuses.length === 1) { emitFilterTelemetry('interaction', { filterType: 'status', @@ -1012,7 +1012,7 @@ export const init: ModuleFn = ({ removeStatusFilters: async (statuses: StatusValue[]) => { await removeFilters('status', statuses); - recomputeStatusFilter(); + await recomputeStatusFilter(); if (statuses.length === 1) { emitFilterTelemetry('interaction', { filterType: 'status', @@ -1025,7 +1025,7 @@ export const init: ModuleFn = ({ const recomputeTagsFilter = () => { const { includedTagFilters, excludedTagFilters } = store.getState(); - api.experimental_setFilter( + return api.experimental_setFilter( TAGS_FILTER, computeTagsFilterFn(includedTagFilters, excludedTagFilters) ); @@ -1033,7 +1033,7 @@ export const init: ModuleFn = ({ const recomputeStatusFilter = () => { const { includedStatusFilters, excludedStatusFilters } = store.getState(); - api.experimental_setFilter( + return api.experimental_setFilter( STATUS_FILTER, computeStatusFilterFn(includedStatusFilters ?? [], excludedStatusFilters ?? []) ); 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/components/sidebar/ReviewChangesButton.tsx b/code/core/src/manager/components/sidebar/ReviewChangesButton.tsx index 77cf81639328..e00fd276f670 100644 --- a/code/core/src/manager/components/sidebar/ReviewChangesButton.tsx +++ b/code/core/src/manager/components/sidebar/ReviewChangesButton.tsx @@ -9,7 +9,7 @@ import type { Tag, } from 'storybook/internal/types'; -import { SweepIcon } from '@storybook/icons'; +import { CloseIcon } from '@storybook/icons'; import { experimental_useStatusStore, @@ -158,7 +158,7 @@ const ReviewChangesButton = () => { ariaLabel="Clear" tooltip="Clear" > - + )} diff --git a/code/core/src/manager/components/sidebar/Tree.stories.tsx b/code/core/src/manager/components/sidebar/Tree.stories.tsx index 4fdff39c3264..8feb9e5c951f 100644 --- a/code/core/src/manager/components/sidebar/Tree.stories.tsx +++ b/code/core/src/manager/components/sidebar/Tree.stories.tsx @@ -510,8 +510,9 @@ export const WithModified: Story = { ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // modified filter is active — icon should be visible - await expect(canvas.queryByTestId('tree-change-status-button')).toBeInTheDocument(); + // modified filter is active — icon should be visible at story leaf and parent branch + const buttons = await canvas.findAllByTestId('tree-change-status-button'); + await expect(buttons.length).toBeGreaterThanOrEqual(1); }, }; @@ -534,7 +535,8 @@ export const WithNew: Story = { }), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // new status is always shown — icon should be present regardless of filters - await expect(canvas.queryByTestId('tree-change-status-button')).toBeInTheDocument(); + // new status is always shown at both leaf and branch levels + const buttons = await canvas.findAllByTestId('tree-change-status-button'); + await expect(buttons.length).toBeGreaterThanOrEqual(1); }, }; diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index 4b04d33ca76e..8dc209dfa81d 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -46,7 +46,6 @@ import { } from '../../utils/tree.ts'; import { useLayout } from '../layout/LayoutProvider.tsx'; import { useContextMenu } from './ContextMenu.tsx'; -import { UseSymbol } from './IconSymbols.tsx'; import { StatusButton } from './StatusButton.tsx'; import { StatusContext } from './StatusContext.tsx'; import { @@ -238,13 +237,15 @@ const Node = React.memo(function Node(props) { const LeafNode = item.type === 'docs' ? DocumentNode : StoryLeafNode; const { changeStatus, testStatus } = getChangeDetectionStatus(statuses || {}); - // Show test statuses and "new" change detection status at the story level; - // other change detection statuses appear at the branch/component level instead - const storyStatus = - changeStatus === 'status-value:new' - ? getMostCriticalStatusValue([changeStatus, testStatus]) - : testStatus; - const { icon: testIcon, textColor } = getStatus(theme, storyStatus); + const leafChangeIcon = + changeStatus === 'status-value:unknown' || + changeStatus === 'status-value:affected' || + (changeStatus === 'status-value:modified' && !isModifiedFilterActive) + ? null + : getStatus(theme, changeStatus).icon; + const { icon: testIcon } = getStatus(theme, testStatus); + const overallStoryStatus = getMostCriticalStatusValue([changeStatus, testStatus]); + const { textColor } = getStatus(theme, overallStoryStatus); return ( (function Node(props) { )} {contextMenu.node} - {testIcon ? ( + {leafChangeIcon && testIcon ? ( + + + {leafChangeIcon} + + + {testIcon} + + + ) : leafChangeIcon ? ( + + {leafChangeIcon} + + ) : testIcon ? ( {testIcon} @@ -369,12 +401,7 @@ const Node = React.memo(function Node(props) { const branchChangeIcon = shouldShowBranchChangeIcon ? getStatus(theme, branchChange).icon : null; - const branchTestIcon = - branchTest === 'status-value:error' || branchTest === 'status-value:warning' ? ( - - - - ) : null; + const branchTestIcon = getStatus(theme, branchTest).icon; const overallStatus = getMostCriticalStatusValue([branchChange, branchTest]); const color = overallStatus ? getStatus(theme, overallStatus).textColor : null; 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..fad18f962770 100644 --- a/code/core/src/shared/checklist-store/index.ts +++ b/code/core/src/shared/checklist-store/index.ts @@ -12,8 +12,10 @@ 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. Treat empty values as false.*/ + aiSetupRun?: boolean; }; export type ItemId = keyof StoreState['items']; diff --git a/code/core/src/core-server/utils/ai-checklist-flags.test.ts b/code/core/src/shared/utils/ai-checklist-flags.test.ts similarity index 64% rename from code/core/src/core-server/utils/ai-checklist-flags.test.ts rename to code/core/src/shared/utils/ai-checklist-flags.test.ts index ee9527436575..f90191ab1369 100644 --- a/code/core/src/core-server/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); }); }); @@ -102,4 +115,31 @@ describe('ai-checklist-flags', () => { expect(await hasAiSetupRun('/any/project/.storybook')).toBe(false); }); }); + + describe('getAiSetupRunId', () => { + it('returns undefined when nothing is cached', async () => { + const { getAiSetupRunId } = await import('./ai-checklist-flags.ts'); + expect(await getAiSetupRunId('/some/project/.storybook')).toBeUndefined(); + }); + + it('returns the runId when the cached configDir matches', async () => { + mockCacheStore.set('ai-setup-ran', { + timestamp: Date.now(), + configDir: resolve('/repo/apps/web/.storybook'), + runId: 'abc123', + }); + const { getAiSetupRunId } = await import('./ai-checklist-flags.ts'); + expect(await getAiSetupRunId('/repo/apps/web/.storybook')).toBe('abc123'); + }); + + it('returns undefined when the cached configDir is for a different project', async () => { + mockCacheStore.set('ai-setup-ran', { + timestamp: Date.now(), + configDir: resolve('/repo/apps/web/.storybook'), + runId: 'abc123', + }); + const { getAiSetupRunId } = await import('./ai-checklist-flags.ts'); + expect(await getAiSetupRunId('/repo/packages/ui/.storybook')).toBeUndefined(); + }); + }); }); diff --git a/code/core/src/core-server/utils/ai-checklist-flags.ts b/code/core/src/shared/utils/ai-checklist-flags.ts similarity index 69% rename from code/core/src/core-server/utils/ai-checklist-flags.ts rename to code/core/src/shared/utils/ai-checklist-flags.ts index ffbef2cd2a25..7a1c9ed5b126 100644 --- a/code/core/src/core-server/utils/ai-checklist-flags.ts +++ b/code/core/src/shared/utils/ai-checklist-flags.ts @@ -22,6 +22,10 @@ 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; } function isProjectScopedFlag(value: unknown): value is ProjectScopedFlag { @@ -33,24 +37,29 @@ function isProjectScopedFlag(value: unknown): value is ProjectScopedFlag { ); } -async function readProjectScopedFlag(key: string, configDir: string): Promise { +async function readProjectScopedFlag( + key: string, + configDir: string +): Promise { try { const value = await cache.get(key); - if (!isProjectScopedFlag(value)) { - return false; + if (isProjectScopedFlag(value) && value.configDir === resolve(configDir)) { + return value; } - return value.configDir === resolve(configDir); - } catch { - return false; - } + } 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 { - return 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. */ export async function hasAiSetupRun(configDir: string): Promise { - return readProjectScopedFlag('ai-setup-ran', configDir); + return !!(await readProjectScopedFlag('ai-setup-ran', configDir)); +} + +export async function getAiSetupRunId(configDir: string): Promise { + return (await readProjectScopedFlag('ai-setup-ran', configDir))?.runId; } diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index 9137526a30bf..6deb54bb41b0 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -46,11 +46,10 @@ export type EventType = | 'share' | 'ghost-stories' | 'sidebar-filter' + | 'ai-init-opt-in' + | 'ai-prompt-nudge' | 'ai-setup' - | 'ai-setup-evidence' | 'ai-setup-final-scoring' - | 'ai-prompt-nudge' - | 'ai-init-opt-in' | 'ai-setup-self-healing-scoring'; export interface Dependency { version: string | undefined; diff --git a/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts index fa06dc6da0e1..41d237e4ac21 100644 --- a/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts +++ b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.ts @@ -388,39 +388,121 @@ function stripServerOption(options: import('@babel/types').ObjectExpression): bo } /** - * Remove import specifiers that are no longer referenced in the AST. - * Drops entire import declarations when all specifiers are unreferenced. + * Collect all non-binding identifier references in the program. + * Excludes binding sites (declarations) and import specifiers. */ -function eliminateDeadImports(programPath: NodePath) { - const referencedIdentifiers = new Set(); - +function collectReferencedIdentifiers( + programPath: NodePath +): Set { + const referenced = new Set(); programPath.traverse({ enter(path) { const { node } = path; if (!t.isIdentifier(node) || path.isBindingIdentifier()) { return; } - // Skip identifiers that live inside an import declaration's specifiers. - // Both `imported` and `local` are Identifiers, but neither should count - // as a real "use" of the binding for the purposes of dead-import removal. if (path.findParent((p) => p.isImportDeclaration())) { return; } - referencedIdentifiers.add(node.name); + referenced.add(node.name); }, }); + return referenced; +} + +/** + * Remove top-level non-exported declarations whose bound names are never + * referenced elsewhere. This handles hoisted helpers that become dead after + * server code is eliminated (e.g. `function helper() { ... }` only called + * inside a `.handler()` arg that was replaced with `__sb_fn()`). + * + * Returns true when at least one declaration was removed. + */ +function removeDeadTopLevelDeclarations( + programPath: NodePath +): boolean { + const referenced = collectReferencedIdentifiers(programPath); + let removed = false; + + for (const stmtPath of programPath.get('body')) { + if (stmtPath.isImportDeclaration() || stmtPath.isExportDeclaration()) { + continue; + } + + if (stmtPath.isFunctionDeclaration()) { + const id = stmtPath.node.id; + if (id && !referenced.has(id.name)) { + stmtPath.remove(); + removed = true; + } + continue; + } + + if (stmtPath.isVariableDeclaration()) { + // Only remove when every declarator is dead: plain identifier binding, + // unreferenced, and initializer absent or provably side-effect-free. + const allDead = stmtPath.get('declarations').every((declPath) => { + if (!t.isIdentifier(declPath.node.id)) return false; + if (referenced.has(declPath.node.id.name)) return false; + const initPath = declPath.get('init'); + return !declPath.node.init || initPath.isPure(); + }); + if (allDead) { + stmtPath.remove(); + removed = true; + } + } + } + + return removed; +} + +/** + * Remove import specifiers that are no longer referenced in the AST. + * Drops entire import declarations when all specifiers are unreferenced. + * + * Returns true when at least one specifier was removed. + */ +function removeDeadImportSpecifiers( + programPath: NodePath +): boolean { + const referenced = collectReferencedIdentifiers(programPath); + let removed = false; programPath.traverse({ ImportDeclaration(path) { - const specifiers = path.node.specifiers.filter((spec) => - referencedIdentifiers.has(spec.local.name) - ); + // Side-effect-only imports (`import './styles.css'`) have no specifiers + // and must never be removed by this pass. + if (path.node.specifiers.length === 0) { + return; + } + + const specifiers = path.node.specifiers.filter((spec) => referenced.has(spec.local.name)); if (specifiers.length === 0) { path.remove(); + removed = true; } else if (specifiers.length !== path.node.specifiers.length) { path.node.specifiers = specifiers; + removed = true; } }, }); + + return removed; +} + +/** + * Iteratively eliminate dead top-level declarations and dead imports until + * the AST reaches a fixed point. The loop is needed because removing a dead + * declaration (e.g. a hoisted helper) may expose new dead imports, and + * removing a dead import may expose further dead declarations. + */ +function eliminateDeadImports(programPath: NodePath) { + let changed = true; + while (changed) { + const d = removeDeadTopLevelDeclarations(programPath); + const i = removeDeadImportSpecifiers(programPath); + changed = d || i; + } } diff --git a/code/lib/cli-storybook/src/ai/index.ts b/code/lib/cli-storybook/src/ai/index.ts index 0f5a4fa20265..bc3fa3c61547 100644 --- a/code/lib/cli-storybook/src/ai/index.ts +++ b/code/lib/cli-storybook/src/ai/index.ts @@ -91,6 +91,7 @@ export async function aiSetup(options: AiSetupOptions): Promise { await cache .set('ai-setup-ran', { timestamp: Date.now(), + runId: options.runId, configDir: resolve(projectInfo.configDir), }) .catch(() => {}); @@ -100,6 +101,7 @@ export async function aiSetup(options: AiSetupOptions): Promise { output: output ? 'file' : undefined, configDir: projectInfo.configDir, packageManager: projectInfo.packageManager.type, + prompt: result.prompt, }, project: { framework: projectInfo.framework, @@ -107,6 +109,7 @@ export async function aiSetup(options: AiSetupOptions): Promise { builder: projectInfo.builderPackage, language: projectInfo.language, }, + runId: options.runId, }); if (output) { diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/index.ts b/code/lib/cli-storybook/src/ai/setup-prompts/index.ts index ee117181e800..8e158e61d6bd 100644 --- a/code/lib/cli-storybook/src/ai/setup-prompts/index.ts +++ b/code/lib/cli-storybook/src/ai/setup-prompts/index.ts @@ -60,23 +60,29 @@ function resolvePromptName(): PromptName { return DEFAULT_PROMPT_NAME; } -export async function getAiSetupPrompt(projectInfo: ProjectInfo): Promise { +export async function getAiSetupPrompt( + projectInfo: ProjectInfo +): Promise<{ content: string; name: PromptName }> { const name = resolvePromptName(); const builder = CURRENTLY_USED_PROMPT[name] ?? (await FORMERLY_USED_PROMPTS[name]()); - return builder(projectInfo); + return { content: builder(projectInfo), name }; } export async function getAiSetupMarkdownOutput(projectInfo: ProjectInfo): Promise<{ markdown: string; + prompt: PromptName; }> { + const { content, name } = await getAiSetupPrompt(projectInfo); + return { markdown: dedent` # Storybook Setup ${getProjectOverview(projectInfo)} - ${await getAiSetupPrompt(projectInfo)} + ${content} `, + prompt: name, }; } diff --git a/code/lib/cli-storybook/src/ai/types.ts b/code/lib/cli-storybook/src/ai/types.ts index 239b8acbef95..0ba8c00f9dd9 100644 --- a/code/lib/cli-storybook/src/ai/types.ts +++ b/code/lib/cli-storybook/src/ai/types.ts @@ -2,11 +2,20 @@ import type { JsPackageManager } from 'storybook/internal/common'; import type { SupportedRenderer } from 'storybook/internal/types'; export interface AiSetupOptions { + /** Location of the Storybook configuration directory. */ configDir?: string; + + /** Package manager to use (npm, yarn1, yarn2, pnpm, bun). */ packageManager?: string; + + /** If provided, the generated instructions and code will be written to this file instead of the console. */ output?: string; + /** Populated from the program-level `--disable-telemetry` flag (defaults from `STORYBOOK_DISABLE_TELEMETRY`). */ disableTelemetry?: boolean; + + /** A random ID attributed by the CLI when running `ai setup` to identify the setup session. */ + runId: string; } export interface ProjectInfo { diff --git a/code/lib/cli-storybook/src/automigrate/fixes/react-vite-to-tanstack-react.ts b/code/lib/cli-storybook/src/automigrate/fixes/react-vite-to-tanstack-react.ts index a6dc060e3465..0b89e0a0b2e2 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/react-vite-to-tanstack-react.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/react-vite-to-tanstack-react.ts @@ -377,7 +377,7 @@ export const reactViteToTanstackReact: Fix = { ); const wantsAiPrompt = yes - ? false + ? true : await prompt.confirm({ message: 'Would you like a ready-to-paste AI prompt to help remove the now-unused TanStack Router decorator?', diff --git a/code/lib/cli-storybook/src/bin/run.ts b/code/lib/cli-storybook/src/bin/run.ts index 5f91b508e4ec..b784848b26aa 100644 --- a/code/lib/cli-storybook/src/bin/run.ts +++ b/code/lib/cli-storybook/src/bin/run.ts @@ -317,7 +317,8 @@ aiCommand .option('-c, --config-dir ', 'Directory of Storybook configuration') .action(async (options, cmd) => { const parentOptions = cmd.parent?.opts() ?? {}; - const mergedOptions = { ...parentOptions, ...options }; + const runId = Math.random().toString(36); + const mergedOptions = { ...parentOptions, ...options, runId }; await withTelemetry('ai-setup', { cliOptions: mergedOptions }, async () => { await aiSetup(mergedOptions); }).catch(handleCommandFailure(mergedOptions.logfile)); diff --git a/code/lib/create-storybook/src/commands/ProjectDetectionCommand.test.ts b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.test.ts index 424921416226..23b2979ea754 100644 --- a/code/lib/create-storybook/src/commands/ProjectDetectionCommand.test.ts +++ b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.test.ts @@ -23,6 +23,7 @@ describe('ProjectDetectionCommand', () => { autoDetectProjectType: ReturnType; isStorybookInstantiated: ReturnType; detectLanguage: ReturnType; + detectIncompatiblePackageVersions: ReturnType; }; let options: CommandOptions; @@ -36,6 +37,7 @@ describe('ProjectDetectionCommand', () => { autoDetectProjectType: vi.fn(), isStorybookInstantiated: vi.fn().mockReturnValue(false), detectLanguage: vi.fn().mockResolvedValue(SupportedLanguage.JAVASCRIPT), + detectIncompatiblePackageVersions: vi.fn().mockResolvedValue([]), }; vi.mocked(ProjectTypeService).mockImplementation(function () { @@ -236,5 +238,29 @@ describe('ProjectDetectionCommand', () => { expect(result.language).toBe(SupportedLanguage.TYPESCRIPT); expect(mockProjectTypeService.detectLanguage).toHaveBeenCalled(); }); + + it('should warn about incompatible packages when falling back to JavaScript', async () => { + options.type = undefined; + options.language = undefined; + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockResolvedValue(ProjectType.REACT); + vi.mocked(mockProjectTypeService.detectLanguage).mockResolvedValue( + SupportedLanguage.JAVASCRIPT + ); + vi.mocked(mockProjectTypeService.detectIncompatiblePackageVersions).mockResolvedValue([ + 'prettier 2.6.2 is below 2.8.0', + ]); + + const result = await command.execute(); + + expect(result.language).toBe(SupportedLanguage.JAVASCRIPT); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Populating with JavaScript examples due to incompatible package versions' + ) + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('prettier 2.6.2 is below 2.8.0') + ); + }); }); }); diff --git a/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts index c445383801cb..34cb08493690 100644 --- a/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts +++ b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts @@ -2,7 +2,7 @@ import { ProjectType } from 'storybook/internal/cli'; import type { JsPackageManager } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; import { telemetry } from 'storybook/internal/telemetry'; -import type { SupportedLanguage } from 'storybook/internal/types'; +import { SupportedLanguage } from 'storybook/internal/types'; import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; @@ -48,11 +48,27 @@ export class ProjectDetectionCommand { // Check for existing installation await this.checkExistingInstallation(projectType); - const language = this.options.language || (await this.projectTypeService.detectLanguage()); + const language = this.options.language || (await this.detectAndReportLanguage()); return { projectType, language }; } + /** Detect language and warn about incompatible packages */ + private async detectAndReportLanguage(): Promise { + const language = await this.projectTypeService.detectLanguage(); + + if (language === SupportedLanguage.JAVASCRIPT) { + const incompatibleReasons = await this.projectTypeService.detectIncompatiblePackageVersions(); + if (incompatibleReasons.length > 0) { + logger.warn( + `Populating with JavaScript examples due to incompatible package versions:\n${incompatibleReasons.map((r) => ` - ${r}`).join('\n')}` + ); + } + } + + return language; + } + /** Prompt user to select React Native variant */ private async promptReactNativeVariant(): Promise { const manualType = await prompt.select({ diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts index 74c0205aa8f1..f65675d433ac 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts @@ -234,7 +234,7 @@ describe('UserPreferencesCommand', () => { expect(prompt.confirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( - 'Would you like to install AI features (MCP addon, skills and prompt suggestions)?' + 'Would you like to install AI features (MCP addon and prompt suggestions)?' ), }) ); diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts index 1a67b7197ca2..371636c3423f 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts @@ -226,8 +226,7 @@ export class UserPreferencesCommand { const useAi = skipPrompt ? true : await prompt.confirm({ - message: - 'Would you like to install AI features (MCP addon, skills and prompt suggestions)?', + message: 'Would you like to install AI features (MCP addon and prompt suggestions)?', }); if (useAi) { diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/generateEntrypoint.test.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/generateEntrypoint.test.ts index 80501cdfadef..3f9bfabda294 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE/generateEntrypoint.test.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/generateEntrypoint.test.ts @@ -34,8 +34,10 @@ describe('generateReactNativeEntrypoint', () => { expect(output).toMatchInlineSnapshot(` "import { AppRegistry } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; + import { LiteUI } from '@storybook/react-native-ui-lite'; import { view } from './storybook.requires'; + import { name as appName } from '../app.json'; /** * This file is user-editable. @@ -49,9 +51,10 @@ describe('generateReactNativeEntrypoint', () => { getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, }, + CustomUIComponent: LiteUI, }); - AppRegistry.registerComponent('main', () => StorybookUIRoot); + AppRegistry.registerComponent(appName, () => StorybookUIRoot); export default StorybookUIRoot; " @@ -78,8 +81,10 @@ describe('generateReactNativeEntrypoint', () => { expect(output).toMatchInlineSnapshot(` "import { AppRegistry } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; + import { LiteUI } from '@storybook/react-native-ui-lite'; import { view } from './storybook.requires'; + import { name as appName } from '../app.json'; /** * This file is user-editable. @@ -93,9 +98,10 @@ describe('generateReactNativeEntrypoint', () => { getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, }, + CustomUIComponent: LiteUI, }); - AppRegistry.registerComponent('main', () => StorybookUIRoot); + AppRegistry.registerComponent(appName, () => StorybookUIRoot); export default StorybookUIRoot; " @@ -165,8 +171,10 @@ describe('generateReactNativeEntrypoint', () => { expect(output).toMatchInlineSnapshot(` "import { AppRegistry } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; + import { LiteUI } from '@storybook/react-native-ui-lite'; import { view } from './storybook.requires'; + import { name as appName } from '../app.json'; /** * This file is user-editable. @@ -180,9 +188,10 @@ describe('generateReactNativeEntrypoint', () => { getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, }, + CustomUIComponent: LiteUI, }); - AppRegistry.registerComponent('main', () => StorybookUIRoot); + AppRegistry.registerComponent(appName, () => StorybookUIRoot); export default StorybookUIRoot; " @@ -216,8 +225,10 @@ describe('generateReactNativeEntrypoint', () => { expect(generated).toMatchInlineSnapshot(` "import { AppRegistry } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; + import { LiteUI } from '@storybook/react-native-ui-lite'; import { view } from './storybook.requires'; + import { name as appName } from '../app.json'; /** * This file is user-editable. @@ -231,9 +242,10 @@ describe('generateReactNativeEntrypoint', () => { getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, }, + CustomUIComponent: LiteUI, }); - AppRegistry.registerComponent('main', () => StorybookUIRoot); + AppRegistry.registerComponent(appName, () => StorybookUIRoot); export default StorybookUIRoot; " diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts index 1a72da1e33da..fad494c104ba 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts @@ -73,6 +73,7 @@ export default defineGeneratorModule({ '@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions', '@storybook/react-native', + '@storybook/react-native-ui-lite', 'storybook', ]; diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 229e1075eacb..225605d1456d 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -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 - .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, diff --git a/code/lib/create-storybook/src/services/ProjectTypeService.test.ts b/code/lib/create-storybook/src/services/ProjectTypeService.test.ts index be14e17dfe20..cef92e839112 100644 --- a/code/lib/create-storybook/src/services/ProjectTypeService.test.ts +++ b/code/lib/create-storybook/src/services/ProjectTypeService.test.ts @@ -243,7 +243,7 @@ describe('ProjectTypeService', () => { await expect(service.detectLanguage()).resolves.toBe('typescript'); }); - it('warns and returns javascript when TS/tooling versions incompatible', async () => { + it('returns javascript when TS/tooling versions are incompatible', async () => { (pm.getAllDependencies as any) = vi.fn(() => ({ typescript: '^4.8.0' })); (pm.getModulePackageJSON as any) = vi.fn(async (name: string) => { const versions: Record = { @@ -255,10 +255,107 @@ describe('ProjectTypeService', () => { }; return { version: versions[name] } as any; }); - const warnSpy = vi.spyOn(logger, 'warn'); const service = new ProjectTypeService(pm); await expect(service.detectLanguage()).resolves.toBe('javascript'); - expect(warnSpy).toHaveBeenCalled(); + }); + + it('returns javascript when only one tool is incompatible', async () => { + (pm.getAllDependencies as any) = vi.fn(() => ({ typescript: '^5.0.0' })); + (pm.getModulePackageJSON as any) = vi.fn(async (name: string) => { + const versions: Record = { + typescript: '5.2.0', + prettier: '2.6.2', // only prettier is below 2.8.0 + '@babel/plugin-transform-typescript': '7.23.0', + '@typescript-eslint/parser': '6.7.0', + 'eslint-plugin-storybook': '0.7.0', + }; + return { version: versions[name] } as any; + }); + const service = new ProjectTypeService(pm); + await expect(service.detectLanguage()).resolves.toBe('javascript'); + }); + + it('returns typescript with canary eslint-plugin-storybook versions', async () => { + (pm.getAllDependencies as any) = vi.fn(() => ({ typescript: '^5.0.0' })); + (pm.getModulePackageJSON as any) = vi.fn(async (name: string) => { + const versions: Record = { + typescript: '5.2.0', + prettier: '3.3.0', + '@babel/plugin-transform-typescript': '7.23.0', + '@typescript-eslint/parser': '6.7.0', + 'eslint-plugin-storybook': '0.0.0-pr-34552-sha-a34e9165', + }; + return { version: versions[name] } as any; + }); + const service = new ProjectTypeService(pm); + await expect(service.detectLanguage()).resolves.toBe('typescript'); + }); + }); + + describe('detectIncompatiblePackageVersions', () => { + it('returns empty array when all tooling is compatible', async () => { + (pm.getModulePackageJSON as any) = vi.fn(async (name: string) => { + const versions: Record = { + typescript: '5.2.0', + prettier: '3.3.0', + '@babel/plugin-transform-typescript': '7.23.0', + '@typescript-eslint/parser': '6.7.0', + 'eslint-plugin-storybook': '0.7.0', + }; + return { version: versions[name] } as any; + }); + const service = new ProjectTypeService(pm); + const reasons = await service.detectIncompatiblePackageVersions(); + expect(reasons).toEqual([]); + }); + + it('returns specific reasons for each incompatible package', async () => { + (pm.getModulePackageJSON as any) = vi.fn(async (name: string) => { + const versions: Record = { + typescript: '4.8.4', + prettier: '2.7.1', + '@babel/plugin-transform-typescript': '7.19.0', + '@typescript-eslint/parser': '5.43.0', + 'eslint-plugin-storybook': '0.6.7', + }; + return { version: versions[name] } as any; + }); + const service = new ProjectTypeService(pm); + const reasons = await service.detectIncompatiblePackageVersions(); + expect(reasons).toContainEqual(expect.stringContaining('typescript 4.8.4 is below 4.9.0')); + expect(reasons).toContainEqual(expect.stringContaining('prettier 2.7.1 is below 2.8.0')); + }); + + it('returns only the specific failing package', async () => { + (pm.getModulePackageJSON as any) = vi.fn(async (name: string) => { + const versions: Record = { + typescript: '5.2.0', + prettier: '2.6.2', // only prettier is below 2.8.0 + '@babel/plugin-transform-typescript': '7.23.0', + '@typescript-eslint/parser': '6.7.0', + 'eslint-plugin-storybook': '0.7.0', + }; + return { version: versions[name] } as any; + }); + const service = new ProjectTypeService(pm); + const reasons = await service.detectIncompatiblePackageVersions(); + expect(reasons).toEqual([expect.stringContaining('prettier 2.6.2 is below 2.8.0')]); + }); + + it('treats canary eslint-plugin-storybook versions as compatible', async () => { + (pm.getModulePackageJSON as any) = vi.fn(async (name: string) => { + const versions: Record = { + typescript: '5.2.0', + prettier: '3.3.0', + '@babel/plugin-transform-typescript': '7.23.0', + '@typescript-eslint/parser': '6.7.0', + 'eslint-plugin-storybook': '0.0.0-pr-34552-sha-a34e9165', + }; + return { version: versions[name] } as any; + }); + const service = new ProjectTypeService(pm); + const reasons = await service.detectIncompatiblePackageVersions(); + expect(reasons).toEqual([]); }); }); }); diff --git a/code/lib/create-storybook/src/services/ProjectTypeService.ts b/code/lib/create-storybook/src/services/ProjectTypeService.ts index ea95f94bcdcf..7ab6e303c473 100644 --- a/code/lib/create-storybook/src/services/ProjectTypeService.ts +++ b/code/lib/create-storybook/src/services/ProjectTypeService.ts @@ -213,6 +213,25 @@ export class ProjectTypeService { const isTypescriptDirectDependency = !!this.jsPackageManager.getAllDependencies().typescript; + if (isTypescriptDirectDependency) { + const incompatibleReasons = await this.detectIncompatiblePackageVersions(); + if (incompatibleReasons.length === 0) { + language = SupportedLanguage.TYPESCRIPT; + } + } else { + // No direct dependency on TypeScript, but could be a transitive dependency + // This is eg the case for Nuxt projects, which support a recent version of TypeScript + // Check for tsconfig.json (https://www.typescriptlang.org/docs/handbook/tsconfig-json.html) + if (existsSync('tsconfig.json')) { + language = SupportedLanguage.TYPESCRIPT; + } + } + + return language; + } + + /** Check installed tooling versions for TypeScript compatibility constraints */ + async detectIncompatiblePackageVersions(): Promise { const getModulePackageJSONVersion = async (pkg: string) => { return (await this.jsPackageManager.getModulePackageJSON(pkg))?.version ?? null; }; @@ -238,31 +257,39 @@ export class ProjectTypeService { return semver.satisfies(version, range, { includePrerelease: true }); }; - if (isTypescriptDirectDependency && typescriptVersion) { - if ( - satisfies(typescriptVersion, '>=4.9.0') && - (!prettierVersion || semver.gte(prettierVersion, '2.8.0')) && - (!babelPluginTransformTypescriptVersion || - satisfies(babelPluginTransformTypescriptVersion, '>=7.20.0')) && - (!typescriptEslintParserVersion || satisfies(typescriptEslintParserVersion, '>=5.44.0')) && - (!eslintPluginStorybookVersion || satisfies(eslintPluginStorybookVersion, '>=0.6.8')) - ) { - language = SupportedLanguage.TYPESCRIPT; - } else { - logger.warn( - 'Detected TypeScript < 4.9 or incompatible tooling, populating with JavaScript examples' - ); - } - } else { - // No direct dependency on TypeScript, but could be a transitive dependency - // This is eg the case for Nuxt projects, which support a recent version of TypeScript - // Check for tsconfig.json (https://www.typescriptlang.org/docs/handbook/tsconfig-json.html) - if (existsSync('tsconfig.json')) { - language = SupportedLanguage.TYPESCRIPT; - } + const incompatibleReasons: string[] = []; + + if (typescriptVersion && !satisfies(typescriptVersion, '>=4.9.0')) { + incompatibleReasons.push(`typescript ${typescriptVersion} is below 4.9.0`); + } + if (prettierVersion && !semver.gte(prettierVersion, '2.8.0')) { + incompatibleReasons.push(`prettier ${prettierVersion} is below 2.8.0`); + } + if ( + babelPluginTransformTypescriptVersion && + !satisfies(babelPluginTransformTypescriptVersion, '>=7.20.0') + ) { + incompatibleReasons.push( + `@babel/plugin-transform-typescript ${babelPluginTransformTypescriptVersion} is below 7.20.0` + ); + } + if (typescriptEslintParserVersion && !satisfies(typescriptEslintParserVersion, '>=5.44.0')) { + incompatibleReasons.push( + `@typescript-eslint/parser ${typescriptEslintParserVersion} is below 5.44.0` + ); + } + // Treat Storybook canary/prerelease versions (e.g. 0.0.0-pr-*) as compatible + if ( + eslintPluginStorybookVersion && + !eslintPluginStorybookVersion.startsWith('0.0.0-') && + !satisfies(eslintPluginStorybookVersion, '>=0.6.8') + ) { + incompatibleReasons.push( + `eslint-plugin-storybook ${eslintPluginStorybookVersion} is below 0.6.8` + ); } - return language; + return incompatibleReasons; } private eqMajor(versionRange: string, major: number) { diff --git a/code/lib/create-storybook/templates/react-native/index.js b/code/lib/create-storybook/templates/react-native/index.js index 8f1e5041f067..c225d0d843ef 100644 --- a/code/lib/create-storybook/templates/react-native/index.js +++ b/code/lib/create-storybook/templates/react-native/index.js @@ -1,7 +1,9 @@ import { AppRegistry } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { LiteUI } from '@storybook/react-native-ui-lite'; import { view } from './storybook.requires'; +import { name as appName } from '../app.json'; /** * This file is user-editable. @@ -15,8 +17,9 @@ const StorybookUIRoot = view.getStorybookUI({ getItem: AsyncStorage.getItem, setItem: AsyncStorage.setItem, }, + CustomUIComponent: LiteUI, }); -AppRegistry.registerComponent('main', () => StorybookUIRoot); +AppRegistry.registerComponent(appName, () => StorybookUIRoot); export default StorybookUIRoot; diff --git a/code/package.json b/code/package.json index dad94cfbc0bb..57c170361e9e 100644 --- a/code/package.json +++ b/code/package.json @@ -196,5 +196,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.4.0-alpha.19" } diff --git a/docs/_assets/configure/change-detection-dual-slot.png b/docs/_assets/configure/change-detection-dual-slot.png index f3f31362f7b9..564f8df4c3d8 100644 Binary files a/docs/_assets/configure/change-detection-dual-slot.png and b/docs/_assets/configure/change-detection-dual-slot.png differ diff --git a/docs/_assets/configure/change-detection-filter-menu.png b/docs/_assets/configure/change-detection-filter-menu.png index 0d47a2d9f002..e5c4723c9c32 100644 Binary files a/docs/_assets/configure/change-detection-filter-menu.png and b/docs/_assets/configure/change-detection-filter-menu.png differ diff --git a/docs/_assets/configure/change-detection-full.png b/docs/_assets/configure/change-detection-full.png index 512aadc64726..2d3171f95a8b 100644 Binary files a/docs/_assets/configure/change-detection-full.png and b/docs/_assets/configure/change-detection-full.png differ diff --git a/docs/_assets/configure/change-detection-review-button-active.png b/docs/_assets/configure/change-detection-review-button-active.png new file mode 100644 index 000000000000..8d6769597b6e Binary files /dev/null and b/docs/_assets/configure/change-detection-review-button-active.png differ diff --git a/docs/_assets/sharing/share-menu.png b/docs/_assets/sharing/share-menu.png new file mode 100644 index 000000000000..2aa7327b9d7e Binary files /dev/null and b/docs/_assets/sharing/share-menu.png differ diff --git a/docs/_snippets/ai-setup-output.md b/docs/_snippets/ai-setup-output.md deleted file mode 100644 index 2bae40082f61..000000000000 --- a/docs/_snippets/ai-setup-output.md +++ /dev/null @@ -1,11 +0,0 @@ -```shell renderer="common" packageManager="npm" -npx storybook ai setup --output storybook-setup.md -``` - -```shell renderer="common" packageManager="pnpm" -pnpm exec storybook ai setup --output storybook-setup.md -``` - -```shell renderer="common" packageManager="yarn" -yarn exec storybook ai setup --output storybook-setup.md -``` diff --git a/docs/_snippets/prompt-install-storybook.md b/docs/_snippets/prompt-install-storybook.md new file mode 100644 index 000000000000..dd4cea4b9a28 --- /dev/null +++ b/docs/_snippets/prompt-install-storybook.md @@ -0,0 +1,3 @@ +```shell renderer="common" +Set up Storybook for me with npx storybook@latest init and follow its instructions precisely +``` diff --git a/docs/_snippets/tanstack-react-query-in-story.md b/docs/_snippets/tanstack-react-query-in-story.md index da94db748a2d..8fc590cf4a67 100644 --- a/docs/_snippets/tanstack-react-query-in-story.md +++ b/docs/_snippets/tanstack-react-query-in-story.md @@ -14,15 +14,13 @@ type Story = StoryObj; export const Default: Story = {}; export const LoggedIn: Story = { - loaders: [ - async ({ parameters }) => { - const qc: QueryClient = parameters.tanstack?.router?.context?.queryClient; - qc?.setQueryData(['currentUser'], { - id: 'user-1', - name: 'Ada Lovelace', - }); - }, - ], + beforeEach: async ({ parameters }) => { + const qc: QueryClient = parameters.tanstack?.router?.context?.queryClient; + qc?.setQueryData(['currentUser'], { + id: 'user-1', + name: 'Ada Lovelace', + }); + }, }; ``` @@ -40,15 +38,13 @@ const meta = preview.meta({ export const Default = meta.story(); export const LoggedIn = meta.story({ - loaders: [ - async ({ parameters }) => { - const qc: QueryClient = parameters.tanstack?.router?.context?.queryClient; - qc?.setQueryData(['currentUser'], { - id: 'user-1', - name: 'Ada Lovelace', - }); - }, - ], + beforeEach: async ({ parameters }) => { + const qc: QueryClient = parameters.tanstack?.router?.context?.queryClient; + qc?.setQueryData(['currentUser'], { + id: 'user-1', + name: 'Ada Lovelace', + }); + }, }); ``` diff --git a/docs/_snippets/tanstack-react-query-setup.md b/docs/_snippets/tanstack-react-query-setup.md index 765bbc25bd69..6aeaca45760e 100644 --- a/docs/_snippets/tanstack-react-query-setup.md +++ b/docs/_snippets/tanstack-react-query-setup.md @@ -12,12 +12,10 @@ const queryClient = new QueryClient({ }); export default { - loaders: [ + beforeEach: () => { // 👇 Clear the cache between stories so each story starts fresh - () => { - queryClient.clear(); - }, - ], + queryClient.clear(); + }, parameters: { tanstack: { router: { @@ -52,12 +50,10 @@ const queryClient = new QueryClient({ }); export default definePreview({ - loaders: [ + beforeEach: () => { // 👇 Clear the cache between stories so each story starts fresh - () => { - queryClient.clear(); - }, - ], + queryClient.clear(); + }, parameters: { tanstack: { router: { diff --git a/docs/ai/best-practices.mdx b/docs/ai/best-practices.mdx index 5a9a98a397a1..d2b762eeb189 100644 --- a/docs/ai/best-practices.mdx +++ b/docs/ai/best-practices.mdx @@ -1,7 +1,7 @@ --- title: Best practices for using Storybook with AI sidebar: - order: 2 + order: 3 title: Best practices --- @@ -133,8 +133,8 @@ import { Meta } from '@storybook/addon-docs/blocks'; **More AI resources** +- [Agentic setup](./setup.mdx) - [MCP server overview](./mcp/overview.mdx) - [MCP server API](./mcp/api.mdx) - [Sharing your MCP server](./mcp/sharing.mdx) - [Manifests](./manifests.mdx) -- [Agentic setup](./setup.mdx) diff --git a/docs/ai/index.mdx b/docs/ai/index.mdx index 2e8899484c2a..dbcee0df2f7b 100644 --- a/docs/ai/index.mdx +++ b/docs/ai/index.mdx @@ -7,17 +7,31 @@ sidebar: -While they are in [preview](../releases/features.mdx#preview), Storybook's AI capabilities (specifically, the [manifests](./manifests.mdx) and [MCP server](./mcp/overview.mdx)) are currently only supported for [React](?renderer=react) projects. +Storybook's AI capabilities are currently in [preview](../releases/features.mdx#preview) and only supported for [React](?renderer=react) projects. -Additionally, the API may change in future releases. We welcome feedback and contributions to help improve this feature. +The API may change in future releases. We welcome feedback and contributions to help improve this feature. -With Storybook's AI capabilities, you can leverage the power of AI agents to speed up your development workflow. By connecting your Storybook to an AI agent via the [Storybook MCP server](./mcp/overview.mdx), you can enable your agent to understand your components and documentation, generate stories, run tests, and more. +Storybook's AI capabilities help you get more out of agentic development at every stage, from adding Storybook to a project to working with your components day to day. -## Get started +[Agentic setup](./setup.mdx) configures Storybook for you and writes an initial set of stories for your project. The [Storybook MCP server](./mcp/overview.mdx) gives your agent ongoing access to your component documentation, story generation, and component tests. + +## Set up Storybook with an agent + +[Agentic setup](./setup.mdx) analyzes your project and produces step-by-step instructions that an agent follows to add Storybook, configure the preview, write stories for your components, and verify them. + +To get started, run this prompt in your agent: + + + +For the full walkthrough, see the [agentic setup guide](./setup.mdx). + +## Connect your agent to Storybook + +Once Storybook is set up, connect your agent to the Storybook MCP server so it can read [manifests](./manifests.mdx) of your components and documentation, generate stories, and run tests against your live Storybook. ### 1. Install the addon @@ -68,7 +82,11 @@ Finally, test your agent's access to the MCP server. First, make sure your Story ## Key concepts -Understanding these concepts will help you make the most of Storybook's AI capabilities and guide you in using the MCP server to enhance your development workflow. +Understanding these concepts will help you make the most of Storybook's AI capabilities and guide you in choosing the right tool for each stage of your workflow. + +### Agentic setup + +When you add Storybook to a project using an agent, Storybook generates project-specific instructions tailored to your framework, renderer, builder, language, and addons. An agent can follow those instructions to configure Storybook's preview, support patterns like portals and providers, mock side effects, write stories for your components, add interaction tests, and verify the result. ### Manifests @@ -85,9 +103,9 @@ These manifests are automatically generated and updated as you work on your Stor **More AI resources** +- [Agentic setup](./setup.mdx) - [MCP server overview](./mcp/overview.mdx) - [MCP server API](./mcp/api.mdx) - [Sharing your MCP server](./mcp/sharing.mdx) - [Best practices for using Storybook with AI](./best-practices.mdx) - [Manifests](./manifests.mdx) -- [Agentic setup](./setup.mdx) diff --git a/docs/ai/manifests.mdx b/docs/ai/manifests.mdx index 5cc4e7d18822..48a59b460aed 100644 --- a/docs/ai/manifests.mdx +++ b/docs/ai/manifests.mdx @@ -1,7 +1,7 @@ --- title: Manifests sidebar: - order: 3 + order: 4 --- @@ -294,8 +294,8 @@ import { Meta } from '@storybook/addon-docs/blocks'; **More AI resources** +- [Agentic setup](./setup.mdx) - [MCP server overview](./mcp/overview.mdx) - [MCP server API](./mcp/api.mdx) - [Sharing your MCP server](./mcp/sharing.mdx) - [Best practices for using Storybook with AI](./best-practices.mdx) -- [Agentic setup](./setup.mdx) diff --git a/docs/ai/mcp/api.mdx b/docs/ai/mcp/api.mdx index 7a41c2e123ff..cc33138caccd 100644 --- a/docs/ai/mcp/api.mdx +++ b/docs/ai/mcp/api.mdx @@ -74,8 +74,8 @@ The testing toolset includes the [`run-story-tests`](./overview.mdx#run-story-te **More AI resources** +- [Agentic setup](../setup.mdx) - [MCP server overview](./overview.mdx) - [Sharing your MCP server](./sharing.mdx) - [Best practices for using Storybook with AI](../best-practices.mdx) - [Manifests](../manifests.mdx) -- [Agentic setup](../setup.mdx) diff --git a/docs/ai/mcp/index.mdx b/docs/ai/mcp/index.mdx index 94c3fe0347ee..02b34dc375c2 100644 --- a/docs/ai/mcp/index.mdx +++ b/docs/ai/mcp/index.mdx @@ -2,7 +2,7 @@ title: MCP server hideRendererSelector: true sidebar: - order: 1 + order: 2 --- Storybook's MCP server connects your Storybook to AI agents, enabling them to understand your components and documentation, generate stories, run tests, and more. It follows the [Model Context Protocol](https://modelcontextprotocol.io/) standard so that any MCP-compatible agent can use it. diff --git a/docs/ai/mcp/overview.mdx b/docs/ai/mcp/overview.mdx index 5688ebc5bb4f..a5fbc68fe61c 100644 --- a/docs/ai/mcp/overview.mdx +++ b/docs/ai/mcp/overview.mdx @@ -171,8 +171,8 @@ The docs toolset relies on the [manifests](../manifests.mdx) generated by Storyb **More AI resources** +- [Agentic setup](../setup.mdx) - [MCP server API](./api.mdx) - [Sharing your MCP server](./sharing.mdx) - [Best practices for using Storybook with AI](../best-practices.mdx) - [Manifests](../manifests.mdx) -- [Agentic setup](../setup.mdx) diff --git a/docs/ai/mcp/sharing.mdx b/docs/ai/mcp/sharing.mdx index 822913624dce..59e62984e251 100644 --- a/docs/ai/mcp/sharing.mdx +++ b/docs/ai/mcp/sharing.mdx @@ -71,8 +71,8 @@ export async function handleRequest(request: Request): Promise { **More AI resources** +- [Agentic setup](../setup.mdx) - [MCP server overview](./overview.mdx) - [MCP server API](./api.mdx) - [Best practices for using Storybook with AI](../best-practices.mdx) - [Manifests](../manifests.mdx) -- [Agentic setup](../setup.mdx) diff --git a/docs/ai/setup.mdx b/docs/ai/setup.mdx index c9955e441ad9..b499f5055f95 100644 --- a/docs/ai/setup.mdx +++ b/docs/ai/setup.mdx @@ -1,7 +1,7 @@ --- title: Agentic setup sidebar: - order: 4 + order: 1 --- @@ -14,70 +14,48 @@ The API may change in future releases. We welcome feedback and contributions to -Getting Storybook wired up to an existing application (configuring providers, mocking side effects, and writing the first few real stories) is the kind of repetitive, project-specific work that AI agents are well suited for. The **agentic setup** flow uses the [`storybook ai setup`](../api/cli-options.mdx#ai) command to generate a detailed, project-aware instruction set that an AI agent can follow to make Storybook fully functional in your project. +Configuring Storybook in an existing application is repetitive, project-specific work that AI agents handle well. When you add Storybook to a project using an agent, it analyzes your project (framework, renderer, builder, language, addons) and produces a Markdown guide with [step-by-step instructions](#generated-setup-instructions). By following this guide, your agent will configure your preview file, set up commonly needed mocks, and write stories for components in your codebase. -The command analyzes your Storybook configuration (framework, renderer, builder, language, addons) and produces a Markdown prompt containing step-by-step instructions tailored to your project, covering everything from configuring your preview file to writing and verifying stories. See [what the generated prompt covers](#what-the-generated-prompt-covers) for the full list of steps. +After an agent follows those instructions, you have a working Storybook with stories for your components and a clear path to expanding coverage across your codebase. -## Starting from `storybook init` +## Set up Storybook with an agent -When an agent runs [`storybook init`](../api/cli-options.mdx#init) to add Storybook to a new project, the output instructs the agent to continue with `storybook ai setup`. No extra prompting is needed; the agent will pick up the agentic setup flow automatically. +To set up Storybook in your project, copy/paste this prompt into your agent's chat: -If Storybook is already installed, you can kick off the flow yourself using one of the two approaches below. + -## Agent-initiated setup +The agent first runs [`storybook init`](../api/cli-options.mdx#init) to add Storybook to your project. When init completes, the agent offers to continue with project-specific configuration. If you agree, the agent generates the instructions, follows them step by step, and applies each change directly to your codebase so you can review its work. -In this flow, you ask your agent to run `storybook ai setup`. For example: +### Generated setup instructions -```txt -Use Storybook's agentic setup command to configure Storybook for this project and write some initial stories. -``` - -The agent will run the command from your project root, read the generated prompt from stdout, and follow the steps in order: analyzing your codebase, updating `preview.tsx`, and writing stories. After each story, it runs Vitest to verify that the story renders and fixes any failures before moving on. - -This flow works best when your agent has access to a terminal in your project (most modern coding agents do). No flags or additional configuration is required. The generated instructions are self-contained. - -## User-initiated setup - -If you'd rather drive the process yourself, you can run the command manually and hand the output to any agent, even one that can't execute shell commands. - -1. From your project root, run: - - - - This writes the instructions to `storybook-setup.md` instead of printing them to your terminal. Omit `--output` to print to stdout and pipe it elsewhere. - -2. Open the generated file and paste its contents into your agent's chat, or attach it as context. The prompt is designed to be self-contained: it references your specific `configDir`, framework, and renderer, and links back to the relevant Storybook docs in Markdown form. - -3. Let the agent work through the steps. You can review each change as the agent applies it. - -Use this flow when you want tighter control over what the agent does, when you're working with an agent that doesn't have shell access, or when you want to save the prompt to reuse across projects. - -## What the generated prompt covers - -Regardless of how the flow is initiated, the generated Markdown prompt walks the agent through the same ordered steps: +The project-specific instructions cover the following steps: 1. **Analyze the codebase:** read providers, global CSS, portals, and data-fetching patterns. -2. **Configure the [preview](../configure/story-rendering.mdx):** set up [decorators](../writing-stories/decorators.mdx), global styles, and any framework-level providers in `preview.tsx`. +2. **Configure the [preview](../configure/story-rendering.mdx):** set up [decorators](../writing-stories/decorators.mdx), [global styles](../configure/styling-and-css.mdx), and any framework-level providers in `preview.tsx`. 3. **Support portals:** ensure portal roots exist in the Storybook preview DOM. 4. **Mock side effects:** intercept [network requests](../writing-stories/mocking-data-and-modules/mocking-network-requests.mdx) (via MSW), storage, timers, and navigation at the preview level rather than per-story. -5. **Write [stories](../writing-stories/index.mdx):** copy real usage patterns from the app, [tagging](../writing-stories/tags.mdx) generated stories with `ai-generated` and `needs-work` so you can review them later. -6. **Add [play functions](../writing-stories/play-function.mdx):** implement interaction tests for the most important flows. +5. **Write [stories](../writing-stories/index.mdx):** add stories from up to 10 components, from simple to complex, [tagging](../writing-stories/tags.mdx) them as `ai-generated` for your review. +6. **Add [play functions](../writing-stories/play-function.mdx):** implement [interaction tests](../writing-tests/interaction-testing.mdx) for the most important flows. 7. **Cover additional patterns:** expand coverage across the components the agent has already touched. 8. **Verify:** run [Vitest](../writing-tests/integrations/vitest-addon/index.mdx) against every new story to confirm it renders, and run the type checker. - - - -The command snapshots your `preview` file so that subsequent runs of `storybook dev`, `storybook build`, and `storybook doctor` can detect progress the agent made and report it via telemetry. If you'd prefer not to share this data, pass `--disable-telemetry` (see [telemetry](../configure/telemetry.mdx)). - - +9. **Install useful addons:** add and configure addons like [MCP](./mcp/overview.mdx) to help you (and your agent) get the most out of Storybook. ## Next steps Once the agent has completed the setup: +- [Run your new Storybook](../get-started/install.mdx#start-storybook) and review the generated configuration files ([main](../configure/index.mdx#configure-your-storybook-project) and [preview](../configure/index.mdx#configure-story-rendering), most importantly). +- Review the stories tagged `ai-generated`, and remove the tag once you've validated each one. - Connect the [Storybook MCP server](./mcp/overview.mdx) to your agent so it can continue reading manifests, generating stories, and running tests against your live Storybook. -- Review the stories tagged `ai-generated` and `needs-work`, and remove those tags once you've validated each one. - Follow the [best practices](./best-practices.mdx) to make your stories and documentation maximally useful to both humans and agents. {/* End supported renderers */} + +**More AI resources** + +- [MCP server overview](./mcp/overview.mdx) +- [MCP server API](./mcp/api.mdx) +- [Sharing your MCP server](./mcp/sharing.mdx) +- [Best practices for using Storybook with AI](./best-practices.mdx) +- [Manifests](./manifests.mdx) diff --git a/docs/api/cli-options.mdx b/docs/api/cli-options.mdx index 64d1830b5421..394c5fef0310 100644 --- a/docs/api/cli-options.mdx +++ b/docs/api/cli-options.mdx @@ -321,7 +321,7 @@ Options include: | `--loglevel [level]` | Controls level of logging.
Available options: `trace`, `debug`, `info` (default), `warn`, `error`, `silent`.
`storybook ai setup --loglevel warn` | | `--logfile [path]` | Write debug logs to a file.
`storybook ai setup --logfile ./sb.log` | -When run without `--output`, the generated prompt is printed to stdout. This is how AI agents typically consume it, by running the command directly and reading the result. When run with `--output`, the prompt is written to the given file path so you can paste or attach it to an agent that doesn't have shell access. See the [agentic setup](../ai/setup.mdx#user-initiated-setup) docs for details on both flows. +When run without `--output`, the generated prompt is printed to stdout. This is how AI agents typically consume it, by running the command directly and reading the result. When run with `--output`, the prompt is written to the given file path so you can paste or attach it to an agent that doesn't have shell access. ### `info` diff --git a/docs/configure/user-interface/change-detection.mdx b/docs/configure/user-interface/change-detection.mdx index 9feaeb90c70c..89d480d4c2fc 100644 --- a/docs/configure/user-interface/change-detection.mdx +++ b/docs/configure/user-interface/change-detection.mdx @@ -5,9 +5,9 @@ sidebar: order: 3 --- -During development, Storybook monitors your git working tree and the builder's module graph to identify which stories are related to your changes. A **Review** button at the top of the sidebar shows live counts of new and modified stories and lets you filter the tree to just those entries with one click. Subtle status icons appear next to **new** stories so you can spot them at a glance. +During development, Storybook monitors your git working tree and the builder's module graph to identify which stories are related to your changes. A Review button at the top of the sidebar announces new and modified stories and lets you filter the tree to just those entries with one click. Status icons appear next to new stories and modified components so you can spot them at a glance. -![Sidebar showing change detection status icons](../../_assets/configure/change-detection-full.png) +![Sidebar showing review button and new story status icons](../../_assets/configure/change-detection-full.png) ## Requirements @@ -24,37 +24,35 @@ If change detection status indicators never appear in your sidebar, check that b When a change is detected, Storybook shows one of the following status icons next to the relevant stories in the sidebar: -| Icon | Status | Definition | -| --------------- | ------------ | ------------------------------------------------------------------------------------- | -| ✦ Sparkle | **new** | The story file is untracked or newly added in git. | -| ● Filled circle | **modified** | The story's own file, or a file it directly imports, was changed. | -| | **related** | A file further up the story's dependency chain was changed (a transitive dependency). | +| Icon | Status | Definition | +| ------------------------- | ------------ | ------------------------------------------------------------------------------------- | +| ✦ Sparkle | **new** | The story file is untracked or newly added in git. | +| ● Filled circle | **modified** | The story's own file, or a file it directly imports, was changed. | +| (no icon) | **related** | A file further up the story's dependency chain was changed (a transitive dependency). | When multiple statuses apply to the same story, the highest priority wins: **new** > **modified** > **related**. - - -**Why are modified and related quiet by default?** - -Heuristic-based statuses can over-report on real-world repositories — a route file that imports many components, or a utility that's imported broadly, can mark a large number of stories as modified or related even when the actual code change doesn't affect them. Storybook keeps these statuses available behind explicit filters so the sidebar stays calm by default while still giving you the information when you ask for it. - - - Change detection statuses are displayed alongside [test statuses](../../writing-tests/integrations/vitest-addon/index.mdx#storybook-ui) in the sidebar. ![Sidebar showing change detection and test status icons](../../_assets/configure/change-detection-dual-slot.png) ## Reviewing changes -A **Review** button appears between the search bar and the story tree whenever you have at least one new or modified story. The button shows live counts and toggles both the **new** and **modified** filters together with one click: +A **Review** button appears between the search bar and the story tree whenever you have at least one new or modified story. The button toggles both the **new** and **modified** filters together with one click. -Clicking the button enables the `new` and `modified` include filters so the sidebar shows just those stories. Clicking it again disables those two filters while preserving any other active filter selections. +![Sidebar with active review button](../../_assets/configure/change-detection-review-button-active.png) -The button hides when search results are shown so it doesn't compete with them. + + +**Why are change detection filters off by default?** + +The heuristics that Storybook uses to determine modified and related stories are designed to be fast and work without any configuration, but they aren't perfect. They can produce false positives (marking stories as modified or related when they aren't), which can be distracting if you have a large repository with many shared dependencies. For example, if you change a widely used utility function, Storybook might mark dozens of stories as related even if the change doesn't actually affect them. To avoid overwhelming you with status icons, Storybook keeps the change detection filters off by default, so you only see these statuses when you choose to review your changes. + + ## Filtering -For more granular control — for example, to inspect only **related** stories — open the filter menu next to the search bar and check or uncheck the individual statuses. Modified branch icons appear in the tree only while the **modified** filter is checked. +For more granular control (e.g., to view only new stories) open the filter menu next to the search bar and check or uncheck the individual statuses. ![Filter menu showing change detection status options](../../_assets/configure/change-detection-filter-menu.png) diff --git a/docs/contribute/code.mdx b/docs/contribute/code.mdx index f35b9993cfab..80d9877a34b4 100644 --- a/docs/contribute/code.mdx +++ b/docs/contribute/code.mdx @@ -102,7 +102,7 @@ Otherwise, if it affects the `Manager` (the outermost Storybook `iframe` where t ![Storybook manager preview](../_assets/addons/manager-preview.png) -The `yarn build` commands accepts arguments to help speed up your development workflow: +The `yarn build` command accepts arguments to help speed up your development workflow: - `--all` will cause all packages to be built - `--watch` will enable watch mode (and skip the watch mode prompt) @@ -163,11 +163,11 @@ yarn test -Storybook relies on [Vitest](https://vitest.dev/) as part of it's testing suite. During the test run, if you spot that snapshot tests are failing, re-run the command with the `-u` flag to update them. +Storybook relies on [Vitest](https://vitest.dev/) as part of its testing suite. During the test run, if you spot that snapshot tests are failing, re-run the command with the `-u` flag to update them. -Doing this prevents last-minute bugs and is a great way to merge your contribution faster once you submit your pull request. Failing to do so will lead to one of the maintainers mark the pull request with the **Work in Progress** label until all tests pass. +Doing this prevents last-minute bugs and is a great way to merge your contribution faster once you submit your pull request. Failing to do so will lead to one of the maintainers marking the pull request with the **Work in Progress** label until all tests pass. ### Target `next` branch diff --git a/docs/get-started/frameworks/tanstack-react.mdx b/docs/get-started/frameworks/tanstack-react.mdx index 43e98aca8eca..90847a6d364c 100644 --- a/docs/get-started/frameworks/tanstack-react.mdx +++ b/docs/get-started/frameworks/tanstack-react.mdx @@ -179,13 +179,13 @@ You can use this framework together with [TanStack Query](https://tanstack.com/q #### Project setup -TanStack Query is not automatically set up. The recommended approach is to create a single [`QueryClient`](https://tanstack.com/query/latest/docs/reference/QueryClient) in your preview file, clear it between stories via [`loaders`](../../writing-stories/loaders.mdx), and share the same instance through both `parameters.tanstack.router.context` and a `QueryClientProvider` decorator. +TanStack Query is not automatically set up. The recommended approach is to create a single [`QueryClient`](https://tanstack.com/query/latest/docs/reference/QueryClient) in your preview file, clear it between stories via [`beforeEach`](../../writing-tests/interaction-testing.mdx#beforeeach), and share the same instance through both `parameters.tanstack.router.context` and a `QueryClientProvider` decorator. #### Seeding query data per story -In individual stories, use [`loaders`](../../writing-stories/loaders.mdx) to call [`setQueryData`](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetquerydata) on the shared `QueryClient` before the component renders. Access it from `parameters.tanstack.router.context`: +In individual stories, use [`beforeEach`](../../writing-tests/interaction-testing.mdx#beforeeach) to call [`setQueryData`](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetquerydata) on the shared `QueryClient` before the component renders. Access it from `parameters.tanstack.router.context`: diff --git a/docs/get-started/install.mdx b/docs/get-started/install.mdx index 2fe7081c2e35..1b361a944364 100644 --- a/docs/get-started/install.mdx +++ b/docs/get-started/install.mdx @@ -9,6 +9,20 @@ Use the Storybook CLI to install it in a single command. Run this inside your pr + + +Ask your AI agent to [set up Storybook for you](../ai/setup.mdx). + + + Storybook will look into your project's dependencies during its install process and provide you with the best configuration available. diff --git a/docs/get-started/whats-a-story.mdx b/docs/get-started/whats-a-story.mdx index 1d0b7aa8ab5b..afa370a1607e 100644 --- a/docs/get-started/whats-a-story.mdx +++ b/docs/get-started/whats-a-story.mdx @@ -85,7 +85,7 @@ Of course, you can always update the story's code directly too: