@1agh/maude 0.37.0 → 0.38.1
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 +2 -0
- package/apps/studio/acp/bootstrap-brief.ts +93 -0
- package/apps/studio/acp/bridge.ts +114 -1
- package/apps/studio/acp/index.ts +184 -21
- package/apps/studio/acp/plugin-bootstrap.ts +115 -0
- package/apps/studio/acp/transcript.ts +36 -3
- package/apps/studio/activity.ts +45 -0
- package/apps/studio/api.ts +265 -47
- package/apps/studio/bin/_ensure-browser.mjs +316 -0
- package/apps/studio/bin/ensure-browser.sh +26 -0
- package/apps/studio/bin/screenshot.sh +33 -8
- package/apps/studio/bin/smoke.sh +46 -0
- package/apps/studio/canvas-edit.ts +422 -6
- package/apps/studio/canvas-lib.tsx +48 -0
- package/apps/studio/canvas-shell.tsx +684 -12
- package/apps/studio/client/app.jsx +683 -33
- package/apps/studio/client/panels/ChatPanel.jsx +593 -31
- package/apps/studio/client/panels/acp-runtime.js +227 -70
- package/apps/studio/client/panels/chat-context.js +124 -0
- package/apps/studio/client/panels/slash-commands.js +147 -0
- package/apps/studio/client/styles/3-shell-maude.css +15 -0
- package/apps/studio/client/styles/6-acp-chat.css +244 -0
- package/apps/studio/commands/reorder-command.ts +77 -0
- package/apps/studio/config.schema.json +6 -0
- package/apps/studio/dist/client.bundle.js +25 -53617
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +27 -19
- package/apps/studio/http.ts +168 -26
- package/apps/studio/inspect.ts +108 -1
- package/apps/studio/paths.ts +32 -0
- package/apps/studio/readiness.ts +79 -30
- package/apps/studio/server.ts +11 -2
- package/apps/studio/test/acp-activity.test.ts +154 -0
- package/apps/studio/test/acp-ai-activity.test.ts +182 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
- package/apps/studio/test/acp-commands.test.ts +108 -0
- package/apps/studio/test/acp-origin-gate.test.ts +64 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
- package/apps/studio/test/acp-session-plugins.test.ts +132 -0
- package/apps/studio/test/acp-transcript.test.ts +53 -0
- package/apps/studio/test/active-state.test.ts +41 -0
- package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/canvas-reorder.test.ts +211 -0
- package/apps/studio/test/chat-context.test.ts +129 -0
- package/apps/studio/test/csrf-write-guard.test.ts +24 -0
- package/apps/studio/test/edit-suppress.test.ts +170 -0
- package/apps/studio/test/ensure-browser.test.ts +92 -0
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
- package/apps/studio/test/hmr-broadcast.test.ts +44 -0
- package/apps/studio/test/inspect-selections.test.ts +219 -0
- package/apps/studio/test/paths.test.ts +57 -0
- package/apps/studio/test/readiness.test.ts +83 -13
- package/apps/studio/test/reorder-api.test.ts +210 -0
- package/apps/studio/test/slash-commands.test.ts +117 -0
- package/apps/studio/undo-stack.ts +6 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +37 -0
- package/cli/commands/design.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +3 -3
|
@@ -17,13 +17,23 @@ import {
|
|
|
17
17
|
ComposerPrimitive,
|
|
18
18
|
MessagePrimitive,
|
|
19
19
|
ThreadPrimitive,
|
|
20
|
+
useComposer,
|
|
21
|
+
useComposerRuntime,
|
|
20
22
|
useLocalRuntime,
|
|
21
23
|
useThread,
|
|
22
24
|
} from '@assistant-ui/react';
|
|
23
25
|
|
|
24
|
-
import { createAcpConnection, makeAcpAdapter } from './acp-runtime.js';
|
|
26
|
+
import { activityLabel, createAcpConnection, makeAcpAdapter } from './acp-runtime.js';
|
|
27
|
+
import { buildChatContext } from './chat-context.js';
|
|
25
28
|
import { Markdown } from './chat-markdown.jsx';
|
|
26
29
|
import ReadinessList, { useReadiness } from './ReadinessList.jsx';
|
|
30
|
+
import {
|
|
31
|
+
buildCommandModel,
|
|
32
|
+
filterCommands,
|
|
33
|
+
matchLeadingCommand,
|
|
34
|
+
normalizeName,
|
|
35
|
+
STATIC_COMMANDS,
|
|
36
|
+
} from './slash-commands.js';
|
|
27
37
|
|
|
28
38
|
// ── inline icons (separate panel files carry their own, like GitPanel) ──
|
|
29
39
|
const Spark = ({ size = 16 }) => (
|
|
@@ -66,6 +76,15 @@ const SendArrow = ({ size = 16 }) => (
|
|
|
66
76
|
/>
|
|
67
77
|
</svg>
|
|
68
78
|
);
|
|
79
|
+
// Token swatches — reads as "design system"; used on the empty-state DS CTA.
|
|
80
|
+
const Swatches = ({ size = 15 }) => (
|
|
81
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
82
|
+
<rect x="2" y="2" width="5" height="5" rx="1" fill="currentColor" />
|
|
83
|
+
<rect x="9" y="2" width="5" height="5" rx="1" fill="currentColor" opacity="0.55" />
|
|
84
|
+
<rect x="2" y="9" width="5" height="5" rx="1" fill="currentColor" opacity="0.55" />
|
|
85
|
+
<rect x="9" y="9" width="5" height="5" rx="1" fill="currentColor" opacity="0.3" />
|
|
86
|
+
</svg>
|
|
87
|
+
);
|
|
69
88
|
|
|
70
89
|
// Persistent quick-action verbs (prefill the composer; never fire blind). The
|
|
71
90
|
// one-time `/design:setup-ds` is offered contextually in the empty state, not here.
|
|
@@ -117,6 +136,112 @@ function prettyCanvas(path) {
|
|
|
117
136
|
return file.replace(/\.(tsx|html)$/i, '');
|
|
118
137
|
}
|
|
119
138
|
|
|
139
|
+
// ── pasted-path / URL → inline chip (Claude-Code-style attachment badge) ──
|
|
140
|
+
// When the whole clipboard is a single path or URL, we collapse it to a short
|
|
141
|
+
// literal token ([image-1] / [file-1] / [link-1]) in the textarea and stash the
|
|
142
|
+
// real value in a per-chat map. The token is the actual text the caret sees, so
|
|
143
|
+
// the mirror can style it as a chip WITHOUT desyncing (chip text === token text),
|
|
144
|
+
// and the adapter expands it back to the real path before sending to Claude.
|
|
145
|
+
const CHIP_RE = /\[(?:image|file|link)-\d+\]/g;
|
|
146
|
+
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|avif|bmp|heic|heif|ico|tiff?)$/i;
|
|
147
|
+
|
|
148
|
+
// Classify a raw clipboard string. Returns { kind, value } only when the trimmed
|
|
149
|
+
// paste is a SINGLE path/URL token (no internal whitespace) — pasting prose that
|
|
150
|
+
// merely contains a link is left as a normal paste. Paths with spaces fall through
|
|
151
|
+
// (kept as plain text) in this first cut.
|
|
152
|
+
function classifyPaste(raw) {
|
|
153
|
+
const s = (raw || '').trim();
|
|
154
|
+
if (!s || /\s/.test(s)) return null;
|
|
155
|
+
const isUrl = /^(?:https?|ftp):\/\/[^\s]+$/i.test(s);
|
|
156
|
+
const isWinPath = /^[a-zA-Z]:\\[^\s]+$/.test(s);
|
|
157
|
+
const isAnchoredPath = /^(?:~|\.{0,2})\/[^\s]+$/.test(s); // /a, ~/a, ./a, ../a
|
|
158
|
+
const looksLikeFile = s.includes('/') && /\.[a-z0-9]{1,8}$/i.test(s); // folder/file.ext
|
|
159
|
+
if (!isUrl && !isWinPath && !isAnchoredPath && !looksLikeFile) return null;
|
|
160
|
+
const bare = s.split(/[?#]/)[0]; // strip URL query/hash before the extension test
|
|
161
|
+
const kind = IMAGE_EXT_RE.test(bare) ? 'image' : isUrl ? 'link' : 'file';
|
|
162
|
+
return { kind, value: s };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Next free index for a chip kind, scanning existing tokens so deletes renumber
|
|
166
|
+
// back down (paste after clearing → [image-1] again, not [image-3]).
|
|
167
|
+
function nextChipIndex(text, kind) {
|
|
168
|
+
const re = new RegExp(`\\[${kind}-(\\d+)\\]`, 'g');
|
|
169
|
+
let max = 0;
|
|
170
|
+
let m;
|
|
171
|
+
while ((m = re.exec(text || ''))) max = Math.max(max, parseInt(m[1], 10));
|
|
172
|
+
return max + 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Tokenize a string, wrapping [image|file|link-N] tokens in chip spans. Preserves
|
|
176
|
+
// every character verbatim (used both in the mirror overlay and the sent-message
|
|
177
|
+
// bubble), so it is safe over the caret-critical mirror.
|
|
178
|
+
function chipNodes(text, keyPrefix = 'chip') {
|
|
179
|
+
const nodes = [];
|
|
180
|
+
let last = 0;
|
|
181
|
+
let m;
|
|
182
|
+
let k = 0;
|
|
183
|
+
CHIP_RE.lastIndex = 0;
|
|
184
|
+
while ((m = CHIP_RE.exec(text))) {
|
|
185
|
+
if (m.index > last) nodes.push(text.slice(last, m.index));
|
|
186
|
+
nodes.push(
|
|
187
|
+
<span className="chat-paste-chip" key={`${keyPrefix}-${k++}`}>
|
|
188
|
+
{m[0]}
|
|
189
|
+
</span>
|
|
190
|
+
);
|
|
191
|
+
last = m.index + m[0].length;
|
|
192
|
+
}
|
|
193
|
+
if (last < text.length) nodes.push(text.slice(last));
|
|
194
|
+
return nodes;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// First image File on the clipboard — a raw screenshot / copied image arrives as
|
|
198
|
+
// bytes with no path (files, or items via getAsFile on some engines).
|
|
199
|
+
function clipboardImageFile(cd) {
|
|
200
|
+
const files = cd?.files ? Array.from(cd.files) : [];
|
|
201
|
+
const fromFiles = files.find((f) => f.type && f.type.startsWith('image/'));
|
|
202
|
+
if (fromFiles) return fromFiles;
|
|
203
|
+
const items = cd?.items ? Array.from(cd.items) : [];
|
|
204
|
+
for (const it of items) {
|
|
205
|
+
if (it.kind === 'file' && it.type && it.type.startsWith('image/')) {
|
|
206
|
+
const f = it.getAsFile();
|
|
207
|
+
if (f) return f;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Insert `chip` at the textarea caret, keeping the undo stack + assistant-ui's
|
|
214
|
+
// controlled value in sync. execCommand is a real user-edit (caret follows); the
|
|
215
|
+
// manual splice is the fallback where execCommand('insertText') is unavailable.
|
|
216
|
+
function insertChipAtCaret(ta, chip) {
|
|
217
|
+
if (document.execCommand && document.execCommand('insertText', false, chip)) return;
|
|
218
|
+
const start = ta.selectionStart ?? ta.value.length;
|
|
219
|
+
const end = ta.selectionEnd ?? start;
|
|
220
|
+
const next = ta.value.slice(0, start) + chip + ta.value.slice(end);
|
|
221
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
222
|
+
window.HTMLTextAreaElement.prototype,
|
|
223
|
+
'value'
|
|
224
|
+
)?.set;
|
|
225
|
+
setter?.call(ta, next);
|
|
226
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
227
|
+
const pos = start + chip.length;
|
|
228
|
+
ta.setSelectionRange(pos, pos);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// POST raw image bytes to the dev-server, which writes them under the runtime
|
|
232
|
+
// _chat/attachments/ and returns the absolute path Claude will Read on send.
|
|
233
|
+
async function uploadChatImage(file) {
|
|
234
|
+
const buf = await file.arrayBuffer();
|
|
235
|
+
const res = await fetch('/_api/acp/attachment', {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
238
|
+
body: buf,
|
|
239
|
+
});
|
|
240
|
+
if (!res.ok) return null;
|
|
241
|
+
const data = await res.json().catch(() => null);
|
|
242
|
+
return (data && data.path) || null;
|
|
243
|
+
}
|
|
244
|
+
|
|
120
245
|
// ── message-part renderers ──
|
|
121
246
|
function ChatText({ text }) {
|
|
122
247
|
return (
|
|
@@ -166,12 +291,16 @@ function ChatToolCard({ toolName, args, result, isError }) {
|
|
|
166
291
|
);
|
|
167
292
|
}
|
|
168
293
|
|
|
294
|
+
// User bubble keeps the collapsed chips in the transcript (Claude Code shows the
|
|
295
|
+
// placeholder in history too — the real path went to the agent, not the log).
|
|
296
|
+
function UserBubble({ text }) {
|
|
297
|
+
return <div className="chat-bubble">{chipNodes(text, 'ub')}</div>;
|
|
298
|
+
}
|
|
299
|
+
|
|
169
300
|
function UserMessage() {
|
|
170
301
|
return (
|
|
171
302
|
<div className="chat-msg chat-msg--user">
|
|
172
|
-
<MessagePrimitive.Parts
|
|
173
|
-
components={{ Text: ({ text }) => <div className="chat-bubble">{text}</div> }}
|
|
174
|
-
/>
|
|
303
|
+
<MessagePrimitive.Parts components={{ Text: UserBubble }} />
|
|
175
304
|
</div>
|
|
176
305
|
);
|
|
177
306
|
}
|
|
@@ -193,32 +322,97 @@ function AssistantMessage() {
|
|
|
193
322
|
}
|
|
194
323
|
|
|
195
324
|
// ── sub-sections ──
|
|
196
|
-
|
|
325
|
+
// "Working" is TRUE while the prompt turn is in-flight OR any tool is still
|
|
326
|
+
// running — so background subagents that outlive the main turn (the ACP adapter
|
|
327
|
+
// settles at the main agent's `result` while they drain, claude-agent-acp #773)
|
|
328
|
+
// keep the panel from looking idle. See RCA issue-acp-subagent-activity-invisible.
|
|
329
|
+
function StatusRow({ tools = [] }) {
|
|
197
330
|
const running = useThread((t) => t.isRunning);
|
|
331
|
+
const working = running || tools.length > 0;
|
|
198
332
|
return (
|
|
199
333
|
<div className="chat-statusrow">
|
|
200
334
|
<span
|
|
201
|
-
className={`chat-status-dot ${
|
|
335
|
+
className={`chat-status-dot ${working ? 'chat-status-dot--working' : 'chat-status-dot--ready'}`}
|
|
202
336
|
/>
|
|
203
|
-
{
|
|
337
|
+
{working ? 'Working…' : 'Ready'}
|
|
204
338
|
<span className="chat-statusrow-sep">·</span>
|
|
205
339
|
<span className="chat-statusrow-cc">Claude Code</span>
|
|
206
340
|
</div>
|
|
207
341
|
);
|
|
208
342
|
}
|
|
209
343
|
|
|
344
|
+
// Live m:ss since work started — a ticking counter reads as "actively working"
|
|
345
|
+
// during a long, output-quiet subagent stretch (the exact case that used to look
|
|
346
|
+
// hung). Resets whenever work stops.
|
|
347
|
+
function useElapsed(active) {
|
|
348
|
+
const [, tick] = useState(0);
|
|
349
|
+
const startRef = useRef(null);
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
if (!active) {
|
|
352
|
+
startRef.current = null;
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
startRef.current = Date.now();
|
|
356
|
+
const id = setInterval(() => tick((n) => n + 1), 1000);
|
|
357
|
+
return () => clearInterval(id);
|
|
358
|
+
}, [active]);
|
|
359
|
+
if (!active || startRef.current == null) return null;
|
|
360
|
+
const secs = Math.floor((Date.now() - startRef.current) / 1000);
|
|
361
|
+
if (secs < 1) return null;
|
|
362
|
+
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`;
|
|
363
|
+
}
|
|
364
|
+
|
|
210
365
|
// Live "still working" indicator — sits at the BOTTOM of the feed (under the
|
|
211
|
-
// latest Claude message)
|
|
212
|
-
//
|
|
366
|
+
// latest Claude message). Driven by turn-in-flight OR any running tool, so a
|
|
367
|
+
// background subagent stays visible ("N subagents running · 0:42") instead of
|
|
368
|
+
// the feed going quiet and looking dead.
|
|
213
369
|
function ActivityBar({ tools }) {
|
|
214
370
|
const running = useThread((t) => t.isRunning);
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
371
|
+
const working = running || tools.length > 0;
|
|
372
|
+
const elapsed = useElapsed(working);
|
|
373
|
+
if (!working) return null;
|
|
218
374
|
return (
|
|
219
375
|
<div className="chat-activity" role="status" aria-live="polite">
|
|
220
376
|
<span className="chat-activity-spin" aria-hidden="true" />
|
|
221
|
-
<span className="chat-activity-text">{
|
|
377
|
+
<span className="chat-activity-text">{activityLabel(tools)}</span>
|
|
378
|
+
{elapsed ? <span className="chat-activity-elapsed">{elapsed}</span> : null}
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Post-turn-end continuation. The ACP adapter settles the prompt at the main
|
|
384
|
+
// agent's first `result` (claude-agent-acp #773) but keeps streaming background
|
|
385
|
+
// work — subagent results, the consolidated report — AFTER the assistant-ui run
|
|
386
|
+
// ended. Those frames can't join the completed message, so acp-runtime collects
|
|
387
|
+
// them and we render them here as a live "continuation" bubble instead of
|
|
388
|
+
// dropping them (the answer used to only appear on reload). See RCA
|
|
389
|
+
// issue-acp-subagent-activity-invisible (facet F2). Cleared when the next turn
|
|
390
|
+
// starts; the durable copy lives in the transcript.
|
|
391
|
+
function ContinuationBubble({ parts }) {
|
|
392
|
+
if (!parts.length) return null;
|
|
393
|
+
return (
|
|
394
|
+
<div className="chat-msg chat-msg--assistant chat-msg--continued">
|
|
395
|
+
<div className="chat-msg-role">
|
|
396
|
+
<span className="chat-msg-spark">
|
|
397
|
+
<Spark size={13} />
|
|
398
|
+
</span>
|
|
399
|
+
Claude
|
|
400
|
+
</div>
|
|
401
|
+
{parts.map((p, i) =>
|
|
402
|
+
p.type === 'text' ? (
|
|
403
|
+
<ChatText key={i} text={p.text} />
|
|
404
|
+
) : p.type === 'reasoning' ? (
|
|
405
|
+
<ChatReasoning key={i} text={p.text} />
|
|
406
|
+
) : p.type === 'tool-call' ? (
|
|
407
|
+
<ChatToolCard
|
|
408
|
+
key={i}
|
|
409
|
+
toolName={p.toolName}
|
|
410
|
+
args={p.args}
|
|
411
|
+
result={p.result}
|
|
412
|
+
isError={p.isError}
|
|
413
|
+
/>
|
|
414
|
+
) : null
|
|
415
|
+
)}
|
|
222
416
|
</div>
|
|
223
417
|
);
|
|
224
418
|
}
|
|
@@ -233,6 +427,19 @@ function ChatEmpty() {
|
|
|
233
427
|
<div className="chat-empty-sub">
|
|
234
428
|
Ask for a change, a critique, or a new screen — Claude runs on your own subscription.
|
|
235
429
|
</div>
|
|
430
|
+
{/* One-time contextual CTA: bootstrap a design system. Prefills the guided
|
|
431
|
+
wizard (name is required, so we never fire it blind — the composer opens
|
|
432
|
+
ready for the slug). Canvas verbs live in the persistent quick-action row. */}
|
|
433
|
+
<ThreadPrimitive.Suggestion
|
|
434
|
+
prompt="/design:setup-ds "
|
|
435
|
+
send={false}
|
|
436
|
+
className="chat-empty-cta"
|
|
437
|
+
>
|
|
438
|
+
<span className="chat-empty-cta-ic">
|
|
439
|
+
<Swatches size={15} />
|
|
440
|
+
</span>
|
|
441
|
+
Create new design system
|
|
442
|
+
</ThreadPrimitive.Suggestion>
|
|
236
443
|
<div className="chat-sugs">
|
|
237
444
|
{SUGGESTIONS.map((s) => (
|
|
238
445
|
<ThreadPrimitive.Suggestion key={s} prompt={s} send={false} className="chat-sug">
|
|
@@ -261,24 +468,289 @@ function QuickActions() {
|
|
|
261
468
|
);
|
|
262
469
|
}
|
|
263
470
|
|
|
264
|
-
|
|
471
|
+
// Merge the static bootstrap list with the live ACP catalogue (pushed over the
|
|
472
|
+
// connection). Returns `{ all, existsSet }` — see slash-commands.js.
|
|
473
|
+
function useSlashCommands(conn) {
|
|
474
|
+
const [live, setLive] = useState([]);
|
|
475
|
+
useEffect(() => conn.onCommands(setLive), [conn]);
|
|
476
|
+
return useMemo(() => buildCommandModel(STATIC_COMMANDS, live), [live]);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Autocomplete menu — opens upward above the composer while a slash command is
|
|
480
|
+
// being typed. Keyboard-driven from the composer (see Composer); mouse hover +
|
|
481
|
+
// click supported. onMouseDown (not onClick) so picking beats the textarea blur.
|
|
482
|
+
function CommandPopover({ items, activeIndex, onPick, onHover }) {
|
|
483
|
+
if (!items.length) return null;
|
|
484
|
+
return (
|
|
485
|
+
<div className="chat-cmd-menu" role="listbox" data-testid="chat-cmd-menu">
|
|
486
|
+
{items.map((c, i) => (
|
|
487
|
+
<button
|
|
488
|
+
key={c.name}
|
|
489
|
+
type="button"
|
|
490
|
+
role="option"
|
|
491
|
+
aria-selected={i === activeIndex}
|
|
492
|
+
data-testid={`chat-cmd-item-${c.name.replace(/[^a-z0-9]+/gi, '-')}`}
|
|
493
|
+
className={`chat-cmd-item${i === activeIndex ? ' is-active' : ''}`}
|
|
494
|
+
onMouseEnter={() => onHover(i)}
|
|
495
|
+
onMouseDown={(e) => {
|
|
496
|
+
e.preventDefault();
|
|
497
|
+
onPick(c);
|
|
498
|
+
}}
|
|
499
|
+
>
|
|
500
|
+
<span className={`chat-cmd-name chat-cmd-name--${c.group}`}>/{c.name}</span>
|
|
501
|
+
{c.description ? <span className="chat-cmd-desc">{c.description}</span> : null}
|
|
502
|
+
{c.argHint ? <span className="chat-cmd-arg">{c.argHint}</span> : null}
|
|
503
|
+
</button>
|
|
504
|
+
))}
|
|
505
|
+
</div>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Inline command highlight via a mirror-overlay: a div behind a transparent-text
|
|
510
|
+
// textarea renders the same text, wrapping the leading command token in a pill —
|
|
511
|
+
// but ONLY when that token is a command that exists (the badge gate). The mirror
|
|
512
|
+
// MUST match the textarea's box model exactly (see .chat-input / .chat-input-mirror
|
|
513
|
+
// in 6-acp-chat.css) or the pill drifts off the text.
|
|
514
|
+
function HighlightedInput({ existsSet, attachmentsRef, onAttachChange }) {
|
|
515
|
+
const text = useComposer((c) => c.text);
|
|
516
|
+
const mirrorRef = useRef(null);
|
|
517
|
+
const match = matchLeadingCommand(text);
|
|
518
|
+
const token = match ? normalizeName(match.token) : '';
|
|
519
|
+
const highlight = !!(match && token && existsSet.has(token));
|
|
520
|
+
|
|
521
|
+
// Build the mirror: an optional leading command pill, then the remainder with
|
|
522
|
+
// any [image|file|link-N] tokens wrapped as chips. Both wrappers preserve the
|
|
523
|
+
// exact glyphs so the transparent-text textarea's caret stays aligned.
|
|
524
|
+
let head = null;
|
|
525
|
+
let bodyStart = 0;
|
|
526
|
+
if (highlight) {
|
|
527
|
+
const leadingWs = text.match(/^\s*/)[0];
|
|
528
|
+
const cmd = `/${match.token}`;
|
|
529
|
+
head = (
|
|
530
|
+
<>
|
|
531
|
+
{leadingWs}
|
|
532
|
+
<span className="chat-cmd-pill">{cmd}</span>
|
|
533
|
+
</>
|
|
534
|
+
);
|
|
535
|
+
bodyStart = leadingWs.length + cmd.length;
|
|
536
|
+
}
|
|
537
|
+
const body = text.slice(bodyStart);
|
|
538
|
+
// Trailing newline needs a zero-width guard so the mirror keeps the last line.
|
|
539
|
+
const guard = text.endsWith('\n') ? '' : '';
|
|
540
|
+
|
|
541
|
+
// Collapse a pasted attachment into a chip token at the caret and stash its real
|
|
542
|
+
// value in the map so the adapter expands it on send. Two sources:
|
|
543
|
+
// 1) a raw clipboard image (screenshot) — insert the chip synchronously, upload
|
|
544
|
+
// the bytes in the background, and fill the map when the path comes back. The
|
|
545
|
+
// in-flight promise is tracked so send can await it (no literal [image-N] if
|
|
546
|
+
// the user hits Enter before the upload lands).
|
|
547
|
+
// 2) a lone path / URL as text — resolved synchronously.
|
|
548
|
+
const onPaste = useCallback(
|
|
549
|
+
(e) => {
|
|
550
|
+
const cd = e.clipboardData;
|
|
551
|
+
const ta = e.currentTarget;
|
|
552
|
+
const { map, pending } = attachmentsRef.current;
|
|
553
|
+
|
|
554
|
+
const img = clipboardImageFile(cd);
|
|
555
|
+
if (img) {
|
|
556
|
+
e.preventDefault();
|
|
557
|
+
const chip = `[image-${nextChipIndex(ta.value, 'image')}]`;
|
|
558
|
+
map.set(chip, null); // pending — expandPasteChips leaves it literal until resolved
|
|
559
|
+
insertChipAtCaret(ta, chip);
|
|
560
|
+
onAttachChange?.();
|
|
561
|
+
const p = uploadChatImage(img)
|
|
562
|
+
.then((path) => {
|
|
563
|
+
if (path) map.set(chip, path);
|
|
564
|
+
else map.delete(chip);
|
|
565
|
+
})
|
|
566
|
+
.catch(() => map.delete(chip))
|
|
567
|
+
.finally(() => {
|
|
568
|
+
pending.delete(p);
|
|
569
|
+
onAttachChange?.();
|
|
570
|
+
});
|
|
571
|
+
pending.add(p);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const cls = classifyPaste(cd?.getData('text/plain'));
|
|
576
|
+
if (!cls) return; // ordinary paste — let assistant-ui handle it
|
|
577
|
+
e.preventDefault();
|
|
578
|
+
const chip = `[${cls.kind}-${nextChipIndex(ta.value, cls.kind)}]`;
|
|
579
|
+
map.set(chip, cls.value);
|
|
580
|
+
insertChipAtCaret(ta, chip);
|
|
581
|
+
onAttachChange?.();
|
|
582
|
+
},
|
|
583
|
+
[attachmentsRef, onAttachChange]
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
return (
|
|
587
|
+
<div className="chat-input-wrap">
|
|
588
|
+
<div className="chat-input-mirror" aria-hidden="true" ref={mirrorRef}>
|
|
589
|
+
{head}
|
|
590
|
+
{chipNodes(body)}
|
|
591
|
+
{guard}
|
|
592
|
+
</div>
|
|
593
|
+
<ComposerPrimitive.Input
|
|
594
|
+
className="chat-input chat-input--overlay"
|
|
595
|
+
submitMode="enter"
|
|
596
|
+
placeholder="Ask Claude to change this canvas…"
|
|
597
|
+
onPaste={onPaste}
|
|
598
|
+
onScroll={(e) => {
|
|
599
|
+
if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
|
600
|
+
}}
|
|
601
|
+
/>
|
|
602
|
+
</div>
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function Composer({
|
|
607
|
+
activeCanvas,
|
|
608
|
+
chatCtx,
|
|
609
|
+
onCtxDismiss,
|
|
610
|
+
model,
|
|
611
|
+
setModel,
|
|
612
|
+
effort,
|
|
613
|
+
setEffort,
|
|
614
|
+
conn,
|
|
615
|
+
chatId,
|
|
616
|
+
attachmentsRef,
|
|
617
|
+
}) {
|
|
265
618
|
const canvasName = prettyCanvas(activeCanvas);
|
|
619
|
+
const { all, existsSet } = useSlashCommands(conn);
|
|
620
|
+
const composerRuntime = useComposerRuntime();
|
|
621
|
+
const text = useComposer((c) => c.text);
|
|
622
|
+
|
|
623
|
+
const match = matchLeadingCommand(text);
|
|
624
|
+
const [dismissed, setDismissed] = useState(false);
|
|
625
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
626
|
+
const menuOpen = !!(match && match.typing) && !dismissed;
|
|
627
|
+
|
|
628
|
+
// Attachment reveal (security — DDR-140): a chip collapses a pasted path/URL to
|
|
629
|
+
// an opaque badge, and the real value is what gets sent to the auto-approving
|
|
630
|
+
// agent. So the composer MUST show what each chip will expand to BEFORE send —
|
|
631
|
+
// otherwise a value the user can't see (e.g. one an untrusted canvas seeded onto
|
|
632
|
+
// the clipboard) rides into the prompt invisibly. `attachTick` re-renders the
|
|
633
|
+
// strip when an async image upload fills its map entry.
|
|
634
|
+
const [attachTick, setAttachTick] = useState(0);
|
|
635
|
+
const bumpAttach = useCallback(() => setAttachTick((t) => t + 1), []);
|
|
636
|
+
const chipList = useMemo(() => {
|
|
637
|
+
const map = attachmentsRef.current.map;
|
|
638
|
+
const seen = new Set();
|
|
639
|
+
const out = [];
|
|
640
|
+
for (const m of text.matchAll(/\[(?:image|file|link)-\d+\]/g)) {
|
|
641
|
+
if (seen.has(m[0])) continue;
|
|
642
|
+
seen.add(m[0]);
|
|
643
|
+
out.push({ token: m[0], value: map.get(m[0]) ?? null });
|
|
644
|
+
}
|
|
645
|
+
return out;
|
|
646
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
647
|
+
}, [text, attachTick, attachmentsRef]);
|
|
648
|
+
const items = useMemo(
|
|
649
|
+
() => (menuOpen ? filterCommands(all, match.token) : []),
|
|
650
|
+
[menuOpen, all, match?.token]
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// Reset selection + un-dismiss whenever the typed token changes.
|
|
654
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
655
|
+
useEffect(() => {
|
|
656
|
+
setActiveIndex(0);
|
|
657
|
+
setDismissed(false);
|
|
658
|
+
}, [match?.token]);
|
|
659
|
+
|
|
660
|
+
// Warm the adapter the first time the user starts a slash command, so the live
|
|
661
|
+
// catalogue arrives without a full prompt. Guarded to fire once per mount.
|
|
662
|
+
const warmedRef = useRef(false);
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
if (menuOpen && !warmedRef.current) {
|
|
665
|
+
warmedRef.current = true;
|
|
666
|
+
conn.warm(chatId, model, effort);
|
|
667
|
+
}
|
|
668
|
+
}, [menuOpen, conn, chatId, model, effort]);
|
|
669
|
+
|
|
670
|
+
const pick = useCallback(
|
|
671
|
+
(cmd) => {
|
|
672
|
+
composerRuntime.setText(`/${cmd.name} `);
|
|
673
|
+
setDismissed(false);
|
|
674
|
+
setActiveIndex(0);
|
|
675
|
+
},
|
|
676
|
+
[composerRuntime]
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
const onKeyDownCapture = useCallback(
|
|
680
|
+
(e) => {
|
|
681
|
+
if (!menuOpen || !items.length) return;
|
|
682
|
+
// Swallow navigation keys before they reach the textarea (capture phase +
|
|
683
|
+
// stopPropagation) so the menu drives them, not the caret / submit.
|
|
684
|
+
const swallow = () => {
|
|
685
|
+
e.preventDefault();
|
|
686
|
+
e.stopPropagation();
|
|
687
|
+
};
|
|
688
|
+
if (e.key === 'ArrowDown') {
|
|
689
|
+
swallow();
|
|
690
|
+
setActiveIndex((i) => (i + 1) % items.length);
|
|
691
|
+
} else if (e.key === 'ArrowUp') {
|
|
692
|
+
swallow();
|
|
693
|
+
setActiveIndex((i) => (i - 1 + items.length) % items.length);
|
|
694
|
+
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
695
|
+
swallow();
|
|
696
|
+
pick(items[Math.min(activeIndex, items.length - 1)]);
|
|
697
|
+
} else if (e.key === 'Escape') {
|
|
698
|
+
swallow();
|
|
699
|
+
setDismissed(true);
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
[menuOpen, items, activeIndex, pick]
|
|
703
|
+
);
|
|
704
|
+
|
|
266
705
|
return (
|
|
267
706
|
<div className="chat-composer">
|
|
268
707
|
<ThreadPrimitive.If running={false}>
|
|
269
|
-
{
|
|
708
|
+
{/* Context attachment chip (feature-acp-context-hardening) — the visible
|
|
709
|
+
reveal of the fenced <maude-context> block the send will prepend
|
|
710
|
+
(built from the SAME buildChatContext result, so chip ≡ payload).
|
|
711
|
+
Removable (DDR-140); a stale selection warns instead of hiding. */}
|
|
712
|
+
{chatCtx ? (
|
|
713
|
+
<div
|
|
714
|
+
className={`chat-ctx${chatCtx.stale ? ' chat-ctx--stale' : ''}`}
|
|
715
|
+
data-testid="chat-context-chip"
|
|
716
|
+
>
|
|
717
|
+
<span className="chat-ctx-label">
|
|
718
|
+
◆ {chatCtx.chipLabel}
|
|
719
|
+
{chatCtx.stale ? ' — canvas changed since selection' : ''}
|
|
720
|
+
</span>
|
|
721
|
+
<button
|
|
722
|
+
type="button"
|
|
723
|
+
className="chat-ctx-x"
|
|
724
|
+
aria-label="Remove canvas context from this message"
|
|
725
|
+
title="Remove canvas context from this message"
|
|
726
|
+
onClick={onCtxDismiss}
|
|
727
|
+
>
|
|
728
|
+
×
|
|
729
|
+
</button>
|
|
730
|
+
</div>
|
|
731
|
+
) : canvasName ? (
|
|
270
732
|
<div className="chat-ctx">
|
|
271
733
|
Editing: <b>{canvasName}</b>
|
|
272
734
|
</div>
|
|
273
735
|
) : null}
|
|
274
|
-
{/* One box: textarea on top, a bottom toolbar (model · effort · send).
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
736
|
+
{/* One box: textarea on top, a bottom toolbar (model · effort · send).
|
|
737
|
+
The anchor is relative so the command popover can float above it. */}
|
|
738
|
+
<div className="chat-cmd-anchor" onKeyDownCapture={onKeyDownCapture}>
|
|
739
|
+
{menuOpen ? (
|
|
740
|
+
<CommandPopover
|
|
741
|
+
items={items}
|
|
742
|
+
activeIndex={activeIndex}
|
|
743
|
+
onPick={pick}
|
|
744
|
+
onHover={setActiveIndex}
|
|
745
|
+
/>
|
|
746
|
+
) : null}
|
|
747
|
+
<ComposerPrimitive.Root className="chat-box">
|
|
748
|
+
<HighlightedInput
|
|
749
|
+
existsSet={existsSet}
|
|
750
|
+
attachmentsRef={attachmentsRef}
|
|
751
|
+
onAttachChange={bumpAttach}
|
|
752
|
+
/>
|
|
753
|
+
<div className="chat-toolbar">
|
|
282
754
|
<select
|
|
283
755
|
className="chat-select"
|
|
284
756
|
value={model}
|
|
@@ -308,9 +780,25 @@ function Composer({ activeCanvas, model, setModel, effort, setEffort }) {
|
|
|
308
780
|
<SendArrow />
|
|
309
781
|
</ComposerPrimitive.Send>
|
|
310
782
|
</div>
|
|
311
|
-
|
|
783
|
+
</ComposerPrimitive.Root>
|
|
784
|
+
</div>
|
|
785
|
+
{chipList.length ? (
|
|
786
|
+
<div className="chat-attach" role="list" aria-label="Attachments — expands on send">
|
|
787
|
+
{chipList.map((c) => (
|
|
788
|
+
<div className="chat-attach-row" role="listitem" key={c.token}>
|
|
789
|
+
<span className="chat-attach-tok">{c.token}</span>
|
|
790
|
+
<span className="chat-attach-arrow" aria-hidden="true">
|
|
791
|
+
→
|
|
792
|
+
</span>
|
|
793
|
+
<span className="chat-attach-val" title={c.value || undefined}>
|
|
794
|
+
{c.value || 'attaching…'}
|
|
795
|
+
</span>
|
|
796
|
+
</div>
|
|
797
|
+
))}
|
|
798
|
+
</div>
|
|
799
|
+
) : null}
|
|
312
800
|
<div className="chat-foot">
|
|
313
|
-
<span
|
|
801
|
+
<span>↵ to send · ⇧↵ newline</span>
|
|
314
802
|
<span className="chat-foot-spacer" />
|
|
315
803
|
<span>your Claude subscription</span>
|
|
316
804
|
</div>
|
|
@@ -415,45 +903,85 @@ function ChatThread({
|
|
|
415
903
|
modelRef,
|
|
416
904
|
effortRef,
|
|
417
905
|
activeCanvas,
|
|
906
|
+
selected,
|
|
418
907
|
model,
|
|
419
908
|
setModel,
|
|
420
909
|
effort,
|
|
421
910
|
setEffort,
|
|
422
911
|
}) {
|
|
912
|
+
// Paste attachments: `map` = chip token ([image-1]…) → real path/URL (null while
|
|
913
|
+
// an image upload is in flight); `pending` = the in-flight upload promises. The
|
|
914
|
+
// adapter awaits `pending` then expands `map` before sending; the composer writes
|
|
915
|
+
// both on paste.
|
|
916
|
+
const attachmentsRef = useRef({ map: new Map(), pending: new Set() });
|
|
917
|
+
// Canvas/selection context attachment (feature-acp-context-hardening). One
|
|
918
|
+
// buildChatContext result drives BOTH the composer chip and the frozen block
|
|
919
|
+
// the adapter prepends at send. The ref is what the adapter reads (freeze =
|
|
920
|
+
// read-once at send); the state re-renders the chip. Dismissing the chip
|
|
921
|
+
// (DDR-140 — the attachment is removable) suppresses the context until it
|
|
922
|
+
// CHANGES (new selection / canvas), which re-arms it.
|
|
923
|
+
const [chatCtx, setChatCtx] = useState(null);
|
|
924
|
+
const [ctxDismissed, setCtxDismissed] = useState(false);
|
|
925
|
+
const contextRef = useRef(null);
|
|
926
|
+
const ctxDismissedRef = useRef(false);
|
|
927
|
+
useEffect(() => {
|
|
928
|
+
const c = buildChatContext({ canvas: activeCanvas, selected });
|
|
929
|
+
setChatCtx(c);
|
|
930
|
+
contextRef.current = c;
|
|
931
|
+
setCtxDismissed(false);
|
|
932
|
+
ctxDismissedRef.current = false;
|
|
933
|
+
}, [activeCanvas, selected]);
|
|
934
|
+
const dismissCtx = useCallback(() => {
|
|
935
|
+
setCtxDismissed(true);
|
|
936
|
+
ctxDismissedRef.current = true;
|
|
937
|
+
}, []);
|
|
423
938
|
const adapter = useMemo(
|
|
424
939
|
() =>
|
|
425
940
|
makeAcpAdapter(
|
|
426
941
|
conn,
|
|
427
942
|
() => chatId,
|
|
428
943
|
() => modelRef.current || null,
|
|
429
|
-
() => effortRef.current
|
|
944
|
+
() => effortRef.current,
|
|
945
|
+
() => attachmentsRef.current,
|
|
946
|
+
() => (ctxDismissedRef.current ? null : contextRef.current)
|
|
430
947
|
),
|
|
431
948
|
[conn, chatId, modelRef, effortRef]
|
|
432
949
|
);
|
|
433
950
|
const runtime = useLocalRuntime(adapter, { initialMessages });
|
|
434
951
|
const [activeTools, setActiveTools] = useState([]);
|
|
435
952
|
useEffect(() => conn.onActivity(setActiveTools), [conn]);
|
|
953
|
+
// Post-turn-end continuation (the tail the client used to drop — RCA F2).
|
|
954
|
+
const [bgParts, setBgParts] = useState([]);
|
|
955
|
+
useEffect(() => conn.onBackground(setBgParts), [conn]);
|
|
436
956
|
|
|
437
957
|
return (
|
|
438
958
|
<AssistantRuntimeProvider runtime={runtime}>
|
|
439
959
|
<div className="chat-panel" style={hidden ? { display: 'none' } : undefined}>
|
|
440
|
-
<StatusRow />
|
|
960
|
+
<StatusRow tools={activeTools} />
|
|
441
961
|
<ThreadPrimitive.Root className="chat-thread">
|
|
442
962
|
<ThreadPrimitive.Viewport className="chat-feed" autoScroll>
|
|
443
963
|
<ThreadPrimitive.Empty>
|
|
444
964
|
<ChatEmpty />
|
|
445
965
|
</ThreadPrimitive.Empty>
|
|
446
966
|
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
|
967
|
+
{/* Background work that outlived the turn — streamed here so it's not
|
|
968
|
+
dropped (RCA F2). */}
|
|
969
|
+
<ContinuationBubble parts={bgParts} />
|
|
447
970
|
{/* "still working" indicator under the latest message */}
|
|
448
971
|
<ActivityBar tools={activeTools} />
|
|
449
972
|
</ThreadPrimitive.Viewport>
|
|
450
973
|
<QuickActions />
|
|
451
974
|
<Composer
|
|
452
975
|
activeCanvas={activeCanvas}
|
|
976
|
+
chatCtx={ctxDismissed ? null : chatCtx}
|
|
977
|
+
onCtxDismiss={dismissCtx}
|
|
453
978
|
model={model}
|
|
454
979
|
setModel={setModel}
|
|
455
980
|
effort={effort}
|
|
456
981
|
setEffort={setEffort}
|
|
982
|
+
conn={conn}
|
|
983
|
+
chatId={chatId}
|
|
984
|
+
attachmentsRef={attachmentsRef}
|
|
457
985
|
/>
|
|
458
986
|
</ThreadPrimitive.Root>
|
|
459
987
|
</div>
|
|
@@ -464,6 +992,7 @@ function ChatThread({
|
|
|
464
992
|
// ── panel root ──
|
|
465
993
|
export default function ChatPanel({
|
|
466
994
|
activeCanvas,
|
|
995
|
+
selected,
|
|
467
996
|
width,
|
|
468
997
|
resizing,
|
|
469
998
|
onClose,
|
|
@@ -559,13 +1088,45 @@ export default function ChatPanel({
|
|
|
559
1088
|
if (existing) return existing;
|
|
560
1089
|
const conn = createAcpConnection();
|
|
561
1090
|
connsRef.current.set(chatId, conn);
|
|
562
|
-
|
|
1091
|
+
// Background work (subagents) can outlive the main turn — the ACP adapter
|
|
1092
|
+
// settles the prompt at the main agent's `result` while they drain
|
|
1093
|
+
// (claude-agent-acp #773). Track live tool activity so the menubar
|
|
1094
|
+
// badge/dot + the "finished" ping reflect TRUE quiescence, not the
|
|
1095
|
+
// premature turn-end. (RCA issue-acp-subagent-activity-invisible.)
|
|
1096
|
+
let bgActive = false; // a tool (e.g. a subagent) is still running
|
|
1097
|
+
let busyNow = false; // the prompt turn itself is in flight
|
|
1098
|
+
let deferredFinish = false; // turn ended while background work continued
|
|
1099
|
+
const syncAggregate = () => {
|
|
563
1100
|
const wasAny = [...busyRef.current.values()].some(Boolean);
|
|
564
|
-
busyRef.current.set(chatId,
|
|
1101
|
+
busyRef.current.set(chatId, busyNow || bgActive);
|
|
565
1102
|
const nowAny = [...busyRef.current.values()].some(Boolean);
|
|
566
1103
|
if (wasAny !== nowAny) cbRef.current.onBusyChange?.(nowAny);
|
|
567
|
-
|
|
568
|
-
|
|
1104
|
+
};
|
|
1105
|
+
conn.onActivity((activeTools) => {
|
|
1106
|
+
bgActive = activeTools.length > 0;
|
|
1107
|
+
syncAggregate();
|
|
1108
|
+
if (deferredFinish && !bgActive && !busyNow) {
|
|
1109
|
+
deferredFinish = false;
|
|
1110
|
+
setBusyChats((prev) => ({ ...prev, [chatId]: false }));
|
|
1111
|
+
cbRef.current.onFinished?.();
|
|
1112
|
+
refreshChats();
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
conn.onBusy((busy) => {
|
|
1116
|
+
busyNow = busy;
|
|
1117
|
+
syncAggregate();
|
|
1118
|
+
if (busy) {
|
|
1119
|
+
deferredFinish = false; // a new turn supersedes any pending drain ping
|
|
1120
|
+
setBusyChats((prev) => ({ ...prev, [chatId]: true })); // reactive dot
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
// Turn ended. If background work is still draining, keep the dot lit and
|
|
1124
|
+
// hold the "finished" ping until the activity list empties (onActivity).
|
|
1125
|
+
if (bgActive) {
|
|
1126
|
+
deferredFinish = true;
|
|
1127
|
+
refreshChats();
|
|
1128
|
+
} else {
|
|
1129
|
+
setBusyChats((prev) => ({ ...prev, [chatId]: false }));
|
|
569
1130
|
cbRef.current.onFinished?.();
|
|
570
1131
|
refreshChats();
|
|
571
1132
|
}
|
|
@@ -782,6 +1343,7 @@ export default function ChatPanel({
|
|
|
782
1343
|
modelRef={modelRef}
|
|
783
1344
|
effortRef={effortRef}
|
|
784
1345
|
activeCanvas={activeCanvas}
|
|
1346
|
+
selected={selected}
|
|
785
1347
|
model={model}
|
|
786
1348
|
setModel={setModel}
|
|
787
1349
|
effort={effort}
|