@atlassian/chippy 0.0.0-stage → 0.0.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +188 -2
  3. package/bin/chippy.mjs +134 -0
  4. package/bin/pack.mjs +184 -0
  5. package/lib/avalanche.js +2570 -0
  6. package/lib/avalanche.js.map +7 -0
  7. package/lib/bootstrap.d.ts +1 -0
  8. package/lib/bootstrap.js +51 -0
  9. package/lib/bootstrap.js.map +7 -0
  10. package/lib/browser.js +15 -0
  11. package/lib/browser.js.map +7 -0
  12. package/lib/extension/avalanche.es.js +5396 -0
  13. package/lib/extension/avalanche.es.js.map +1 -0
  14. package/lib/extension/chippy-page-bridge.js +1833 -0
  15. package/lib/extension/inspect-react.js +216 -0
  16. package/lib/extension/layout-lab.js +1453 -0
  17. package/lib/extension/panel-dom.js +90 -0
  18. package/lib/extension/resolve-probes.js +342 -0
  19. package/lib/mcp-skill/LICENSE +201 -0
  20. package/lib/mcp-skill/README.md +150 -0
  21. package/lib/mcp-skill/SKILL.md +330 -0
  22. package/lib/mcp-skill/browser-scripts/playwright-probes.js +368 -0
  23. package/lib/mcp-skill/browser-scripts/playwright-session.js +114 -0
  24. package/lib/mcp-skill/extension/avalanche.es.js +5396 -0
  25. package/lib/mcp-skill/extension/avalanche.es.js.map +1 -0
  26. package/lib/mcp-skill/extension/avalanche.iife.js +5397 -0
  27. package/lib/mcp-skill/extension/avalanche.iife.js.map +1 -0
  28. package/lib/mcp-skill/extension/background.js +323 -0
  29. package/lib/mcp-skill/extension/chippy-page-bridge.js +1833 -0
  30. package/lib/mcp-skill/extension/content-script.js +167 -0
  31. package/lib/mcp-skill/extension/devtools-hook-shim.js +68 -0
  32. package/lib/mcp-skill/extension/early-bootstrap.js +47 -0
  33. package/lib/mcp-skill/extension/icon-128.png +0 -0
  34. package/lib/mcp-skill/extension/icon-16.png +0 -0
  35. package/lib/mcp-skill/extension/icon-32.png +0 -0
  36. package/lib/mcp-skill/extension/icon-48.png +0 -0
  37. package/lib/mcp-skill/extension/inspect-react.js +216 -0
  38. package/lib/mcp-skill/extension/layout-lab.js +1453 -0
  39. package/lib/mcp-skill/extension/logo.svg +14 -0
  40. package/lib/mcp-skill/extension/manifest.json +69 -0
  41. package/lib/mcp-skill/extension/panel-dom.js +90 -0
  42. package/lib/mcp-skill/extension/panel-settings.js +191 -0
  43. package/lib/mcp-skill/extension/popup.html +41 -0
  44. package/lib/mcp-skill/extension/popup.js +80 -0
  45. package/lib/mcp-skill/extension/probe-descriptor.js +241 -0
  46. package/lib/mcp-skill/extension/resolve-probes.js +342 -0
  47. package/lib/mcp-skill/package.json +1 -0
  48. package/lib/mcp-skill/references/README.md +56 -0
  49. package/lib/mcp-skill/references/active-stimulus.md +85 -0
  50. package/lib/mcp-skill/references/avalanche-detector.md +731 -0
  51. package/lib/mcp-skill/references/bdd-router.md +310 -0
  52. package/lib/mcp-skill/references/browser-runtime-api.md +193 -0
  53. package/lib/mcp-skill/references/command-output-formats.md +1075 -0
  54. package/lib/mcp-skill/references/command-surface.md +183 -0
  55. package/lib/mcp-skill/references/cynefin.md +99 -0
  56. package/lib/mcp-skill/references/debug-hook.md +161 -0
  57. package/lib/mcp-skill/references/fixing-performance.md +490 -0
  58. package/lib/mcp-skill/references/hook-metrics.md +199 -0
  59. package/lib/mcp-skill/references/hooks-inspection.md +61 -0
  60. package/lib/mcp-skill/references/jira-taxonomy.md +81 -0
  61. package/lib/mcp-skill/references/mcp.md +122 -0
  62. package/lib/mcp-skill/references/mode-a-injection.md +68 -0
  63. package/lib/mcp-skill/references/operating-model.md +128 -0
  64. package/lib/mcp-skill/references/playwright-cli-setup.md +105 -0
  65. package/lib/mcp-skill/references/playwright-probes.md +205 -0
  66. package/lib/mcp-skill/references/procedures.md +545 -0
  67. package/lib/mcp-skill/references/recipes.txt +114 -0
  68. package/lib/mcp-skill/references/stability-render.md +428 -0
  69. package/lib/mcp-skill/references/test-mode-playbook.md +193 -0
  70. package/lib/mcp-skill/references/troubleshooting.md +54 -0
  71. package/lib/mcp-skill/scripts/avalanche-analyze.cjs +495 -0
  72. package/lib/mcp-skill/scripts/build-avalanche-command.js +34 -0
  73. package/lib/mcp-skill/scripts/build-fiber-state-command.js +19 -0
  74. package/lib/mcp-skill/scripts/chippy-helper-server.js +1710 -0
  75. package/lib/mcp-skill/scripts/chippy-mcp.cjs +273 -0
  76. package/lib/mcp-skill/scripts/chippy.sh +1218 -0
  77. package/lib/mcp-skill/scripts/format-probe-result.js +34 -0
  78. package/lib/mcp-skill/scripts/helper-access.js +144 -0
  79. package/lib/mcp-skill/scripts/probe-annotation-channel.js +116 -0
  80. package/lib/mcp-skill/scripts/search-recipes.js +248 -0
  81. package/lib/mcp-skill/snippets/README.md +89 -0
  82. package/lib/mcp-skill/snippets/count-context-consumers.js +40 -0
  83. package/lib/mcp-skill/snippets/find-context-providers.js +38 -0
  84. package/lib/mcp-skill/snippets/find-legacy-context.js +47 -0
  85. package/lib/mcp-skill/snippets/find-prop.js +83 -0
  86. package/lib/mcp-skill/snippets/hook-metrics-sample.js +69 -0
  87. package/lib/mcp-skill/snippets/hook-metrics.js +60 -0
  88. package/lib/mcp-skill/snippets/hook-tracker.js +198 -0
  89. package/lib/mcp-skill/snippets/stability-nudge.js +101 -0
  90. package/lib/package.json +1 -0
  91. package/lib/plugin.js +119 -0
  92. package/lib/plugin.js.map +7 -0
  93. package/lib/storybook/avalanche-panel-model.js +79 -0
  94. package/lib/storybook/brand.jsx +6 -0
  95. package/lib/storybook/connection-fallback.jsx +47 -0
  96. package/lib/storybook/constants.js +15 -0
  97. package/lib/storybook/context-card.jsx +171 -0
  98. package/lib/storybook/manager.jsx +403 -0
  99. package/lib/storybook/preset.js +7 -0
  100. package/lib/storybook/preview.js +263 -0
  101. package/lib/storybook/probe-annotation-editor.jsx +52 -0
  102. package/lib/storybook/probe-intent.js +23 -0
  103. package/lib/storybook/setup.js +93 -0
  104. package/lib/types/avalanche/commit-metrics-adapter.d.ts +76 -0
  105. package/lib/types/avalanche/commit-stats-indices.d.ts +41 -0
  106. package/lib/types/avalanche/commit-surface-ownership.d.ts +9 -0
  107. package/lib/types/avalanche/detector.d.ts +183 -0
  108. package/lib/types/avalanche/interaction-capture.d.ts +25 -0
  109. package/lib/types/avalanche/internals.d.ts +189 -0
  110. package/lib/types/avalanche/report-serializer.d.ts +283 -0
  111. package/lib/types/avalanche/reporter.d.ts +610 -0
  112. package/lib/types/chippy-bridge/fiber-source.d.ts +37 -0
  113. package/lib/types/chippy-bridge/react-renderer.d.ts +49 -0
  114. package/lib/types/chippy-bridge/source-map.d.ts +30 -0
  115. package/lib/types/debug-render/core.d.ts +72 -0
  116. package/lib/types/layout-lab/fiber-boundaries.d.ts +51 -0
  117. package/lib/types/layout-lab/fiber-walk.d.ts +131 -0
  118. package/lib/types/layout-lab/marker-context.d.ts +94 -0
  119. package/lib/types/package/avalanche.d.ts +6 -0
  120. package/lib/types/package/browser.d.ts +10 -0
  121. package/lib/types/package/plugin.d.ts +15 -0
  122. package/lib/types/plugins/atl/commit-metrics-adapter.d.ts +65 -0
  123. package/lib/types/plugins/atl/metrics-slot.d.ts +37 -0
  124. package/lib/types/plugins/index.d.ts +49 -0
  125. package/lib/types/plugins/jira/context-provider.d.ts +36 -0
  126. package/package.json +104 -4
@@ -0,0 +1,403 @@
1
+ import { ChippyLogo } from './brand.jsx';
2
+ import React, { useEffect, useState } from 'react';
3
+ import { addons, types } from 'storybook/manager-api';
4
+ import { AddonPanel, Badge, Button, ScrollArea } from 'storybook/internal/components';
5
+ import { ADDON_ID, AVALANCHE_PANEL_ID, CHIPPY_PANEL_ID, EVENTS, PREVIEW_MESSAGE_SOURCE } from './constants.js';
6
+ import { ChippyConnectionFallback } from './connection-fallback.jsx';
7
+ import { commitSpan, describeCommitAction, groupCommitRows } from './avalanche-panel-model.js';
8
+ import { ContextCard } from './context-card.jsx';
9
+ import { ProbeAnnotationEditor } from './probe-annotation-editor.jsx';
10
+
11
+ const panelFont = "'Atlassian Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
12
+ const theme = {
13
+ text: 'var(--ds-text, var(--color-default-text, CanvasText))',
14
+ muted: 'var(--color-subtle-text, color-mix(in srgb, CanvasText 64%, transparent))',
15
+ border: 'var(--color-medium-border, color-mix(in srgb, CanvasText 18%, transparent))',
16
+ panel: 'var(--ds-surface, var(--color-surface, Canvas))',
17
+ raised: 'var(--color-elevated-surface, color-mix(in srgb, Canvas 94%, CanvasText 6%))',
18
+ row: 'var(--color-hover-background, color-mix(in srgb, Canvas 88%, CanvasText 12%))',
19
+ danger: 'var(--color-negative-text, #ffb4ab)',
20
+ };
21
+
22
+ function postToPreview(message) {
23
+ const iframe = document.getElementById('storybook-preview-iframe');
24
+ iframe?.contentWindow?.postMessage({ source: PREVIEW_MESSAGE_SOURCE, ...message }, '*');
25
+ }
26
+
27
+ function isModeSelected(mode) {
28
+ const label = mode === 'avalanche' ? 'Avalanche' : 'Chippy';
29
+ const tab = Array.from(document.querySelectorAll('[role="tab"]'))
30
+ .find((candidate) => candidate.textContent?.trim() === label);
31
+ return tab?.getAttribute('aria-selected') === 'true';
32
+ }
33
+
34
+ function syncMode(channel, mode, active) {
35
+ if (!active || !isModeSelected(mode)) return;
36
+ channel.emit(EVENTS.SET_MODE, mode);
37
+ postToPreview({ event: EVENTS.SET_MODE, payload: mode });
38
+ }
39
+
40
+ function useChippyChannel(mode, active) {
41
+ const channel = addons.getChannel();
42
+ const [payload, setPayload] = useState(null);
43
+ const [lastResult, setLastResult] = useState(null);
44
+
45
+ useEffect(() => {
46
+ const onState = (next) => setPayload(next);
47
+ const onResult = (next) => setLastResult(next);
48
+ channel.on(EVENTS.STATE, onState);
49
+ channel.on(EVENTS.RESULT, onResult);
50
+ channel.emit(EVENTS.REQUEST_STATE);
51
+ syncMode(channel, mode, active);
52
+ const timer = setInterval(() => {
53
+ channel.emit(EVENTS.REQUEST_STATE);
54
+ syncMode(channel, mode, active);
55
+ }, 3000);
56
+ return () => {
57
+ clearInterval(timer);
58
+ channel.off(EVENTS.STATE, onState);
59
+ channel.off(EVENTS.RESULT, onResult);
60
+ };
61
+ }, [channel, active, mode]);
62
+
63
+ return {
64
+ payload,
65
+ lastResult,
66
+ command: (type, extra = {}) => {
67
+ const command = { type, ...extra };
68
+ if (!active || !isModeSelected(mode)) return;
69
+ channel.emit(EVENTS.COMMAND, command);
70
+ postToPreview({ event: EVENTS.COMMAND, payload: command });
71
+ },
72
+ refresh: () => channel.emit(EVENTS.REQUEST_STATE),
73
+ };
74
+ }
75
+
76
+ function statusFrom(payload) {
77
+ if (payload?.blocked) return { label: 'blocked', status: 'negative' };
78
+ const state = payload?.state;
79
+ if (!state) return { label: 'waiting', status: 'warning' };
80
+ return { label: state.connState || 'ready', status: state.connState === 'connected' ? 'positive' : 'warning' };
81
+ }
82
+
83
+ function RuntimeLine({ payload }) {
84
+ const state = payload?.state;
85
+ const owner = payload?.owner || state?.owner;
86
+ const integration = state?.integration || owner?.integration;
87
+ const status = statusFrom(payload);
88
+ const story = state?.page?.storyId || 'no story';
89
+ const client = state?.clientId ? String(state.clientId).slice(0, 8) : 'no client';
90
+ return (
91
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center', color: theme.muted, fontSize: 11 }}>
92
+ <Badge status={status.status}>{status.label}</Badge>
93
+ <span>{integration?.who || 'Unidentified Chippy integration'}</span>
94
+ <span>({integration?.kind || 'unknown'})</span>
95
+ <span>·</span>
96
+ <span>{story}</span>
97
+ <span>·</span>
98
+ <span>{client}</span>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ function Header({ title, children }) {
104
+ return (
105
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10 }}>
106
+ <h2 style={{ margin: 0, fontSize: 14, lineHeight: 1, color: theme.text, display: 'flex', alignItems: 'center', gap: 8 }}><ChippyLogo />{title}</h2>
107
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>{children}</div>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ function Footer({ payload }) {
113
+ return (
114
+ <details style={{ borderTop: `1px solid ${theme.border}`, paddingTop: 8, marginTop: 8 }}>
115
+ <summary style={{ cursor: 'pointer', color: theme.muted, fontSize: 11 }}>Connection details</summary>
116
+ <div style={{ marginTop: 8 }}><RuntimeLine payload={payload} /></div>
117
+ </details>
118
+ );
119
+ }
120
+
121
+ function Section({ title, children }) {
122
+ return (
123
+ <section style={{ borderTop: `1px solid ${theme.border}`, padding: '10px 0', color: theme.text }}>
124
+ {title ? <h3 style={{ margin: '0 0 8px', fontSize: 13, color: theme.text }}>{title}</h3> : null}
125
+ {children}
126
+ </section>
127
+ );
128
+ }
129
+
130
+ function Empty({ children }) {
131
+ return <div style={{ color: theme.muted, fontSize: 12, padding: '8px 0' }}>{children}</div>;
132
+ }
133
+
134
+ function SmallJson({ value }) {
135
+ if (value == null) return null;
136
+ return (
137
+ <details style={{ marginTop: 8, color: theme.text }}>
138
+ <summary style={{ cursor: 'pointer', color: theme.muted, fontSize: 12 }}>raw</summary>
139
+ <pre style={{ margin: '8px 0 0', whiteSpace: 'pre-wrap', fontSize: 11, lineHeight: 1.4, maxHeight: 240, overflow: 'auto', color: theme.text, background: theme.raised, border: `1px solid ${theme.border}`, borderRadius: 6, padding: 8 }}>
140
+ {JSON.stringify(value, null, 2)}
141
+ </pre>
142
+ </details>
143
+ );
144
+ }
145
+
146
+ function compactSource(source) {
147
+ if (!source?.fileName) return '—';
148
+ const file = String(source.fileName).split('/').slice(-2).join('/');
149
+ return `${file}${source.lineNumber ? `:${source.lineNumber}` : ''}`;
150
+ }
151
+
152
+ function MarkerSummary({ markers, onInspectBoundary, onAttachAnnotation }) {
153
+ const entries = Object.entries(markers || {});
154
+ if (!entries.length) return <Empty>Open the Chippy panel to activate probes. Drag P1/P2 from the dock onto the story.</Empty>;
155
+ return (
156
+ <div style={{ display: 'grid', gap: 8 }}>
157
+ {entries.map(([id, marker]) => {
158
+ const resolved = marker?.resolved || {};
159
+ const component = resolved.component || {};
160
+ const dom = resolved.dom || {};
161
+ const owners = component.owners || [];
162
+ const source = component.source || dom.source;
163
+ const markerContext = marker?.context ?? null;
164
+ return (
165
+ <div key={id} style={{ border: `1px solid ${theme.border}`, borderRadius: 8, padding: 8, background: theme.raised, color: theme.text }}>
166
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
167
+ <strong>P{id}</strong>
168
+ <Badge status={resolved.confidence === 'dev' || resolved.confidence === 'high' ? 'positive' : 'neutral'}>{marker?.docked ? 'docked' : 'active'}</Badge>
169
+ </div>
170
+ <div style={{ color: theme.text, fontSize: 13, marginTop: 6, fontWeight: 600 }}>
171
+ {component.name || dom.tag || 'Drag marker onto the story'}
172
+ </div>
173
+ <div style={{ color: theme.muted, fontSize: 12, marginTop: 3 }}>
174
+ {resolved.nearestTestId ? `testId ${resolved.nearestTestId} · ` : ''}{dom.tag ? `<${dom.tag}>` : 'no DOM target'}
175
+ </div>
176
+ <div style={{ color: theme.muted, fontSize: 12, marginTop: 3 }}>source {compactSource(source)}</div>
177
+ {owners.length ? <div style={{ color: theme.muted, fontSize: 12, marginTop: 3 }}>owner {owners[0]?.name || '—'} · chain {owners.length}</div> : null}
178
+ <ContextCard
179
+ context={markerContext}
180
+ onInspectBoundary={
181
+ onInspectBoundary
182
+ ? (kind) => onInspectBoundary(id, kind, markerContext?.boundaries?.[kind] || null)
183
+ : undefined
184
+ }
185
+ />
186
+ {!marker?.docked ? (
187
+ <ProbeAnnotationEditor
188
+ markerId={id}
189
+ intent={marker?.intent}
190
+ comment={marker?.comment}
191
+ onAttach={(annotation) => onAttachAnnotation?.(id, annotation)}
192
+ />
193
+ ) : null}
194
+ </div>
195
+ );
196
+ })}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ function resultEnvelope(lastResult) {
202
+ const result = lastResult?.result || null;
203
+ const payload = result?.payload || null;
204
+ return { result, payload };
205
+ }
206
+
207
+ function rowsFromAvalanchePayload(payload) {
208
+ const auto = payload?.avalancheAuto || {};
209
+ return {
210
+ hotItems: auto.hotSources?.payload?.items || auto.hotSources?.items || [],
211
+ commitRows: auto.commitList?.rows || auto.commitList?.payload?.rows || [],
212
+ };
213
+ }
214
+
215
+ function rowsFromAvalancheResult(lastResult) {
216
+ const { result, payload } = resultEnvelope(lastResult);
217
+ if (!result) return { result: null, hotItems: [], commitRows: [], peek: null };
218
+ return {
219
+ result,
220
+ hotItems: result.action === 'hot-sources' ? payload?.items || [] : [],
221
+ commitRows: result.action === 'commit-list' ? result.rows || [] : [],
222
+ peek: result.action === 'commit-peek' ? payload || null : null,
223
+ };
224
+ }
225
+
226
+ function SimpleTable({ columns, rows, empty, onRowClick }) {
227
+ if (!rows?.length) return <Empty>{empty}</Empty>;
228
+ return (
229
+ <div style={{ border: `1px solid ${theme.border}`, borderRadius: 8, overflow: 'hidden', background: theme.panel, color: theme.text }}>
230
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, color: theme.text }}>
231
+ <thead style={{ background: theme.raised }}>
232
+ <tr>{columns.map((column) => <th key={column.key} style={{ textAlign: 'left', padding: 8, borderBottom: `1px solid ${theme.border}`, color: theme.text }}>{column.label}</th>)}</tr>
233
+ </thead>
234
+ <tbody>
235
+ {rows.map((row, index) => (
236
+ <tr key={row.commitId || row.sourceId || index} onClick={onRowClick ? () => onRowClick(row) : undefined} style={{ cursor: onRowClick ? 'pointer' : 'default' }}>
237
+ {columns.map((column) => <td key={column.key} style={{ padding: 8, borderTop: index ? `1px solid ${theme.border}` : 'none', verticalAlign: 'top', color: theme.text }}>{column.render ? column.render(row) : String(row[column.key] ?? '—')}</td>)}
238
+ </tr>
239
+ ))}
240
+ </tbody>
241
+ </table>
242
+ </div>
243
+ );
244
+ }
245
+
246
+ function ChippyPanel({ active }) {
247
+ const { payload, command } = useChippyChannel('chippy', active);
248
+ const inspectBoundary = React.useCallback(
249
+ (markerId, boundaryKind, boundary) =>
250
+ command('inspect-boundary', {
251
+ markerId,
252
+ boundaryKind,
253
+ boundary,
254
+ distance: boundary?.distance ?? null,
255
+ }),
256
+ [command],
257
+ );
258
+ const attachAnnotation = React.useCallback(
259
+ (markerId, annotation) => command('set-probe-annotation', { markerId, ...annotation }),
260
+ [command],
261
+ );
262
+ const state = payload?.state;
263
+ const markers = state?.markers || {};
264
+ const probesVisible = Object.values(markers).some((marker) => marker?.position != null);
265
+ return (
266
+ <AddonPanel active={active}>
267
+ <ScrollArea vertical horizontal>
268
+ <div style={{ padding: 16, fontFamily: panelFont, maxWidth: 840, color: theme.text, background: theme.panel, minHeight: '100%' }}>
269
+ <Header title="Chippy">
270
+ <Button
271
+ size="small"
272
+ variant={probesVisible ? 'outline' : undefined}
273
+ onClick={() => command(probesVisible ? 'clear-probes' : 'ensure-probes')}
274
+ >
275
+ {probesVisible ? 'Hide probes' : 'Show probes'}
276
+ </Button>
277
+ </Header>
278
+ <ChippyConnectionFallback connState={state?.connState} />
279
+ <Section>
280
+ {probesVisible ? (
281
+ <div style={{ color: theme.muted, fontSize: 12, marginBottom: 8 }}>
282
+ While dragging: ← parent DOM · → toward target · ↑ parent component · ↓ toward target
283
+ </div>
284
+ ) : null}
285
+ {probesVisible
286
+ ? <MarkerSummary markers={markers} onInspectBoundary={inspectBoundary} onAttachAnnotation={attachAnnotation} />
287
+ : <Empty>Show probes when you want to inspect an element in the story.</Empty>}
288
+ </Section>
289
+ <Footer payload={payload} />
290
+ </div>
291
+ </ScrollArea>
292
+ </AddonPanel>
293
+ );
294
+ }
295
+
296
+ function AvalanchePanel({ active }) {
297
+ const { payload, lastResult, command } = useChippyChannel('avalanche', active);
298
+ const autoRows = rowsFromAvalanchePayload(payload);
299
+ const { result, peek } = rowsFromAvalancheResult(lastResult);
300
+ const hotItems = autoRows.hotItems;
301
+ const rows = autoRows.commitRows;
302
+ const patterns = groupCommitRows(rows);
303
+
304
+ const status = payload?.avalancheStatus?.status || payload?.state?.avalanche?.status || null;
305
+ const error = result?.ok === false ? result.error : null;
306
+
307
+ useEffect(() => {
308
+ if (!active) return;
309
+ if (status?.installed) return;
310
+ const timer = setTimeout(() => command('avalanche-install', { reason: 'panel-active-retry' }), 250);
311
+ return () => clearTimeout(timer);
312
+ }, [active, status?.installed]);
313
+
314
+ return (
315
+ <AddonPanel active={active}>
316
+ <ScrollArea vertical horizontal>
317
+ <div style={{ padding: 16, fontFamily: panelFont, maxWidth: 980, color: theme.text, background: theme.panel, minHeight: '100%' }}>
318
+ <Header title="Avalanche" />
319
+ <ChippyConnectionFallback connState={payload?.state?.connState} />
320
+ {error ? <div style={{ color: theme.danger, fontSize: 12, marginBottom: 8 }}>{error}</div> : null}
321
+ <Section title="Commit patterns">
322
+ <SimpleTable
323
+ rows={patterns}
324
+ empty="Waiting for the story to render something new."
325
+ onRowClick={(group) => command('avalanche-commit-peek', { commitId: group.representative.commitId })}
326
+ columns={[
327
+ { key: 'count', label: 'repeats', render: (group) => group.count === 1 ? 'once' : `${group.count}×` },
328
+ { key: 'span', label: 'commits', render: commitSpan },
329
+ { key: 'action', label: 'activity', render: (group) => describeCommitAction(group.representative) },
330
+ { key: 'sources', label: 'sources', render: (group) => group.representative.sourceCount ?? 0 },
331
+ { key: 'collateral', label: 'collateral', render: (group) => group.representative.stat?.collateral ?? 0 },
332
+ { key: 'effects', label: 'effects', render: (group) => group.representative.stat?.effects ?? 0 },
333
+ ]}
334
+ />
335
+ </Section>
336
+ <Section title="Sources with the widest impact">
337
+ <SimpleTable
338
+ rows={hotItems.slice(0, 10)}
339
+ empty="No source has caused measurable downstream work yet."
340
+ columns={[
341
+ { key: 'sourceId', label: 'source' },
342
+ { key: 'collateralUpdates', label: 'collateral' },
343
+ { key: 'causedUpdates', label: 'caused' },
344
+ { key: 'restartWaste', label: 'restart waste' },
345
+ ]}
346
+ />
347
+ </Section>
348
+ {peek ? (
349
+ <Section title={`Commit ${peek.commitId} peek`}>
350
+ <div style={{ display: 'flex', gap: 10, color: theme.muted, fontSize: 12, marginBottom: 8 }}>
351
+ <span>found: <strong style={{ color: theme.text }}>{String(!!peek.found)}</strong></span>
352
+ <span>sources: <strong style={{ color: theme.text }}>{peek.sourcesTotal ?? peek.sources?.length ?? 0}</strong></span>
353
+ <span>actions: <strong style={{ color: theme.text }}>{peek.actionsTotal ?? peek.actions?.length ?? 0}</strong></span>
354
+ </div>
355
+ <SimpleTable
356
+ rows={peek.sources || []}
357
+ empty="No source rows in this peek."
358
+ columns={[
359
+ { key: 'sourceId', label: 'source' },
360
+ { key: 'collateralAtCommit', label: 'collateral@commit' },
361
+ { key: 'causedUpdatesTotal', label: 'caused total' },
362
+ { key: 'collateralUpdatesTotal', label: 'collateral total' },
363
+ ]}
364
+ />
365
+ <SmallJson value={peek} />
366
+ </Section>
367
+ ) : null}
368
+ <details style={{ marginTop: 8 }}>
369
+ <summary style={{ cursor: 'pointer', color: theme.muted, fontSize: 12 }}>Individual commits ({rows.length})</summary>
370
+ <div style={{ marginTop: 8 }}>
371
+ <SimpleTable
372
+ rows={rows}
373
+ empty="No commits captured."
374
+ onRowClick={(row) => command('avalanche-commit-peek', { commitId: row.commitId })}
375
+ columns={[
376
+ { key: 'commitId', label: 'commit' },
377
+ { key: 'sourceCount', label: 'sources' },
378
+ { key: 'collateral', label: 'collateral', render: (row) => row.stat?.collateral ?? 0 },
379
+ { key: 'effects', label: 'effects', render: (row) => row.stat?.effects ?? 0 },
380
+ { key: 'action', label: 'activity', render: describeCommitAction },
381
+ ]}
382
+ />
383
+ </div>
384
+ </details>
385
+ <Footer payload={payload} />
386
+ </div>
387
+ </ScrollArea>
388
+ </AddonPanel>
389
+ );
390
+ }
391
+
392
+ addons.register(ADDON_ID, () => {
393
+ addons.add(CHIPPY_PANEL_ID, {
394
+ title: 'Chippy',
395
+ type: types.PANEL,
396
+ render: ({ active }) => <ChippyPanel active={active} />,
397
+ });
398
+ addons.add(AVALANCHE_PANEL_ID, {
399
+ title: 'Avalanche',
400
+ type: types.PANEL,
401
+ render: ({ active }) => <AvalanchePanel active={active} />,
402
+ });
403
+ });
@@ -0,0 +1,7 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ const here = dirname(fileURLToPath(import.meta.url));
5
+
6
+ export const managerEntries = (entry = []) => [...entry, join(here, 'manager.jsx')];
7
+ export const previewAnnotations = (entry = []) => [...entry, join(here, 'preview.js')];
@@ -0,0 +1,263 @@
1
+ import { addons } from 'storybook/preview-api';
2
+ import { FORCE_REMOUNT, STORY_CHANGED, STORY_HOT_UPDATED } from 'storybook/internal/core-events';
3
+ import { EVENTS, PREVIEW_MESSAGE_SOURCE } from './constants.js';
4
+ import { OWNER_ID, ownerResult } from './setup.js';
5
+
6
+ import '../extension/inspect-react.js';
7
+ import '../extension/panel-dom.js';
8
+ import '../extension/layout-lab.js';
9
+ import '../extension/resolve-probes.js';
10
+ import avalanche from '../extension/avalanche.es.js';
11
+ import '../extension/chippy-page-bridge.js';
12
+
13
+ if (ownerResult.ok) {
14
+ globalThis.__AVALANCHE__ = globalThis.__AVALANCHE__ || avalanche;
15
+ // Static ESM imports evaluate before setup.js can finish stamping
16
+ // __CHIPPY_STARTUP_CONFIG. Honor reload-with-avalanche autoStart here too,
17
+ // after setup.js has run and the addon owns the page.
18
+ const arm = globalThis.__CHIPPY_STARTUP_CONFIG?.avalanche || null;
19
+ const api = globalThis.__AVALANCHE__;
20
+ if (arm?.autoStart && api && typeof api.install === 'function' && !api.status?.().installed) {
21
+ try {
22
+ api.install({
23
+ detectionMode: arm.options?.detectionMode || 'sources',
24
+ historySize: arm.max || 1000,
25
+ captureFibers: arm.options?.captureFibers !== false,
26
+ });
27
+ } catch (_) {}
28
+ }
29
+ }
30
+
31
+ function emit(channel, event, payload) {
32
+ try {
33
+ channel.emit(event, payload);
34
+ } catch {
35
+ // Storybook channel can be briefly unavailable during preview reloads.
36
+ }
37
+ }
38
+
39
+ function getBridge() {
40
+ return globalThis.__CHIPPY?.pageBridge || null;
41
+ }
42
+
43
+ function getAvalancheApi() {
44
+ return globalThis.__AVALANCHE__ || avalanche || null;
45
+ }
46
+
47
+ function runAvalanche(action, command = {}) {
48
+ const api = getAvalancheApi();
49
+ const dispatcher = api?.bridge?.evalAvalanche;
50
+ if (typeof dispatcher !== 'function') {
51
+ return { ok: false, available: !!api, action, error: 'avalanche dispatcher unavailable' };
52
+ }
53
+ return dispatcher({ action, ...command }, api, api);
54
+ }
55
+
56
+ let avalancheStoryId = null;
57
+ let avalancheAutoCache = null;
58
+
59
+ function currentStoryId() {
60
+ return new URLSearchParams(globalThis.location.search || '').get('id') || globalThis.__STORYBOOK_STORY_STORE__?.selection?.storyId || null;
61
+ }
62
+
63
+ function ensureAvalancheInstalled({ resetForStory = false } = {}) {
64
+ const storyId = currentStoryId();
65
+ const status = runAvalanche('status');
66
+ const installed = status?.status?.installed || status?.result?.status?.installed;
67
+ if (!installed) {
68
+ runAvalanche('install', { options: { detectionMode: 'sources', historySize: 5000, captureFibers: true } });
69
+ avalancheStoryId = storyId;
70
+ avalancheAutoCache = null;
71
+ return;
72
+ }
73
+ if (resetForStory && storyId && storyId !== avalancheStoryId) {
74
+ runAvalanche('clear');
75
+ avalancheStoryId = storyId;
76
+ avalancheAutoCache = null;
77
+ }
78
+ }
79
+
80
+ function refreshAvalancheAuto() {
81
+ ensureAvalancheInstalled({ resetForStory: true });
82
+ avalancheAutoCache = {
83
+ storyId: currentStoryId(),
84
+ status: runAvalanche('status'),
85
+ hotSources: runAvalanche('hot-sources', { limit: 50 }),
86
+ commitList: runAvalanche('commit-list', { top: 1000, by: 'collateral' }),
87
+ };
88
+ return avalancheAutoCache;
89
+ }
90
+
91
+ function statePayload(extra = {}) {
92
+ const bridge = getBridge();
93
+ const mode = globalThis.__CHIPPY_OWNER__?.mode || bridge?.getState?.()?.config?.mode;
94
+ const avalancheAuto = mode === 'avalanche' ? refreshAvalancheAuto() : avalancheAutoCache;
95
+ return {
96
+ ok: !!bridge,
97
+ owner: globalThis.__CHIPPY_OWNER__ || null,
98
+ blocked: globalThis.__CHIPPY_BLOCKED__ || null,
99
+ state: bridge?.getState?.() || null,
100
+ avalancheStatus: avalancheAuto?.status || runAvalanche('status'),
101
+ avalancheAuto,
102
+ ...extra,
103
+ };
104
+ }
105
+
106
+ function activeMode() {
107
+ return globalThis.__CHIPPY_OWNER__?.mode || globalThis.__CHIPPY?.pageBridge?.getState?.()?.config?.mode;
108
+ }
109
+
110
+ function resetAvalancheForBoundary(reason) {
111
+ runAvalanche('clear');
112
+ avalancheStoryId = currentStoryId();
113
+ avalancheAutoCache = null;
114
+ if (activeMode() === 'avalanche') {
115
+ ensureAvalancheInstalled();
116
+ refreshAvalancheAuto();
117
+ publishState(channel, `avalanche-reset:${reason}`);
118
+ }
119
+ }
120
+
121
+ function syncActiveLifecycle() {
122
+ if (activeMode() === 'avalanche') ensureAvalancheInstalled({ resetForStory: true });
123
+ }
124
+
125
+ function publishState(channel, reason = 'state') {
126
+ syncActiveLifecycle();
127
+ emit(channel, EVENTS.STATE, statePayload({ reason }));
128
+ }
129
+
130
+ function executeCommand(command) {
131
+ const bridge = getBridge();
132
+ if (!bridge) return { ok: false, error: 'bridge-not-ready' };
133
+ switch (command?.type) {
134
+ case 'activate':
135
+ bridge.activate?.();
136
+ return { ok: true };
137
+ case 'deactivate':
138
+ bridge.deactivate?.();
139
+ return { ok: true };
140
+ case 'ensure-probes':
141
+ bridge.activate?.();
142
+ bridge.ensureProbes?.();
143
+ return { ok: true };
144
+ case 'clear-probes':
145
+ bridge.clearProbes?.();
146
+ return { ok: true };
147
+ case 'set-probe-annotation': {
148
+ const annotation = bridge.setProbeAnnotation?.(command.markerId, {
149
+ intent: command.intent ?? null,
150
+ comment: command.comment ?? '',
151
+ });
152
+ return annotation
153
+ ? { ok: true, markerId: command.markerId, annotation }
154
+ : { ok: false, error: `probe-not-found:${command.markerId || 'missing'}` };
155
+ }
156
+ case 'avalanche-install': {
157
+ globalThis.__CHIPPY_LOCK__?.heartbeat?.(OWNER_ID, { mode: 'avalanche' });
158
+ bridge.applyConfig?.({ mode: 'avalanche', activated: false });
159
+ ensureAvalancheInstalled({ resetForStory: true });
160
+ return runAvalanche('status');
161
+ }
162
+ case 'avalanche-stop':
163
+ return runAvalanche('uninstall');
164
+ case 'avalanche-clear':
165
+ ensureAvalancheInstalled();
166
+ return runAvalanche('clear');
167
+ case 'avalanche-status':
168
+ ensureAvalancheInstalled();
169
+ return runAvalanche('status');
170
+ case 'avalanche-export':
171
+ ensureAvalancheInstalled();
172
+ return runAvalanche('export');
173
+ case 'avalanche-hot-sources':
174
+ ensureAvalancheInstalled();
175
+ return runAvalanche('hot-sources', { limit: command.limit || 50 });
176
+ case 'avalanche-commit-list':
177
+ ensureAvalancheInstalled();
178
+ return runAvalanche('commit-list', { top: command.top || 1000, by: command.by || 'collateral' });
179
+ case 'avalanche-commit-peek':
180
+ ensureAvalancheInstalled();
181
+ return runAvalanche('commit-peek', {
182
+ commitId: command.commitId,
183
+ include: command.include || ['stats', 'sources', 'timing', 'metrics', 'actions'],
184
+ maxItems: command.maxItems || 20,
185
+ });
186
+ case 'inspect-boundary':
187
+ // Report the selected collateral boundary (owner/error/suspense) + its
188
+ // fiber-hop distance back to the backend. The click already carries the
189
+ // resolved descriptor; the addon acknowledges it as a first-class result
190
+ // so downstream consumers can act on the collateral boundary.
191
+ return {
192
+ ok: true,
193
+ markerId: command.markerId ?? null,
194
+ boundaryKind: command.boundaryKind ?? null,
195
+ boundary: command.boundary ?? null,
196
+ distance: command.distance ?? command.boundary?.distance ?? null,
197
+ };
198
+ default:
199
+ return { ok: false, error: `unknown-command:${command?.type || 'missing'}` };
200
+ }
201
+ }
202
+
203
+ const channel = addons.getChannel();
204
+
205
+ function handleMode(mode) {
206
+ globalThis.__CHIPPY_LOCK__?.heartbeat?.(OWNER_ID, { mode });
207
+ const bridge = getBridge();
208
+ if (bridge?.applyConfig) bridge.applyConfig(mode === 'chippy' ? { mode } : { mode, activated: false });
209
+ if (mode !== 'chippy') {
210
+ bridge?.clearProbes?.();
211
+ }
212
+ if (mode === 'avalanche') ensureAvalancheInstalled({ resetForStory: true });
213
+ publishState(channel, 'mode');
214
+ }
215
+
216
+ function handleCommand(command) {
217
+ const result = executeCommand(command);
218
+ emit(channel, EVENTS.RESULT, { command, result, state: statePayload({ reason: 'command' }) });
219
+ publishState(channel, `command:${command?.type || 'unknown'}`);
220
+ }
221
+
222
+ channel.on(EVENTS.REQUEST_STATE, () => publishState(channel, 'request'));
223
+ channel.on(EVENTS.SET_MODE, handleMode);
224
+ channel.on(EVENTS.COMMAND, handleCommand);
225
+ channel.on(STORY_CHANGED, () => resetAvalancheForBoundary('story-changed'));
226
+ channel.on(FORCE_REMOUNT, () => resetAvalancheForBoundary('force-remount'));
227
+ channel.on(STORY_HOT_UPDATED, () => resetAvalancheForBoundary('story-hot-updated'));
228
+ window.addEventListener('message', (event) => {
229
+ const data = event.data || {};
230
+ if (data.source !== PREVIEW_MESSAGE_SOURCE) return;
231
+ if (data.event === EVENTS.SET_MODE) handleMode(data.payload);
232
+ if (data.event === EVENTS.COMMAND) handleCommand(data.payload);
233
+ });
234
+
235
+ window.addEventListener('chippy-storybook-force-remount', (event) => {
236
+ // Keep Storybook's manager and preview alive. FORCE_REMOUNT replaces only
237
+ // the rendered SUT, unlike reloading the preview iframe document.
238
+ emit(channel, FORCE_REMOUNT);
239
+ event.preventDefault();
240
+ });
241
+
242
+ window.addEventListener('chippy-conn-state', () => publishState(channel, 'conn-state'));
243
+ window.addEventListener('layout-lab:update', () => publishState(channel, 'layout-lab'));
244
+
245
+ // A marker starts life after a manager-panel button was focused. Because the
246
+ // shared drag handler prevents pointerdown's default, Storybook would otherwise
247
+ // keep keyboard focus in the manager document and arrow navigation would never
248
+ // reach the preview iframe.
249
+ document.addEventListener('pointerdown', (event) => {
250
+ const marker = event.target?.closest?.('[data-layout-lab-marker]');
251
+ marker?.focus?.({ preventScroll: true });
252
+ }, true);
253
+
254
+ setInterval(() => {
255
+ globalThis.__CHIPPY_LOCK__?.heartbeat?.(OWNER_ID);
256
+ publishState(channel, 'heartbeat');
257
+ }, 5000);
258
+
259
+ publishState(channel, ownerResult.ok ? 'ready' : 'blocked');
260
+
261
+ export const decorators = [
262
+ (Story) => Story(),
263
+ ];