@1agh/maude 0.36.1 → 0.38.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 (71) hide show
  1. package/README.md +2 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +93 -0
  3. package/apps/studio/acp/bridge.ts +114 -1
  4. package/apps/studio/acp/index.ts +184 -21
  5. package/apps/studio/acp/plugin-bootstrap.ts +115 -0
  6. package/apps/studio/acp/transcript.ts +36 -3
  7. package/apps/studio/activity.ts +45 -0
  8. package/apps/studio/api.ts +265 -47
  9. package/apps/studio/bin/_ensure-browser.mjs +305 -0
  10. package/apps/studio/bin/ensure-browser.sh +26 -0
  11. package/apps/studio/bin/screenshot.sh +33 -8
  12. package/apps/studio/bin/smoke.sh +46 -0
  13. package/apps/studio/canvas-edit.ts +422 -6
  14. package/apps/studio/canvas-lib.tsx +48 -0
  15. package/apps/studio/canvas-shell.tsx +684 -12
  16. package/apps/studio/client/app.jsx +686 -35
  17. package/apps/studio/client/github.js +6 -0
  18. package/apps/studio/client/panels/ChatPanel.jsx +593 -31
  19. package/apps/studio/client/panels/CreateProject.jsx +57 -23
  20. package/apps/studio/client/panels/GitPanel.jsx +1 -1
  21. package/apps/studio/client/panels/IdentityBar.jsx +43 -2
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +24 -21
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +4 -4
  24. package/apps/studio/client/panels/acp-runtime.js +227 -70
  25. package/apps/studio/client/panels/chat-context.js +124 -0
  26. package/apps/studio/client/panels/slash-commands.js +147 -0
  27. package/apps/studio/client/styles/3-shell-maude.css +15 -0
  28. package/apps/studio/client/styles/6-acp-chat.css +244 -0
  29. package/apps/studio/client/tour/collab-tour.js +3 -3
  30. package/apps/studio/commands/reorder-command.ts +77 -0
  31. package/apps/studio/config.schema.json +6 -0
  32. package/apps/studio/dist/client.bundle.js +17 -13
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/github/endpoints.ts +42 -0
  35. package/apps/studio/hmr-broadcast.ts +27 -19
  36. package/apps/studio/http.ts +183 -26
  37. package/apps/studio/inspect.ts +108 -1
  38. package/apps/studio/paths.ts +32 -0
  39. package/apps/studio/readiness.ts +79 -30
  40. package/apps/studio/scaffold-design.ts +95 -2
  41. package/apps/studio/server.ts +11 -2
  42. package/apps/studio/test/acp-activity.test.ts +154 -0
  43. package/apps/studio/test/acp-ai-activity.test.ts +182 -0
  44. package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
  45. package/apps/studio/test/acp-commands.test.ts +108 -0
  46. package/apps/studio/test/acp-origin-gate.test.ts +64 -1
  47. package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
  48. package/apps/studio/test/acp-session-plugins.test.ts +132 -0
  49. package/apps/studio/test/acp-transcript.test.ts +53 -0
  50. package/apps/studio/test/active-state.test.ts +41 -0
  51. package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
  52. package/apps/studio/test/canvas-origin-gate.test.ts +10 -0
  53. package/apps/studio/test/canvas-reorder.test.ts +211 -0
  54. package/apps/studio/test/chat-context.test.ts +129 -0
  55. package/apps/studio/test/csrf-write-guard.test.ts +24 -0
  56. package/apps/studio/test/edit-suppress.test.ts +170 -0
  57. package/apps/studio/test/ensure-browser.test.ts +92 -0
  58. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
  59. package/apps/studio/test/hmr-broadcast.test.ts +44 -0
  60. package/apps/studio/test/inspect-selections.test.ts +219 -0
  61. package/apps/studio/test/paths.test.ts +57 -0
  62. package/apps/studio/test/readiness.test.ts +83 -13
  63. package/apps/studio/test/reorder-api.test.ts +210 -0
  64. package/apps/studio/test/slash-commands.test.ts +117 -0
  65. package/apps/studio/test/sync-agent.test.ts +9 -2
  66. package/apps/studio/undo-stack.ts +6 -0
  67. package/apps/studio/whats-new.json +63 -0
  68. package/apps/studio/ws.ts +37 -0
  69. package/cli/commands/design.mjs +1 -0
  70. package/package.json +11 -9
  71. 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
- function StatusRow() {
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 ${running ? 'chat-status-dot--working' : 'chat-status-dot--ready'}`}
335
+ className={`chat-status-dot ${working ? 'chat-status-dot--working' : 'chat-status-dot--ready'}`}
202
336
  />
203
- {running ? 'Working…' : 'Ready'}
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) so it's clear the turn is still going. Shows the
212
- // in-flight tool (edit/read/shell/sub-agent) when there is one, else "Working…".
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
- if (!running) return null;
216
- const label =
217
- tools.length === 1 ? tools[0].title : tools.length > 1 ? `${tools.length} tasks running` : 'Working…';
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">{label}</span>
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
- function Composer({ activeCanvas, model, setModel, effort, setEffort }) {
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
- {canvasName ? (
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
- <ComposerPrimitive.Root className="chat-box">
276
- <ComposerPrimitive.Input
277
- className="chat-input"
278
- submitMode="ctrlEnter"
279
- placeholder="Ask Claude to change this canvas…"
280
- />
281
- <div className="chat-toolbar">
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
- </ComposerPrimitive.Root>
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>⌘↵ to send</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
- conn.onBusy((busy) => {
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, busy);
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
- setBusyChats((prev) => ({ ...prev, [chatId]: busy })); // reactive dot
568
- if (!busy) {
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}