diff --git a/code/addons/review/build-config.ts b/code/addons/review/build-config.ts index 22ccaa8e3bd6..fed4597b0146 100644 --- a/code/addons/review/build-config.ts +++ b/code/addons/review/build-config.ts @@ -13,13 +13,6 @@ const config: BuildEntries = { dts: false, }, ], - node: [ - { - exportEntries: ['./preset'], - entryPoint: './src/preset.ts', - dts: false, - }, - ], }, }; diff --git a/code/addons/review/package.json b/code/addons/review/package.json index 6dc62de62c69..78560f5fef7c 100644 --- a/code/addons/review/package.json +++ b/code/addons/review/package.json @@ -31,8 +31,7 @@ "default": "./dist/index.js" }, "./manager": "./dist/manager.js", - "./package.json": "./package.json", - "./preset": "./dist/preset.js" + "./package.json": "./package.json" }, "files": [ "dist/**/*", diff --git a/code/addons/review/preset.js b/code/addons/review/preset.js deleted file mode 100644 index 4bd63d324002..000000000000 --- a/code/addons/review/preset.js +++ /dev/null @@ -1 +0,0 @@ -export * from './dist/preset.js'; diff --git a/code/addons/review/src/constants.ts b/code/addons/review/src/constants.ts index ed817759f417..552bf4f077ec 100644 --- a/code/addons/review/src/constants.ts +++ b/code/addons/review/src/constants.ts @@ -1,36 +1,17 @@ -// Core-owned namespace for the review ingest contract: channel events, session -// keys, status type id, and page/route ids all live under `storybook/review/*`. -// The external `@storybook/addon-mcp` producer must emit the same namespace. -export const ADDON_ID = 'storybook/review'; -export const PAGE_ID = `${ADDON_ID}/page`; +// The channel events are the core-owned ingest contract; re-export the single +// source so the manager and the external `@storybook/addon-mcp` producer agree. +export { REVIEW_EVENTS as EVENTS, REVIEW_NAMESPACE as ADDON_ID } from 'storybook/internal/types'; + +import { REVIEW_NAMESPACE } from 'storybook/internal/types'; + +export const PAGE_ID = `${REVIEW_NAMESPACE}/page`; export const REVIEW_CHANGES_URL = '/review/'; // sessionStorage key for the canvas search to return to when leaving review // mode (both summary back-to-Storybook and dismiss). Captured while browsing // stories/docs outside review mode, so it points at the pre-review canvas. -export const PRE_REVIEW_RETURN_KEY = `${ADDON_ID}/pre-review-return`; +export const PRE_REVIEW_RETURN_KEY = `${REVIEW_NAMESPACE}/pre-review-return`; // sessionStorage marker deduplicating the one-time auto-enter on first landing // on the review summary. Reset on dismiss and when a new review payload arrives. -export const AUTO_ENTERED_SESSION_KEY = `${ADDON_ID}/auto-entered`; - -// `@storybook/addon-mcp` display-review tool call emits this event with the raw agent payload. -const PUSH_REVIEW = `${ADDON_ID}/push-review`; -// Display agent review in the UI -const DISPLAY_REVIEW = `${ADDON_ID}/display-review`; -// Requests for the cached state of the agent review -const REQUEST_REVIEW = `${ADDON_ID}/request-review`; -// Server signals that a source file changed after the cached review was created, -// so the open review page should surface a "may be stale" banner. -const REVIEW_STALE = `${ADDON_ID}/review-stale`; -const DISMISS_REVIEW = `${ADDON_ID}/dismiss-review`; -const REVIEW_DISMISSED = `${ADDON_ID}/review-dismissed`; - -export const EVENTS = { - PUSH_REVIEW, - DISPLAY_REVIEW, - REQUEST_REVIEW, - REVIEW_STALE, - DISMISS_REVIEW, - REVIEW_DISMISSED, -}; +export const AUTO_ENTERED_SESSION_KEY = `${REVIEW_NAMESPACE}/auto-entered`; diff --git a/code/addons/review/src/preset.ts b/code/addons/review/src/preset.ts deleted file mode 100644 index 8b1ed3e874c2..000000000000 --- a/code/addons/review/src/preset.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { Channel } from 'storybook/internal/channels'; -import type { Options } from 'storybook/internal/types'; - -import type { ModuleGraphService } from 'storybook/internal/core-server'; - -import { EVENTS } from './constants.ts'; -import type { ReviewState } from './review-state.ts'; - -/** - * Window after a review's `createdAt` during which graph changes are ignored. - * Absorbs the agent's own edits (which precede the display-review call) whose - * file-system events may land a few milliseconds after the review is cached, - * preventing a freshly-pushed review from being marked stale immediately. - */ -const STALE_GRACE_MS = 1000; - -type SubscribeToModuleGraphChanges = (onChange: () => void) => () => void; - -/** - * Default subscription to the `core/module-graph` open service. The review goes - * stale when any file in the story module graph changes (the service's revision - * only advances for in-graph changes, so unrelated file edits never trip it). - * The service is imported lazily so merely loading this preset (e.g. in unit - * tests) does not pull in core-server; if the service is unavailable (e.g. a - * builder without module-graph support), staleness simply never triggers. - */ -const defaultSubscribeToModuleGraphChanges: SubscribeToModuleGraphChanges = (onChange) => { - let unsubscribe: () => void = () => {}; - let cancelled = false; - void import('storybook/internal/core-server') - .then(({ getService }) => { - if (cancelled) { - return; - } - const service = getService('core/module-graph'); - // Omit the input to watch the entire graph. The initial emission carries - // revision 0 (or the current revision at subscribe time); only subsequent - // advances represent a change after the review was cached. - unsubscribe = service.queries.getGraphRevision.subscribe(undefined, (revision) => { - if (revision > 0) { - onChange(); - } - }); - }) - .catch(() => { - // Module graph unavailable (e.g. builder without support); no staleness. - }); - return () => { - cancelled = true; - unsubscribe(); - }; -}; - -// Server-side cache for the agent-pushed review. Storybook's dev server is -// long-lived; this single slot survives across reconnecting browser tabs and -// is what REQUEST_REVIEW replays. It is intentionally not persisted to disk — -// a dev-server restart wipes the slate. -let cached: ReviewState | undefined; - -/** Test-only: reset the module-level cache between cases. */ -export function __resetCache(): void { - cached = undefined; -} - -function prepareReview(payload: ReviewState): ReviewState { - // Staleness is server-authoritative (set by the file-watch handler), so a - // fresh push must never inherit a stale flag from the agent payload. - const { stale: _untrustedStale, ...rest } = payload; - return { - ...rest, - // Server-side timestamp is authoritative for "Created x minutes ago". - createdAt: Date.now(), - }; -} - -export interface ServerChannelOptions { - /** Override the module-graph-change subscription. Used by tests. */ - subscribeToModuleGraphChanges?: SubscribeToModuleGraphChanges; -} - -/** - * Storybook's preset hook that hands us the long-lived dev-server channel. - * - * Responsibilities: - * - PUSH_REVIEW (from @storybook/addon-mcp): stamp the server createdAt, - * cache, broadcast as DISPLAY_REVIEW so any open tab updates. - * - REQUEST_REVIEW (from a tab that just mounted): re-broadcast the cached - * payload as DISPLAY_REVIEW so the late tab catches up. - */ -export const experimental_serverChannel = async ( - channel: Channel, - _options: Options, - serverOptions: ServerChannelOptions = {} -) => { - const subscribeToModuleGraphChanges = - serverOptions.subscribeToModuleGraphChanges ?? defaultSubscribeToModuleGraphChanges; - - channel.on(EVENTS.PUSH_REVIEW, (payload: ReviewState) => { - // A fresh review starts non-stale; its new createdAt re-anchors staleness. - cached = prepareReview(payload); - channel.emit(EVENTS.DISPLAY_REVIEW, cached); - }); - - channel.on(EVENTS.REQUEST_REVIEW, () => { - if (cached) { - channel.emit(EVENTS.DISPLAY_REVIEW, cached); - } - }); - - channel.on(EVENTS.DISMISS_REVIEW, (returnSearch?: string | null) => { - cached = undefined; - channel.emit(EVENTS.REVIEW_DISMISSED, returnSearch ?? null); - }); - - // Mark the cached review stale on the first module-graph change that lands - // after its createdAt (past the grace window). Staleness rides on the cached - // state so REQUEST_REVIEW replays it to tabs that open after the change. - subscribeToModuleGraphChanges(() => { - if (!cached || cached.stale || cached.createdAt === undefined) { - return; - } - if (Date.now() < cached.createdAt + STALE_GRACE_MS) { - return; - } - cached = { ...cached, stale: true }; - channel.emit(EVENTS.REVIEW_STALE); - }); - - return channel; -}; diff --git a/code/addons/review/src/review-state.ts b/code/addons/review/src/review-state.ts index 3ed291de5ac5..1f5c43633ca3 100644 --- a/code/addons/review/src/review-state.ts +++ b/code/addons/review/src/review-state.ts @@ -1,40 +1,3 @@ -/** - * The review payload an agent pushes via the `display-review` MCP tool. - * - * Flow: - * MCP `display-review` tool → emit PUSH_REVIEW on the Storybook channel - * → this addon's server preset stamps `createdAt` and caches it - * → emits DISPLAY_REVIEW to all open tabs (or replays on REQUEST_REVIEW). - * - * This mirrors the canonical valibot schema in `@storybook/addon-mcp` → - * `tools/display-review.ts`. This side only renders the data — it does - * not validate — so it needs the type, not the validator. Keep `title` / - * `description` / `collections` in sync with that schema. - */ - -export type CollectionKind = 'atomic' | 'consumer' | 'transitive' | 'catch-all'; - -export interface ReviewCollection { - title: string; - rationale: string; - storyIds: string[]; - kind?: CollectionKind; -} - -export interface ReviewState { - title: string; - description: string; - collections: ReviewCollection[]; - changedFiles?: string[]; - /** - * Server-side creation timestamp (unix ms) assigned when PUSH_REVIEW is - * received; used for live "Created x minutes ago" UI in the summary. - */ - createdAt?: number; - /** - * Set server-side once a watched source file changes after `createdAt`. - * Drives the "this review may be stale" banner. Persisted on the cached - * review so REQUEST_REVIEW replays it to late/refreshed tabs. - */ - stale?: boolean; -} +// The review payload type is the core-owned ingest contract. Re-exported here so +// the addon's manager code keeps importing it from a local path. +export type { CollectionKind, ReviewCollection, ReviewState } from 'storybook/internal/types'; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index b6913c9e3156..7b603ca19dca 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -43,6 +43,7 @@ import { initCreateNewStoryChannel } from '../server-channel/create-new-story-ch import { initFileSearchChannel } from '../server-channel/file-search-channel.ts'; import { initGhostStoriesChannel } from '../server-channel/ghost-stories-channel.ts'; import { initOpenInEditorChannel } from '../server-channel/open-in-editor-channel.ts'; +import { initReviewChannel } from '../server-channel/review-channel.ts'; import { initTelemetryChannel } from '../server-channel/telemetry-channel.ts'; import { initializeChecklist } from '../utils/checklist.ts'; import { defaultFavicon, defaultStaticDirs } from '../utils/constants.ts'; @@ -289,6 +290,7 @@ export const experimental_serverChannel = async ( initCreateNewStoryChannel(channel, options); initGhostStoriesChannel(channel, options); initOpenInEditorChannel(channel); + initReviewChannel(channel); initTelemetryChannel(channel); return channel; diff --git a/code/addons/review/src/preset.test.ts b/code/core/src/core-server/server-channel/review-channel.test.ts similarity index 64% rename from code/addons/review/src/preset.test.ts rename to code/core/src/core-server/server-channel/review-channel.test.ts index 536ba1942720..e0e8aae0f4c4 100644 --- a/code/addons/review/src/preset.test.ts +++ b/code/core/src/core-server/server-channel/review-channel.test.ts @@ -1,11 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Channel } from 'storybook/internal/channels'; -import type { Options } from 'storybook/internal/types'; -import { EVENTS } from './constants.ts'; -import type { ReviewState } from './review-state.ts'; -import { __resetCache, experimental_serverChannel } from './preset.ts'; +import { REVIEW_EVENTS } from '../../shared/review/events.ts'; +import type { ReviewState } from '../../shared/review/review-state.ts'; +import { initReviewChannel } from './review-channel.ts'; function createMockSubscribe() { let captured: (() => void) | undefined; @@ -60,11 +59,10 @@ const sampleReview: ReviewState = { ], }; -describe('addon-review experimental_serverChannel', () => { +describe('initReviewChannel', () => { const NOW = 1_700_000_000_000; beforeEach(() => { - __resetCache(); vi.spyOn(Date, 'now').mockReturnValue(NOW); }); @@ -75,12 +73,12 @@ describe('addon-review experimental_serverChannel', () => { it('on PUSH_REVIEW, stamps createdAt, caches, and broadcasts DISPLAY_REVIEW', async () => { const { channel, emitted } = createMockChannel(); - await experimental_serverChannel(channel, {} as Options, {}); - await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + initReviewChannel(channel); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); expect(emitted).toEqual([ { - event: EVENTS.DISPLAY_REVIEW, + event: REVIEW_EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW }, }, ]); @@ -90,12 +88,12 @@ describe('addon-review experimental_serverChannel', () => { const { channel, emitted } = createMockChannel(); const payloadWithStale: ReviewState = { ...sampleReview, stale: true }; - await experimental_serverChannel(channel, {} as Options, {}); - await (channel as any).fire(EVENTS.PUSH_REVIEW, payloadWithStale); + initReviewChannel(channel); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, payloadWithStale); expect(emitted).toEqual([ { - event: EVENTS.DISPLAY_REVIEW, + event: REVIEW_EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW }, }, ]); @@ -105,8 +103,8 @@ describe('addon-review experimental_serverChannel', () => { it('on REQUEST_REVIEW with no cached state, emits nothing', async () => { const { channel, emitted } = createMockChannel(); - await experimental_serverChannel(channel, {} as Options, {}); - await (channel as any).fire(EVENTS.REQUEST_REVIEW); + initReviewChannel(channel); + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); expect(emitted).toEqual([]); }); @@ -114,60 +112,60 @@ describe('addon-review experimental_serverChannel', () => { it('on REQUEST_REVIEW after a PUSH_REVIEW, replays the cached payload', async () => { const { channel, emitted } = createMockChannel(); - await experimental_serverChannel(channel, {} as Options, {}); - await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + initReviewChannel(channel); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); emitted.length = 0; - await (channel as any).fire(EVENTS.REQUEST_REVIEW); + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); expect(emitted).toEqual([ - { event: EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW } }, + { event: REVIEW_EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW } }, ]); }); it('registers exactly one listener per cross-repo event', async () => { const { channel } = createMockChannel(); - await experimental_serverChannel(channel, {} as Options, { + initReviewChannel(channel, { subscribeToModuleGraphChanges: vi.fn(() => () => {}), }); - expect(channel.on).toHaveBeenCalledWith(EVENTS.PUSH_REVIEW, expect.any(Function)); - expect(channel.on).toHaveBeenCalledWith(EVENTS.REQUEST_REVIEW, expect.any(Function)); - expect(channel.on).toHaveBeenCalledWith(EVENTS.DISMISS_REVIEW, expect.any(Function)); + expect(channel.on).toHaveBeenCalledWith(REVIEW_EVENTS.PUSH_REVIEW, expect.any(Function)); + expect(channel.on).toHaveBeenCalledWith(REVIEW_EVENTS.REQUEST_REVIEW, expect.any(Function)); + expect(channel.on).toHaveBeenCalledWith(REVIEW_EVENTS.DISMISS_REVIEW, expect.any(Function)); expect(channel.on).toHaveBeenCalledTimes(3); }); it('on DISMISS_REVIEW, clears cache and emits REVIEW_DISMISSED with return search', async () => { const { channel, emitted } = createMockChannel(); - await experimental_serverChannel(channel, {} as Options, {}); - await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + initReviewChannel(channel); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); emitted.length = 0; - await (channel as any).fire(EVENTS.DISMISS_REVIEW, '?path=/story/foo'); + await (channel as any).fire(REVIEW_EVENTS.DISMISS_REVIEW, '?path=/story/foo'); - expect(emitted).toEqual([{ event: EVENTS.REVIEW_DISMISSED, payload: '?path=/story/foo' }]); + expect(emitted).toEqual([ + { event: REVIEW_EVENTS.REVIEW_DISMISSED, payload: '?path=/story/foo' }, + ]); emitted.length = 0; - await (channel as any).fire(EVENTS.REQUEST_REVIEW); + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); expect(emitted).toEqual([]); }); describe('staleness', () => { - const setup = async () => { + const setup = () => { const { channel, emitted } = createMockChannel(); const { subscribeToModuleGraphChanges, fireChange } = createMockSubscribe(); - await experimental_serverChannel(channel, {} as Options, { - subscribeToModuleGraphChanges, - }); + initReviewChannel(channel, { subscribeToModuleGraphChanges }); return { channel, emitted, fireChange }; }; const staleOf = (emitted: Array<{ event: string; payload: unknown }>) => - emitted.filter((e) => e.event === EVENTS.REVIEW_STALE); + emitted.filter((e) => e.event === REVIEW_EVENTS.REVIEW_STALE); it('marks the cached review stale and emits REVIEW_STALE after the grace window', async () => { - const { channel, emitted, fireChange } = await setup(); - await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + const { channel, emitted, fireChange } = setup(); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); // Past the grace window relative to createdAt (NOW). vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000); @@ -177,10 +175,10 @@ describe('addon-review experimental_serverChannel', () => { // Replay to a late tab carries the staleness on the cached state. emitted.length = 0; - await (channel as any).fire(EVENTS.REQUEST_REVIEW); + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); expect(emitted).toEqual([ { - event: EVENTS.DISPLAY_REVIEW, + event: REVIEW_EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW, @@ -191,20 +189,20 @@ describe('addon-review experimental_serverChannel', () => { }); it('ignores source changes within the grace window', async () => { - const { channel, emitted, fireChange } = await setup(); - await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + const { channel, emitted, fireChange } = setup(); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); // Date.now is still NOW (mocked in beforeEach) → within grace. fireChange(); expect(staleOf(emitted)).toHaveLength(0); emitted.length = 0; - await (channel as any).fire(EVENTS.REQUEST_REVIEW); + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); expect((emitted[0].payload as ReviewState).stale).toBeUndefined(); }); - it('ignores source changes when no review is cached', async () => { - const { emitted, fireChange } = await setup(); + it('ignores source changes when no review is cached', () => { + const { emitted, fireChange } = setup(); vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000); fireChange(); @@ -213,8 +211,8 @@ describe('addon-review experimental_serverChannel', () => { }); it('emits REVIEW_STALE only once across multiple changes', async () => { - const { channel, emitted, fireChange } = await setup(); - await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + const { channel, emitted, fireChange } = setup(); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000); fireChange(); @@ -225,17 +223,17 @@ describe('addon-review experimental_serverChannel', () => { }); it('resets staleness when a new review is pushed', async () => { - const { channel, emitted, fireChange } = await setup(); - await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + const { channel, emitted, fireChange } = setup(); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000); fireChange(); expect(staleOf(emitted)).toHaveLength(1); // A fresh push re-anchors createdAt and clears stale. - await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); emitted.length = 0; - await (channel as any).fire(EVENTS.REQUEST_REVIEW); + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); expect((emitted[0].payload as ReviewState).stale).toBeUndefined(); }); }); diff --git a/code/core/src/core-server/server-channel/review-channel.ts b/code/core/src/core-server/server-channel/review-channel.ts new file mode 100644 index 000000000000..4415f60f7375 --- /dev/null +++ b/code/core/src/core-server/server-channel/review-channel.ts @@ -0,0 +1,109 @@ +import type { Channel } from 'storybook/internal/channels'; + +import { getService } from '../../shared/open-service/server.ts'; +import type { ModuleGraphService } from '../../shared/open-service/services/module-graph/definition.ts'; +import { REVIEW_EVENTS } from '../../shared/review/events.ts'; +import type { ReviewState } from '../../shared/review/review-state.ts'; + +/** + * Window after a review's `createdAt` during which graph changes are ignored. + * Absorbs the agent's own edits (which precede the display-review call) whose + * file-system events may land a few milliseconds after the review is cached, + * preventing a freshly-pushed review from being marked stale immediately. + */ +const STALE_GRACE_MS = 1000; + +type SubscribeToModuleGraphChanges = (onChange: () => void) => () => void; + +/** + * Default subscription to the `core/module-graph` open service. The review goes + * stale when any file in the story module graph changes (the service's revision + * only advances for in-graph changes, so unrelated file edits never trip it). + * The `services` preset registers the service before `experimental_serverChannel` + * runs, so the lookup succeeds synchronously here; if it's unavailable (e.g. a + * builder without module-graph support), staleness simply never triggers. + */ +const defaultSubscribeToModuleGraphChanges: SubscribeToModuleGraphChanges = (onChange) => { + try { + const service = getService('core/module-graph'); + // Omit the input to watch the entire graph. The initial emission carries + // revision 0 (or the current revision at subscribe time); only subsequent + // advances represent a change after the review was cached. + return service.queries.getGraphRevision.subscribe(undefined, (revision) => { + if (revision > 0) { + onChange(); + } + }); + } catch { + // Module graph unavailable (e.g. builder without support); no staleness. + return () => {}; + } +}; + +export interface ReviewChannelOptions { + /** Override the module-graph-change subscription. Used by tests. */ + subscribeToModuleGraphChanges?: SubscribeToModuleGraphChanges; +} + +function prepareReview(payload: ReviewState): ReviewState { + // Staleness is server-authoritative (set by the file-watch handler), so a + // fresh push must never inherit a stale flag from the agent payload. + const { stale: _untrustedStale, ...rest } = payload; + return { + ...rest, + // Server-side timestamp is authoritative for "Created x minutes ago". + createdAt: Date.now(), + }; +} + +/** + * Owns the server-side review cache and staleness tracking. + * + * - PUSH_REVIEW (from `@storybook/addon-mcp`): stamp the server createdAt, + * cache, broadcast as DISPLAY_REVIEW so any open tab updates. + * - REQUEST_REVIEW (from a tab that just mounted): re-broadcast the cached + * payload as DISPLAY_REVIEW so the late tab catches up. + * - DISMISS_REVIEW: clear the cache and broadcast REVIEW_DISMISSED. + * + * The cache is a single in-memory slot scoped to this dev-server channel; it is + * intentionally not persisted, so a restart wipes the slate. + */ +export function initReviewChannel(channel: Channel, options: ReviewChannelOptions = {}) { + const subscribeToModuleGraphChanges = + options.subscribeToModuleGraphChanges ?? defaultSubscribeToModuleGraphChanges; + + let cached: ReviewState | undefined; + + channel.on(REVIEW_EVENTS.PUSH_REVIEW, (payload: ReviewState) => { + // A fresh review starts non-stale; its new createdAt re-anchors staleness. + cached = prepareReview(payload); + channel.emit(REVIEW_EVENTS.DISPLAY_REVIEW, cached); + }); + + channel.on(REVIEW_EVENTS.REQUEST_REVIEW, () => { + if (cached) { + channel.emit(REVIEW_EVENTS.DISPLAY_REVIEW, cached); + } + }); + + channel.on(REVIEW_EVENTS.DISMISS_REVIEW, (returnSearch?: string | null) => { + cached = undefined; + channel.emit(REVIEW_EVENTS.REVIEW_DISMISSED, returnSearch ?? null); + }); + + // Mark the cached review stale on the first module-graph change that lands + // after its createdAt (past the grace window). Staleness rides on the cached + // state so REQUEST_REVIEW replays it to tabs that open after the change. + subscribeToModuleGraphChanges(() => { + if (!cached || cached.stale || cached.createdAt === undefined) { + return; + } + if (Date.now() < cached.createdAt + STALE_GRACE_MS) { + return; + } + cached = { ...cached, stale: true }; + channel.emit(REVIEW_EVENTS.REVIEW_STALE); + }); + + return channel; +} diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 726b29f94e00..35e576765048 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -693,6 +693,8 @@ export default { 'CoreWebpackCompiler', 'Feature', 'NON_AGGREGATED_STATUS_TYPE_IDS', + 'REVIEW_EVENTS', + 'REVIEW_NAMESPACE', 'REVIEW_STATUS_TYPE_ID', 'SupportedBuilder', 'SupportedFramework', diff --git a/code/core/src/shared/review/events.ts b/code/core/src/shared/review/events.ts new file mode 100644 index 000000000000..2b06466d8551 --- /dev/null +++ b/code/core/src/shared/review/events.ts @@ -0,0 +1,21 @@ +/** + * Core-owned namespace for the review ingest contract. The external + * `@storybook/addon-mcp` producer must emit these same event names. + */ +export const REVIEW_NAMESPACE = 'storybook/review'; + +/** Channel events exchanged between the MCP producer, core-server, and the manager. */ +export const REVIEW_EVENTS = { + // `@storybook/addon-mcp` display-review tool → core-server: the raw agent payload. + PUSH_REVIEW: `${REVIEW_NAMESPACE}/push-review`, + // core-server → tabs: display the (createdAt-stamped) review. + DISPLAY_REVIEW: `${REVIEW_NAMESPACE}/display-review`, + // tab → core-server: replay the cached review on mount. + REQUEST_REVIEW: `${REVIEW_NAMESPACE}/request-review`, + // core-server → tabs: a watched source file changed after the review was cached. + REVIEW_STALE: `${REVIEW_NAMESPACE}/review-stale`, + // tab → core-server: dismiss the cached review. + DISMISS_REVIEW: `${REVIEW_NAMESPACE}/dismiss-review`, + // core-server → tabs: the review was dismissed. + REVIEW_DISMISSED: `${REVIEW_NAMESPACE}/review-dismissed`, +} as const; diff --git a/code/core/src/shared/review/index.ts b/code/core/src/shared/review/index.ts new file mode 100644 index 000000000000..7752ba4892d2 --- /dev/null +++ b/code/core/src/shared/review/index.ts @@ -0,0 +1,2 @@ +export { REVIEW_EVENTS, REVIEW_NAMESPACE } from './events.ts'; +export type { CollectionKind, ReviewCollection, ReviewState } from './review-state.ts'; diff --git a/code/core/src/shared/review/review-state.ts b/code/core/src/shared/review/review-state.ts new file mode 100644 index 000000000000..ad7f25049dca --- /dev/null +++ b/code/core/src/shared/review/review-state.ts @@ -0,0 +1,40 @@ +/** + * The review payload an agent pushes via the `display-review` MCP tool. + * + * Flow: + * MCP `display-review` tool → emit PUSH_REVIEW on the Storybook channel + * → core-server stamps `createdAt` and caches it + * → emits DISPLAY_REVIEW to all open tabs (or replays on REQUEST_REVIEW). + * + * This mirrors the canonical valibot schema in `@storybook/addon-mcp` → + * `tools/display-review.ts`. The manager only renders the data — it does + * not validate — so it needs the type, not the validator. Keep `title` / + * `description` / `collections` in sync with that schema. + */ + +export type CollectionKind = 'atomic' | 'consumer' | 'transitive' | 'catch-all'; + +export interface ReviewCollection { + title: string; + rationale: string; + storyIds: string[]; + kind?: CollectionKind; +} + +export interface ReviewState { + title: string; + description: string; + collections: ReviewCollection[]; + changedFiles?: string[]; + /** + * Server-side creation timestamp (unix ms) assigned when PUSH_REVIEW is + * received; used for live "Created x minutes ago" UI in the summary. + */ + createdAt?: number; + /** + * Set server-side once a watched source file changes after `createdAt`. + * Drives the "this review may be stale" banner. Persisted on the cached + * review so REQUEST_REVIEW replays it to late/refreshed tabs. + */ + stale?: boolean; +} diff --git a/code/core/src/types/index.ts b/code/core/src/types/index.ts index 186b4a1ee82d..2309770d1be7 100644 --- a/code/core/src/types/index.ts +++ b/code/core/src/types/index.ts @@ -14,6 +14,7 @@ export * from './modules/channelApi.ts'; export * from './modules/frameworks.ts'; export * from './modules/renderers.ts'; export * from './modules/status.ts'; +export * from './modules/review.ts'; export * from './modules/test-provider.ts'; export * from './modules/universal-store.ts'; export * from './modules/webpack.ts'; diff --git a/code/core/src/types/modules/review.ts b/code/core/src/types/modules/review.ts new file mode 100644 index 000000000000..0f32590c5643 --- /dev/null +++ b/code/core/src/types/modules/review.ts @@ -0,0 +1,6 @@ +export { REVIEW_EVENTS, REVIEW_NAMESPACE } from '../../shared/review/events.ts'; +export type { + CollectionKind, + ReviewCollection, + ReviewState, +} from '../../shared/review/review-state.ts';