@bitovi/vybit 0.8.2 → 0.9.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.
@@ -16,8 +16,8 @@
16
16
  font-size: 12px;
17
17
  }
18
18
  </style>
19
- <script type="module" crossorigin src="/panel/assets/index-DwDMVGkn.js"></script>
20
- <link rel="stylesheet" crossorigin href="/panel/assets/index-Bk7q5Yfb.css">
19
+ <script type="module" crossorigin src="/panel/assets/index-D5VCKvC6.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/panel/assets/index-BdUOBofL.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/server/app.ts CHANGED
@@ -4,17 +4,23 @@ import express from "express";
4
4
  import cors from "cors";
5
5
  import { request as makeRequest } from "http";
6
6
  import path from "path";
7
+ import { createProxyMiddleware } from "http-proxy-middleware";
7
8
 
8
9
  import { getByStatus, getQueueUpdate, clearAll } from "./queue.js";
9
10
  import { resolveTailwindConfig, generateCssForClasses, getTailwindVersion } from "./tailwind.js";
11
+ import { loadStoryArgTypes } from "./storybook.js";
12
+ import { loadCache, getAllCachedGhosts, setCachedGhost, invalidateAll as invalidateGhostCache } from "./ghost-cache.js";
10
13
  import type { PatchStatus } from "../shared/types.js";
11
14
 
12
15
  const VALID_STATUSES = new Set<string>(['staged', 'committed', 'implementing', 'implemented', 'error']);
13
16
 
14
- export function createApp(packageRoot: string): express.Express {
17
+ export function createApp(packageRoot: string, storybookUrl: string | null = null): express.Express {
15
18
  const app = express();
16
19
  app.use(cors());
17
20
 
21
+ // Load ghost cache from disk on startup
22
+ loadCache();
23
+
18
24
  app.get("/overlay.js", (_req, res) => {
19
25
  const overlayPath = path.join(packageRoot, "overlay", "dist", "overlay.js");
20
26
  res.sendFile(overlayPath, (err) => {
@@ -82,6 +88,87 @@ export function createApp(packageRoot: string): express.Express {
82
88
  res.json({ ok: true });
83
89
  });
84
90
 
91
+ // --- Ghost cache REST endpoints ---
92
+ app.get('/api/ghost-cache', (_req, res) => {
93
+ res.json(getAllCachedGhosts());
94
+ });
95
+
96
+ app.post('/api/ghost-cache', express.json({ limit: '1mb' }), (req, res) => {
97
+ const { storyId, args, ghostHtml, hostStyles, storyBackground, componentName, componentPath } = req.body;
98
+ if (!storyId || typeof ghostHtml !== 'string') {
99
+ res.status(400).json({ error: 'storyId and ghostHtml are required' });
100
+ return;
101
+ }
102
+ setCachedGhost({ storyId, args, ghostHtml, hostStyles: hostStyles ?? {}, storyBackground, componentName: componentName ?? '', componentPath });
103
+ res.json({ ok: true });
104
+ });
105
+
106
+ app.delete('/api/ghost-cache', (_req, res) => {
107
+ invalidateGhostCache();
108
+ res.json({ ok: true });
109
+ });
110
+
111
+ // --- Storybook proxy ---
112
+ if (storybookUrl) {
113
+ // Vite's dev server serves assets at many root-absolute path prefixes:
114
+ // /@vite/client, /@react-refresh, /@id/..., /node_modules/.cache/..., /src/...
115
+ // Rather than enumerate them all, proxy everything that isn't ours.
116
+ const OWN_PATHS = new Set(['/panel', '/overlay.js', '/api', '/patches', '/css', '/tailwind-config']);
117
+ const isOwnPath = (p: string) =>
118
+ OWN_PATHS.has(p) ||
119
+ [...OWN_PATHS].some(own => p.startsWith(own + '/')) ||
120
+ p === '/';
121
+
122
+ app.use('/storybook', createProxyMiddleware({
123
+ target: storybookUrl,
124
+ changeOrigin: true,
125
+ pathRewrite: { '^/storybook': '' },
126
+ }));
127
+
128
+ app.use(createProxyMiddleware({
129
+ target: storybookUrl,
130
+ changeOrigin: true,
131
+ pathFilter: (pathname) => !isOwnPath(pathname),
132
+ }));
133
+
134
+ console.error(`[storybook] Proxying /storybook + Vite asset paths → ${storybookUrl}`);
135
+ }
136
+
137
+ app.get('/api/storybook-status', (_req, res) => {
138
+ res.json({ url: storybookUrl ? '/storybook' : null, directUrl: storybookUrl ?? null });
139
+ });
140
+
141
+ app.get('/api/storybook-argtypes', async (_req, res) => {
142
+ if (!storybookUrl) { res.json({}); return; }
143
+ try {
144
+ const argTypes = await loadStoryArgTypes(storybookUrl);
145
+ res.json(argTypes);
146
+ } catch (err) {
147
+ console.error('[storybook] /api/storybook-argtypes failed:', err);
148
+ res.json({});
149
+ }
150
+ });
151
+
152
+ // Single endpoint: everything the Draw tab panel needs in one request.
153
+ app.get('/api/storybook-data', async (_req, res) => {
154
+ if (!storybookUrl) {
155
+ res.json({ available: false });
156
+ return;
157
+ }
158
+ try {
159
+ const [indexRes, argTypes] = await Promise.all([
160
+ fetch(`${storybookUrl}/index.json`, { signal: AbortSignal.timeout(5000) }),
161
+ loadStoryArgTypes(storybookUrl),
162
+ ]);
163
+ if (!indexRes.ok) { res.json({ available: false }); return; }
164
+ const index = await indexRes.json();
165
+ res.json({ available: true, directUrl: storybookUrl, index, argTypes });
166
+ } catch (err) {
167
+ console.error('[storybook] /api/storybook-data failed:', err);
168
+ res.json({ available: false });
169
+ }
170
+ });
171
+
85
172
  // --- Serve Panel app ---
86
173
  if (process.env.PANEL_DEV) {
87
174
  const panelDevPort = Number(process.env.PANEL_DEV_PORT) || 5174;
@@ -0,0 +1,154 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+
5
+ export interface GhostCacheEntry {
6
+ storyId: string;
7
+ argsHash: string;
8
+ ghostHtml: string;
9
+ hostStyles: Record<string, string>;
10
+ storyBackground?: string;
11
+ componentName: string;
12
+ componentPath?: string;
13
+ extractedAt: number;
14
+ }
15
+
16
+ interface CacheFile {
17
+ entries: GhostCacheEntry[];
18
+ }
19
+
20
+ const MAX_ENTRIES = 200;
21
+ const MAX_TOTAL_BYTES = 5 * 1024 * 1024; // 5 MB
22
+
23
+ const cache = new Map<string, GhostCacheEntry>();
24
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
25
+ let cacheDir: string | null = null;
26
+ let cacheFilePath: string | null = null;
27
+
28
+ function getCacheDir(): string {
29
+ if (!cacheDir) {
30
+ cacheDir = path.join(process.cwd(), "node_modules", ".cache", "vybit");
31
+ }
32
+ return cacheDir;
33
+ }
34
+
35
+ function getCacheFilePath(): string {
36
+ if (!cacheFilePath) {
37
+ cacheFilePath = path.join(getCacheDir(), "ghost-cache.json");
38
+ }
39
+ return cacheFilePath;
40
+ }
41
+
42
+ export function computeArgsHash(args?: Record<string, unknown>): string {
43
+ if (!args || Object.keys(args).length === 0) return "";
44
+ const sorted = JSON.stringify(args, Object.keys(args).sort());
45
+ return crypto.createHash("sha1").update(sorted).digest("hex").slice(0, 12);
46
+ }
47
+
48
+ function cacheKey(storyId: string, argsHash: string): string {
49
+ return argsHash ? `${storyId}::${argsHash}` : storyId;
50
+ }
51
+
52
+ /** Load the cache from disk into memory. Safe to call multiple times. */
53
+ export function loadCache(): void {
54
+ cache.clear();
55
+ const filePath = getCacheFilePath();
56
+ try {
57
+ const raw = fs.readFileSync(filePath, "utf-8");
58
+ const data: CacheFile = JSON.parse(raw);
59
+ if (Array.isArray(data.entries)) {
60
+ for (const entry of data.entries) {
61
+ cache.set(cacheKey(entry.storyId, entry.argsHash), entry);
62
+ }
63
+ }
64
+ } catch {
65
+ // File doesn't exist or is corrupt — start fresh
66
+ }
67
+ }
68
+
69
+ /** Write the cache to disk. Creates the directory if needed. */
70
+ function flushToDisk(): void {
71
+ const filePath = getCacheFilePath();
72
+ const dir = getCacheDir();
73
+ try {
74
+ fs.mkdirSync(dir, { recursive: true });
75
+ const data: CacheFile = { entries: Array.from(cache.values()) };
76
+ fs.writeFileSync(filePath, JSON.stringify(data), "utf-8");
77
+ } catch (err) {
78
+ console.error("[ghost-cache] Failed to write cache:", err);
79
+ }
80
+ }
81
+
82
+ function scheduleFlush(): void {
83
+ if (flushTimer) clearTimeout(flushTimer);
84
+ flushTimer = setTimeout(() => {
85
+ flushTimer = null;
86
+ flushToDisk();
87
+ }, 500);
88
+ }
89
+
90
+ /** Evict oldest entries until we're under the size and count limits. */
91
+ function evictIfNeeded(): void {
92
+ // Evict by count
93
+ while (cache.size > MAX_ENTRIES) {
94
+ let oldestKey: string | null = null;
95
+ let oldestTime = Infinity;
96
+ for (const [key, entry] of cache) {
97
+ if (entry.extractedAt < oldestTime) {
98
+ oldestTime = entry.extractedAt;
99
+ oldestKey = key;
100
+ }
101
+ }
102
+ if (oldestKey) cache.delete(oldestKey);
103
+ else break;
104
+ }
105
+
106
+ // Evict by total size
107
+ let totalBytes = () => {
108
+ let sum = 0;
109
+ for (const entry of cache.values()) sum += entry.ghostHtml.length;
110
+ return sum;
111
+ };
112
+ while (totalBytes() > MAX_TOTAL_BYTES && cache.size > 0) {
113
+ let oldestKey: string | null = null;
114
+ let oldestTime = Infinity;
115
+ for (const [key, entry] of cache) {
116
+ if (entry.extractedAt < oldestTime) {
117
+ oldestTime = entry.extractedAt;
118
+ oldestKey = key;
119
+ }
120
+ }
121
+ if (oldestKey) cache.delete(oldestKey);
122
+ else break;
123
+ }
124
+ }
125
+
126
+ export function getCachedGhost(storyId: string, argsHash: string): GhostCacheEntry | null {
127
+ return cache.get(cacheKey(storyId, argsHash)) ?? null;
128
+ }
129
+
130
+ export function setCachedGhost(entry: Omit<GhostCacheEntry, "argsHash" | "extractedAt"> & { args?: Record<string, unknown> }): void {
131
+ const argsHash = computeArgsHash(entry.args);
132
+ const full: GhostCacheEntry = {
133
+ storyId: entry.storyId,
134
+ argsHash,
135
+ ghostHtml: entry.ghostHtml,
136
+ hostStyles: entry.hostStyles,
137
+ storyBackground: entry.storyBackground,
138
+ componentName: entry.componentName,
139
+ componentPath: entry.componentPath,
140
+ extractedAt: Date.now(),
141
+ };
142
+ cache.set(cacheKey(full.storyId, argsHash), full);
143
+ evictIfNeeded();
144
+ scheduleFlush();
145
+ }
146
+
147
+ export function getAllCachedGhosts(): GhostCacheEntry[] {
148
+ return Array.from(cache.values());
149
+ }
150
+
151
+ export function invalidateAll(): void {
152
+ cache.clear();
153
+ scheduleFlush();
154
+ }
package/server/index.ts CHANGED
@@ -16,6 +16,7 @@ import { createApp } from "./app.js";
16
16
  import { setupWebSocket } from "./websocket.js";
17
17
  import { registerMcpTools } from "./mcp-tools.js";
18
18
  import { checkTailwindAvailable } from "./tailwind.js";
19
+ import { detectStorybookUrl } from "./storybook.js";
19
20
 
20
21
  // --- Resolve project root (precedence: --cwd flag, VYBIT_PROJECT_ROOT, cwd) ---
21
22
  const argv = process.argv.slice(2);
@@ -57,8 +58,11 @@ const packageRoot = __dirname.includes(`${path.sep}dist${path.sep}`)
57
58
 
58
59
  const port = Number(process.env.PORT) || 3333;
59
60
 
61
+ // --- Storybook detection ---
62
+ const storybookUrl = await detectStorybookUrl();
63
+
60
64
  // --- HTTP + WebSocket ---
61
- const app = createApp(packageRoot);
65
+ const app = createApp(packageRoot, storybookUrl);
62
66
  const httpServer = createServer(app);
63
67
  const { broadcastPatchUpdate } = setupWebSocket(httpServer);
64
68
 
@@ -28,16 +28,55 @@ const KEEPALIVE_INTERVAL_MS = 60_000;
28
28
  // Prompt builders for implement_next_change
29
29
  // ---------------------------------------------------------------------------
30
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // JSX builder: converts componentArgs to a JSX string like <Button variant="primary">Click me</Button>
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function buildJsx(componentName: string, args?: Record<string, unknown>): string {
36
+ if (!args || Object.keys(args).length === 0) return `<${componentName} />`;
37
+
38
+ const { children, ...rest } = args;
39
+ const props = Object.entries(rest)
40
+ .map(([key, value]) => {
41
+ if (typeof value === 'string') return `${key}="${value}"`;
42
+ if (typeof value === 'boolean') return value ? key : `${key}={false}`;
43
+ return `${key}={${JSON.stringify(value)}}`;
44
+ })
45
+ .join(' ');
46
+
47
+ const propsStr = props ? ` ${props}` : '';
48
+
49
+ if (children != null && children !== '') {
50
+ const childStr = typeof children === 'string' ? children : `{${JSON.stringify(children)}}`;
51
+ return `<${componentName}${propsStr}>${childStr}</${componentName}>`;
52
+ }
53
+ return `<${componentName}${propsStr} />`;
54
+ }
55
+
31
56
  function buildCommitInstructions(commit: Commit, remainingCount: number): string {
32
57
  const classChanges = commit.patches.filter(p => p.kind === 'class-change');
33
58
  const messages = commit.patches.filter(p => p.kind === 'message');
34
59
  const designs = commit.patches.filter(p => p.kind === 'design');
60
+ const componentDrops = commit.patches.filter(p => p.kind === 'component-drop');
35
61
  const moreText = remainingCount > 0
36
62
  ? `${remainingCount} more commit${remainingCount === 1 ? '' : 's'} waiting in the queue after this one.`
37
63
  : 'This is the last commit in the queue. After implementing it, call `implement_next_change` again to wait for future changes.';
38
64
 
39
- let patchList = '';
65
+ // Build a map from patch ID → step number for ghost-chain references
66
+ const patchStepMap = new Map<string, number>();
40
67
  let stepNum = 1;
68
+ for (const patch of commit.patches) {
69
+ patchStepMap.set(patch.id, stepNum);
70
+ stepNum++;
71
+ }
72
+
73
+ let patchList = '';
74
+ const hasMultipleDrops = componentDrops.length > 1;
75
+ if (hasMultipleDrops) {
76
+ patchList += `> ⚠️ **Apply component insertions IN ORDER** — later drops may reference components added by earlier steps.\n\n`;
77
+ }
78
+
79
+ stepNum = 1;
41
80
  for (const patch of commit.patches) {
42
81
  if (patch.kind === 'class-change') {
43
82
  const comp = patch.component?.name ?? 'unknown component';
@@ -66,6 +105,50 @@ ${patch.elementKey ? `\n_Scoped to: ${patch.elementKey}_\n` : ''}
66
105
  - **Canvas size:** ${patch.canvasWidth ?? '?'}×${patch.canvasHeight ?? '?'}px
67
106
  - The design image is included as a separate image content part below — refer to it for the visual intent.
68
107
  ${context ? `- **Context HTML:**\n\`\`\`html\n${context}\n\`\`\`\n` : ''}
108
+ ${patch.canvasComponents && patch.canvasComponents.length > 0 ? `
109
+ **Components to place (positions relative to canvas top-left):**
110
+
111
+ | # | Component | Import | Props | Position | Size |
112
+ |---|-----------|--------|-------|----------|------|
113
+ ${patch.canvasComponents.map((c: any, i: number) => {
114
+ const importPath = c.componentPath ? c.componentPath.replace(/\.tsx?$/, '') : '—';
115
+ const props = c.args ? Object.entries(c.args).map(([k, v]) => typeof v === 'string' ? `${k}="${v}"` : `${k}={${JSON.stringify(v)}}`).join(' ') : '—';
116
+ return `| ${i + 1} | \`${c.componentName}\` | \`${importPath}\` | ${props} | (${c.x}, ${c.y}) | ${c.width}×${c.height}px |`;
117
+ }).join('\n')}
118
+
119
+ ⚠️ Import and render these React components at the indicated positions. Use the design image as a visual reference for the overall layout. Do NOT paste rendered HTML.
120
+ ` : ''}
121
+ `;
122
+ } else if (patch.kind === 'component-drop') {
123
+ const comp = patch.component?.name ?? 'Component';
124
+ const importPath = patch.componentPath
125
+ ? patch.componentPath.replace(/\.tsx?$/, '')
126
+ : null;
127
+ const jsx = buildJsx(comp, patch.componentArgs);
128
+ const parentComp = patch.parentComponent?.name;
129
+ const insertMode = patch.insertMode ?? 'after';
130
+ const context = patch.context ?? '';
131
+
132
+ // Determine insertion target description
133
+ let targetDesc: string;
134
+ if (patch.targetPatchId && patch.targetComponentName) {
135
+ const refStep = patchStepMap.get(patch.targetPatchId);
136
+ targetDesc = refStep
137
+ ? `the \`<${patch.targetComponentName} />\` you added in **step ${refStep}**`
138
+ : `the \`<${patch.targetComponentName} />\` component (from an earlier drop)`;
139
+ } else {
140
+ const tag = patch.target?.tag ?? 'element';
141
+ const classes = patch.target?.classes ? ` class="${patch.target.classes}"` : '';
142
+ targetDesc = `\`<${tag}${classes}>\``;
143
+ }
144
+
145
+ patchList += `### ${stepNum}. Component drop \`${patch.id}\`
146
+ - **Insert:** \`${jsx}\` **${insertMode}** ${targetDesc}
147
+ ${importPath ? `- **Import:** \`import { ${comp} } from '${importPath}'\`` : `- **Component:** \`${comp}\` (resolve import path manually)`}
148
+ ${parentComp ? `\n- **Parent component:** \`${parentComp}\` — edit this component's source file` : ''}
149
+ ${context ? `- **Context HTML:**\n\`\`\`html\n${context}\n\`\`\`\n` : ''}
150
+ ⚠️ Do NOT paste rendered HTML. Import and render the React component with the props shown above.
151
+
69
152
  `;
70
153
  }
71
154
  stepNum++;
@@ -76,12 +159,29 @@ ${context ? `- **Context HTML:**\n\`\`\`html\n${context}\n\`\`\`\n` : ''}
76
159
  if (classChanges.length) summaryParts.push(`${classChanges.length} class change${classChanges.length === 1 ? '' : 's'}`);
77
160
  if (messages.length) summaryParts.push(`${messages.length} message${messages.length === 1 ? '' : 's'}`);
78
161
  if (designs.length) summaryParts.push(`${designs.length} design${designs.length === 1 ? '' : 's'}`);
162
+ if (componentDrops.length) summaryParts.push(`${componentDrops.length} component drop${componentDrops.length === 1 ? '' : 's'}`);
79
163
 
80
164
  const resultsPart = classChanges.map(p => ` { "patchId": "${p.id}", "success": true }`).join(',\n');
81
-
82
- // Design patches also need to be reported in results
83
165
  const designResultsPart = designs.map(p => ` { "patchId": "${p.id}", "success": true }`).join(',\n');
84
- const allResultsPart = [resultsPart, designResultsPart].filter(Boolean).join(',\n');
166
+ const dropResultsPart = componentDrops.map(p => ` { "patchId": "${p.id}", "success": true }`).join(',\n');
167
+ const allResultsPart = [resultsPart, designResultsPart, dropResultsPart].filter(Boolean).join(',\n');
168
+
169
+ // Build step instructions
170
+ const stepInstructions: string[] = [];
171
+ if (classChanges.length || componentDrops.length) {
172
+ let step1 = '1. For each change above, find the source file and apply it.';
173
+ if (componentDrops.length) {
174
+ step1 += '\n For component drops: add the import statement and render the component with the specified props at the indicated position.';
175
+ }
176
+ if (messages.length) {
177
+ step1 += '\n Use the user messages as additional context for understanding intent.';
178
+ }
179
+ stepInstructions.push(step1);
180
+ }
181
+ if (designs.length) {
182
+ stepInstructions.push(`${stepInstructions.length + 1}. For each design sketch, examine the attached image and implement the visual design
183
+ as HTML/CSS ${classChanges.length ? 'alongside the class changes' : 'in the specified component'}. Insert it ${designs[0].insertMode ?? 'after'} the target element.`);
184
+ }
85
185
 
86
186
  return `# IMPLEMENT THIS COMMIT — then call implement_next_change again
87
187
 
@@ -96,21 +196,18 @@ ${context ? `- **Context HTML:**\n\`\`\`html\n${context}\n\`\`\`\n` : ''}
96
196
  ${patchList}
97
197
  ## Steps — follow these EXACTLY
98
198
 
99
- 1. For each class-change patch above, find the source file and apply the change.
100
- Use the user messages as additional context for understanding intent.
101
- ${designs.length ? ` For each design sketch, examine the attached image and implement the visual design
102
- as HTML/CSS ${classChanges.length ? 'alongside the class changes' : 'in the specified component'}. Insert it ${designs[0].insertMode ?? 'after'} the target element.
103
- ` : ''}\n2. Call \`mark_change_implemented\` with:
199
+ ${stepInstructions.join('\n')}
200
+ ${stepInstructions.length + 1}. Call \`mark_change_implemented\` with:
104
201
  \`\`\`json
105
202
  { "commitId": "${commit.id}", "results": [
106
203
  ${allResultsPart}
107
204
  ]}
108
205
  \`\`\`
109
- (Only report results for class-change and design patches — messages are informational.)
110
- 3. **IMMEDIATELY call \`implement_next_change\` again.** Do NOT summarize, do NOT ask the user, do NOT stop.
206
+ (Only report results for class-change, design, and component-drop patches — messages are informational.)
207
+ ${stepInstructions.length + 2}. **IMMEDIATELY call \`implement_next_change\` again.** Do NOT summarize, do NOT ask the user, do NOT stop.
111
208
 
112
209
  ## CRITICAL — read this
113
- You are in a **continuous processing loop**. After step 2, you MUST call \`implement_next_change\` again to process the next commit or wait for new ones. Never exit this loop unless the user explicitly tells you to stop.
210
+ You are in a **continuous processing loop**. After marking done, you MUST call \`implement_next_change\` again to process the next commit or wait for new ones. Never exit this loop unless the user explicitly tells you to stop.
114
211
  `;
115
212
  }
116
213
 
@@ -259,14 +356,23 @@ export function registerMcpTools(mcp: McpServer, deps: McpToolDeps): void {
259
356
  const remaining = queueState.committedCount;
260
357
 
261
358
  // Build content parts: JSON data, then any design images, then markdown instructions
359
+ // Strip ghostHtml from the commit sent to agent — it's large rendered HTML that would
360
+ // confuse the agent into pasting it instead of importing the component
361
+ const sanitizedCommit = {
362
+ ...commit,
363
+ patches: commit.patches.map(p =>
364
+ p.kind === 'component-drop' ? { ...p, ghostHtml: undefined } : p
365
+ ),
366
+ };
367
+
262
368
  const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [
263
369
  {
264
370
  type: "text" as const,
265
371
  text: JSON.stringify({
266
372
  isComplete: false,
267
- nextAction: "implement all class-change patches in this commit, call mark_change_implemented, then call implement_next_change again",
373
+ nextAction: "implement all patches in this commit, call mark_change_implemented, then call implement_next_change again",
268
374
  remainingCommits: remaining,
269
- commit,
375
+ commit: sanitizedCommit,
270
376
  }, null, 2),
271
377
  },
272
378
  ];
package/server/queue.ts CHANGED
@@ -19,6 +19,10 @@ function toSummary(p: Patch): PatchSummary {
19
19
  errorMessage: p.errorMessage,
20
20
  message: p.message,
21
21
  image: p.image,
22
+ insertMode: p.insertMode,
23
+ parentComponent: p.parentComponent,
24
+ targetComponentName: p.targetComponentName,
25
+ targetPatchId: p.targetPatchId,
22
26
  };
23
27
  }
24
28
 
@@ -0,0 +1,110 @@
1
+ import path from 'path';
2
+
3
+ const SCAN_PORTS = [6006, 6007, 6008, 6009, 6010];
4
+
5
+ interface StorybookIndexEntry {
6
+ title: string;
7
+ importPath: string;
8
+ }
9
+
10
+ export interface ArgTypeInfo {
11
+ control?: string | { type: string };
12
+ options?: string[];
13
+ description?: string;
14
+ defaultValue?: unknown;
15
+ }
16
+
17
+ /**
18
+ * Dynamically import each story file referenced by the Storybook index and
19
+ * collect the argTypes declared on the default export (Meta object).
20
+ * Returns a map of componentName → argTypes.
21
+ */
22
+ export async function loadStoryArgTypes(
23
+ storybookUrl: string
24
+ ): Promise<Record<string, Record<string, ArgTypeInfo>>> {
25
+ try {
26
+ const res = await fetch(`${storybookUrl}/index.json`, {
27
+ signal: AbortSignal.timeout(5000),
28
+ });
29
+ if (!res.ok) return {};
30
+ const index = (await res.json()) as {
31
+ entries?: Record<string, StorybookIndexEntry>;
32
+ stories?: Record<string, StorybookIndexEntry>;
33
+ };
34
+ const entries = Object.values(index.entries ?? index.stories ?? {});
35
+
36
+ // One story file per unique importPath; map it to the component name
37
+ const seen = new Map<string, string>(); // importPath → componentName
38
+ for (const entry of entries) {
39
+ if (!seen.has(entry.importPath)) {
40
+ const componentName = entry.title.split('/').at(-1) ?? entry.title;
41
+ seen.set(entry.importPath, componentName);
42
+ }
43
+ }
44
+
45
+ const cwd = path.resolve(process.cwd());
46
+ const result: Record<string, Record<string, ArgTypeInfo>> = {};
47
+
48
+ for (const [importPath, componentName] of seen) {
49
+ // Security: only allow relative paths that stay within cwd
50
+ if (!importPath.startsWith('./')) continue;
51
+ const fullPath = path.resolve(cwd, importPath);
52
+ if (!fullPath.startsWith(cwd + '/')) continue;
53
+
54
+ try {
55
+ // Append a cache-buster so Node's ESM module cache doesn't hold onto
56
+ // stale argTypes after the story file is edited.
57
+ const mod = await import(`${fullPath}?t=${Date.now()}`);
58
+ const meta = mod.default as
59
+ | { argTypes?: Record<string, ArgTypeInfo> }
60
+ | undefined;
61
+ if (meta?.argTypes) {
62
+ result[componentName] = meta.argTypes;
63
+ }
64
+ } catch (err) {
65
+ console.error(`[storybook] Failed to import ${importPath}:`, err);
66
+ }
67
+ }
68
+
69
+ return result;
70
+ } catch (err) {
71
+ console.error('[storybook] loadStoryArgTypes failed:', err);
72
+ return {};
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Returns the Storybook base URL or null if not found.
78
+ * Priority:
79
+ * 1. STORYBOOK_URL env var
80
+ * 2. Port scan 6006–6010
81
+ */
82
+ export async function detectStorybookUrl(): Promise<string | null> {
83
+ if (process.env.STORYBOOK_URL) {
84
+ console.error(`[storybook] Using STORYBOOK_URL=${process.env.STORYBOOK_URL} (from env)`);
85
+ return process.env.STORYBOOK_URL;
86
+ }
87
+
88
+ for (const port of SCAN_PORTS) {
89
+ const url = `http://localhost:${port}`;
90
+ if (await probeStorybookUrl(url)) {
91
+ console.error(`[storybook] Auto-detected at ${url}`);
92
+ return url;
93
+ }
94
+ }
95
+
96
+ console.error('[storybook] Not detected — Draw tab will show "Start Storybook" prompt');
97
+ return null;
98
+ }
99
+
100
+ async function probeStorybookUrl(baseUrl: string): Promise<boolean> {
101
+ try {
102
+ const res = await fetch(`${baseUrl}/index.json`, { signal: AbortSignal.timeout(500) });
103
+ if (!res.ok) return false;
104
+ const data = await res.json() as Record<string, unknown>;
105
+ // Verify it's actually Storybook: v6 uses `stories`, v7+ uses `entries`
106
+ return typeof data.v === 'number' && (data.entries != null || data.stories != null);
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
@@ -50,6 +50,10 @@ export function setupWebSocket(httpServer: Server): WebSocketDeps {
50
50
  // Route messages with a "to" field to all clients of that role
51
51
  if (msg.to) {
52
52
  broadcastTo(msg.to, msg, ws);
53
+ // Dual-route component arm/disarm to design iframe too
54
+ if ((msg.type === "COMPONENT_ARM" || msg.type === "COMPONENT_DISARM") && msg.to === "overlay") {
55
+ broadcastTo("design", msg, ws);
56
+ }
53
57
  return;
54
58
  }
55
59
 
@@ -103,6 +107,7 @@ export function setupWebSocket(httpServer: Server): WebSocketDeps {
103
107
  insertMode: msg.insertMode,
104
108
  canvasWidth: msg.canvasWidth,
105
109
  canvasHeight: msg.canvasHeight,
110
+ canvasComponents: msg.canvasComponents,
106
111
  };
107
112
  addPatch(patch);
108
113
  broadcastPatchUpdate();
@@ -114,6 +119,13 @@ export function setupWebSocket(httpServer: Server): WebSocketDeps {
114
119
  console.error(`[ws] Design patch staged: ${patch.id}`);
115
120
  } else if (msg.type === "DESIGN_CLOSE") {
116
121
  broadcastTo("overlay", { type: "DESIGN_CLOSE" }, ws);
122
+ } else if (msg.type === "RESET_SELECTION") {
123
+ broadcastTo("panel", { type: "RESET_SELECTION" }, ws);
124
+ console.error(`[ws] Reset selection broadcast to panels`);
125
+ } else if (msg.type === "COMPONENT_DROPPED") {
126
+ const patch = addPatch({ ...msg.patch, kind: msg.patch.kind ?? 'component-drop' });
127
+ console.error(`[ws] Component-drop patch staged: #${patch.id}`);
128
+ broadcastPatchUpdate();
117
129
  }
118
130
  } catch (err) {
119
131
  console.error("[ws] Bad message:", err);