@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.
- package/README.md +5 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +781 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +613 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +28780 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5633 -1617
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +384 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +73 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +30 -8
|
@@ -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
|
+
}
|