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