@1agh/maude 0.30.0 → 0.32.0

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 (93) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +788 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +796 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
  23. package/apps/studio/client/panels/ReadinessList.jsx +189 -0
  24. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  25. package/apps/studio/client/panels/acp-runtime.js +286 -0
  26. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  27. package/apps/studio/client/panels/git-grouping.js +86 -0
  28. package/apps/studio/client/styles/0-reset.css +4 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +625 -3
  30. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +821 -0
  32. package/apps/studio/client/styles/_index.css +2 -0
  33. package/apps/studio/client/tour/collab-tour.js +61 -0
  34. package/apps/studio/client/tour/overlay.jsx +11 -1
  35. package/apps/studio/client/whats-new.jsx +25 -10
  36. package/apps/studio/collab/registry.ts +13 -0
  37. package/apps/studio/collab/room.ts +36 -0
  38. package/apps/studio/cursors-overlay.tsx +17 -1
  39. package/apps/studio/dist/client.bundle.js +29097 -1534
  40. package/apps/studio/dist/comment-mount.js +4 -2
  41. package/apps/studio/dist/styles.css +5816 -1614
  42. package/apps/studio/git/endpoints.ts +338 -0
  43. package/apps/studio/git/service.ts +1334 -0
  44. package/apps/studio/git/watch.ts +97 -0
  45. package/apps/studio/github/endpoints.ts +358 -0
  46. package/apps/studio/github/service.ts +231 -0
  47. package/apps/studio/github/token.ts +53 -0
  48. package/apps/studio/hmr-broadcast.ts +9 -2
  49. package/apps/studio/http.ts +399 -1
  50. package/apps/studio/participants-chrome.tsx +69 -9
  51. package/apps/studio/paths.ts +12 -0
  52. package/apps/studio/readiness.ts +220 -0
  53. package/apps/studio/scaffold-design.ts +57 -0
  54. package/apps/studio/server.ts +65 -2
  55. package/apps/studio/sync/agent.ts +81 -1
  56. package/apps/studio/sync/codec.ts +24 -0
  57. package/apps/studio/sync/cold-start.ts +40 -0
  58. package/apps/studio/sync/hub-link.ts +137 -0
  59. package/apps/studio/test/acp-bridge.test.ts +127 -0
  60. package/apps/studio/test/acp-env.test.ts +65 -0
  61. package/apps/studio/test/acp-origin-gate.test.ts +95 -0
  62. package/apps/studio/test/acp-transcript.test.ts +112 -0
  63. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  64. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  65. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  66. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  67. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  68. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  69. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  70. package/apps/studio/test/editing-presence.test.ts +103 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  72. package/apps/studio/test/git-api.test.ts +0 -0
  73. package/apps/studio/test/git-branches.test.ts +106 -0
  74. package/apps/studio/test/git-grouping.test.ts +106 -0
  75. package/apps/studio/test/git-watch.test.ts +97 -0
  76. package/apps/studio/test/github-api.test.ts +465 -0
  77. package/apps/studio/test/hub-link.test.ts +69 -0
  78. package/apps/studio/test/participants-chrome.test.ts +36 -1
  79. package/apps/studio/test/readiness.test.ts +127 -0
  80. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  81. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  82. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  83. package/apps/studio/tool-palette.tsx +18 -9
  84. package/apps/studio/use-chrome-visibility.tsx +66 -0
  85. package/apps/studio/use-collab.tsx +414 -187
  86. package/apps/studio/whats-new.json +82 -0
  87. package/apps/studio/ws.ts +44 -1
  88. package/cli/commands/design.mjs +1 -0
  89. package/cli/lib/gitignore-block.mjs +16 -3
  90. package/cli/lib/gitignore-block.test.mjs +13 -1
  91. package/package.json +11 -9
  92. package/plugins/design/dependencies.json +17 -0
  93. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,112 @@
1
+ // acp/transcript — repo-level chat list + raw-transcript → clean-messages.
2
+
3
+ import { afterEach, describe, expect, test } from 'bun:test';
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ import { deleteChat, listChats, readChatMessages } from '../acp/transcript.ts';
9
+
10
+ let root: string;
11
+ afterEach(() => {
12
+ if (root) rmSync(root, { recursive: true, force: true });
13
+ });
14
+
15
+ function seed(chatId: string, lines: object[]): string {
16
+ root = mkdtempSync(join(tmpdir(), 'acp-tx-'));
17
+ const dir = join(root, '_chat');
18
+ mkdirSync(dir, { recursive: true });
19
+ writeFileSync(join(dir, `${chatId}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n'));
20
+ return root;
21
+ }
22
+
23
+ describe('readChatMessages — raw → clean turns', () => {
24
+ test('aggregates agent updates into one assistant message; drops noise', () => {
25
+ const designRoot = seed('c1', [
26
+ { ts: 1, role: 'user', text: 'make the button red' },
27
+ {
28
+ ts: 2,
29
+ role: 'agent',
30
+ update: { sessionUpdate: 'available_commands_update', availableCommands: [] },
31
+ },
32
+ {
33
+ ts: 3,
34
+ role: 'agent',
35
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Sure, ' } },
36
+ },
37
+ {
38
+ ts: 4,
39
+ role: 'agent',
40
+ update: {
41
+ sessionUpdate: 'tool_call',
42
+ toolCallId: 't1',
43
+ title: 'Edit Button.tsx',
44
+ kind: 'edit',
45
+ status: 'pending',
46
+ },
47
+ },
48
+ {
49
+ ts: 5,
50
+ role: 'agent',
51
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'done.' } },
52
+ },
53
+ {
54
+ ts: 6,
55
+ role: 'agent',
56
+ update: { sessionUpdate: 'tool_call_update', toolCallId: 't1', status: 'completed' },
57
+ },
58
+ { ts: 7, role: 'agent', update: { sessionUpdate: 'usage_update', used: 10 } },
59
+ { ts: 8, role: 'stop', stopReason: 'end_turn' },
60
+ ]);
61
+
62
+ const msgs = readChatMessages(designRoot, 'c1');
63
+ expect(msgs.length).toBe(2);
64
+ expect(msgs[0]).toEqual({
65
+ role: 'user',
66
+ parts: [{ type: 'text', text: 'make the button red' }],
67
+ });
68
+ expect(msgs[1].role).toBe('assistant');
69
+ // text parts merged across the tool call boundary order-preserving
70
+ const texts = msgs[1].parts.filter((p) => p.type === 'text').map((p) => p.text);
71
+ expect(texts.join('')).toBe('Sure, done.');
72
+ const tool = msgs[1].parts.find((p) => p.type === 'tool');
73
+ expect(tool?.toolName).toBe('Edit Button.tsx');
74
+ expect(tool?.done).toBe(true);
75
+ // noise (available_commands_update / usage_update) is not rendered
76
+ expect(JSON.stringify(msgs)).not.toContain('availableCommands');
77
+ expect(JSON.stringify(msgs)).not.toContain('usage');
78
+ });
79
+
80
+ test('missing chat → empty', () => {
81
+ const designRoot = seed('c1', [{ ts: 1, role: 'user', text: 'hi' }]);
82
+ expect(readChatMessages(designRoot, 'nope')).toEqual([]);
83
+ });
84
+ });
85
+
86
+ describe('listChats', () => {
87
+ test('lists chats with a title derived from the first user line', () => {
88
+ const designRoot = seed('chat-7', [
89
+ { ts: 1, role: 'user', text: 'redesign the pricing page please' },
90
+ { ts: 2, role: 'stop', stopReason: 'end_turn' },
91
+ ]);
92
+ const chats = listChats(designRoot);
93
+ expect(chats.length).toBe(1);
94
+ expect(chats[0].id).toBe('chat-7');
95
+ expect(chats[0].title).toBe('redesign the pricing page please');
96
+ });
97
+
98
+ test('no _chat dir → empty', () => {
99
+ root = mkdtempSync(join(tmpdir(), 'acp-tx-'));
100
+ expect(listChats(root)).toEqual([]);
101
+ });
102
+ });
103
+
104
+ describe('deleteChat', () => {
105
+ test('removes the transcript; missing → false', () => {
106
+ const designRoot = seed('gone', [{ ts: 1, role: 'user', text: 'bye' }]);
107
+ expect(listChats(designRoot).length).toBe(1);
108
+ expect(deleteChat(designRoot, 'gone')).toBe(true);
109
+ expect(listChats(designRoot).length).toBe(0);
110
+ expect(deleteChat(designRoot, 'gone')).toBe(false);
111
+ });
112
+ });
@@ -122,6 +122,71 @@ describe('/_api/canvas — POST round-trip', () => {
122
122
  }
123
123
  });
124
124
 
125
+ test('create + delete emit canvas-list-update over the inspector WS (Phase 30)', async () => {
126
+ const { root } = makeSandbox();
127
+ const port = nextPort();
128
+ const proc = await bootServer(root, port);
129
+ let socket: WebSocket | null = null;
130
+ try {
131
+ const messages: Array<{ type: string; payload?: { action?: string; slug?: string } }> = [];
132
+ const sock = new WebSocket(`ws://localhost:${port}/_ws`);
133
+ socket = sock;
134
+ await new Promise<void>((res, rej) => {
135
+ sock.addEventListener('open', () => res());
136
+ sock.addEventListener('error', () => rej(new Error('ws error')));
137
+ setTimeout(() => rej(new Error('ws open timeout')), 2000);
138
+ });
139
+ sock.addEventListener('message', (ev) => {
140
+ try {
141
+ messages.push(JSON.parse(String(ev.data)));
142
+ } catch {
143
+ /* ignore non-JSON */
144
+ }
145
+ });
146
+
147
+ const waitFor = async (pred: (m: (typeof messages)[number]) => boolean) => {
148
+ const deadline = Date.now() + 2000;
149
+ while (Date.now() < deadline) {
150
+ const hit = messages.find(pred);
151
+ if (hit) return hit;
152
+ await Bun.sleep(25);
153
+ }
154
+ return undefined;
155
+ };
156
+
157
+ const created = await fetch(`http://localhost:${port}/_api/canvas`, {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({ name: 'Live Add', kind: 'brief-board' }),
161
+ });
162
+ expect(created.status).toBe(201);
163
+ const cj = (await created.json()) as { rel: string };
164
+
165
+ const added = await waitFor(
166
+ (m) => m.type === 'canvas-list-update' && m.payload?.action === 'added'
167
+ );
168
+ expect(added?.payload?.slug).toBe('ui-live_add');
169
+
170
+ const del = await fetch(
171
+ `http://localhost:${port}/_api/canvas?file=${encodeURIComponent(cj.rel)}`,
172
+ { method: 'DELETE' }
173
+ );
174
+ expect(del.status).toBe(200);
175
+
176
+ const removed = await waitFor(
177
+ (m) => m.type === 'canvas-list-update' && m.payload?.action === 'removed'
178
+ );
179
+ expect(removed?.payload?.slug).toBe('ui-live_add');
180
+ } finally {
181
+ try {
182
+ socket?.close();
183
+ } catch {
184
+ /* ignore */
185
+ }
186
+ await killProc(proc);
187
+ }
188
+ }, 10_000);
189
+
125
190
  test('defaults kind to brief-board when omitted', async () => {
126
191
  const { root } = makeSandbox();
127
192
  const port = nextPort();
@@ -284,6 +349,10 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
284
349
  join(designRoot, `${created.slug}.annotations.svg`),
285
350
  '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"></svg>'
286
351
  );
352
+ // Seed the per-machine camera view file (DDR-115) to prove it's swept too.
353
+ mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
354
+ const viewAbs = join(designRoot, '_canvas-state', `${created.slug}.view.json`);
355
+ writeFileSync(viewAbs, JSON.stringify({ viewport: { x: 1, y: 2, zoom: 1 } }));
287
356
  const tsx = join(designRoot, 'ui', 'Trash Me.tsx');
288
357
  expect(existsSync(tsx)).toBe(true);
289
358
 
@@ -293,14 +362,17 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
293
362
  expect(j.ok).toBe(true);
294
363
  expect(j.trashed.some((t) => t.endsWith('Trash Me.tsx'))).toBe(true);
295
364
  expect(j.trashed.some((t) => t.endsWith('.annotations.svg'))).toBe(true);
365
+ expect(j.trashed.some((t) => t.endsWith('.view.json'))).toBe(true);
296
366
 
297
367
  // Gone from the group; present (recoverable) under _trash/ with a manifest.
298
368
  expect(existsSync(tsx)).toBe(false);
299
369
  expect(existsSync(join(designRoot, `${created.slug}.annotations.svg`))).toBe(false);
370
+ expect(existsSync(viewAbs)).toBe(false);
300
371
  const trashDirs = readdirSync(join(designRoot, '_trash'));
301
372
  expect(trashDirs.length).toBe(1);
302
373
  const td = join(designRoot, '_trash', trashDirs[0] as string);
303
374
  expect(existsSync(join(td, 'Trash Me.tsx'))).toBe(true);
375
+ expect(existsSync(join(td, `_canvas-state__${created.slug}.view.json`))).toBe(true);
304
376
  expect(existsSync(join(td, '_trash-manifest.json'))).toBe(true);
305
377
  } finally {
306
378
  await killProc(proc);
@@ -0,0 +1,322 @@
1
+ // canvas-list-watch — external-canvas list watcher. Emits `canvas-list-update`
2
+ // when a canvas file appears/disappears on disk from OUTSIDE the dev-server
3
+ // (the ACP agent's `/design:new`, agent Write, git checkout), so the browser
4
+ // file tree refreshes without a reload.
5
+ // RCA: .ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import {
13
+ CANVAS_LIST_DEBOUNCE_MS,
14
+ createCanvasListWatch,
15
+ isCanvasCandidate,
16
+ } from '../canvas-list-watch.ts';
17
+ import { type Context, createBus } from '../context.ts';
18
+ import { createFsWatch } from '../fs-watch.ts';
19
+
20
+ const GROUPS = [
21
+ { label: 'System', path: 'system' },
22
+ { label: 'UI', path: 'ui' },
23
+ ];
24
+
25
+ function sandbox(): { root: string; designRoot: string } {
26
+ const root = mkdtempSync(join(tmpdir(), 'clw-test-'));
27
+ const designRoot = join(root, '.design');
28
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
29
+ return { root, designRoot };
30
+ }
31
+
32
+ function mkCtx(designRoot: string): Context {
33
+ return {
34
+ cfg: { canvasGroups: GROUPS } as Context['cfg'],
35
+ projectLabel: '',
36
+ paths: { designRoot, designRel: '.design' } as Context['paths'],
37
+ bus: createBus(),
38
+ };
39
+ }
40
+
41
+ const TSX = 'export default function C(){return null}';
42
+
43
+ interface Update {
44
+ action?: string;
45
+ rel?: string;
46
+ slug?: string;
47
+ }
48
+
49
+ function collect(ctx: Context): Update[] {
50
+ const got: Update[] = [];
51
+ ctx.bus.on('canvas-list-update', (p) => got.push(p as Update));
52
+ return got;
53
+ }
54
+
55
+ describe('canvas-list-watch / isCanvasCandidate (pure gate)', () => {
56
+ const groups = ['system', 'ui'];
57
+ test('accepts openable canvases under a configured group', () => {
58
+ expect(isCanvasCandidate('ui/Pricing.tsx', groups)).toBe(true);
59
+ expect(isCanvasCandidate('ui/project/Pricing.tsx', groups)).toBe(true);
60
+ expect(isCanvasCandidate('ui/Smoke.html', groups)).toBe(true);
61
+ // DS preview specimens DO show in the tree, so they must trigger a refresh.
62
+ expect(isCanvasCandidate('system/project/preview/colors.tsx', groups)).toBe(true);
63
+ });
64
+
65
+ test('rejects non-canvas extensions (sidecars, tokens, docs)', () => {
66
+ expect(isCanvasCandidate('ui/Pricing.meta.json', groups)).toBe(false);
67
+ expect(isCanvasCandidate('ui/Pricing.css', groups)).toBe(false);
68
+ expect(isCanvasCandidate('system/project/README.md', groups)).toBe(false);
69
+ });
70
+
71
+ test('rejects runtime-state (`_`-prefixed segment) + SKIP_DIRS', () => {
72
+ expect(isCanvasCandidate('_history/ui-x/00-screen.tsx', groups)).toBe(false);
73
+ expect(isCanvasCandidate('ui/_draft.tsx', groups)).toBe(false);
74
+ expect(isCanvasCandidate('_active.json', groups)).toBe(false);
75
+ expect(isCanvasCandidate('node_modules/pkg/index.tsx', groups)).toBe(false);
76
+ });
77
+
78
+ test('rejects canvases outside any configured group', () => {
79
+ expect(isCanvasCandidate('docs/Thing.tsx', groups)).toBe(false);
80
+ expect(isCanvasCandidate('Thing.tsx', groups)).toBe(false);
81
+ });
82
+
83
+ test('non-string / empty input is rejected, not thrown', () => {
84
+ expect(isCanvasCandidate('', groups)).toBe(false);
85
+ expect(isCanvasCandidate(undefined as unknown as string, groups)).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe('canvas-list-watch / diff + emit', () => {
90
+ test('seeding existing canvases emits nothing', async () => {
91
+ const { root, designRoot } = sandbox();
92
+ writeFileSync(join(designRoot, 'ui', 'Existing.tsx'), TSX);
93
+ const ctx = mkCtx(designRoot);
94
+ const got = collect(ctx);
95
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
96
+ await w.ready;
97
+ try {
98
+ expect(got).toHaveLength(0);
99
+ expect(w.known.has('.design/ui/Existing.tsx')).toBe(true);
100
+ } finally {
101
+ w.stop();
102
+ rmSync(root, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ test('a canvas written straight to disk emits action:added (the bug)', async () => {
107
+ const { root, designRoot } = sandbox();
108
+ const ctx = mkCtx(designRoot);
109
+ const got = collect(ctx);
110
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
111
+ await w.ready;
112
+ try {
113
+ // Simulate `/design:new` writing the canvas (and its sidecar) directly —
114
+ // NO API call, so api.ts never emits.
115
+ writeFileSync(join(designRoot, 'ui', 'Pricing.tsx'), TSX);
116
+ writeFileSync(join(designRoot, 'ui', 'Pricing.meta.json'), '{}');
117
+ await w.refresh();
118
+ expect(got).toHaveLength(1);
119
+ expect(got[0]?.action).toBe('added');
120
+ expect(got[0]?.slug).toBe('ui-pricing');
121
+ } finally {
122
+ w.stop();
123
+ rmSync(root, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test('deleting a canvas on disk emits action:removed', async () => {
128
+ const { root, designRoot } = sandbox();
129
+ const ctx = mkCtx(designRoot);
130
+ const got = collect(ctx);
131
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
132
+ await w.ready;
133
+ try {
134
+ const abs = join(designRoot, 'ui', 'Gone.tsx');
135
+ writeFileSync(abs, TSX);
136
+ await w.refresh();
137
+ got.length = 0; // drop the 'added'
138
+ rmSync(abs);
139
+ await w.refresh();
140
+ expect(got).toHaveLength(1);
141
+ expect(got[0]?.action).toBe('removed');
142
+ expect(got[0]?.slug).toBe('ui-gone');
143
+ } finally {
144
+ w.stop();
145
+ rmSync(root, { recursive: true, force: true });
146
+ }
147
+ });
148
+
149
+ test('editing an existing canvas body emits nothing (no tree thrash)', async () => {
150
+ const { root, designRoot } = sandbox();
151
+ const abs = join(designRoot, 'ui', 'Edited.tsx');
152
+ writeFileSync(abs, TSX);
153
+ const ctx = mkCtx(designRoot);
154
+ const got = collect(ctx);
155
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
156
+ await w.ready;
157
+ try {
158
+ writeFileSync(abs, `${TSX}\n// edited`);
159
+ writeFileSync(join(designRoot, 'ui', 'Edited.meta.json'), '{"x":1}');
160
+ await w.refresh();
161
+ expect(got).toHaveLength(0);
162
+ } finally {
163
+ w.stop();
164
+ rmSync(root, { recursive: true, force: true });
165
+ }
166
+ });
167
+
168
+ test('runtime-state + sidecar writes never emit', async () => {
169
+ const { root, designRoot } = sandbox();
170
+ const ctx = mkCtx(designRoot);
171
+ const got = collect(ctx);
172
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
173
+ await w.ready;
174
+ try {
175
+ writeFileSync(join(designRoot, '_active.json'), '{}');
176
+ mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
177
+ writeFileSync(join(designRoot, '_canvas-state', 'ui-x.view.json'), '{}');
178
+ writeFileSync(join(designRoot, 'config.json'), '{}');
179
+ await w.refresh();
180
+ expect(got).toHaveLength(0);
181
+ } finally {
182
+ w.stop();
183
+ rmSync(root, { recursive: true, force: true });
184
+ }
185
+ });
186
+ });
187
+
188
+ describe('canvas-list-watch / live fs:any path', () => {
189
+ test('a candidate fs:any event schedules a debounced refresh that emits', async () => {
190
+ const { root, designRoot } = sandbox();
191
+ const ctx = mkCtx(designRoot);
192
+ const got = collect(ctx);
193
+ const w = createCanvasListWatch(ctx, { debounceMs: 20 });
194
+ await w.ready;
195
+ try {
196
+ writeFileSync(join(designRoot, 'ui', 'Live.tsx'), TSX);
197
+ // The fs-watch would emit this; assert the subscriber wires through.
198
+ ctx.bus.emit('fs:any', 'ui/Live.tsx');
199
+ await new Promise((r) => setTimeout(r, 20 + 80));
200
+ await w.refresh(); // flush the chain
201
+ const added = got.find((m) => m.action === 'added' && m.slug === 'ui-live');
202
+ expect(added).toBeTruthy();
203
+ // Exactly one 'added' — the timer + the explicit flush must not double-emit.
204
+ expect(got.filter((m) => m.action === 'added' && m.slug === 'ui-live')).toHaveLength(1);
205
+ } finally {
206
+ w.stop();
207
+ rmSync(root, { recursive: true, force: true });
208
+ }
209
+ });
210
+
211
+ test('a non-candidate fs:any event does not schedule a refresh', async () => {
212
+ const { root, designRoot } = sandbox();
213
+ const ctx = mkCtx(designRoot);
214
+ const got = collect(ctx);
215
+ const w = createCanvasListWatch(ctx, { debounceMs: 20 });
216
+ await w.ready;
217
+ try {
218
+ // Create a canvas but only announce a NON-candidate path. The gate must
219
+ // skip the recompute, so the new canvas stays unseen until a real event.
220
+ writeFileSync(join(designRoot, 'ui', 'Sneaky.tsx'), TSX);
221
+ ctx.bus.emit('fs:any', 'ui/Sneaky.meta.json');
222
+ ctx.bus.emit('fs:any', '_active.json');
223
+ await new Promise((r) => setTimeout(r, 20 + 80));
224
+ expect(got).toHaveLength(0);
225
+ } finally {
226
+ w.stop();
227
+ rmSync(root, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test('stop() unsubscribes — later events are ignored', async () => {
232
+ const { root, designRoot } = sandbox();
233
+ const ctx = mkCtx(designRoot);
234
+ const got = collect(ctx);
235
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
236
+ await w.ready;
237
+ w.stop();
238
+ try {
239
+ writeFileSync(join(designRoot, 'ui', 'After.tsx'), TSX);
240
+ ctx.bus.emit('fs:any', 'ui/After.tsx');
241
+ await new Promise((r) => setTimeout(r, CANVAS_LIST_DEBOUNCE_MS + 40));
242
+ expect(got).toHaveLength(0);
243
+ } finally {
244
+ rmSync(root, { recursive: true, force: true });
245
+ }
246
+ });
247
+ });
248
+
249
+ describe('canvas-list-watch / serialization + multi-remove', () => {
250
+ test('overlapping refresh() calls serialize — a create between them emits exactly once', async () => {
251
+ const { root, designRoot } = sandbox();
252
+ const ctx = mkCtx(designRoot);
253
+ const got = collect(ctx);
254
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
255
+ await w.ready;
256
+ try {
257
+ writeFileSync(join(designRoot, 'ui', 'Pair.tsx'), TSX);
258
+ // Fire two refreshes WITHOUT awaiting the first. The promise-chain must
259
+ // serialize them: the first diffs the create against the pre-create set
260
+ // and updates `known`; the second sees no change. Without the chain both
261
+ // would snapshot concurrently against the stale `known` and emit twice.
262
+ const a = w.refresh();
263
+ const b = w.refresh();
264
+ await Promise.all([a, b]);
265
+ expect(got.filter((m) => m.action === 'added' && m.slug === 'ui-pair')).toHaveLength(1);
266
+ } finally {
267
+ w.stop();
268
+ rmSync(root, { recursive: true, force: true });
269
+ }
270
+ });
271
+
272
+ test('removing a whole group subdir emits one removed per canvas (git-checkout shape)', async () => {
273
+ const { root, designRoot } = sandbox();
274
+ mkdirSync(join(designRoot, 'ui', 'project'), { recursive: true });
275
+ writeFileSync(join(designRoot, 'ui', 'project', 'A.tsx'), TSX);
276
+ writeFileSync(join(designRoot, 'ui', 'project', 'B.tsx'), TSX);
277
+ const ctx = mkCtx(designRoot);
278
+ const got = collect(ctx);
279
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
280
+ await w.ready;
281
+ try {
282
+ rmSync(join(designRoot, 'ui', 'project'), { recursive: true, force: true });
283
+ await w.refresh();
284
+ const removed = got.filter((m) => m.action === 'removed').map((m) => m.slug);
285
+ expect(removed.sort()).toEqual(['ui-project-a', 'ui-project-b']);
286
+ } finally {
287
+ w.stop();
288
+ rmSync(root, { recursive: true, force: true });
289
+ }
290
+ });
291
+ });
292
+
293
+ describe('canvas-list-watch / real fs-watch end-to-end', () => {
294
+ test('a raw disk write through createFsWatch emits added (the actual bug path)', async () => {
295
+ const { root, designRoot } = sandbox();
296
+ const ctx = mkCtx(designRoot);
297
+ const got = collect(ctx);
298
+ const fsWatch = createFsWatch(ctx);
299
+ const w = createCanvasListWatch(ctx, { debounceMs: 30 });
300
+ await w.ready;
301
+ fsWatch.start();
302
+ try {
303
+ // Let the OS watcher arm, then write straight to disk — NO manual bus
304
+ // emit. This exercises the genuine ACP-agent path: disk write →
305
+ // fs.watch → fs:any → gate → debounce → snapshot diff → canvas-list-update.
306
+ await new Promise((r) => setTimeout(r, 60));
307
+ writeFileSync(join(designRoot, 'ui', 'Real.tsx'), TSX);
308
+ const deadline = Date.now() + 3000;
309
+ while (
310
+ Date.now() < deadline &&
311
+ !got.some((m) => m.action === 'added' && m.slug === 'ui-real')
312
+ ) {
313
+ await new Promise((r) => setTimeout(r, 50));
314
+ }
315
+ expect(got.some((m) => m.action === 'added' && m.slug === 'ui-real')).toBe(true);
316
+ } finally {
317
+ fsWatch.stop();
318
+ w.stop();
319
+ rmSync(root, { recursive: true, force: true });
320
+ }
321
+ }, 10_000);
322
+ });