@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,796 @@
1
+ // Phase 31 (DDR-123) — the native ACP chat sidepanel.
2
+ //
3
+ // Built over @assistant-ui/react's HEADLESS primitives (ThreadPrimitive /
4
+ // MessagePrimitive / ComposerPrimitive + useLocalRuntime) with our own
5
+ // Maude-styled components on top — NOT the lib's shadcn/Tailwind theme. The
6
+ // streaming/runtime plumbing comes from assistant-ui; the look is Maude CSS
7
+ // (the `chat-*` classes ported from `.design/ui/ChatPanel.css`). The runtime
8
+ // adapter (acp-runtime.js) bridges to the dev-server `/_ws/acp` bridge, which
9
+ // drives the user's own `claude` on their subscription (never API billing).
10
+ //
11
+ // Native-app only — app.jsx mounts this gated on isNativeApp().
12
+
13
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
+
15
+ import {
16
+ AssistantRuntimeProvider,
17
+ ComposerPrimitive,
18
+ MessagePrimitive,
19
+ ThreadPrimitive,
20
+ useLocalRuntime,
21
+ useThread,
22
+ } from '@assistant-ui/react';
23
+
24
+ import { createAcpConnection, makeAcpAdapter } from './acp-runtime.js';
25
+ import { Markdown } from './chat-markdown.jsx';
26
+ import ReadinessList, { useReadiness } from './ReadinessList.jsx';
27
+
28
+ // ── inline icons (separate panel files carry their own, like GitPanel) ──
29
+ const Spark = ({ size = 16 }) => (
30
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
31
+ <path
32
+ d="M8 1.5l1.4 3.7 3.7 1.4-3.7 1.4L8 11.7 6.6 8 2.9 6.6l3.7-1.4L8 1.5z"
33
+ fill="currentColor"
34
+ />
35
+ </svg>
36
+ );
37
+ const Check = ({ size = 13 }) => (
38
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
39
+ <path
40
+ d="M13.5 4.5l-7 7L3 8"
41
+ stroke="currentColor"
42
+ strokeWidth="1.6"
43
+ strokeLinecap="round"
44
+ strokeLinejoin="round"
45
+ />
46
+ </svg>
47
+ );
48
+ const Close = ({ size = 14 }) => (
49
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
50
+ <path
51
+ d="M4 4l8 8M12 4l-8 8"
52
+ stroke="currentColor"
53
+ strokeWidth="1.5"
54
+ strokeLinecap="round"
55
+ />
56
+ </svg>
57
+ );
58
+ const SendArrow = ({ size = 16 }) => (
59
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
60
+ <path
61
+ d="M8 12.5V4.2M4.4 7.6 8 4l3.6 3.6"
62
+ stroke="currentColor"
63
+ strokeWidth="2.1"
64
+ strokeLinecap="round"
65
+ strokeLinejoin="round"
66
+ />
67
+ </svg>
68
+ );
69
+
70
+ // Persistent quick-action verbs (prefill the composer; never fire blind). The
71
+ // one-time `/design:setup-ds` is offered contextually in the empty state, not here.
72
+ const QUICK_ACTIONS = [
73
+ { label: '/design:edit', prompt: '/design:edit ' },
74
+ { label: '/design:new', prompt: '/design:new ' },
75
+ { label: '/design:critic', prompt: '/design:critic' },
76
+ { label: '/design:screenshot', prompt: '/design:screenshot' },
77
+ ];
78
+
79
+ const SUGGESTIONS = [
80
+ '/design:edit make the primary button more prominent',
81
+ '/design:critic',
82
+ '/design:new Pricing "a 3-tier pricing page"',
83
+ ];
84
+
85
+ // Model — '' = the user's own `claude` default; the rest are CLI aliases passed
86
+ // as ANTHROPIC_MODEL. Effort → extended-thinking budget (server maps the label).
87
+ const MODELS = [
88
+ { value: '', label: 'Default model' },
89
+ { value: 'opus', label: 'Opus' },
90
+ { value: 'sonnet', label: 'Sonnet' },
91
+ { value: 'haiku', label: 'Haiku' },
92
+ ];
93
+ const EFFORTS = [
94
+ { value: 'fast', label: 'Fast' },
95
+ { value: 'balanced', label: 'Balanced' },
96
+ { value: 'thorough', label: 'Thorough' },
97
+ ];
98
+
99
+ function safeStorageGet(key, fallback) {
100
+ try {
101
+ return localStorage.getItem(key) ?? fallback;
102
+ } catch {
103
+ return fallback;
104
+ }
105
+ }
106
+ function safeStorageSet(key, value) {
107
+ try {
108
+ localStorage.setItem(key, value);
109
+ } catch {
110
+ /* private mode / unavailable — non-fatal */
111
+ }
112
+ }
113
+
114
+ function prettyCanvas(path) {
115
+ if (!path) return null;
116
+ const file = path.split('/').pop() || path;
117
+ return file.replace(/\.(tsx|html)$/i, '');
118
+ }
119
+
120
+ // ── message-part renderers ──
121
+ function ChatText({ text }) {
122
+ return (
123
+ <div className="chat-bubble">
124
+ <Markdown text={text} />
125
+ </div>
126
+ );
127
+ }
128
+
129
+ function ChatReasoning({ text }) {
130
+ return (
131
+ <details className="chat-think">
132
+ <summary className="chat-think-sum">
133
+ <span className="chat-think-spark">
134
+ <Spark size={11} />
135
+ </span>
136
+ Thinking
137
+ </summary>
138
+ <div className="chat-think-body">
139
+ <Markdown text={text} />
140
+ </div>
141
+ </details>
142
+ );
143
+ }
144
+
145
+ function ChatToolCard({ toolName, args, result, isError }) {
146
+ const running = result === undefined;
147
+ const path =
148
+ args && typeof args === 'object' ? args.path || args.file || args.filePath : undefined;
149
+ return (
150
+ <div className="chat-tool">
151
+ <div className="chat-tool-hd">
152
+ <b>{toolName}</b>
153
+ {path ? <span className="chat-tool-path">{String(path).split('/').pop()}</span> : null}
154
+ <span
155
+ className={`chat-tool-dot ${running ? 'chat-tool-dot--run' : 'chat-tool-dot--done'}`}
156
+ />
157
+ </div>
158
+ {!running ? (
159
+ <div className="chat-tool-body">
160
+ <div className={`chat-tool-line${isError ? ' del' : ''}`}>
161
+ {isError ? 'failed' : 'done'}
162
+ </div>
163
+ </div>
164
+ ) : null}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function UserMessage() {
170
+ return (
171
+ <div className="chat-msg chat-msg--user">
172
+ <MessagePrimitive.Parts
173
+ components={{ Text: ({ text }) => <div className="chat-bubble">{text}</div> }}
174
+ />
175
+ </div>
176
+ );
177
+ }
178
+
179
+ function AssistantMessage() {
180
+ return (
181
+ <div className="chat-msg chat-msg--assistant">
182
+ <div className="chat-msg-role">
183
+ <span className="chat-msg-spark">
184
+ <Spark size={13} />
185
+ </span>
186
+ Claude
187
+ </div>
188
+ <MessagePrimitive.Parts
189
+ components={{ Text: ChatText, ToolCall: ChatToolCard, Reasoning: ChatReasoning }}
190
+ />
191
+ </div>
192
+ );
193
+ }
194
+
195
+ // ── sub-sections ──
196
+ function StatusRow() {
197
+ const running = useThread((t) => t.isRunning);
198
+ return (
199
+ <div className="chat-statusrow">
200
+ <span
201
+ className={`chat-status-dot ${running ? 'chat-status-dot--working' : 'chat-status-dot--ready'}`}
202
+ />
203
+ {running ? 'Working…' : 'Ready'}
204
+ <span className="chat-statusrow-sep">·</span>
205
+ <span className="chat-statusrow-cc">Claude Code</span>
206
+ </div>
207
+ );
208
+ }
209
+
210
+ // Live "still working" indicator — sits at the BOTTOM of the feed (under the
211
+ // latest Claude message) so it's clear the turn is still going. Shows the
212
+ // in-flight tool (edit/read/shell/sub-agent) when there is one, else "Working…".
213
+ function ActivityBar({ tools }) {
214
+ const running = useThread((t) => t.isRunning);
215
+ if (!running) return null;
216
+ const label =
217
+ tools.length === 1 ? tools[0].title : tools.length > 1 ? `${tools.length} tasks running` : 'Working…';
218
+ return (
219
+ <div className="chat-activity" role="status" aria-live="polite">
220
+ <span className="chat-activity-spin" aria-hidden="true" />
221
+ <span className="chat-activity-text">{label}</span>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ function ChatEmpty() {
227
+ return (
228
+ <div className="chat-empty">
229
+ <span className="chat-empty-mark">
230
+ <Spark size={28} />
231
+ </span>
232
+ <div className="chat-empty-title">Edit this canvas with Claude</div>
233
+ <div className="chat-empty-sub">
234
+ Ask for a change, a critique, or a new screen — Claude runs on your own subscription.
235
+ </div>
236
+ <div className="chat-sugs">
237
+ {SUGGESTIONS.map((s) => (
238
+ <ThreadPrimitive.Suggestion key={s} prompt={s} send={false} className="chat-sug">
239
+ {s}
240
+ </ThreadPrimitive.Suggestion>
241
+ ))}
242
+ </div>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ function QuickActions() {
248
+ return (
249
+ <div className="chat-quick">
250
+ {QUICK_ACTIONS.map((a) => (
251
+ <ThreadPrimitive.Suggestion
252
+ key={a.label}
253
+ prompt={a.prompt}
254
+ send={false}
255
+ className="btn btn--ghost btn--sm chat-qa"
256
+ >
257
+ {a.label}
258
+ </ThreadPrimitive.Suggestion>
259
+ ))}
260
+ </div>
261
+ );
262
+ }
263
+
264
+ function Composer({ activeCanvas, model, setModel, effort, setEffort }) {
265
+ const canvasName = prettyCanvas(activeCanvas);
266
+ return (
267
+ <div className="chat-composer">
268
+ <ThreadPrimitive.If running={false}>
269
+ {canvasName ? (
270
+ <div className="chat-ctx">
271
+ Editing: <b>{canvasName}</b>
272
+ </div>
273
+ ) : null}
274
+ {/* One box: textarea on top, a bottom toolbar (model · effort · send). */}
275
+ <ComposerPrimitive.Root className="chat-box">
276
+ <ComposerPrimitive.Input
277
+ className="chat-input"
278
+ submitMode="ctrlEnter"
279
+ placeholder="Ask Claude to change this canvas…"
280
+ />
281
+ <div className="chat-toolbar">
282
+ <select
283
+ className="chat-select"
284
+ value={model}
285
+ onChange={(e) => setModel(e.target.value)}
286
+ aria-label="Model"
287
+ >
288
+ {MODELS.map((m) => (
289
+ <option key={m.value} value={m.value}>
290
+ {m.label}
291
+ </option>
292
+ ))}
293
+ </select>
294
+ <select
295
+ className="chat-select"
296
+ value={effort}
297
+ onChange={(e) => setEffort(e.target.value)}
298
+ aria-label="Effort"
299
+ >
300
+ {EFFORTS.map((x) => (
301
+ <option key={x.value} value={x.value}>
302
+ {x.label}
303
+ </option>
304
+ ))}
305
+ </select>
306
+ <span className="chat-toolbar-spacer" />
307
+ <ComposerPrimitive.Send className="chat-send" aria-label="Send message">
308
+ <SendArrow />
309
+ </ComposerPrimitive.Send>
310
+ </div>
311
+ </ComposerPrimitive.Root>
312
+ <div className="chat-foot">
313
+ <span>⌘↵ to send</span>
314
+ <span className="chat-foot-spacer" />
315
+ <span>your Claude subscription</span>
316
+ </div>
317
+ </ThreadPrimitive.If>
318
+ <ThreadPrimitive.If running>
319
+ <div className="chat-stopbar">
320
+ <span className="chat-stop-meta">
321
+ <span className="chat-status-dot chat-status-dot--working" />
322
+ Working…
323
+ </span>
324
+ <span className="chat-foot-spacer" />
325
+ <ComposerPrimitive.Cancel className="btn btn--danger" aria-label="Stop">
326
+ Stop
327
+ </ComposerPrimitive.Cancel>
328
+ </div>
329
+ </ThreadPrimitive.If>
330
+ </div>
331
+ );
332
+ }
333
+
334
+ function NotConnected({ reason, claudeMissing, readiness, readinessLoading, onRecheck }) {
335
+ return (
336
+ <div className="chat-disabled">
337
+ <span className="chat-disabled-mark">
338
+ <Spark size={28} />
339
+ </span>
340
+ <div className="chat-disabled-title">AI editing isn't ready yet</div>
341
+ <div className="chat-disabled-sub">
342
+ {reason ? <p>{reason}</p> : null}
343
+ {readiness ? (
344
+ <p>AI editing pairs with a Claude Code you have installed. Here's what it still needs:</p>
345
+ ) : claudeMissing ? (
346
+ <p>
347
+ Install it with <code>npm i -g @anthropic-ai/claude-code</code>, then run{' '}
348
+ <code>claude</code> and <code>/login</code> in a terminal.
349
+ </p>
350
+ ) : (
351
+ <p>
352
+ Open a terminal, run <code>claude</code> and <code>/login</code>, then reopen this panel.
353
+ </p>
354
+ )}
355
+ </div>
356
+ {readiness ? (
357
+ <ReadinessList report={readiness} loading={readinessLoading} refresh={onRecheck} />
358
+ ) : null}
359
+ <div className="chat-trust">
360
+ <div className="chat-trust-row">
361
+ <Check /> Runs on your Pro/Max subscription
362
+ </div>
363
+ <div className="chat-trust-row">
364
+ <Check /> No login inside Maude
365
+ </div>
366
+ <div className="chat-trust-row">
367
+ <Check /> Never metered API billing
368
+ </div>
369
+ </div>
370
+ </div>
371
+ );
372
+ }
373
+
374
+ // Repo-level chat id — generated for each new chat.
375
+ function newChatId() {
376
+ return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
377
+ }
378
+
379
+ // Disk transcript (clean ChatMessage[]) → assistant-ui initial messages.
380
+ function toThreadMessages(msgs) {
381
+ return (msgs || []).map((m) => {
382
+ if (m.role === 'user') {
383
+ return {
384
+ role: 'user',
385
+ content: [{ type: 'text', text: (m.parts || []).map((p) => p.text || '').join('') }],
386
+ };
387
+ }
388
+ return {
389
+ role: 'assistant',
390
+ content: (m.parts || []).map((p, i) =>
391
+ p.type === 'text'
392
+ ? { type: 'text', text: p.text || '' }
393
+ : {
394
+ type: 'tool-call',
395
+ toolCallId: `h-${i}`,
396
+ toolName: p.toolName || 'tool',
397
+ args: {},
398
+ argsText: '{}',
399
+ result: p.done ? {} : undefined,
400
+ }
401
+ ),
402
+ };
403
+ });
404
+ }
405
+
406
+ // One thread = one chat, with its OWN connection (own WS / bridge / claude) so
407
+ // chats run in parallel. ChatPanel keeps every open thread MOUNTED and just
408
+ // toggles `hidden`, so switching never interrupts a running chat — it keeps
409
+ // streaming in the background.
410
+ function ChatThread({
411
+ conn,
412
+ chatId,
413
+ initialMessages,
414
+ hidden,
415
+ modelRef,
416
+ effortRef,
417
+ activeCanvas,
418
+ model,
419
+ setModel,
420
+ effort,
421
+ setEffort,
422
+ }) {
423
+ const adapter = useMemo(
424
+ () =>
425
+ makeAcpAdapter(
426
+ conn,
427
+ () => chatId,
428
+ () => modelRef.current || null,
429
+ () => effortRef.current
430
+ ),
431
+ [conn, chatId, modelRef, effortRef]
432
+ );
433
+ const runtime = useLocalRuntime(adapter, { initialMessages });
434
+ const [activeTools, setActiveTools] = useState([]);
435
+ useEffect(() => conn.onActivity(setActiveTools), [conn]);
436
+
437
+ return (
438
+ <AssistantRuntimeProvider runtime={runtime}>
439
+ <div className="chat-panel" style={hidden ? { display: 'none' } : undefined}>
440
+ <StatusRow />
441
+ <ThreadPrimitive.Root className="chat-thread">
442
+ <ThreadPrimitive.Viewport className="chat-feed" autoScroll>
443
+ <ThreadPrimitive.Empty>
444
+ <ChatEmpty />
445
+ </ThreadPrimitive.Empty>
446
+ <ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
447
+ {/* "still working" indicator under the latest message */}
448
+ <ActivityBar tools={activeTools} />
449
+ </ThreadPrimitive.Viewport>
450
+ <QuickActions />
451
+ <Composer
452
+ activeCanvas={activeCanvas}
453
+ model={model}
454
+ setModel={setModel}
455
+ effort={effort}
456
+ setEffort={setEffort}
457
+ />
458
+ </ThreadPrimitive.Root>
459
+ </div>
460
+ </AssistantRuntimeProvider>
461
+ );
462
+ }
463
+
464
+ // ── panel root ──
465
+ export default function ChatPanel({
466
+ activeCanvas,
467
+ width,
468
+ resizing,
469
+ onClose,
470
+ hidden = false,
471
+ onBusyChange,
472
+ onFinished,
473
+ }) {
474
+ // Model + effort — persisted across sessions; read live by each chat's adapter.
475
+ const [model, setModel] = useState(() => safeStorageGet('maude-acp-model', ''));
476
+ const [effort, setEffort] = useState(() => safeStorageGet('maude-acp-effort', 'balanced'));
477
+ const modelRef = useRef(model);
478
+ const effortRef = useRef(effort);
479
+ useEffect(() => {
480
+ modelRef.current = model;
481
+ safeStorageSet('maude-acp-model', model);
482
+ }, [model]);
483
+ useEffect(() => {
484
+ effortRef.current = effort;
485
+ safeStorageSet('maude-acp-effort', effort);
486
+ }, [effort]);
487
+
488
+ // Availability is global (is claude installed) — a single probe, no connection.
489
+ const [status, setStatus] = useState({ available: null, reason: undefined, claudeMissing: false });
490
+ const probeStatus = useCallback(
491
+ () =>
492
+ fetch('/_api/acp/status')
493
+ .then((r) => r.json())
494
+ .then((d) =>
495
+ setStatus({
496
+ available: d.available,
497
+ reason: d.reason,
498
+ claudeMissing: !!d.adapterEntry && !d.claudePath,
499
+ })
500
+ )
501
+ .catch(() =>
502
+ setStatus({
503
+ available: false,
504
+ reason: 'Could not reach the Claude bridge.',
505
+ claudeMissing: false,
506
+ })
507
+ ),
508
+ []
509
+ );
510
+ useEffect(() => {
511
+ probeStatus();
512
+ }, [probeStatus]);
513
+
514
+ // DDR-128 — when not connected, surface the full readiness breakdown (claude ·
515
+ // maude · plugins) in the not-connected explainer. Only probes when actually
516
+ // disconnected; Re-check re-probes BOTH the readiness report and the bridge
517
+ // status, so installing the missing pieces and re-checking can unlock the panel
518
+ // without reopening it (the persistent re-check surface).
519
+ const {
520
+ report: readiness,
521
+ loading: readinessLoading,
522
+ refresh: refreshReadiness,
523
+ } = useReadiness(status.available === false);
524
+ const recheck = useCallback(() => {
525
+ refreshReadiness();
526
+ return probeStatus();
527
+ }, [refreshReadiness, probeStatus]);
528
+
529
+ // Recents (for the switcher).
530
+ const [chats, setChats] = useState([]);
531
+ const refreshChats = useCallback(() => {
532
+ fetch('/_api/acp/chats')
533
+ .then((r) => r.json())
534
+ .then((d) => Array.isArray(d) && setChats(d))
535
+ .catch(() => {});
536
+ }, []);
537
+ useEffect(() => {
538
+ refreshChats();
539
+ }, [refreshChats]);
540
+
541
+ // PARALLEL chats — one connection (own WS / bridge / claude) per open chat.
542
+ // Every open chat stays mounted; switching just changes which is visible, so
543
+ // a running chat keeps going in the background.
544
+ const connsRef = useRef(new Map()); // chatId → connection
545
+ const busyRef = useRef(new Map()); // chatId → busy (aggregated for the menubar)
546
+ const hydratedRef = useRef(new Map()); // chatId → initial messages
547
+ const [openChatIds, setOpenChatIds] = useState([]);
548
+ const [activeChatId, setActiveChatId] = useState(null);
549
+ const [busyChats, setBusyChats] = useState({}); // reactive: chatId → busy (for the dot)
550
+ const [menuOpen, setMenuOpen] = useState(false);
551
+ const cbRef = useRef({ onBusyChange, onFinished });
552
+ useEffect(() => {
553
+ cbRef.current = { onBusyChange, onFinished };
554
+ }, [onBusyChange, onFinished]);
555
+
556
+ const ensureConn = useCallback(
557
+ (chatId) => {
558
+ const existing = connsRef.current.get(chatId);
559
+ if (existing) return existing;
560
+ const conn = createAcpConnection();
561
+ connsRef.current.set(chatId, conn);
562
+ conn.onBusy((busy) => {
563
+ const wasAny = [...busyRef.current.values()].some(Boolean);
564
+ busyRef.current.set(chatId, busy);
565
+ const nowAny = [...busyRef.current.values()].some(Boolean);
566
+ if (wasAny !== nowAny) cbRef.current.onBusyChange?.(nowAny);
567
+ setBusyChats((prev) => ({ ...prev, [chatId]: busy })); // reactive dot
568
+ if (!busy) {
569
+ cbRef.current.onFinished?.();
570
+ refreshChats();
571
+ }
572
+ });
573
+ return conn;
574
+ },
575
+ [refreshChats]
576
+ );
577
+
578
+ const openChat = useCallback(
579
+ (chatId, initialMessages) => {
580
+ hydratedRef.current.set(chatId, initialMessages || []);
581
+ ensureConn(chatId);
582
+ setOpenChatIds((ids) => (ids.includes(chatId) ? ids : [...ids, chatId]));
583
+ setActiveChatId(chatId);
584
+ },
585
+ [ensureConn]
586
+ );
587
+
588
+ const newChat = useCallback(() => openChat(newChatId(), []), [openChat]);
589
+
590
+ const switchTo = useCallback(
591
+ (id) => {
592
+ if (!id || id === activeChatId) return;
593
+ if (connsRef.current.has(id)) {
594
+ setActiveChatId(id); // already open in the background → just show it
595
+ return;
596
+ }
597
+ fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`)
598
+ .then((r) => r.json())
599
+ .then((msgs) => openChat(id, toThreadMessages(msgs)))
600
+ .catch(() => {});
601
+ },
602
+ [activeChatId, openChat]
603
+ );
604
+
605
+ // Delete a chat — kill its claude (close the conn), drop the transcript, and
606
+ // fall back to another open chat (or a fresh one) if it was active.
607
+ const deleteChat = useCallback(
608
+ (id) => {
609
+ const conn = connsRef.current.get(id);
610
+ if (conn) conn.close();
611
+ connsRef.current.delete(id);
612
+ busyRef.current.delete(id);
613
+ hydratedRef.current.delete(id);
614
+ setBusyChats((prev) => {
615
+ const next = { ...prev };
616
+ delete next[id];
617
+ return next;
618
+ });
619
+ fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
620
+ .catch(() => {})
621
+ .finally(refreshChats);
622
+ setOpenChatIds((ids) => {
623
+ const remaining = ids.filter((x) => x !== id);
624
+ if (id === activeChatId) {
625
+ if (remaining.length) setActiveChatId(remaining[remaining.length - 1]);
626
+ else {
627
+ const fresh = newChatId();
628
+ hydratedRef.current.set(fresh, []);
629
+ ensureConn(fresh);
630
+ setActiveChatId(fresh);
631
+ return [fresh];
632
+ }
633
+ }
634
+ return remaining;
635
+ });
636
+ },
637
+ [activeChatId, ensureConn, refreshChats]
638
+ );
639
+
640
+ // Open a fresh chat on mount; close every connection on unmount.
641
+ useEffect(() => {
642
+ newChat();
643
+ const conns = connsRef.current;
644
+ return () => {
645
+ for (const c of conns.values()) c.close();
646
+ };
647
+ // eslint-disable-next-line react-hooks/exhaustive-deps
648
+ }, []);
649
+
650
+ // Switcher options — open chats first (parallel), then the rest of the recents.
651
+ const chatOptions = useMemo(() => {
652
+ const seen = new Set();
653
+ const list = [];
654
+ for (const id of openChatIds) {
655
+ if (seen.has(id)) continue;
656
+ seen.add(id);
657
+ list.push({ id, title: chats.find((c) => c.id === id)?.title || 'New chat', open: true });
658
+ }
659
+ for (const c of chats) {
660
+ if (seen.has(c.id)) continue;
661
+ seen.add(c.id);
662
+ list.push(c);
663
+ }
664
+ return list;
665
+ }, [chats, openChatIds]);
666
+
667
+ const connected = status.available !== false;
668
+
669
+ return (
670
+ <aside
671
+ className={`st-rpanel${resizing ? ' is-resizing' : ''}`}
672
+ style={{
673
+ ...(width ? { width, flexBasis: width } : {}),
674
+ ...(hidden ? { display: 'none' } : {}),
675
+ }}
676
+ aria-label="Assistant"
677
+ aria-hidden={hidden || undefined}
678
+ >
679
+ <div className="st-rp-tabs">
680
+ <span className="st-rp-tab is-active">
681
+ <Spark size={13} /> Assistant
682
+ </span>
683
+ <button
684
+ type="button"
685
+ className="st-iconbtn"
686
+ aria-label="Close assistant"
687
+ style={{ marginLeft: 'auto' }}
688
+ onClick={onClose}
689
+ >
690
+ <Close />
691
+ </button>
692
+ </div>
693
+ {connected ? (
694
+ <div className="chat-bar">
695
+ <div className="chat-switch">
696
+ <button
697
+ type="button"
698
+ className="chat-switch-trigger"
699
+ onClick={() => setMenuOpen((v) => !v)}
700
+ aria-haspopup="listbox"
701
+ aria-expanded={menuOpen}
702
+ >
703
+ <span
704
+ className={`chat-dot ${busyChats[activeChatId] ? 'chat-dot--busy' : 'chat-dot--idle'}`}
705
+ />
706
+ <span className="chat-switch-title">
707
+ {chatOptions.find((c) => c.id === activeChatId)?.title || 'New chat'}
708
+ </span>
709
+ <span className="chat-switch-caret">▾</span>
710
+ </button>
711
+ {menuOpen ? (
712
+ <>
713
+ <div className="chat-menu-backdrop" onClick={() => setMenuOpen(false)} />
714
+ <div className="chat-menu" role="listbox">
715
+ {chatOptions.map((c) => (
716
+ <div
717
+ key={c.id}
718
+ className={`chat-menu-row${c.id === activeChatId ? ' is-active' : ''}`}
719
+ >
720
+ <button
721
+ type="button"
722
+ className="chat-menu-open"
723
+ onClick={() => {
724
+ switchTo(c.id);
725
+ setMenuOpen(false);
726
+ }}
727
+ >
728
+ <span
729
+ className={`chat-dot ${busyChats[c.id] ? 'chat-dot--busy' : c.open ? 'chat-dot--idle' : 'chat-dot--off'}`}
730
+ title={busyChats[c.id] ? 'Running' : c.open ? 'Open' : 'Saved'}
731
+ />
732
+ <span className="chat-menu-title">{c.title}</span>
733
+ </button>
734
+ <button
735
+ type="button"
736
+ className="chat-menu-del"
737
+ onClick={() => deleteChat(c.id)}
738
+ aria-label="Delete chat"
739
+ title="Delete chat"
740
+ >
741
+ <Close size={11} />
742
+ </button>
743
+ </div>
744
+ ))}
745
+ </div>
746
+ </>
747
+ ) : null}
748
+ </div>
749
+ <button
750
+ type="button"
751
+ className="chat-newbtn"
752
+ onClick={() => {
753
+ newChat();
754
+ setMenuOpen(false);
755
+ }}
756
+ title="Start a new chat"
757
+ >
758
+ + New
759
+ </button>
760
+ </div>
761
+ ) : null}
762
+ <div className="st-rp-body st-rp-body--chat">
763
+ {status.available === false ? (
764
+ <NotConnected
765
+ reason={status.reason}
766
+ claudeMissing={status.claudeMissing}
767
+ readiness={readiness}
768
+ readinessLoading={readinessLoading}
769
+ onRecheck={recheck}
770
+ />
771
+ ) : (
772
+ openChatIds.map((id) => {
773
+ const conn = connsRef.current.get(id);
774
+ if (!conn) return null;
775
+ return (
776
+ <ChatThread
777
+ key={id}
778
+ conn={conn}
779
+ chatId={id}
780
+ initialMessages={hydratedRef.current.get(id) || []}
781
+ hidden={id !== activeChatId}
782
+ modelRef={modelRef}
783
+ effortRef={effortRef}
784
+ activeCanvas={activeCanvas}
785
+ model={model}
786
+ setModel={setModel}
787
+ effort={effort}
788
+ setEffort={setEffort}
789
+ />
790
+ );
791
+ })
792
+ )}
793
+ </div>
794
+ </aside>
795
+ );
796
+ }