@1agh/maude 0.45.1 → 0.46.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 (91) hide show
  1. package/apps/studio/acp/bridge.ts +628 -74
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/annotations-snap.ts +50 -10
  6. package/apps/studio/api.ts +344 -14
  7. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  8. package/apps/studio/bin/_import-asset.mjs +35 -5
  9. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  10. package/apps/studio/bin/_png-playwright.mjs +38 -4
  11. package/apps/studio/bin/_pw-launch.mjs +52 -0
  12. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  13. package/apps/studio/bin/_smart-frames.mjs +419 -0
  14. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  15. package/apps/studio/bin/smart-frames.sh +30 -0
  16. package/apps/studio/canvas-edit.ts +302 -3
  17. package/apps/studio/canvas-lib.tsx +203 -11
  18. package/apps/studio/canvas-shell.tsx +167 -28
  19. package/apps/studio/client/app.jsx +678 -176
  20. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  21. package/apps/studio/client/panels/ChatPanel.jsx +846 -133
  22. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  23. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  24. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  25. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  26. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  27. package/apps/studio/client/panels/acp-capabilities.js +82 -0
  28. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  29. package/apps/studio/client/panels/acp-runtime.js +248 -9
  30. package/apps/studio/client/panels/acp-usage.js +65 -0
  31. package/apps/studio/client/panels/transcript-view.js +36 -0
  32. package/apps/studio/client/styles/6-acp-chat.css +689 -11
  33. package/apps/studio/dist/client.bundle.js +1738 -1736
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/generation/whisper-models.test.ts +28 -1
  44. package/apps/studio/generation/whisper-models.ts +22 -7
  45. package/apps/studio/http.ts +246 -9
  46. package/apps/studio/print/marks.ts +113 -0
  47. package/apps/studio/print/units.ts +269 -0
  48. package/apps/studio/print-overlay-content.tsx +132 -0
  49. package/apps/studio/test/acp-bridge.test.ts +11 -6
  50. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  51. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  52. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  53. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  54. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  56. package/apps/studio/test/acp-permission.test.ts +262 -0
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  58. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  59. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  60. package/apps/studio/test/acp-transcript.test.ts +75 -2
  61. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  62. package/apps/studio/test/acp-usage.test.ts +143 -0
  63. package/apps/studio/test/annotations-snap.test.ts +56 -0
  64. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  65. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  66. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  67. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  68. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  69. package/apps/studio/test/exporters/png.test.ts +49 -1
  70. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  73. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  74. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  75. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  76. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  77. package/apps/studio/test/import-asset.test.ts +31 -3
  78. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  79. package/apps/studio/test/print-marks.test.ts +113 -0
  80. package/apps/studio/test/print-units.test.ts +173 -0
  81. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  82. package/apps/studio/use-chrome-visibility.tsx +19 -0
  83. package/apps/studio/use-element-resize.tsx +21 -2
  84. package/apps/studio/use-snap-guides.tsx +73 -5
  85. package/apps/studio/use-spacing-handles.tsx +9 -5
  86. package/apps/studio/whats-new.json +62 -0
  87. package/cli/commands/design.mjs +116 -3
  88. package/cli/lib/pkg-root.mjs +42 -10
  89. package/cli/lib/pkg-root.test.mjs +33 -1
  90. package/package.json +11 -9
  91. package/plugins/design/dependencies.json +35 -0
@@ -13,6 +13,7 @@
13
13
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
14
14
 
15
15
  import {
16
+ ActionBarPrimitive,
16
17
  AssistantRuntimeProvider,
17
18
  ComposerPrimitive,
18
19
  MessagePrimitive,
@@ -20,18 +21,30 @@ import {
20
21
  useComposer,
21
22
  useComposerRuntime,
22
23
  useLocalRuntime,
24
+ useMessage,
23
25
  useThread,
24
26
  } from '@assistant-ui/react';
25
27
 
28
+ import {
29
+ flattenSelectOptions,
30
+ modeBlocksEdits,
31
+ parseConfigOptions,
32
+ resolvePersistedPick,
33
+ } from './acp-capabilities.js';
34
+ import { parseUsage } from './acp-usage.js';
26
35
  import {
27
36
  activityLabel,
28
37
  attachmentName,
29
38
  createAcpConnection,
30
39
  designImageRefs,
31
40
  extractAttachmentRefs,
41
+ lastUserText,
32
42
  makeAcpAdapter,
33
43
  } from './acp-runtime.js';
34
44
  import { buildChatContext } from './chat-context.js';
45
+ import CapabilityBar from './CapabilityBar.jsx';
46
+ import ElicitationPrompt from './ElicitationPrompt.jsx';
47
+ import PermissionPrompt from './PermissionPrompt.jsx';
35
48
  import { Markdown } from './chat-markdown.jsx';
36
49
  import ReadinessList, { useReadiness } from './ReadinessList.jsx';
37
50
  import {
@@ -41,6 +54,13 @@ import {
41
54
  normalizeName,
42
55
  STATIC_COMMANDS,
43
56
  } from './slash-commands.js';
57
+ import ToolGroup, { groupToolCalls } from './ToolGroup.jsx';
58
+ import {
59
+ DEFAULT_TRANSCRIPT_VIEW,
60
+ filterTranscriptParts,
61
+ transcriptForcesExpand,
62
+ TRANSCRIPT_VIEWS,
63
+ } from './transcript-view.js';
44
64
 
45
65
  // ── inline icons (separate panel files carry their own, like GitPanel) ──
46
66
  const Spark = ({ size = 16 }) => (
@@ -83,6 +103,30 @@ const SendArrow = ({ size = 16 }) => (
83
103
  />
84
104
  </svg>
85
105
  );
106
+ // Per-message action icons (Task C2).
107
+ const Copy = ({ size = 14 }) => (
108
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
109
+ <rect x="5.5" y="5.5" width="8" height="8" rx="1.3" stroke="currentColor" strokeWidth="1.4" />
110
+ <path
111
+ d="M10.5 5.5V3.8A1.3 1.3 0 0 0 9.2 2.5H3.8A1.3 1.3 0 0 0 2.5 3.8v5.4a1.3 1.3 0 0 0 1.3 1.3h1.7"
112
+ stroke="currentColor"
113
+ strokeWidth="1.4"
114
+ strokeLinecap="round"
115
+ strokeLinejoin="round"
116
+ />
117
+ </svg>
118
+ );
119
+ const Retry = ({ size = 14 }) => (
120
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
121
+ <path
122
+ d="M13 8A5 5 0 1 1 11.3 4.3M13 2.5v3.2h-3.2"
123
+ stroke="currentColor"
124
+ strokeWidth="1.4"
125
+ strokeLinecap="round"
126
+ strokeLinejoin="round"
127
+ />
128
+ </svg>
129
+ );
86
130
  // Token swatches — reads as "design system"; used on the empty-state DS CTA.
87
131
  const Swatches = ({ size = 15 }) => (
88
132
  <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -108,20 +152,6 @@ const SUGGESTIONS = [
108
152
  '/design:new Pricing "a 3-tier pricing page"',
109
153
  ];
110
154
 
111
- // Model — '' = the user's own `claude` default; the rest are CLI aliases passed
112
- // as ANTHROPIC_MODEL. Effort → extended-thinking budget (server maps the label).
113
- const MODELS = [
114
- { value: '', label: 'Default model' },
115
- { value: 'opus', label: 'Opus' },
116
- { value: 'sonnet', label: 'Sonnet' },
117
- { value: 'haiku', label: 'Haiku' },
118
- ];
119
- const EFFORTS = [
120
- { value: 'fast', label: 'Fast' },
121
- { value: 'balanced', label: 'Balanced' },
122
- { value: 'thorough', label: 'Thorough' },
123
- ];
124
-
125
155
  function safeStorageGet(key, fallback) {
126
156
  try {
127
157
  return localStorage.getItem(key) ?? fallback;
@@ -137,12 +167,88 @@ function safeStorageSet(key, value) {
137
167
  }
138
168
  }
139
169
 
170
+ // Model/effort/mode picks (feature-acp-panel-dynamic-claude-code-capabilities)
171
+ // — persisted generically by config id in ONE blob, since the set of options
172
+ // is agent-defined and NOT a fixed list (a hardcoded per-field key would miss
173
+ // a future option like "agent" persona). One-time migration reads the OLD
174
+ // fixed keys this panel used before the dynamic capability channel shipped.
175
+ const PICKS_KEY = 'maude-acp-picks';
176
+ function loadPersistedPicks() {
177
+ const raw = safeStorageGet(PICKS_KEY, null);
178
+ if (raw) {
179
+ try {
180
+ const parsed = JSON.parse(raw);
181
+ if (parsed && typeof parsed === 'object') return parsed;
182
+ } catch {
183
+ /* corrupt blob — fall through to a clean slate */
184
+ }
185
+ }
186
+ const picks = {};
187
+ const legacyModel = safeStorageGet('maude-acp-model', '');
188
+ const legacyEffort = safeStorageGet('maude-acp-effort', '');
189
+ if (legacyModel) picks.model = legacyModel;
190
+ if (legacyEffort && legacyEffort !== 'balanced') picks.effort = legacyEffort;
191
+ return picks;
192
+ }
193
+ function savePersistedPicks(picks) {
194
+ safeStorageSet(PICKS_KEY, JSON.stringify(picks));
195
+ }
196
+
140
197
  function prettyCanvas(path) {
141
198
  if (!path) return null;
142
199
  const file = path.split('/').pop() || path;
143
200
  return file.replace(/\.(tsx|html)$/i, '');
144
201
  }
145
202
 
203
+ // Composer footer note (Task B3) — tells the user whether the CURRENT mode
204
+ // will interrupt them for a permission decision. `bypassPermissions`/
205
+ // `dontAsk` never call requestPermission at all (adapter short-circuits); every
206
+ // other mode does. Sourced from the mode's own id — never a hardcoded label
207
+ // beyond this yes/no framing, since the mode SET itself is fully dynamic.
208
+ const NO_PROMPT_MODE_IDS = new Set(['bypassPermissions', 'dontAsk']);
209
+ function modeFootnote(modes) {
210
+ if (!modes?.currentModeId || !modes.availableModes?.length) return null;
211
+ const current = modes.availableModes.find((m) => m.id === modes.currentModeId);
212
+ const name = current?.name || modes.currentModeId;
213
+ return NO_PROMPT_MODE_IDS.has(modes.currentModeId) ? `${name} — no prompts` : `${name} — you'll be asked`;
214
+ }
215
+
216
+ // The loud, top-of-thread alert shown ONLY while the current mode blocks edits
217
+ // (`modeBlocksEdits`, DDR-184 #3 — the quiet composer footnote above isn't enough
218
+ // when a user typing "change X" silently gets nothing back).
219
+ // Offers a one-click hop to the least-privilege mode that CAN edit (Accept Edits
220
+ // if advertised, else Manual) so the fix is right there, not buried in the picker.
221
+ function ModeBanner({ modes, onSetMode }) {
222
+ if (!modeBlocksEdits(modes)) return null;
223
+ const current = modes.availableModes.find((m) => m.id === modes.currentModeId);
224
+ const name = current?.name || modes.currentModeId;
225
+ const isPlan = modes.currentModeId === 'plan';
226
+ const ids = new Set(modes.availableModes.map((m) => m.id));
227
+ const target = ids.has('acceptEdits') ? 'acceptEdits' : ids.has('default') ? 'default' : null;
228
+ const targetName = target && (modes.availableModes.find((m) => m.id === target)?.name || target);
229
+ return (
230
+ <div className="chat-mode-banner" role="alert" data-testid="chat-mode-banner">
231
+ <span className="chat-mode-banner-icon" aria-hidden="true">⏸</span>
232
+ <span className="chat-mode-banner-text">
233
+ <b>{name}</b>{' — '}
234
+ {isPlan
235
+ ? "Claude is planning and won't edit files or run tools."
236
+ : 'edits and tools without a standing approval are denied — nothing will change.'}
237
+ </span>
238
+ {target ? (
239
+ <button
240
+ type="button"
241
+ className="chat-mode-banner-cta"
242
+ onClick={() => onSetMode(target)}
243
+ data-testid="chat-mode-banner-switch"
244
+ >
245
+ Switch to {targetName}
246
+ </button>
247
+ ) : null}
248
+ </div>
249
+ );
250
+ }
251
+
146
252
  // ── pasted-path / URL → inline chip (Claude-Code-style attachment badge) ──
147
253
  // When the whole clipboard is a single path or URL, we collapse it to a short
148
254
  // literal token ([image-1] / [file-1] / [link-1]) in the textarea and stash the
@@ -287,10 +393,54 @@ function ChatReasoning({ text }) {
287
393
  );
288
394
  }
289
395
 
290
- function ChatToolCard({ toolName, args, result, isError }) {
396
+ // Bounded JSON preview for the verbose transcript view (Task C4) — a raw tool
397
+ // arg/result can be arbitrarily large (a big file's content, a long stdout);
398
+ // cap it so one tool call can't blow up the feed's layout or paint cost.
399
+ function jsonPreview(value, cap = 800) {
400
+ let s;
401
+ try {
402
+ s = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
403
+ } catch {
404
+ return null;
405
+ }
406
+ // An empty object/array/string preview ("{}", "[]", '""') is noise, not
407
+ // information — a tool that legitimately took no args or returned nothing
408
+ // shouldn't render a boxed detail block just to say so.
409
+ if (!s || s === '{}' || s === '[]' || s === '""' || s === 'null') return null;
410
+ return s.length > cap ? `${s.slice(0, cap)}…` : s;
411
+ }
412
+
413
+ // `flat` (Task C1 revision, after dogfooding showed grouped tool calls
414
+ // rendering as nested bordered cards inside a bordered card — "divné") skips
415
+ // the standalone `.chat-tool` card chrome for a lighter row, used only when
416
+ // ChatToolCard renders inside ToolGroup's already-bordered accordion body.
417
+ function ChatToolCard({ toolName, args, result, isError, verbose, flat = false }) {
291
418
  const running = result === undefined;
292
419
  const path =
293
420
  args && typeof args === 'object' ? args.path || args.file || args.filePath : undefined;
421
+ const argsPreview = verbose && args && typeof args === 'object' && Object.keys(args).length
422
+ ? jsonPreview(args)
423
+ : null;
424
+ const resultPreview = verbose && !running && result != null ? jsonPreview(result) : null;
425
+ const statusLabel = running ? 'running…' : isError ? 'failed' : 'done';
426
+
427
+ if (flat) {
428
+ return (
429
+ <div className="chat-tool-row" data-testid="chat-tool-row">
430
+ <div className="chat-tool-row-main">
431
+ <span
432
+ className={`chat-tool-dot ${running ? 'chat-tool-dot--run' : 'chat-tool-dot--done'}`}
433
+ />
434
+ <b>{toolName}</b>
435
+ {path ? <span className="chat-tool-path">{String(path).split('/').pop()}</span> : null}
436
+ <span className={`chat-tool-row-status${isError ? ' del' : ''}`}>{statusLabel}</span>
437
+ </div>
438
+ {argsPreview ? <pre className="chat-tool-detail">{argsPreview}</pre> : null}
439
+ {resultPreview ? <pre className="chat-tool-detail">{resultPreview}</pre> : null}
440
+ </div>
441
+ );
442
+ }
443
+
294
444
  return (
295
445
  <div className="chat-tool">
296
446
  <div className="chat-tool-hd">
@@ -305,6 +455,8 @@ function ChatToolCard({ toolName, args, result, isError }) {
305
455
  <div className={`chat-tool-line${isError ? ' del' : ''}`}>
306
456
  {isError ? 'failed' : 'done'}
307
457
  </div>
458
+ {argsPreview ? <pre className="chat-tool-detail">{argsPreview}</pre> : null}
459
+ {resultPreview ? <pre className="chat-tool-detail">{resultPreview}</pre> : null}
308
460
  </div>
309
461
  ) : null}
310
462
  </div>
@@ -468,11 +620,26 @@ function UserMessage() {
468
620
  return (
469
621
  <div className="chat-msg chat-msg--user">
470
622
  <MessagePrimitive.Parts components={{ Text: UserBubble }} />
623
+ <div className="chat-msg-actions" data-testid="chat-msg-actions">
624
+ <ActionBarPrimitive.Copy className="chat-msg-action" aria-label="Copy message" title="Copy message">
625
+ <Copy size={12} />
626
+ </ActionBarPrimitive.Copy>
627
+ </div>
471
628
  </div>
472
629
  );
473
630
  }
474
631
 
632
+ // Reads the message's raw parts directly (rather than MessagePrimitive.Parts'
633
+ // per-part renderer) so consecutive tool-call parts can fold into one
634
+ // ToolGroup (Task C1) — grouping needs to see NEIGHBORING parts, which a
635
+ // per-part component registry can't.
475
636
  function AssistantMessage() {
637
+ const rawParts = useMessage((m) => m.content) || [];
638
+ const { transcriptView } = useContext(ChatMediaContext) || {};
639
+ const view = transcriptView || DEFAULT_TRANSCRIPT_VIEW;
640
+ const parts = useMemo(() => filterTranscriptParts(rawParts, view), [rawParts, view]);
641
+ const grouped = useMemo(() => groupToolCalls(parts), [parts]);
642
+ const forceExpand = transcriptForcesExpand(view);
476
643
  return (
477
644
  <div className="chat-msg chat-msg--assistant">
478
645
  <div className="chat-msg-role">
@@ -481,9 +648,38 @@ function AssistantMessage() {
481
648
  </span>
482
649
  Claude
483
650
  </div>
484
- <MessagePrimitive.Parts
485
- components={{ Text: ChatText, ToolCall: ChatToolCard, Reasoning: ChatReasoning }}
486
- />
651
+ {grouped.map((g, i) =>
652
+ g.type === 'tool-group' ? (
653
+ <ToolGroup
654
+ key={`g-${i}`}
655
+ parts={g.parts}
656
+ ToolCard={ChatToolCard}
657
+ forceOpen={forceExpand}
658
+ verbose={forceExpand}
659
+ />
660
+ ) : g.part?.type === 'text' ? (
661
+ <ChatText key={`t-${i}`} text={g.part.text} />
662
+ ) : g.part?.type === 'reasoning' ? (
663
+ <ChatReasoning key={`r-${i}`} text={g.part.text} />
664
+ ) : g.part?.type === 'tool-call' ? (
665
+ <ChatToolCard
666
+ key={g.part.toolCallId || `tc-${i}`}
667
+ toolName={g.part.toolName}
668
+ args={g.part.args}
669
+ result={g.part.result}
670
+ isError={g.part.isError}
671
+ verbose={forceExpand}
672
+ />
673
+ ) : null
674
+ )}
675
+ <div className="chat-msg-actions" data-testid="chat-msg-actions">
676
+ <ActionBarPrimitive.Copy className="chat-msg-action" aria-label="Copy message" title="Copy message">
677
+ <Copy size={12} />
678
+ </ActionBarPrimitive.Copy>
679
+ <ActionBarPrimitive.Reload className="chat-msg-action" aria-label="Retry" title="Retry — re-send the last message">
680
+ <Retry size={12} />
681
+ </ActionBarPrimitive.Reload>
682
+ </div>
487
683
  </div>
488
684
  );
489
685
  }
@@ -493,7 +689,7 @@ function AssistantMessage() {
493
689
  // running — so background subagents that outlive the main turn (the ACP adapter
494
690
  // settles at the main agent's `result` while they drain, claude-agent-acp #773)
495
691
  // keep the panel from looking idle. See RCA issue-acp-subagent-activity-invisible.
496
- function StatusRow({ tools = [] }) {
692
+ function StatusRow({ tools = [], transcriptView, onSetTranscriptView }) {
497
693
  const running = useThread((t) => t.isRunning);
498
694
  const working = running || tools.length > 0;
499
695
  return (
@@ -504,6 +700,25 @@ function StatusRow({ tools = [] }) {
504
700
  {working ? 'Working…' : 'Ready'}
505
701
  <span className="chat-statusrow-sep">·</span>
506
702
  <span className="chat-statusrow-cc">Claude Code</span>
703
+ <span className="chat-statusrow-spacer" />
704
+ {/* Transcript view (Task C4/C5) — moved here (user feedback) instead of
705
+ crowding the chat switcher row; panel-wide, not per-chat. */}
706
+ <label className="chat-view-label" title="Transcript view — how much detail replies show">
707
+ <span className="chat-view-label-tx">View</span>
708
+ <select
709
+ className="chat-select"
710
+ value={transcriptView}
711
+ aria-label="Transcript view"
712
+ data-testid="chat-transcript-menu"
713
+ onChange={(e) => onSetTranscriptView(e.target.value)}
714
+ >
715
+ {TRANSCRIPT_VIEWS.map((v) => (
716
+ <option key={v} value={v}>
717
+ {v[0].toUpperCase() + v.slice(1)}
718
+ </option>
719
+ ))}
720
+ </select>
721
+ </label>
507
722
  </div>
508
723
  );
509
724
  }
@@ -555,8 +770,12 @@ function ActivityBar({ tools }) {
555
770
  // dropping them (the answer used to only appear on reload). See RCA
556
771
  // issue-acp-subagent-activity-invisible (facet F2). Cleared when the next turn
557
772
  // starts; the durable copy lives in the transcript.
558
- function ContinuationBubble({ parts }) {
773
+ function ContinuationBubble({ parts: rawParts, viewMode }) {
774
+ const view = viewMode || DEFAULT_TRANSCRIPT_VIEW;
775
+ const parts = useMemo(() => filterTranscriptParts(rawParts, view), [rawParts, view]);
559
776
  if (!parts.length) return null;
777
+ const grouped = groupToolCalls(parts);
778
+ const forceExpand = transcriptForcesExpand(view);
560
779
  return (
561
780
  <div className="chat-msg chat-msg--assistant chat-msg--continued">
562
781
  <div className="chat-msg-role">
@@ -565,18 +784,27 @@ function ContinuationBubble({ parts }) {
565
784
  </span>
566
785
  Claude
567
786
  </div>
568
- {parts.map((p, i) =>
569
- p.type === 'text' ? (
570
- <ChatText key={i} text={p.text} />
571
- ) : p.type === 'reasoning' ? (
572
- <ChatReasoning key={i} text={p.text} />
573
- ) : p.type === 'tool-call' ? (
787
+ {grouped.map((g, i) =>
788
+ g.type === 'tool-group' ? (
789
+ <ToolGroup
790
+ key={`g-${i}`}
791
+ parts={g.parts}
792
+ ToolCard={ChatToolCard}
793
+ forceOpen={forceExpand}
794
+ verbose={forceExpand}
795
+ />
796
+ ) : g.part?.type === 'text' ? (
797
+ <ChatText key={`t-${i}`} text={g.part.text} />
798
+ ) : g.part?.type === 'reasoning' ? (
799
+ <ChatReasoning key={`r-${i}`} text={g.part.text} />
800
+ ) : g.part?.type === 'tool-call' ? (
574
801
  <ChatToolCard
575
- key={i}
576
- toolName={p.toolName}
577
- args={p.args}
578
- result={p.result}
579
- isError={p.isError}
802
+ key={g.part.toolCallId || `tc-${i}`}
803
+ toolName={g.part.toolName}
804
+ args={g.part.args}
805
+ result={g.part.result}
806
+ isError={g.part.isError}
807
+ verbose={forceExpand}
580
808
  />
581
809
  ) : null
582
810
  )}
@@ -584,6 +812,207 @@ function ContinuationBubble({ parts }) {
584
812
  );
585
813
  }
586
814
 
815
+ // Epoch `resetsAt` from SDKRateLimitInfo isn't documented as seconds vs ms —
816
+ // disambiguate by magnitude (a seconds-epoch never reaches 10^11 until the
817
+ // year 2286) rather than guessing wrong silently.
818
+ function formatResetTime(resetsAt) {
819
+ if (!resetsAt) return null;
820
+ const ms = resetsAt > 1e11 ? resetsAt : resetsAt * 1000;
821
+ try {
822
+ return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
823
+ } catch {
824
+ return null;
825
+ }
826
+ }
827
+
828
+ // Composer footer info (Task D3, revised repeatedly after dogfooding — the
829
+ // keyboard hint + context gauge + mode footnote + subscription note first
830
+ // sat inline in `.chat-foot` and wrapped into an unreadable mess, then moved
831
+ // behind this info icon. The icon itself later moved INTO `.chat-toolbar`
832
+ // (inside `.chat-box`, which clips its content with `overflow: hidden` for
833
+ // the rounded-corner focus ring) — a plain `position: absolute` popover
834
+ // anchored to the icon got clipped by that box, rendering squashed inside
835
+ // the textarea instead of floating above the whole composer. Fixed
836
+ // positioning computed from the button's own rect escapes that clip (fixed
837
+ // is relative to the viewport, not an ancestor's overflow, unless an
838
+ // ancestor establishes its own containing block via transform/filter —
839
+ // none of `.chat-box`/`.chat-composer`/`.chat-thread` do). Context is
840
+ // PER-CHAT (this session's own window); cost is cumulative for this
841
+ // session only — labeled accordingly so it doesn't read as an
842
+ // account-wide total.
843
+ function ChatFootInfo({ usage, modes }) {
844
+ const [open, setOpen] = useState(false);
845
+ const [pos, setPos] = useState(null);
846
+ const rootRef = useRef(null);
847
+ const btnRef = useRef(null);
848
+
849
+ useEffect(() => {
850
+ if (!open) return undefined;
851
+ const place = () => {
852
+ const r = btnRef.current?.getBoundingClientRect();
853
+ if (r) setPos({ bottom: window.innerHeight - r.top + 8, right: window.innerWidth - r.right });
854
+ };
855
+ place();
856
+ const onPointerDown = (e) => {
857
+ if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
858
+ };
859
+ const onKeyDown = (e) => {
860
+ if (e.key === 'Escape') setOpen(false);
861
+ };
862
+ document.addEventListener('pointerdown', onPointerDown);
863
+ document.addEventListener('keydown', onKeyDown);
864
+ window.addEventListener('resize', place);
865
+ return () => {
866
+ document.removeEventListener('pointerdown', onPointerDown);
867
+ document.removeEventListener('keydown', onKeyDown);
868
+ window.removeEventListener('resize', place);
869
+ };
870
+ }, [open]);
871
+
872
+ const context = usage.context;
873
+ const footnote = modeFootnote(modes);
874
+ // `asOf` is our OWN Date.now() ms timestamp (acp-usage.js) — unambiguous,
875
+ // unlike SDKRateLimitInfo's resetsAt (see formatResetTime's doc comment).
876
+ const timeLabel = usage.asOf
877
+ ? new Date(usage.asOf).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
878
+ : null;
879
+ // Genuine Pro/Max subscription-plan context — the adapter forwards a THIN,
880
+ // event-driven single-window signal (SDKRateLimitInfo, only fires near a
881
+ // real limit event); the rich Claude-Desktop-style multi-window panel
882
+ // (5h + weekly + per-model, live at rest) isn't reachable — its SDK method
883
+ // is explicitly marked experimental and the pinned adapter never calls it
884
+ // (see the Milestone D research in the ACP capabilities plan). Show what's
885
+ // real; don't fabricate the rest.
886
+ const rateLimitResetLabel = usage.rateLimit ? formatResetTime(usage.rateLimit.resetsAt) : null;
887
+
888
+ return (
889
+ <div className="chat-foot-info" ref={rootRef}>
890
+ <button
891
+ ref={btnRef}
892
+ type="button"
893
+ className="chat-foot-info-btn"
894
+ aria-label="Chat info"
895
+ aria-expanded={open}
896
+ title="Chat info"
897
+ data-testid="chat-foot-info-btn"
898
+ onClick={() => setOpen((v) => !v)}
899
+ >
900
+ ⓘ
901
+ </button>
902
+ {open && pos ? (
903
+ <div
904
+ className="chat-foot-popover"
905
+ role="dialog"
906
+ aria-label="Chat info"
907
+ data-testid="chat-foot-popover"
908
+ style={{ bottom: pos.bottom, right: pos.right }}
909
+ >
910
+ {context ? (
911
+ <div className="chat-foot-popover-section">
912
+ <div className="chat-foot-popover-label-row">
913
+ <span>Context</span>
914
+ <span>{context.pct}%</span>
915
+ </div>
916
+ <span className="chat-usage-bar chat-usage-bar--wide" aria-hidden="true">
917
+ <span className="chat-usage-bar-fill" style={{ width: `${context.pct}%` }} />
918
+ </span>
919
+ <div className="chat-foot-popover-row--muted">
920
+ {context.used.toLocaleString()} / {context.size.toLocaleString()} tokens
921
+ </div>
922
+ </div>
923
+ ) : null}
924
+ {usage.rateLimit ? (
925
+ <>
926
+ <div className="chat-foot-popover-sep" />
927
+ <div className="chat-foot-popover-label-row">
928
+ <span>{usage.rateLimit.label}</span>
929
+ <span>{usage.rateLimit.pct != null ? `${usage.rateLimit.pct}%` : '—'}</span>
930
+ </div>
931
+ {rateLimitResetLabel ? (
932
+ <div className="chat-foot-popover-row--muted">resets {rateLimitResetLabel}</div>
933
+ ) : null}
934
+ </>
935
+ ) : null}
936
+ {footnote ? (
937
+ <>
938
+ <div className="chat-foot-popover-sep" />
939
+ <div className="chat-foot-popover-row">{footnote}</div>
940
+ </>
941
+ ) : null}
942
+ <div className="chat-foot-popover-sep" />
943
+ <div className="chat-foot-popover-foot">
944
+ <div className="chat-foot-popover-row--muted">↵ to send · ⇧↵ newline</div>
945
+ <div className="chat-foot-popover-row--muted">
946
+ Uses your Claude subscription{timeLabel ? ` · updated ${timeLabel}` : ''}
947
+ </div>
948
+ </div>
949
+ </div>
950
+ ) : null}
951
+ </div>
952
+ );
953
+ }
954
+
955
+ // Event-driven single-window rate-limit banner (Task D3) — only the ONE
956
+ // active window `SDKRateLimitInfo` carries; NOT the full multi-window "Plan
957
+ // usage limits" panel (out of scope — see the plan's Milestone D research).
958
+ // Dismissible per reset window (re-appears once a NEW window starts warning).
959
+ function RateLimitBanner({ rateLimit, onDismiss }) {
960
+ if (!rateLimit) return null;
961
+ const resetLabel = formatResetTime(rateLimit.resetsAt);
962
+ return (
963
+ <div className="chat-usage-banner" role="status" data-testid="chat-usage-banner">
964
+ <span>
965
+ You're at {rateLimit.pct != null ? `${rateLimit.pct}%` : 'high usage'} of your {rateLimit.label}
966
+ {resetLabel ? ` · resets ${resetLabel}` : ''}
967
+ </span>
968
+ <button
969
+ type="button"
970
+ className="chat-usage-banner-x"
971
+ aria-label="Dismiss"
972
+ title="Dismiss"
973
+ onClick={onDismiss}
974
+ >
975
+ ×
976
+ </button>
977
+ </div>
978
+ );
979
+ }
980
+
981
+ // Connection-problem error card (Task C3) — a transient bridge/socket failure
982
+ // mid-turn, distinct from the hard NotConnected state (claude not installed/
983
+ // signed in): that one gates the WHOLE panel before any chat exists; this one
984
+ // is per-chat and recoverable with a retry. Rendered below the feed, like
985
+ // ActivityBar/ContinuationBubble — chrome, not a per-message assistant-ui error.
986
+ function ErrorCard({ error, onRetry }) {
987
+ const [expanded, setExpanded] = useState(false);
988
+ if (!error) return null;
989
+ return (
990
+ <div className="chat-error-card" role="alert" data-testid="chat-error-card">
991
+ <div className="chat-error-hd">
992
+ <span className="chat-error-ic" aria-hidden="true">
993
+ ⚠
994
+ </span>
995
+ <b>Connection problem</b>
996
+ </div>
997
+ {expanded ? (
998
+ <p className="chat-error-detail">{error.message}</p>
999
+ ) : (
1000
+ <p className="chat-error-detail chat-error-detail--muted">
1001
+ Something interrupted the connection to Claude.
1002
+ </p>
1003
+ )}
1004
+ <div className="chat-error-actions">
1005
+ <button type="button" className="btn btn--sm" onClick={() => setExpanded((v) => !v)}>
1006
+ {expanded ? 'Hide details' : 'View details'}
1007
+ </button>
1008
+ <button type="button" className="btn btn--primary btn--sm" onClick={onRetry}>
1009
+ Try again
1010
+ </button>
1011
+ </div>
1012
+ </div>
1013
+ );
1014
+ }
1015
+
587
1016
  function ChatEmpty() {
588
1017
  return (
589
1018
  <div className="chat-empty">
@@ -774,13 +1203,16 @@ function Composer({
774
1203
  activeCanvas,
775
1204
  chatCtx,
776
1205
  onCtxDismiss,
777
- model,
778
- setModel,
779
- effort,
780
- setEffort,
1206
+ picksRef,
1207
+ caps,
1208
+ onSetMode,
1209
+ onSetConfig,
781
1210
  conn,
782
1211
  chatId,
783
1212
  attachmentsRef,
1213
+ usage,
1214
+ showRateLimitBanner,
1215
+ onDismissRateLimitBanner,
784
1216
  }) {
785
1217
  const canvasName = prettyCanvas(activeCanvas);
786
1218
  const { all, existsSet } = useSlashCommands(conn);
@@ -826,13 +1258,17 @@ function Composer({
826
1258
 
827
1259
  // Warm the adapter the first time the user starts a slash command, so the live
828
1260
  // catalogue arrives without a full prompt. Guarded to fire once per mount.
1261
+ // model/effort/mode are the user's PERSISTED picks (read live off the ref so
1262
+ // this effect doesn't need to re-run when they change) — applied once, live,
1263
+ // by the bridge right after the session establishes.
829
1264
  const warmedRef = useRef(false);
830
1265
  useEffect(() => {
831
1266
  if (menuOpen && !warmedRef.current) {
832
1267
  warmedRef.current = true;
833
- conn.warm(chatId, model, effort);
1268
+ const p = picksRef.current;
1269
+ conn.warm(chatId, p.model, p.effort, p.mode);
834
1270
  }
835
- }, [menuOpen, conn, chatId, model, effort]);
1271
+ }, [menuOpen, conn, chatId, picksRef]);
836
1272
 
837
1273
  const pick = useCallback(
838
1274
  (cmd) => {
@@ -872,6 +1308,9 @@ function Composer({
872
1308
  return (
873
1309
  <div className="chat-composer" data-testid="chat-composer">
874
1310
  <ThreadPrimitive.If running={false}>
1311
+ {showRateLimitBanner ? (
1312
+ <RateLimitBanner rateLimit={usage.rateLimit} onDismiss={onDismissRateLimitBanner} />
1313
+ ) : null}
875
1314
  {/* Context attachment chip (feature-acp-context-hardening) — the visible
876
1315
  reveal of the fenced <maude-context> block the send will prepend
877
1316
  (built from the SAME buildChatContext result, so chip ≡ payload).
@@ -918,35 +1357,18 @@ function Composer({
918
1357
  onAttachChange={bumpAttach}
919
1358
  />
920
1359
  <div className="chat-toolbar">
921
- <select
922
- className="chat-select"
923
- value={model}
924
- onChange={(e) => setModel(e.target.value)}
925
- aria-label="Model"
926
- >
927
- {MODELS.map((m) => (
928
- <option key={m.value} value={m.value}>
929
- {m.label}
930
- </option>
931
- ))}
932
- </select>
933
- <select
934
- className="chat-select"
935
- value={effort}
936
- onChange={(e) => setEffort(e.target.value)}
937
- aria-label="Effort"
938
- >
939
- {EFFORTS.map((x) => (
940
- <option key={x.value} value={x.value}>
941
- {x.label}
942
- </option>
943
- ))}
944
- </select>
945
- <span className="chat-toolbar-spacer" />
946
- <ComposerPrimitive.Send className="chat-send" aria-label="Send message">
947
- <SendArrow />
948
- </ComposerPrimitive.Send>
949
- </div>
1360
+ <CapabilityBar
1361
+ modes={caps.modes}
1362
+ configOptions={caps.parsedOptions}
1363
+ onSetMode={onSetMode}
1364
+ onSetConfig={onSetConfig}
1365
+ />
1366
+ <span className="chat-toolbar-spacer" />
1367
+ <ChatFootInfo usage={usage} modes={caps.modes} />
1368
+ <ComposerPrimitive.Send className="chat-send" aria-label="Send message">
1369
+ <SendArrow />
1370
+ </ComposerPrimitive.Send>
1371
+ </div>
950
1372
  </ComposerPrimitive.Root>
951
1373
  </div>
952
1374
  {chipList.length ? (
@@ -964,11 +1386,6 @@ function Composer({
964
1386
  ))}
965
1387
  </div>
966
1388
  ) : null}
967
- <div className="chat-foot">
968
- <span>↵ to send · ⇧↵ newline</span>
969
- <span className="chat-foot-spacer" />
970
- <span>your Claude subscription</span>
971
- </div>
972
1389
  </ThreadPrimitive.If>
973
1390
  <ThreadPrimitive.If running>
974
1391
  <div className="chat-stopbar">
@@ -1062,16 +1479,86 @@ function ChatThread({
1062
1479
  chatId,
1063
1480
  initialMessages,
1064
1481
  hidden,
1065
- modelRef,
1066
- effortRef,
1482
+ picksRef,
1483
+ setPick,
1067
1484
  activeCanvas,
1068
1485
  selected,
1069
1486
  designRel,
1070
- model,
1071
- setModel,
1072
- effort,
1073
- setEffort,
1487
+ transcriptView,
1488
+ onSetTranscriptView,
1074
1489
  }) {
1490
+ // Live session capabilities (feature-acp-panel-dynamic-claude-code-
1491
+ // capabilities) — `caps` is THIS chat's own connection's mode roster +
1492
+ // config-option set, sourced entirely from the ACP session. Never a
1493
+ // hardcoded list; re-renders on every `caps` frame (a model switch can
1494
+ // change which OTHER options — effort, fast — are even offered).
1495
+ const [caps, setCaps] = useState({ modes: null, configOptions: [] });
1496
+ useEffect(() => conn.onCaps(setCaps), [conn]);
1497
+ const parsedOptions = useMemo(() => parseConfigOptions(caps.configOptions), [caps.configOptions]);
1498
+
1499
+ // Warm up as soon as this chat becomes the VISIBLE one — the capability bar
1500
+ // has nothing to show until a live session exists, and deferring the spawn
1501
+ // to "the user typed a slash command" (the original Composer-only trigger,
1502
+ // still below) left the model/effort/mode row completely EMPTY until then —
1503
+ // read as "I lost the picker" in dogfooding, a real regression. Fires once
1504
+ // per chat; a chat that's opened but never viewed still doesn't spawn
1505
+ // `claude`, so a background/hidden chat keeps some of DDR-123's "opening
1506
+ // the panel costs nothing" intent — only the chat actually on screen warms.
1507
+ const warmedOnVisibleRef = useRef(false);
1508
+ useEffect(() => {
1509
+ if (hidden || warmedOnVisibleRef.current) return;
1510
+ warmedOnVisibleRef.current = true;
1511
+ const p = picksRef.current;
1512
+ conn.warm(chatId, p.model, p.effort, p.mode);
1513
+ }, [hidden, conn, chatId, picksRef]);
1514
+
1515
+ // One-time reconciliation: if the user's persisted pick (from a DIFFERENT
1516
+ // chat, or an earlier session) isn't what THIS freshly-established session
1517
+ // actually landed on but IS now offered, nudge it live once. The bridge
1518
+ // already tries this itself on establish (Task A3) — this is a client-side
1519
+ // safety net for the rare race where the persisted value changed after the
1520
+ // `warm`/`prompt` frame was already in flight. Bounded to once per mount so
1521
+ // it can never loop.
1522
+ const reconciledRef = useRef(false);
1523
+ useEffect(() => {
1524
+ if (reconciledRef.current) return;
1525
+ const hasModes = (caps.modes?.availableModes?.length ?? 0) > 0;
1526
+ const hasOptions = caps.configOptions.length > 0;
1527
+ if (!hasModes && !hasOptions) return; // nothing to reconcile against yet
1528
+ reconciledRef.current = true;
1529
+ const picks = picksRef.current;
1530
+ if (parsedOptions.model && picks.model) {
1531
+ const values = flattenSelectOptions(parsedOptions.model.options).map((o) => o.value);
1532
+ const resolved = resolvePersistedPick(values, picks.model, parsedOptions.model.currentValue);
1533
+ if (resolved !== parsedOptions.model.currentValue) conn.setConfig(chatId, 'model', resolved);
1534
+ }
1535
+ if (parsedOptions.effort && picks.effort) {
1536
+ const values = flattenSelectOptions(parsedOptions.effort.options).map((o) => o.value);
1537
+ const resolved = resolvePersistedPick(values, picks.effort, parsedOptions.effort.currentValue);
1538
+ if (resolved !== parsedOptions.effort.currentValue) conn.setConfig(chatId, 'effort', resolved);
1539
+ }
1540
+ if (hasModes && picks.mode) {
1541
+ const ids = caps.modes.availableModes.map((m) => m.id);
1542
+ const resolved = resolvePersistedPick(ids, picks.mode, caps.modes.currentModeId);
1543
+ if (resolved !== caps.modes.currentModeId) conn.setMode(chatId, resolved);
1544
+ }
1545
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1546
+ }, [caps, parsedOptions]);
1547
+
1548
+ const onSetMode = useCallback(
1549
+ (modeId) => {
1550
+ conn.setMode(chatId, modeId);
1551
+ setPick('mode', modeId);
1552
+ },
1553
+ [conn, chatId, setPick]
1554
+ );
1555
+ const onSetConfig = useCallback(
1556
+ (configId, value) => {
1557
+ conn.setConfig(chatId, configId, value);
1558
+ setPick(configId, value);
1559
+ },
1560
+ [conn, chatId, setPick]
1561
+ );
1075
1562
  // Paste attachments: `map` = chip token ([image-1]…) → real path/URL (null while
1076
1563
  // an image upload is in flight); `pending` = the in-flight upload promises. The
1077
1564
  // adapter awaits `pending` then expands `map` before sending; the composer writes
@@ -1103,36 +1590,87 @@ function ChatThread({
1103
1590
  makeAcpAdapter(
1104
1591
  conn,
1105
1592
  () => chatId,
1106
- () => modelRef.current || null,
1107
- () => effortRef.current,
1593
+ () => picksRef.current.model || null,
1594
+ () => picksRef.current.effort || null,
1108
1595
  () => attachmentsRef.current,
1109
- () => (ctxDismissedRef.current ? null : contextRef.current)
1596
+ () => (ctxDismissedRef.current ? null : contextRef.current),
1597
+ () => picksRef.current.mode || null
1110
1598
  ),
1111
- [conn, chatId, modelRef, effortRef]
1599
+ [conn, chatId, picksRef]
1112
1600
  );
1113
1601
  const runtime = useLocalRuntime(adapter, { initialMessages });
1114
1602
  // Image thumbnails + lightbox — one overlay per thread; bubbles reach it (and
1115
- // the chip → attachment-name resolution) through ChatMediaContext.
1603
+ // the chip → attachment-name resolution) through ChatMediaContext. Also
1604
+ // carries `transcriptView` (Task C4) — AssistantMessage is rendered via
1605
+ // ThreadPrimitive.Messages' component registry, which doesn't pass custom
1606
+ // props through, so this context is the only way it reaches the filter.
1116
1607
  const [lightboxSrc, setLightboxSrc] = useState(null);
1117
1608
  const media = useMemo(
1118
1609
  () => ({
1119
1610
  chipName: (token) => attachmentName(attachmentsRef.current.map.get(token) || ''),
1120
1611
  openLightbox: setLightboxSrc,
1121
1612
  designRel,
1613
+ transcriptView,
1122
1614
  }),
1123
- [designRel]
1615
+ [designRel, transcriptView]
1124
1616
  );
1125
1617
  const [activeTools, setActiveTools] = useState([]);
1126
1618
  useEffect(() => conn.onActivity(setActiveTools), [conn]);
1127
1619
  // Post-turn-end continuation (the tail the client used to drop — RCA F2).
1128
1620
  const [bgParts, setBgParts] = useState([]);
1129
1621
  useEffect(() => conn.onBackground(setBgParts), [conn]);
1622
+ // Pending permission requests (Milestone B) — one card at a time, oldest first.
1623
+ const [pendingPermissions, setPendingPermissions] = useState([]);
1624
+ useEffect(() => conn.onPermission(setPendingPermissions), [conn]);
1625
+ const activePermission = pendingPermissions[0] || null;
1626
+ const respondPermission = useCallback(
1627
+ (decision) => activePermission && conn.respondPermission(activePermission.id, decision),
1628
+ [conn, activePermission]
1629
+ );
1630
+ // Pending elicitation requests (feature-acp-ask-user-question) — same
1631
+ // one-at-a-time, oldest-first treatment as pendingPermissions above.
1632
+ const [pendingElicitations, setPendingElicitations] = useState([]);
1633
+ useEffect(() => conn.onElicitation(setPendingElicitations), [conn]);
1634
+ const activeElicitation = pendingElicitations[0] || null;
1635
+ const respondElicitation = useCallback(
1636
+ (response) => activeElicitation && conn.respondElicitation(activeElicitation.id, response),
1637
+ [conn, activeElicitation]
1638
+ );
1639
+ // Connection-problem error card (Task C3) — see conn.onError's doc comment.
1640
+ const [turnError, setTurnError] = useState(null);
1641
+ useEffect(() => conn.onError(setTurnError), [conn]);
1642
+ const retryLastTurn = useCallback(() => {
1643
+ const text = lastUserText(runtime.thread.getState().messages);
1644
+ if (text) runtime.thread.append(text);
1645
+ }, [runtime]);
1646
+
1647
+ // Usage (Milestone D) — context-window gauge + session cost + an event-
1648
+ // driven single-window rate-limit banner. Per-chat: each ChatThread has its
1649
+ // own connection, so this is naturally scoped to the right session.
1650
+ const [rawUsage, setRawUsage] = useState(null);
1651
+ useEffect(() => conn.onUsage(setRawUsage), [conn]);
1652
+ const usage = useMemo(() => parseUsage({ usage: rawUsage }), [rawUsage]);
1653
+ const [bannerDismissedFor, setBannerDismissedFor] = useState(null);
1654
+ const showRateLimitBanner =
1655
+ usage.rateLimit &&
1656
+ (usage.rateLimit.status === 'allowed_warning' || usage.rateLimit.status === 'rejected') &&
1657
+ bannerDismissedFor !== `${usage.rateLimit.type}:${usage.rateLimit.resetsAt}`;
1658
+ const dismissRateLimitBanner = useCallback(() => {
1659
+ if (usage.rateLimit) setBannerDismissedFor(`${usage.rateLimit.type}:${usage.rateLimit.resetsAt}`);
1660
+ }, [usage.rateLimit]);
1130
1661
 
1131
1662
  return (
1132
1663
  <AssistantRuntimeProvider runtime={runtime}>
1133
1664
  <ChatMediaContext.Provider value={media}>
1134
1665
  <div className="chat-panel" style={hidden ? { display: 'none' } : undefined}>
1135
- <StatusRow tools={activeTools} />
1666
+ <StatusRow
1667
+ tools={activeTools}
1668
+ transcriptView={transcriptView}
1669
+ onSetTranscriptView={onSetTranscriptView}
1670
+ />
1671
+ {/* DDR-184 #3 — loud alert when the current mode can't edit (plan / dontAsk),
1672
+ so a user typing "change X" isn't silently ignored. Above the feed. */}
1673
+ <ModeBanner modes={caps.modes} onSetMode={onSetMode} />
1136
1674
  <ThreadPrimitive.Root className="chat-thread">
1137
1675
  <ThreadPrimitive.Viewport className="chat-feed" autoScroll>
1138
1676
  <ThreadPrimitive.Empty>
@@ -1141,22 +1679,36 @@ function ChatThread({
1141
1679
  <ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
1142
1680
  {/* Background work that outlived the turn — streamed here so it's not
1143
1681
  dropped (RCA F2). */}
1144
- <ContinuationBubble parts={bgParts} />
1682
+ <ContinuationBubble parts={bgParts} viewMode={transcriptView} />
1145
1683
  {/* "still working" indicator under the latest message */}
1146
1684
  <ActivityBar tools={activeTools} />
1147
1685
  </ThreadPrimitive.Viewport>
1686
+ {activePermission ? (
1687
+ <PermissionPrompt
1688
+ request={activePermission}
1689
+ onRespond={respondPermission}
1690
+ queueLength={pendingPermissions.length}
1691
+ />
1692
+ ) : activeElicitation ? (
1693
+ <ElicitationPrompt request={activeElicitation} onRespond={respondElicitation} />
1694
+ ) : (
1695
+ <ErrorCard error={turnError} onRetry={retryLastTurn} />
1696
+ )}
1148
1697
  <QuickActions />
1149
1698
  <Composer
1150
1699
  activeCanvas={activeCanvas}
1151
1700
  chatCtx={ctxDismissed ? null : chatCtx}
1152
1701
  onCtxDismiss={dismissCtx}
1153
- model={model}
1154
- setModel={setModel}
1155
- effort={effort}
1156
- setEffort={setEffort}
1702
+ picksRef={picksRef}
1703
+ caps={{ modes: caps.modes, parsedOptions }}
1704
+ onSetMode={onSetMode}
1705
+ onSetConfig={onSetConfig}
1157
1706
  conn={conn}
1158
1707
  chatId={chatId}
1159
1708
  attachmentsRef={attachmentsRef}
1709
+ usage={usage}
1710
+ showRateLimitBanner={showRateLimitBanner}
1711
+ onDismissRateLimitBanner={dismissRateLimitBanner}
1160
1712
  />
1161
1713
  </ThreadPrimitive.Root>
1162
1714
  {lightboxSrc ? (
@@ -1180,19 +1732,29 @@ export default function ChatPanel({
1180
1732
  onBusyChange,
1181
1733
  onFinished,
1182
1734
  }) {
1183
- // Model + effort — persisted across sessions; read live by each chat's adapter.
1184
- const [model, setModel] = useState(() => safeStorageGet('maude-acp-model', ''));
1185
- const [effort, setEffort] = useState(() => safeStorageGet('maude-acp-effort', 'balanced'));
1186
- const modelRef = useRef(model);
1187
- const effortRef = useRef(effort);
1735
+ // Model/effort/mode picks — persisted across sessions per config id (never a
1736
+ // fixed model/effort pair; see loadPersistedPicks). Read live by each open
1737
+ // chat's adapter (for a FRESH session's one-time apply) and by the picker's
1738
+ // one-time reconciliation effect.
1739
+ const [picks, setPicks] = useState(loadPersistedPicks);
1740
+ const picksRef = useRef(picks);
1188
1741
  useEffect(() => {
1189
- modelRef.current = model;
1190
- safeStorageSet('maude-acp-model', model);
1191
- }, [model]);
1742
+ picksRef.current = picks;
1743
+ savePersistedPicks(picks);
1744
+ }, [picks]);
1745
+ const setPick = useCallback((id, value) => {
1746
+ setPicks((prev) => (prev[id] === value ? prev : { ...prev, [id]: value }));
1747
+ }, []);
1748
+
1749
+ // Transcript view mode (Task C4) — panel-wide (applies to every open chat),
1750
+ // persisted, changed via the per-chat overflow menu (Task C5).
1751
+ const [transcriptView, setTranscriptView] = useState(() => {
1752
+ const saved = safeStorageGet('maude-acp-transcript-view', DEFAULT_TRANSCRIPT_VIEW);
1753
+ return TRANSCRIPT_VIEWS.includes(saved) ? saved : DEFAULT_TRANSCRIPT_VIEW;
1754
+ });
1192
1755
  useEffect(() => {
1193
- effortRef.current = effort;
1194
- safeStorageSet('maude-acp-effort', effort);
1195
- }, [effort]);
1756
+ safeStorageSet('maude-acp-transcript-view', transcriptView);
1757
+ }, [transcriptView]);
1196
1758
 
1197
1759
  // Availability is global (is claude installed) — a single probe, no connection.
1198
1760
  const [status, setStatus] = useState({ available: null, reason: undefined });
@@ -1225,10 +1787,19 @@ export default function ChatPanel({
1225
1787
 
1226
1788
  // Recents (for the switcher).
1227
1789
  const [chats, setChats] = useState([]);
1790
+ // Chat ids the server confirms have a user-set title (ChatSummary.renamed)
1791
+ // — session_info_update must never clobber those (Task C5 gotcha: "an
1792
+ // explicit rename must not be silently clobbered by an auto-generated
1793
+ // summary landing afterward"). Seeded/kept in sync from every refreshChats().
1794
+ const renamedChatIdsRef = useRef(new Set());
1228
1795
  const refreshChats = useCallback(() => {
1229
1796
  fetch('/_api/acp/chats')
1230
1797
  .then((r) => r.json())
1231
- .then((d) => Array.isArray(d) && setChats(d))
1798
+ .then((d) => {
1799
+ if (!Array.isArray(d)) return;
1800
+ setChats(d);
1801
+ renamedChatIdsRef.current = new Set(d.filter((c) => c.renamed).map((c) => c.id));
1802
+ })
1232
1803
  .catch(() => {});
1233
1804
  }, []);
1234
1805
  useEffect(() => {
@@ -1244,6 +1815,9 @@ export default function ChatPanel({
1244
1815
  const [openChatIds, setOpenChatIds] = useState([]);
1245
1816
  const [activeChatId, setActiveChatId] = useState(null);
1246
1817
  const [busyChats, setBusyChats] = useState({}); // reactive: chatId → busy (for the dot)
1818
+ // Agent-generated chat titles (`session_info_update`) — wins over the
1819
+ // client's "New chat" heuristic once the agent names the first turn.
1820
+ const [sessionTitles, setSessionTitles] = useState({});
1247
1821
  const [menuOpen, setMenuOpen] = useState(false);
1248
1822
  const cbRef = useRef({ onBusyChange, onFinished });
1249
1823
  useEffect(() => {
@@ -1256,6 +1830,14 @@ export default function ChatPanel({
1256
1830
  if (existing) return existing;
1257
1831
  const conn = createAcpConnection();
1258
1832
  connsRef.current.set(chatId, conn);
1833
+ // Agent-generated title (session_info_update — fires at turn-end, so a
1834
+ // fresh chat shows the client's "New chat" heuristic until the first
1835
+ // turn completes; expected). Read live by chatOptions below.
1836
+ conn.onSessionInfo((info) => {
1837
+ if (info.title && !renamedChatIdsRef.current.has(chatId)) {
1838
+ setSessionTitles((prev) => ({ ...prev, [chatId]: info.title }));
1839
+ }
1840
+ });
1259
1841
  // Background work (subagents) can outlive the main turn — the ACP adapter
1260
1842
  // settles the prompt at the main agent's `result` while they drain
1261
1843
  // (claude-agent-acp #773). Track live tool activity so the menubar
@@ -1345,6 +1927,12 @@ export default function ChatPanel({
1345
1927
  delete next[id];
1346
1928
  return next;
1347
1929
  });
1930
+ setSessionTitles((prev) => {
1931
+ if (!(id in prev)) return prev;
1932
+ const next = { ...prev };
1933
+ delete next[id];
1934
+ return next;
1935
+ });
1348
1936
  fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
1349
1937
  .catch(() => {})
1350
1938
  .finally(refreshChats);
@@ -1366,6 +1954,78 @@ export default function ChatPanel({
1366
1954
  [activeChatId, ensureConn, refreshChats]
1367
1955
  );
1368
1956
 
1957
+ // Rename / Archive / Copy transcript (Task C5 — per-chat overflow menu).
1958
+ // Rename/Archive PATCH the `_chat/<id>.meta.json` sidecar (acp/transcript.ts)
1959
+ // — chosen over injecting a synthetic `/rename` prompt (Task B/C research):
1960
+ // deterministic, doesn't pollute the transcript with a synthetic turn, and
1961
+ // needs no unverified assumption about how the CLI's own /rename behaves
1962
+ // over ACP. `renamedChatIdsRef` (set above) keeps a later live auto-title
1963
+ // from clobbering it.
1964
+ const patchChatMeta = useCallback((id, patch) => {
1965
+ return fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`, {
1966
+ method: 'PATCH',
1967
+ headers: { 'Content-Type': 'application/json' },
1968
+ body: JSON.stringify(patch),
1969
+ })
1970
+ .then((r) => (r.ok ? r.json() : null))
1971
+ .catch(() => null);
1972
+ }, []);
1973
+
1974
+ const [overflowChatId, setOverflowChatId] = useState(null);
1975
+ const [renamingChatId, setRenamingChatId] = useState(null);
1976
+ const [renameValue, setRenameValue] = useState('');
1977
+
1978
+ const startRename = useCallback((c) => {
1979
+ setRenamingChatId(c.id);
1980
+ setRenameValue(c.title === 'New chat' ? '' : c.title);
1981
+ setOverflowChatId(null);
1982
+ }, []);
1983
+
1984
+ const commitRename = useCallback(
1985
+ (id) => {
1986
+ const value = renameValue.trim();
1987
+ setRenamingChatId(null);
1988
+ if (!value) return;
1989
+ renamedChatIdsRef.current.add(id);
1990
+ setSessionTitles((prev) => ({ ...prev, [id]: value })); // optimistic — see gotcha above
1991
+ patchChatMeta(id, { title: value }).then(refreshChats);
1992
+ },
1993
+ [renameValue, patchChatMeta, refreshChats]
1994
+ );
1995
+
1996
+ const archiveChat = useCallback(
1997
+ (id) => {
1998
+ setOverflowChatId(null);
1999
+ patchChatMeta(id, { archived: true }).then(() => {
2000
+ refreshChats();
2001
+ // An archived chat can still be OPEN (mounted, running) — archiving
2002
+ // only hides it from recents, per the task spec; it does not close
2003
+ // the connection or force-switch away.
2004
+ });
2005
+ },
2006
+ [patchChatMeta, refreshChats]
2007
+ );
2008
+
2009
+ const copyTranscript = useCallback((id) => {
2010
+ setOverflowChatId(null);
2011
+ fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`)
2012
+ .then((r) => r.json())
2013
+ .then((msgs) => {
2014
+ const text = (Array.isArray(msgs) ? msgs : [])
2015
+ .map((m) => {
2016
+ const body = (m.parts || [])
2017
+ .map((p) =>
2018
+ p.type === 'text' ? p.text || '' : p.type === 'tool' ? `[${p.toolName || 'tool'}]` : ''
2019
+ )
2020
+ .join('');
2021
+ return `${m.role === 'user' ? 'User' : 'Claude'}: ${body}`;
2022
+ })
2023
+ .join('\n\n');
2024
+ return navigator.clipboard?.writeText(text);
2025
+ })
2026
+ .catch(() => {});
2027
+ }, []);
2028
+
1369
2029
  // Open a fresh chat on mount; close every connection on unmount.
1370
2030
  useEffect(() => {
1371
2031
  newChat();
@@ -1377,21 +2037,24 @@ export default function ChatPanel({
1377
2037
  }, []);
1378
2038
 
1379
2039
  // Switcher options — open chats first (parallel), then the rest of the recents.
2040
+ // Title precedence: the agent's own session_info_update title (live, this
2041
+ // process lifetime) > the persisted transcript's title > "New chat".
1380
2042
  const chatOptions = useMemo(() => {
1381
2043
  const seen = new Set();
1382
2044
  const list = [];
1383
2045
  for (const id of openChatIds) {
1384
2046
  if (seen.has(id)) continue;
1385
2047
  seen.add(id);
1386
- list.push({ id, title: chats.find((c) => c.id === id)?.title || 'New chat', open: true });
2048
+ const title = sessionTitles[id] || chats.find((c) => c.id === id)?.title || 'New chat';
2049
+ list.push({ id, title, open: true });
1387
2050
  }
1388
2051
  for (const c of chats) {
1389
2052
  if (seen.has(c.id)) continue;
1390
2053
  seen.add(c.id);
1391
- list.push(c);
2054
+ list.push(sessionTitles[c.id] ? { ...c, title: sessionTitles[c.id] } : c);
1392
2055
  }
1393
2056
  return list;
1394
- }, [chats, openChatIds]);
2057
+ }, [chats, openChatIds, sessionTitles]);
1395
2058
 
1396
2059
  const connected = status.available !== false;
1397
2060
 
@@ -1439,36 +2102,88 @@ export default function ChatPanel({
1439
2102
  </button>
1440
2103
  {menuOpen ? (
1441
2104
  <>
1442
- <div className="chat-menu-backdrop" onClick={() => setMenuOpen(false)} />
2105
+ <div
2106
+ className="chat-menu-backdrop"
2107
+ onClick={() => {
2108
+ setMenuOpen(false);
2109
+ setOverflowChatId(null);
2110
+ setRenamingChatId(null);
2111
+ }}
2112
+ />
1443
2113
  <div className="chat-menu" role="listbox">
1444
2114
  {chatOptions.map((c) => (
1445
2115
  <div
1446
2116
  key={c.id}
1447
2117
  className={`chat-menu-row${c.id === activeChatId ? ' is-active' : ''}`}
1448
2118
  >
1449
- <button
1450
- type="button"
1451
- className="chat-menu-open"
1452
- onClick={() => {
1453
- switchTo(c.id);
1454
- setMenuOpen(false);
1455
- }}
1456
- >
1457
- <span
1458
- className={`chat-dot ${busyChats[c.id] ? 'chat-dot--busy' : c.open ? 'chat-dot--idle' : 'chat-dot--off'}`}
1459
- title={busyChats[c.id] ? 'Running' : c.open ? 'Open' : 'Saved'}
2119
+ {renamingChatId === c.id ? (
2120
+ <input
2121
+ type="text"
2122
+ className="chat-menu-rename-input"
2123
+ value={renameValue}
2124
+ autoFocus
2125
+ onChange={(e) => setRenameValue(e.target.value)}
2126
+ onBlur={() => commitRename(c.id)}
2127
+ onKeyDown={(e) => {
2128
+ if (e.key === 'Enter') commitRename(c.id);
2129
+ else if (e.key === 'Escape') setRenamingChatId(null);
2130
+ }}
1460
2131
  />
1461
- <span className="chat-menu-title">{c.title}</span>
1462
- </button>
1463
- <button
1464
- type="button"
1465
- className="chat-menu-del"
1466
- onClick={() => deleteChat(c.id)}
1467
- aria-label="Delete chat"
1468
- title="Delete chat"
1469
- >
1470
- <Close size={11} />
1471
- </button>
2132
+ ) : (
2133
+ <button
2134
+ type="button"
2135
+ className="chat-menu-open"
2136
+ onClick={() => {
2137
+ switchTo(c.id);
2138
+ setMenuOpen(false);
2139
+ }}
2140
+ >
2141
+ <span
2142
+ className={`chat-dot ${busyChats[c.id] ? 'chat-dot--busy' : c.open ? 'chat-dot--idle' : 'chat-dot--off'}`}
2143
+ title={busyChats[c.id] ? 'Running' : c.open ? 'Open' : 'Saved'}
2144
+ />
2145
+ <span className="chat-menu-title">{c.title}</span>
2146
+ </button>
2147
+ )}
2148
+ <div className="chat-menu-overflow-wrap">
2149
+ <button
2150
+ type="button"
2151
+ className="chat-menu-more"
2152
+ aria-label="More options"
2153
+ title="More options"
2154
+ aria-haspopup="menu"
2155
+ aria-expanded={overflowChatId === c.id}
2156
+ onClick={() =>
2157
+ setOverflowChatId((prev) => (prev === c.id ? null : c.id))
2158
+ }
2159
+ >
2160
+ ⋯
2161
+ </button>
2162
+ {overflowChatId === c.id ? (
2163
+ <div className="chat-menu-overflow" role="menu" data-testid="chat-overflow-menu">
2164
+ <button type="button" role="menuitem" onClick={() => startRename(c)}>
2165
+ Rename
2166
+ </button>
2167
+ <button type="button" role="menuitem" onClick={() => archiveChat(c.id)}>
2168
+ Archive
2169
+ </button>
2170
+ <button type="button" role="menuitem" onClick={() => copyTranscript(c.id)}>
2171
+ Copy transcript
2172
+ </button>
2173
+ <button
2174
+ type="button"
2175
+ role="menuitem"
2176
+ className="chat-menu-overflow-danger"
2177
+ onClick={() => {
2178
+ setOverflowChatId(null);
2179
+ deleteChat(c.id);
2180
+ }}
2181
+ >
2182
+ Delete
2183
+ </button>
2184
+ </div>
2185
+ ) : null}
2186
+ </div>
1472
2187
  </div>
1473
2188
  ))}
1474
2189
  </div>
@@ -1507,15 +2222,13 @@ export default function ChatPanel({
1507
2222
  chatId={id}
1508
2223
  initialMessages={hydratedRef.current.get(id) || []}
1509
2224
  hidden={id !== activeChatId}
1510
- modelRef={modelRef}
1511
- effortRef={effortRef}
2225
+ picksRef={picksRef}
2226
+ setPick={setPick}
1512
2227
  activeCanvas={activeCanvas}
1513
2228
  selected={selected}
1514
2229
  designRel={designRel}
1515
- model={model}
1516
- setModel={setModel}
1517
- effort={effort}
1518
- setEffort={setEffort}
2230
+ transcriptView={transcriptView}
2231
+ onSetTranscriptView={setTranscriptView}
1519
2232
  />
1520
2233
  );
1521
2234
  })