@1agh/maude 0.30.0 → 0.31.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 (90) 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 +781 -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 +770 -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 +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,127 @@
1
+ // End-to-end bridge test against a mock ACP agent (test/fixtures/mock-acp-agent.mjs)
2
+ // so no real `claude` install is needed. Proves: spawn + handshake + streamed
3
+ // update + turn completion, AND that ANTHROPIC_API_KEY never reaches the child
4
+ // (DDR-123 guardrail #1, verified end-to-end — the mock echoes its own env).
5
+
6
+ import { afterEach, describe, expect, test } from 'bun:test';
7
+ import { join } from 'node:path';
8
+
9
+ import { AcpBridge } from '../acp/bridge.ts';
10
+ import { probeAcpAvailability } from '../acp/probe.ts';
11
+
12
+ const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent.mjs');
13
+ const TEST_ENV_KEYS = [
14
+ 'ANTHROPIC_API_KEY',
15
+ 'MAUDE_ACP_ADAPTER_ENTRY',
16
+ 'MAUDE_ACP_RUNTIME',
17
+ 'MAUDE_CLAUDE_BIN',
18
+ ];
19
+
20
+ afterEach(() => {
21
+ for (const key of TEST_ENV_KEYS) delete process.env[key];
22
+ });
23
+
24
+ describe('AcpBridge — round-trip + subscription guardrail', () => {
25
+ test('connects, streams an update, completes a turn, and scrubs ANTHROPIC_API_KEY', async () => {
26
+ process.env.ANTHROPIC_API_KEY = 'sk-must-be-scrubbed';
27
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
28
+ process.env.MAUDE_ACP_RUNTIME = process.execPath; // run the mock under this bun
29
+ process.env.MAUDE_CLAUDE_BIN = process.execPath; // satisfy the claude-present check
30
+
31
+ const updates: unknown[] = [];
32
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
33
+ try {
34
+ const result = await bridge.prompt('hello', 'c1');
35
+ expect(bridge.connected).toBe(true);
36
+ expect(bridge.sessionId).toBe('mock-session-1');
37
+ expect(result.stopReason).toBe('end_turn');
38
+
39
+ // The mock streamed its own ANTHROPIC_API_KEY — it must read `<unset>`,
40
+ // proving scrubAgentEnv stripped it from the child env before spawn.
41
+ const streamed = JSON.stringify(updates);
42
+ expect(streamed).toContain('apiKey=<unset>');
43
+ expect(streamed).not.toContain('sk-must-be-scrubbed');
44
+ } finally {
45
+ await bridge.stop();
46
+ }
47
+ }, 15000);
48
+
49
+ test('setConfig passes model + effort to the child env (ANTHROPIC_MODEL / MAX_THINKING_TOKENS)', async () => {
50
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
51
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
52
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
53
+
54
+ const updates: unknown[] = [];
55
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
56
+ try {
57
+ bridge.setConfig('opus', 'thorough');
58
+ await bridge.prompt('hi', 'c1');
59
+ const first = JSON.stringify(updates);
60
+ expect(first).toContain('model=opus');
61
+ expect(first).toContain('thinking=31999');
62
+
63
+ // Changing the config re-spawns with the new env on the next prompt.
64
+ updates.length = 0;
65
+ bridge.setConfig(null, 'fast');
66
+ await bridge.prompt('again', 'c1');
67
+ const second = JSON.stringify(updates);
68
+ expect(second).toContain('model=<unset>'); // null model → ANTHROPIC_MODEL not set
69
+ expect(second).toContain('thinking=0'); // fast → thinking disabled
70
+ } finally {
71
+ await bridge.stop();
72
+ }
73
+ }, 15000);
74
+
75
+ test('a second prompt reuses the same live session', async () => {
76
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
77
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
78
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
79
+
80
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
81
+ try {
82
+ await bridge.prompt('first', 'c1');
83
+ const sid = bridge.sessionId;
84
+ await bridge.prompt('second', 'c1');
85
+ expect(bridge.sessionId).toBe(sid);
86
+ } finally {
87
+ await bridge.stop();
88
+ }
89
+ }, 15000);
90
+
91
+ test('different chat ids get separate claude sessions; same id reuses its own', async () => {
92
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
93
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
94
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
95
+
96
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
97
+ try {
98
+ await bridge.prompt('a', 'chatA');
99
+ const sa = bridge.sessionId;
100
+ await bridge.prompt('b', 'chatB');
101
+ const sb = bridge.sessionId;
102
+ expect(sa).not.toBe(sb); // separate per-chat contexts
103
+ await bridge.prompt('a again', 'chatA');
104
+ expect(bridge.sessionId).toBe(sa); // chatA reuses its own session
105
+ } finally {
106
+ await bridge.stop();
107
+ }
108
+ }, 15000);
109
+ });
110
+
111
+ describe('probeAcpAvailability — not-connected detection', () => {
112
+ test('reports not-available with a Claude-Code reason when the CLI is absent', () => {
113
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
114
+ process.env.MAUDE_CLAUDE_BIN = join(import.meta.dir, 'no-such-claude-bin');
115
+ const probe = probeAcpAvailability();
116
+ expect(probe.available).toBe(false);
117
+ expect(probe.reason ?? '').toMatch(/Claude Code/i);
118
+ });
119
+
120
+ test('reports available when both the adapter and a claude binary resolve', () => {
121
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
122
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
123
+ const probe = probeAcpAvailability();
124
+ expect(probe.available).toBe(true);
125
+ expect(probe.adapterEntry).toBe(FIXTURE);
126
+ });
127
+ });
@@ -0,0 +1,65 @@
1
+ // DDR-123 guardrail #1 — the single load-bearing detail that keeps the chat
2
+ // panel on the user's Pro/Max subscription instead of metered API billing.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import { SUBSCRIPTION_SCRUBBED_ENV_KEYS, scrubAgentEnv } from '../acp/env.ts';
7
+
8
+ describe('scrubAgentEnv — subscription guardrail', () => {
9
+ test('strips the billing-switching keys, keeps everything else', () => {
10
+ const out = scrubAgentEnv({
11
+ PATH: '/usr/bin',
12
+ HOME: '/Users/x',
13
+ ANTHROPIC_API_KEY: 'sk-live-xxx',
14
+ ANTHROPIC_AUTH_TOKEN: 'tok-xxx',
15
+ UNDEFINED_VAR: undefined,
16
+ });
17
+ expect(out.PATH).toBe('/usr/bin');
18
+ expect(out.HOME).toBe('/Users/x');
19
+ expect('ANTHROPIC_API_KEY' in out).toBe(false);
20
+ expect('ANTHROPIC_AUTH_TOKEN' in out).toBe(false);
21
+ // undefined values are dropped (Bun.spawn env wants Record<string,string>)
22
+ expect('UNDEFINED_VAR' in out).toBe(false);
23
+ });
24
+
25
+ test('pins exactly the two precedence-relevant keys', () => {
26
+ expect([...SUBSCRIPTION_SCRUBBED_ENV_KEYS].sort()).toEqual([
27
+ 'ANTHROPIC_API_KEY',
28
+ 'ANTHROPIC_AUTH_TOKEN',
29
+ ]);
30
+ });
31
+
32
+ test('scrubs the whole provider/billing namespace, not just the two keys (F1)', () => {
33
+ const out = scrubAgentEnv({
34
+ PATH: '/usr/bin',
35
+ ANTHROPIC_BASE_URL: 'https://evil.example/v1',
36
+ ANTHROPIC_MODEL: 'attacker-pinned',
37
+ ANTHROPIC_BEDROCK_BASE_URL: 'https://evil',
38
+ CLAUDE_CODE_USE_BEDROCK: '1',
39
+ CLAUDE_CODE_USE_VERTEX: '1',
40
+ AWS_BEARER_TOKEN_BEDROCK: 'tok',
41
+ claude_session: 'keep-me', // not a provider redirect → kept
42
+ });
43
+ expect(out.PATH).toBe('/usr/bin');
44
+ expect('ANTHROPIC_BASE_URL' in out).toBe(false);
45
+ expect('ANTHROPIC_MODEL' in out).toBe(false); // re-added by the bridge from a validated value
46
+ expect('ANTHROPIC_BEDROCK_BASE_URL' in out).toBe(false);
47
+ expect('CLAUDE_CODE_USE_BEDROCK' in out).toBe(false);
48
+ expect('CLAUDE_CODE_USE_VERTEX' in out).toBe(false);
49
+ expect('AWS_BEARER_TOKEN_BEDROCK' in out).toBe(false);
50
+ expect(out.claude_session).toBe('keep-me');
51
+ });
52
+
53
+ test('never mutates the source (process.env stays intact for the parent)', () => {
54
+ const src: Record<string, string | undefined> = { ANTHROPIC_API_KEY: 'sk', PATH: '/bin' };
55
+ const out = scrubAgentEnv(src);
56
+ expect(src.ANTHROPIC_API_KEY).toBe('sk');
57
+ expect(out).not.toBe(src);
58
+ });
59
+
60
+ test('defaults to process.env when called with no argument', () => {
61
+ // Smoke: doesn't throw, returns a string map without the scrubbed keys.
62
+ const out = scrubAgentEnv();
63
+ expect('ANTHROPIC_API_KEY' in out).toBe(false);
64
+ });
65
+ });
@@ -0,0 +1,78 @@
1
+ // ACP bridge origin gate (DDR-123). The agent bridge spawns the user's `claude`
2
+ // and can drive file edits, so it lives ONLY on the main (privileged) origin and
3
+ // only over loopback. This proves:
4
+ // (1) GET /_api/acp/status serves on the main origin;
5
+ // (2) it is 403'd on the untrusted canvas origin (off CANVAS_SAFE_API +
6
+ // startCanvasServer routes — the dual-allowlist rule);
7
+ // (3) /_ws/acp is wired on the main origin (upgrade-without-headers → 400);
8
+ // (4) the loopback guard that fronts /_ws/acp rejects non-loopback hosts.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ import { isLoopbackHost } from '../ws.ts';
15
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
16
+
17
+ async function readCanvasOrigin(designRoot: string): Promise<string> {
18
+ for (let i = 0; i < 40; i++) {
19
+ try {
20
+ const info = JSON.parse(readFileSync(join(designRoot, '_server.json'), 'utf8'));
21
+ if (info.canvasOrigin) return info.canvasOrigin as string;
22
+ } catch {
23
+ /* not written yet */
24
+ }
25
+ await Bun.sleep(50);
26
+ }
27
+ throw new Error('canvasOrigin never appeared in _server.json');
28
+ }
29
+
30
+ describe('ACP bridge origin gate', () => {
31
+ test('status is main-origin only; /_ws/acp wired on main', async () => {
32
+ const { root, designRoot } = makeSandbox();
33
+ const port = nextPort();
34
+ const proc = await bootServer(root, port, { MAUDE_CANVAS_ORIGIN_SPLIT: '1' });
35
+ try {
36
+ const main = `http://localhost:${port}`;
37
+ const canvas = await readCanvasOrigin(designRoot);
38
+ const status = async (base: string, path: string) =>
39
+ (await fetch(base + path, { signal: AbortSignal.timeout(2000) })).status;
40
+
41
+ // (1) main origin exposes the readiness probe
42
+ const probe = await fetch(`${main}/_api/acp/status`, { signal: AbortSignal.timeout(2000) });
43
+ expect(probe.status).toBe(200);
44
+ expect(typeof (await probe.json()).available).toBe('boolean');
45
+
46
+ // (2) the untrusted canvas origin must NOT reach the probe
47
+ expect(await status(canvas, '/_api/acp/status')).toBe(403);
48
+
49
+ // (3) /_ws/acp is wired on the main origin — a plain GET (no Upgrade
50
+ // headers) passes the loopback guard then fails the WS upgrade → 400,
51
+ // which proves the route branch exists (a 404 would mean it's unwired).
52
+ expect(await status(main, '/_ws/acp')).toBe(400);
53
+
54
+ // (4) /_api/acp/focus (the /design:chat hook) — POST-only on the main
55
+ // origin, 403 on the canvas origin.
56
+ const focusPost = await fetch(`${main}/_api/acp/focus`, {
57
+ method: 'POST',
58
+ signal: AbortSignal.timeout(2000),
59
+ });
60
+ expect(focusPost.status).toBe(200);
61
+ expect((await focusPost.json()).ok).toBe(true);
62
+ expect(await status(main, '/_api/acp/focus')).toBe(405); // GET not allowed
63
+ expect(await status(canvas, '/_api/acp/focus')).toBe(403); // off the canvas origin
64
+ } finally {
65
+ await killProc(proc);
66
+ }
67
+ }, 15000);
68
+
69
+ test('the loopback guard fronting /_ws/acp rejects non-loopback hosts', () => {
70
+ // Same guard server.ts applies before upgrading the acp socket.
71
+ expect(isLoopbackHost('127.0.0.1:4399')).toBe(true);
72
+ expect(isLoopbackHost('localhost:4399')).toBe(true);
73
+ expect(isLoopbackHost('[::1]:4399')).toBe(true);
74
+ expect(isLoopbackHost('evil.example.com')).toBe(false);
75
+ expect(isLoopbackHost('10.0.0.5:4399')).toBe(false);
76
+ expect(isLoopbackHost(null)).toBe(false);
77
+ });
78
+ });
@@ -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);