@1agh/maude 0.45.1 → 0.45.2

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 (47) hide show
  1. package/apps/studio/acp/bridge.ts +585 -73
  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/bin/_import-asset.mjs +35 -5
  6. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  7. package/apps/studio/client/panels/ChatPanel.jsx +802 -133
  8. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  9. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  10. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  11. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  12. package/apps/studio/client/panels/acp-capabilities.js +71 -0
  13. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  14. package/apps/studio/client/panels/acp-runtime.js +248 -9
  15. package/apps/studio/client/panels/acp-usage.js +65 -0
  16. package/apps/studio/client/panels/transcript-view.js +36 -0
  17. package/apps/studio/client/styles/6-acp-chat.css +648 -11
  18. package/apps/studio/dist/client.bundle.js +1596 -1594
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/generation/whisper-models.test.ts +28 -1
  21. package/apps/studio/generation/whisper-models.ts +22 -7
  22. package/apps/studio/http.ts +33 -2
  23. package/apps/studio/test/acp-bridge.test.ts +11 -6
  24. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  25. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  26. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  27. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  28. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  29. package/apps/studio/test/acp-permission.test.ts +262 -0
  30. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  31. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  32. package/apps/studio/test/acp-transcript.test.ts +75 -2
  33. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  34. package/apps/studio/test/acp-usage.test.ts +143 -0
  35. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  36. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  37. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  38. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  39. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  40. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  41. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  42. package/apps/studio/test/import-asset.test.ts +31 -3
  43. package/apps/studio/whats-new.json +34 -0
  44. package/cli/commands/design.mjs +107 -2
  45. package/cli/lib/pkg-root.mjs +42 -10
  46. package/cli/lib/pkg-root.test.mjs +33 -1
  47. package/package.json +11 -9
@@ -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,25 @@ 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 { flattenSelectOptions, parseConfigOptions, resolvePersistedPick } from './acp-capabilities.js';
29
+ import { parseUsage } from './acp-usage.js';
26
30
  import {
27
31
  activityLabel,
28
32
  attachmentName,
29
33
  createAcpConnection,
30
34
  designImageRefs,
31
35
  extractAttachmentRefs,
36
+ lastUserText,
32
37
  makeAcpAdapter,
33
38
  } from './acp-runtime.js';
34
39
  import { buildChatContext } from './chat-context.js';
40
+ import CapabilityBar from './CapabilityBar.jsx';
41
+ import ElicitationPrompt from './ElicitationPrompt.jsx';
42
+ import PermissionPrompt from './PermissionPrompt.jsx';
35
43
  import { Markdown } from './chat-markdown.jsx';
36
44
  import ReadinessList, { useReadiness } from './ReadinessList.jsx';
37
45
  import {
@@ -41,6 +49,13 @@ import {
41
49
  normalizeName,
42
50
  STATIC_COMMANDS,
43
51
  } from './slash-commands.js';
52
+ import ToolGroup, { groupToolCalls } from './ToolGroup.jsx';
53
+ import {
54
+ DEFAULT_TRANSCRIPT_VIEW,
55
+ filterTranscriptParts,
56
+ transcriptForcesExpand,
57
+ TRANSCRIPT_VIEWS,
58
+ } from './transcript-view.js';
44
59
 
45
60
  // ── inline icons (separate panel files carry their own, like GitPanel) ──
46
61
  const Spark = ({ size = 16 }) => (
@@ -83,6 +98,30 @@ const SendArrow = ({ size = 16 }) => (
83
98
  />
84
99
  </svg>
85
100
  );
101
+ // Per-message action icons (Task C2).
102
+ const Copy = ({ size = 14 }) => (
103
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
104
+ <rect x="5.5" y="5.5" width="8" height="8" rx="1.3" stroke="currentColor" strokeWidth="1.4" />
105
+ <path
106
+ 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"
107
+ stroke="currentColor"
108
+ strokeWidth="1.4"
109
+ strokeLinecap="round"
110
+ strokeLinejoin="round"
111
+ />
112
+ </svg>
113
+ );
114
+ const Retry = ({ size = 14 }) => (
115
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
116
+ <path
117
+ d="M13 8A5 5 0 1 1 11.3 4.3M13 2.5v3.2h-3.2"
118
+ stroke="currentColor"
119
+ strokeWidth="1.4"
120
+ strokeLinecap="round"
121
+ strokeLinejoin="round"
122
+ />
123
+ </svg>
124
+ );
86
125
  // Token swatches — reads as "design system"; used on the empty-state DS CTA.
87
126
  const Swatches = ({ size = 15 }) => (
88
127
  <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -108,20 +147,6 @@ const SUGGESTIONS = [
108
147
  '/design:new Pricing "a 3-tier pricing page"',
109
148
  ];
110
149
 
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
150
  function safeStorageGet(key, fallback) {
126
151
  try {
127
152
  return localStorage.getItem(key) ?? fallback;
@@ -137,12 +162,52 @@ function safeStorageSet(key, value) {
137
162
  }
138
163
  }
139
164
 
165
+ // Model/effort/mode picks (feature-acp-panel-dynamic-claude-code-capabilities)
166
+ // — persisted generically by config id in ONE blob, since the set of options
167
+ // is agent-defined and NOT a fixed list (a hardcoded per-field key would miss
168
+ // a future option like "agent" persona). One-time migration reads the OLD
169
+ // fixed keys this panel used before the dynamic capability channel shipped.
170
+ const PICKS_KEY = 'maude-acp-picks';
171
+ function loadPersistedPicks() {
172
+ const raw = safeStorageGet(PICKS_KEY, null);
173
+ if (raw) {
174
+ try {
175
+ const parsed = JSON.parse(raw);
176
+ if (parsed && typeof parsed === 'object') return parsed;
177
+ } catch {
178
+ /* corrupt blob — fall through to a clean slate */
179
+ }
180
+ }
181
+ const picks = {};
182
+ const legacyModel = safeStorageGet('maude-acp-model', '');
183
+ const legacyEffort = safeStorageGet('maude-acp-effort', '');
184
+ if (legacyModel) picks.model = legacyModel;
185
+ if (legacyEffort && legacyEffort !== 'balanced') picks.effort = legacyEffort;
186
+ return picks;
187
+ }
188
+ function savePersistedPicks(picks) {
189
+ safeStorageSet(PICKS_KEY, JSON.stringify(picks));
190
+ }
191
+
140
192
  function prettyCanvas(path) {
141
193
  if (!path) return null;
142
194
  const file = path.split('/').pop() || path;
143
195
  return file.replace(/\.(tsx|html)$/i, '');
144
196
  }
145
197
 
198
+ // Composer footer note (Task B3) — tells the user whether the CURRENT mode
199
+ // will interrupt them for a permission decision. `bypassPermissions`/
200
+ // `dontAsk` never call requestPermission at all (adapter short-circuits); every
201
+ // other mode does. Sourced from the mode's own id — never a hardcoded label
202
+ // beyond this yes/no framing, since the mode SET itself is fully dynamic.
203
+ const NO_PROMPT_MODE_IDS = new Set(['bypassPermissions', 'dontAsk']);
204
+ function modeFootnote(modes) {
205
+ if (!modes?.currentModeId || !modes.availableModes?.length) return null;
206
+ const current = modes.availableModes.find((m) => m.id === modes.currentModeId);
207
+ const name = current?.name || modes.currentModeId;
208
+ return NO_PROMPT_MODE_IDS.has(modes.currentModeId) ? `${name} — no prompts` : `${name} — you'll be asked`;
209
+ }
210
+
146
211
  // ── pasted-path / URL → inline chip (Claude-Code-style attachment badge) ──
147
212
  // When the whole clipboard is a single path or URL, we collapse it to a short
148
213
  // literal token ([image-1] / [file-1] / [link-1]) in the textarea and stash the
@@ -287,10 +352,54 @@ function ChatReasoning({ text }) {
287
352
  );
288
353
  }
289
354
 
290
- function ChatToolCard({ toolName, args, result, isError }) {
355
+ // Bounded JSON preview for the verbose transcript view (Task C4) — a raw tool
356
+ // arg/result can be arbitrarily large (a big file's content, a long stdout);
357
+ // cap it so one tool call can't blow up the feed's layout or paint cost.
358
+ function jsonPreview(value, cap = 800) {
359
+ let s;
360
+ try {
361
+ s = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
362
+ } catch {
363
+ return null;
364
+ }
365
+ // An empty object/array/string preview ("{}", "[]", '""') is noise, not
366
+ // information — a tool that legitimately took no args or returned nothing
367
+ // shouldn't render a boxed detail block just to say so.
368
+ if (!s || s === '{}' || s === '[]' || s === '""' || s === 'null') return null;
369
+ return s.length > cap ? `${s.slice(0, cap)}…` : s;
370
+ }
371
+
372
+ // `flat` (Task C1 revision, after dogfooding showed grouped tool calls
373
+ // rendering as nested bordered cards inside a bordered card — "divné") skips
374
+ // the standalone `.chat-tool` card chrome for a lighter row, used only when
375
+ // ChatToolCard renders inside ToolGroup's already-bordered accordion body.
376
+ function ChatToolCard({ toolName, args, result, isError, verbose, flat = false }) {
291
377
  const running = result === undefined;
292
378
  const path =
293
379
  args && typeof args === 'object' ? args.path || args.file || args.filePath : undefined;
380
+ const argsPreview = verbose && args && typeof args === 'object' && Object.keys(args).length
381
+ ? jsonPreview(args)
382
+ : null;
383
+ const resultPreview = verbose && !running && result != null ? jsonPreview(result) : null;
384
+ const statusLabel = running ? 'running…' : isError ? 'failed' : 'done';
385
+
386
+ if (flat) {
387
+ return (
388
+ <div className="chat-tool-row" data-testid="chat-tool-row">
389
+ <div className="chat-tool-row-main">
390
+ <span
391
+ className={`chat-tool-dot ${running ? 'chat-tool-dot--run' : 'chat-tool-dot--done'}`}
392
+ />
393
+ <b>{toolName}</b>
394
+ {path ? <span className="chat-tool-path">{String(path).split('/').pop()}</span> : null}
395
+ <span className={`chat-tool-row-status${isError ? ' del' : ''}`}>{statusLabel}</span>
396
+ </div>
397
+ {argsPreview ? <pre className="chat-tool-detail">{argsPreview}</pre> : null}
398
+ {resultPreview ? <pre className="chat-tool-detail">{resultPreview}</pre> : null}
399
+ </div>
400
+ );
401
+ }
402
+
294
403
  return (
295
404
  <div className="chat-tool">
296
405
  <div className="chat-tool-hd">
@@ -305,6 +414,8 @@ function ChatToolCard({ toolName, args, result, isError }) {
305
414
  <div className={`chat-tool-line${isError ? ' del' : ''}`}>
306
415
  {isError ? 'failed' : 'done'}
307
416
  </div>
417
+ {argsPreview ? <pre className="chat-tool-detail">{argsPreview}</pre> : null}
418
+ {resultPreview ? <pre className="chat-tool-detail">{resultPreview}</pre> : null}
308
419
  </div>
309
420
  ) : null}
310
421
  </div>
@@ -468,11 +579,26 @@ function UserMessage() {
468
579
  return (
469
580
  <div className="chat-msg chat-msg--user">
470
581
  <MessagePrimitive.Parts components={{ Text: UserBubble }} />
582
+ <div className="chat-msg-actions" data-testid="chat-msg-actions">
583
+ <ActionBarPrimitive.Copy className="chat-msg-action" aria-label="Copy message" title="Copy message">
584
+ <Copy size={12} />
585
+ </ActionBarPrimitive.Copy>
586
+ </div>
471
587
  </div>
472
588
  );
473
589
  }
474
590
 
591
+ // Reads the message's raw parts directly (rather than MessagePrimitive.Parts'
592
+ // per-part renderer) so consecutive tool-call parts can fold into one
593
+ // ToolGroup (Task C1) — grouping needs to see NEIGHBORING parts, which a
594
+ // per-part component registry can't.
475
595
  function AssistantMessage() {
596
+ const rawParts = useMessage((m) => m.content) || [];
597
+ const { transcriptView } = useContext(ChatMediaContext) || {};
598
+ const view = transcriptView || DEFAULT_TRANSCRIPT_VIEW;
599
+ const parts = useMemo(() => filterTranscriptParts(rawParts, view), [rawParts, view]);
600
+ const grouped = useMemo(() => groupToolCalls(parts), [parts]);
601
+ const forceExpand = transcriptForcesExpand(view);
476
602
  return (
477
603
  <div className="chat-msg chat-msg--assistant">
478
604
  <div className="chat-msg-role">
@@ -481,9 +607,38 @@ function AssistantMessage() {
481
607
  </span>
482
608
  Claude
483
609
  </div>
484
- <MessagePrimitive.Parts
485
- components={{ Text: ChatText, ToolCall: ChatToolCard, Reasoning: ChatReasoning }}
486
- />
610
+ {grouped.map((g, i) =>
611
+ g.type === 'tool-group' ? (
612
+ <ToolGroup
613
+ key={`g-${i}`}
614
+ parts={g.parts}
615
+ ToolCard={ChatToolCard}
616
+ forceOpen={forceExpand}
617
+ verbose={forceExpand}
618
+ />
619
+ ) : g.part?.type === 'text' ? (
620
+ <ChatText key={`t-${i}`} text={g.part.text} />
621
+ ) : g.part?.type === 'reasoning' ? (
622
+ <ChatReasoning key={`r-${i}`} text={g.part.text} />
623
+ ) : g.part?.type === 'tool-call' ? (
624
+ <ChatToolCard
625
+ key={g.part.toolCallId || `tc-${i}`}
626
+ toolName={g.part.toolName}
627
+ args={g.part.args}
628
+ result={g.part.result}
629
+ isError={g.part.isError}
630
+ verbose={forceExpand}
631
+ />
632
+ ) : null
633
+ )}
634
+ <div className="chat-msg-actions" data-testid="chat-msg-actions">
635
+ <ActionBarPrimitive.Copy className="chat-msg-action" aria-label="Copy message" title="Copy message">
636
+ <Copy size={12} />
637
+ </ActionBarPrimitive.Copy>
638
+ <ActionBarPrimitive.Reload className="chat-msg-action" aria-label="Retry" title="Retry — re-send the last message">
639
+ <Retry size={12} />
640
+ </ActionBarPrimitive.Reload>
641
+ </div>
487
642
  </div>
488
643
  );
489
644
  }
@@ -493,7 +648,7 @@ function AssistantMessage() {
493
648
  // running — so background subagents that outlive the main turn (the ACP adapter
494
649
  // settles at the main agent's `result` while they drain, claude-agent-acp #773)
495
650
  // keep the panel from looking idle. See RCA issue-acp-subagent-activity-invisible.
496
- function StatusRow({ tools = [] }) {
651
+ function StatusRow({ tools = [], transcriptView, onSetTranscriptView }) {
497
652
  const running = useThread((t) => t.isRunning);
498
653
  const working = running || tools.length > 0;
499
654
  return (
@@ -504,6 +659,25 @@ function StatusRow({ tools = [] }) {
504
659
  {working ? 'Working…' : 'Ready'}
505
660
  <span className="chat-statusrow-sep">·</span>
506
661
  <span className="chat-statusrow-cc">Claude Code</span>
662
+ <span className="chat-statusrow-spacer" />
663
+ {/* Transcript view (Task C4/C5) — moved here (user feedback) instead of
664
+ crowding the chat switcher row; panel-wide, not per-chat. */}
665
+ <label className="chat-view-label" title="Transcript view — how much detail replies show">
666
+ <span className="chat-view-label-tx">View</span>
667
+ <select
668
+ className="chat-select"
669
+ value={transcriptView}
670
+ aria-label="Transcript view"
671
+ data-testid="chat-transcript-menu"
672
+ onChange={(e) => onSetTranscriptView(e.target.value)}
673
+ >
674
+ {TRANSCRIPT_VIEWS.map((v) => (
675
+ <option key={v} value={v}>
676
+ {v[0].toUpperCase() + v.slice(1)}
677
+ </option>
678
+ ))}
679
+ </select>
680
+ </label>
507
681
  </div>
508
682
  );
509
683
  }
@@ -555,8 +729,12 @@ function ActivityBar({ tools }) {
555
729
  // dropping them (the answer used to only appear on reload). See RCA
556
730
  // issue-acp-subagent-activity-invisible (facet F2). Cleared when the next turn
557
731
  // starts; the durable copy lives in the transcript.
558
- function ContinuationBubble({ parts }) {
732
+ function ContinuationBubble({ parts: rawParts, viewMode }) {
733
+ const view = viewMode || DEFAULT_TRANSCRIPT_VIEW;
734
+ const parts = useMemo(() => filterTranscriptParts(rawParts, view), [rawParts, view]);
559
735
  if (!parts.length) return null;
736
+ const grouped = groupToolCalls(parts);
737
+ const forceExpand = transcriptForcesExpand(view);
560
738
  return (
561
739
  <div className="chat-msg chat-msg--assistant chat-msg--continued">
562
740
  <div className="chat-msg-role">
@@ -565,18 +743,27 @@ function ContinuationBubble({ parts }) {
565
743
  </span>
566
744
  Claude
567
745
  </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' ? (
746
+ {grouped.map((g, i) =>
747
+ g.type === 'tool-group' ? (
748
+ <ToolGroup
749
+ key={`g-${i}`}
750
+ parts={g.parts}
751
+ ToolCard={ChatToolCard}
752
+ forceOpen={forceExpand}
753
+ verbose={forceExpand}
754
+ />
755
+ ) : g.part?.type === 'text' ? (
756
+ <ChatText key={`t-${i}`} text={g.part.text} />
757
+ ) : g.part?.type === 'reasoning' ? (
758
+ <ChatReasoning key={`r-${i}`} text={g.part.text} />
759
+ ) : g.part?.type === 'tool-call' ? (
574
760
  <ChatToolCard
575
- key={i}
576
- toolName={p.toolName}
577
- args={p.args}
578
- result={p.result}
579
- isError={p.isError}
761
+ key={g.part.toolCallId || `tc-${i}`}
762
+ toolName={g.part.toolName}
763
+ args={g.part.args}
764
+ result={g.part.result}
765
+ isError={g.part.isError}
766
+ verbose={forceExpand}
580
767
  />
581
768
  ) : null
582
769
  )}
@@ -584,6 +771,207 @@ function ContinuationBubble({ parts }) {
584
771
  );
585
772
  }
586
773
 
774
+ // Epoch `resetsAt` from SDKRateLimitInfo isn't documented as seconds vs ms —
775
+ // disambiguate by magnitude (a seconds-epoch never reaches 10^11 until the
776
+ // year 2286) rather than guessing wrong silently.
777
+ function formatResetTime(resetsAt) {
778
+ if (!resetsAt) return null;
779
+ const ms = resetsAt > 1e11 ? resetsAt : resetsAt * 1000;
780
+ try {
781
+ return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
782
+ } catch {
783
+ return null;
784
+ }
785
+ }
786
+
787
+ // Composer footer info (Task D3, revised repeatedly after dogfooding — the
788
+ // keyboard hint + context gauge + mode footnote + subscription note first
789
+ // sat inline in `.chat-foot` and wrapped into an unreadable mess, then moved
790
+ // behind this info icon. The icon itself later moved INTO `.chat-toolbar`
791
+ // (inside `.chat-box`, which clips its content with `overflow: hidden` for
792
+ // the rounded-corner focus ring) — a plain `position: absolute` popover
793
+ // anchored to the icon got clipped by that box, rendering squashed inside
794
+ // the textarea instead of floating above the whole composer. Fixed
795
+ // positioning computed from the button's own rect escapes that clip (fixed
796
+ // is relative to the viewport, not an ancestor's overflow, unless an
797
+ // ancestor establishes its own containing block via transform/filter —
798
+ // none of `.chat-box`/`.chat-composer`/`.chat-thread` do). Context is
799
+ // PER-CHAT (this session's own window); cost is cumulative for this
800
+ // session only — labeled accordingly so it doesn't read as an
801
+ // account-wide total.
802
+ function ChatFootInfo({ usage, modes }) {
803
+ const [open, setOpen] = useState(false);
804
+ const [pos, setPos] = useState(null);
805
+ const rootRef = useRef(null);
806
+ const btnRef = useRef(null);
807
+
808
+ useEffect(() => {
809
+ if (!open) return undefined;
810
+ const place = () => {
811
+ const r = btnRef.current?.getBoundingClientRect();
812
+ if (r) setPos({ bottom: window.innerHeight - r.top + 8, right: window.innerWidth - r.right });
813
+ };
814
+ place();
815
+ const onPointerDown = (e) => {
816
+ if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
817
+ };
818
+ const onKeyDown = (e) => {
819
+ if (e.key === 'Escape') setOpen(false);
820
+ };
821
+ document.addEventListener('pointerdown', onPointerDown);
822
+ document.addEventListener('keydown', onKeyDown);
823
+ window.addEventListener('resize', place);
824
+ return () => {
825
+ document.removeEventListener('pointerdown', onPointerDown);
826
+ document.removeEventListener('keydown', onKeyDown);
827
+ window.removeEventListener('resize', place);
828
+ };
829
+ }, [open]);
830
+
831
+ const context = usage.context;
832
+ const footnote = modeFootnote(modes);
833
+ // `asOf` is our OWN Date.now() ms timestamp (acp-usage.js) — unambiguous,
834
+ // unlike SDKRateLimitInfo's resetsAt (see formatResetTime's doc comment).
835
+ const timeLabel = usage.asOf
836
+ ? new Date(usage.asOf).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
837
+ : null;
838
+ // Genuine Pro/Max subscription-plan context — the adapter forwards a THIN,
839
+ // event-driven single-window signal (SDKRateLimitInfo, only fires near a
840
+ // real limit event); the rich Claude-Desktop-style multi-window panel
841
+ // (5h + weekly + per-model, live at rest) isn't reachable — its SDK method
842
+ // is explicitly marked experimental and the pinned adapter never calls it
843
+ // (see the Milestone D research in the ACP capabilities plan). Show what's
844
+ // real; don't fabricate the rest.
845
+ const rateLimitResetLabel = usage.rateLimit ? formatResetTime(usage.rateLimit.resetsAt) : null;
846
+
847
+ return (
848
+ <div className="chat-foot-info" ref={rootRef}>
849
+ <button
850
+ ref={btnRef}
851
+ type="button"
852
+ className="chat-foot-info-btn"
853
+ aria-label="Chat info"
854
+ aria-expanded={open}
855
+ title="Chat info"
856
+ data-testid="chat-foot-info-btn"
857
+ onClick={() => setOpen((v) => !v)}
858
+ >
859
+
860
+ </button>
861
+ {open && pos ? (
862
+ <div
863
+ className="chat-foot-popover"
864
+ role="dialog"
865
+ aria-label="Chat info"
866
+ data-testid="chat-foot-popover"
867
+ style={{ bottom: pos.bottom, right: pos.right }}
868
+ >
869
+ {context ? (
870
+ <div className="chat-foot-popover-section">
871
+ <div className="chat-foot-popover-label-row">
872
+ <span>Context</span>
873
+ <span>{context.pct}%</span>
874
+ </div>
875
+ <span className="chat-usage-bar chat-usage-bar--wide" aria-hidden="true">
876
+ <span className="chat-usage-bar-fill" style={{ width: `${context.pct}%` }} />
877
+ </span>
878
+ <div className="chat-foot-popover-row--muted">
879
+ {context.used.toLocaleString()} / {context.size.toLocaleString()} tokens
880
+ </div>
881
+ </div>
882
+ ) : null}
883
+ {usage.rateLimit ? (
884
+ <>
885
+ <div className="chat-foot-popover-sep" />
886
+ <div className="chat-foot-popover-label-row">
887
+ <span>{usage.rateLimit.label}</span>
888
+ <span>{usage.rateLimit.pct != null ? `${usage.rateLimit.pct}%` : '—'}</span>
889
+ </div>
890
+ {rateLimitResetLabel ? (
891
+ <div className="chat-foot-popover-row--muted">resets {rateLimitResetLabel}</div>
892
+ ) : null}
893
+ </>
894
+ ) : null}
895
+ {footnote ? (
896
+ <>
897
+ <div className="chat-foot-popover-sep" />
898
+ <div className="chat-foot-popover-row">{footnote}</div>
899
+ </>
900
+ ) : null}
901
+ <div className="chat-foot-popover-sep" />
902
+ <div className="chat-foot-popover-foot">
903
+ <div className="chat-foot-popover-row--muted">↵ to send · ⇧↵ newline</div>
904
+ <div className="chat-foot-popover-row--muted">
905
+ Uses your Claude subscription{timeLabel ? ` · updated ${timeLabel}` : ''}
906
+ </div>
907
+ </div>
908
+ </div>
909
+ ) : null}
910
+ </div>
911
+ );
912
+ }
913
+
914
+ // Event-driven single-window rate-limit banner (Task D3) — only the ONE
915
+ // active window `SDKRateLimitInfo` carries; NOT the full multi-window "Plan
916
+ // usage limits" panel (out of scope — see the plan's Milestone D research).
917
+ // Dismissible per reset window (re-appears once a NEW window starts warning).
918
+ function RateLimitBanner({ rateLimit, onDismiss }) {
919
+ if (!rateLimit) return null;
920
+ const resetLabel = formatResetTime(rateLimit.resetsAt);
921
+ return (
922
+ <div className="chat-usage-banner" role="status" data-testid="chat-usage-banner">
923
+ <span>
924
+ You're at {rateLimit.pct != null ? `${rateLimit.pct}%` : 'high usage'} of your {rateLimit.label}
925
+ {resetLabel ? ` · resets ${resetLabel}` : ''}
926
+ </span>
927
+ <button
928
+ type="button"
929
+ className="chat-usage-banner-x"
930
+ aria-label="Dismiss"
931
+ title="Dismiss"
932
+ onClick={onDismiss}
933
+ >
934
+ ×
935
+ </button>
936
+ </div>
937
+ );
938
+ }
939
+
940
+ // Connection-problem error card (Task C3) — a transient bridge/socket failure
941
+ // mid-turn, distinct from the hard NotConnected state (claude not installed/
942
+ // signed in): that one gates the WHOLE panel before any chat exists; this one
943
+ // is per-chat and recoverable with a retry. Rendered below the feed, like
944
+ // ActivityBar/ContinuationBubble — chrome, not a per-message assistant-ui error.
945
+ function ErrorCard({ error, onRetry }) {
946
+ const [expanded, setExpanded] = useState(false);
947
+ if (!error) return null;
948
+ return (
949
+ <div className="chat-error-card" role="alert" data-testid="chat-error-card">
950
+ <div className="chat-error-hd">
951
+ <span className="chat-error-ic" aria-hidden="true">
952
+
953
+ </span>
954
+ <b>Connection problem</b>
955
+ </div>
956
+ {expanded ? (
957
+ <p className="chat-error-detail">{error.message}</p>
958
+ ) : (
959
+ <p className="chat-error-detail chat-error-detail--muted">
960
+ Something interrupted the connection to Claude.
961
+ </p>
962
+ )}
963
+ <div className="chat-error-actions">
964
+ <button type="button" className="btn btn--sm" onClick={() => setExpanded((v) => !v)}>
965
+ {expanded ? 'Hide details' : 'View details'}
966
+ </button>
967
+ <button type="button" className="btn btn--primary btn--sm" onClick={onRetry}>
968
+ Try again
969
+ </button>
970
+ </div>
971
+ </div>
972
+ );
973
+ }
974
+
587
975
  function ChatEmpty() {
588
976
  return (
589
977
  <div className="chat-empty">
@@ -774,13 +1162,16 @@ function Composer({
774
1162
  activeCanvas,
775
1163
  chatCtx,
776
1164
  onCtxDismiss,
777
- model,
778
- setModel,
779
- effort,
780
- setEffort,
1165
+ picksRef,
1166
+ caps,
1167
+ onSetMode,
1168
+ onSetConfig,
781
1169
  conn,
782
1170
  chatId,
783
1171
  attachmentsRef,
1172
+ usage,
1173
+ showRateLimitBanner,
1174
+ onDismissRateLimitBanner,
784
1175
  }) {
785
1176
  const canvasName = prettyCanvas(activeCanvas);
786
1177
  const { all, existsSet } = useSlashCommands(conn);
@@ -826,13 +1217,17 @@ function Composer({
826
1217
 
827
1218
  // Warm the adapter the first time the user starts a slash command, so the live
828
1219
  // catalogue arrives without a full prompt. Guarded to fire once per mount.
1220
+ // model/effort/mode are the user's PERSISTED picks (read live off the ref so
1221
+ // this effect doesn't need to re-run when they change) — applied once, live,
1222
+ // by the bridge right after the session establishes.
829
1223
  const warmedRef = useRef(false);
830
1224
  useEffect(() => {
831
1225
  if (menuOpen && !warmedRef.current) {
832
1226
  warmedRef.current = true;
833
- conn.warm(chatId, model, effort);
1227
+ const p = picksRef.current;
1228
+ conn.warm(chatId, p.model, p.effort, p.mode);
834
1229
  }
835
- }, [menuOpen, conn, chatId, model, effort]);
1230
+ }, [menuOpen, conn, chatId, picksRef]);
836
1231
 
837
1232
  const pick = useCallback(
838
1233
  (cmd) => {
@@ -872,6 +1267,9 @@ function Composer({
872
1267
  return (
873
1268
  <div className="chat-composer" data-testid="chat-composer">
874
1269
  <ThreadPrimitive.If running={false}>
1270
+ {showRateLimitBanner ? (
1271
+ <RateLimitBanner rateLimit={usage.rateLimit} onDismiss={onDismissRateLimitBanner} />
1272
+ ) : null}
875
1273
  {/* Context attachment chip (feature-acp-context-hardening) — the visible
876
1274
  reveal of the fenced <maude-context> block the send will prepend
877
1275
  (built from the SAME buildChatContext result, so chip ≡ payload).
@@ -918,35 +1316,18 @@ function Composer({
918
1316
  onAttachChange={bumpAttach}
919
1317
  />
920
1318
  <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>
1319
+ <CapabilityBar
1320
+ modes={caps.modes}
1321
+ configOptions={caps.parsedOptions}
1322
+ onSetMode={onSetMode}
1323
+ onSetConfig={onSetConfig}
1324
+ />
1325
+ <span className="chat-toolbar-spacer" />
1326
+ <ChatFootInfo usage={usage} modes={caps.modes} />
1327
+ <ComposerPrimitive.Send className="chat-send" aria-label="Send message">
1328
+ <SendArrow />
1329
+ </ComposerPrimitive.Send>
1330
+ </div>
950
1331
  </ComposerPrimitive.Root>
951
1332
  </div>
952
1333
  {chipList.length ? (
@@ -964,11 +1345,6 @@ function Composer({
964
1345
  ))}
965
1346
  </div>
966
1347
  ) : 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
1348
  </ThreadPrimitive.If>
973
1349
  <ThreadPrimitive.If running>
974
1350
  <div className="chat-stopbar">
@@ -1062,16 +1438,86 @@ function ChatThread({
1062
1438
  chatId,
1063
1439
  initialMessages,
1064
1440
  hidden,
1065
- modelRef,
1066
- effortRef,
1441
+ picksRef,
1442
+ setPick,
1067
1443
  activeCanvas,
1068
1444
  selected,
1069
1445
  designRel,
1070
- model,
1071
- setModel,
1072
- effort,
1073
- setEffort,
1446
+ transcriptView,
1447
+ onSetTranscriptView,
1074
1448
  }) {
1449
+ // Live session capabilities (feature-acp-panel-dynamic-claude-code-
1450
+ // capabilities) — `caps` is THIS chat's own connection's mode roster +
1451
+ // config-option set, sourced entirely from the ACP session. Never a
1452
+ // hardcoded list; re-renders on every `caps` frame (a model switch can
1453
+ // change which OTHER options — effort, fast — are even offered).
1454
+ const [caps, setCaps] = useState({ modes: null, configOptions: [] });
1455
+ useEffect(() => conn.onCaps(setCaps), [conn]);
1456
+ const parsedOptions = useMemo(() => parseConfigOptions(caps.configOptions), [caps.configOptions]);
1457
+
1458
+ // Warm up as soon as this chat becomes the VISIBLE one — the capability bar
1459
+ // has nothing to show until a live session exists, and deferring the spawn
1460
+ // to "the user typed a slash command" (the original Composer-only trigger,
1461
+ // still below) left the model/effort/mode row completely EMPTY until then —
1462
+ // read as "I lost the picker" in dogfooding, a real regression. Fires once
1463
+ // per chat; a chat that's opened but never viewed still doesn't spawn
1464
+ // `claude`, so a background/hidden chat keeps some of DDR-123's "opening
1465
+ // the panel costs nothing" intent — only the chat actually on screen warms.
1466
+ const warmedOnVisibleRef = useRef(false);
1467
+ useEffect(() => {
1468
+ if (hidden || warmedOnVisibleRef.current) return;
1469
+ warmedOnVisibleRef.current = true;
1470
+ const p = picksRef.current;
1471
+ conn.warm(chatId, p.model, p.effort, p.mode);
1472
+ }, [hidden, conn, chatId, picksRef]);
1473
+
1474
+ // One-time reconciliation: if the user's persisted pick (from a DIFFERENT
1475
+ // chat, or an earlier session) isn't what THIS freshly-established session
1476
+ // actually landed on but IS now offered, nudge it live once. The bridge
1477
+ // already tries this itself on establish (Task A3) — this is a client-side
1478
+ // safety net for the rare race where the persisted value changed after the
1479
+ // `warm`/`prompt` frame was already in flight. Bounded to once per mount so
1480
+ // it can never loop.
1481
+ const reconciledRef = useRef(false);
1482
+ useEffect(() => {
1483
+ if (reconciledRef.current) return;
1484
+ const hasModes = (caps.modes?.availableModes?.length ?? 0) > 0;
1485
+ const hasOptions = caps.configOptions.length > 0;
1486
+ if (!hasModes && !hasOptions) return; // nothing to reconcile against yet
1487
+ reconciledRef.current = true;
1488
+ const picks = picksRef.current;
1489
+ if (parsedOptions.model && picks.model) {
1490
+ const values = flattenSelectOptions(parsedOptions.model.options).map((o) => o.value);
1491
+ const resolved = resolvePersistedPick(values, picks.model, parsedOptions.model.currentValue);
1492
+ if (resolved !== parsedOptions.model.currentValue) conn.setConfig(chatId, 'model', resolved);
1493
+ }
1494
+ if (parsedOptions.effort && picks.effort) {
1495
+ const values = flattenSelectOptions(parsedOptions.effort.options).map((o) => o.value);
1496
+ const resolved = resolvePersistedPick(values, picks.effort, parsedOptions.effort.currentValue);
1497
+ if (resolved !== parsedOptions.effort.currentValue) conn.setConfig(chatId, 'effort', resolved);
1498
+ }
1499
+ if (hasModes && picks.mode) {
1500
+ const ids = caps.modes.availableModes.map((m) => m.id);
1501
+ const resolved = resolvePersistedPick(ids, picks.mode, caps.modes.currentModeId);
1502
+ if (resolved !== caps.modes.currentModeId) conn.setMode(chatId, resolved);
1503
+ }
1504
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1505
+ }, [caps, parsedOptions]);
1506
+
1507
+ const onSetMode = useCallback(
1508
+ (modeId) => {
1509
+ conn.setMode(chatId, modeId);
1510
+ setPick('mode', modeId);
1511
+ },
1512
+ [conn, chatId, setPick]
1513
+ );
1514
+ const onSetConfig = useCallback(
1515
+ (configId, value) => {
1516
+ conn.setConfig(chatId, configId, value);
1517
+ setPick(configId, value);
1518
+ },
1519
+ [conn, chatId, setPick]
1520
+ );
1075
1521
  // Paste attachments: `map` = chip token ([image-1]…) → real path/URL (null while
1076
1522
  // an image upload is in flight); `pending` = the in-flight upload promises. The
1077
1523
  // adapter awaits `pending` then expands `map` before sending; the composer writes
@@ -1103,36 +1549,84 @@ function ChatThread({
1103
1549
  makeAcpAdapter(
1104
1550
  conn,
1105
1551
  () => chatId,
1106
- () => modelRef.current || null,
1107
- () => effortRef.current,
1552
+ () => picksRef.current.model || null,
1553
+ () => picksRef.current.effort || null,
1108
1554
  () => attachmentsRef.current,
1109
- () => (ctxDismissedRef.current ? null : contextRef.current)
1555
+ () => (ctxDismissedRef.current ? null : contextRef.current),
1556
+ () => picksRef.current.mode || null
1110
1557
  ),
1111
- [conn, chatId, modelRef, effortRef]
1558
+ [conn, chatId, picksRef]
1112
1559
  );
1113
1560
  const runtime = useLocalRuntime(adapter, { initialMessages });
1114
1561
  // Image thumbnails + lightbox — one overlay per thread; bubbles reach it (and
1115
- // the chip → attachment-name resolution) through ChatMediaContext.
1562
+ // the chip → attachment-name resolution) through ChatMediaContext. Also
1563
+ // carries `transcriptView` (Task C4) — AssistantMessage is rendered via
1564
+ // ThreadPrimitive.Messages' component registry, which doesn't pass custom
1565
+ // props through, so this context is the only way it reaches the filter.
1116
1566
  const [lightboxSrc, setLightboxSrc] = useState(null);
1117
1567
  const media = useMemo(
1118
1568
  () => ({
1119
1569
  chipName: (token) => attachmentName(attachmentsRef.current.map.get(token) || ''),
1120
1570
  openLightbox: setLightboxSrc,
1121
1571
  designRel,
1572
+ transcriptView,
1122
1573
  }),
1123
- [designRel]
1574
+ [designRel, transcriptView]
1124
1575
  );
1125
1576
  const [activeTools, setActiveTools] = useState([]);
1126
1577
  useEffect(() => conn.onActivity(setActiveTools), [conn]);
1127
1578
  // Post-turn-end continuation (the tail the client used to drop — RCA F2).
1128
1579
  const [bgParts, setBgParts] = useState([]);
1129
1580
  useEffect(() => conn.onBackground(setBgParts), [conn]);
1581
+ // Pending permission requests (Milestone B) — one card at a time, oldest first.
1582
+ const [pendingPermissions, setPendingPermissions] = useState([]);
1583
+ useEffect(() => conn.onPermission(setPendingPermissions), [conn]);
1584
+ const activePermission = pendingPermissions[0] || null;
1585
+ const respondPermission = useCallback(
1586
+ (decision) => activePermission && conn.respondPermission(activePermission.id, decision),
1587
+ [conn, activePermission]
1588
+ );
1589
+ // Pending elicitation requests (feature-acp-ask-user-question) — same
1590
+ // one-at-a-time, oldest-first treatment as pendingPermissions above.
1591
+ const [pendingElicitations, setPendingElicitations] = useState([]);
1592
+ useEffect(() => conn.onElicitation(setPendingElicitations), [conn]);
1593
+ const activeElicitation = pendingElicitations[0] || null;
1594
+ const respondElicitation = useCallback(
1595
+ (response) => activeElicitation && conn.respondElicitation(activeElicitation.id, response),
1596
+ [conn, activeElicitation]
1597
+ );
1598
+ // Connection-problem error card (Task C3) — see conn.onError's doc comment.
1599
+ const [turnError, setTurnError] = useState(null);
1600
+ useEffect(() => conn.onError(setTurnError), [conn]);
1601
+ const retryLastTurn = useCallback(() => {
1602
+ const text = lastUserText(runtime.thread.getState().messages);
1603
+ if (text) runtime.thread.append(text);
1604
+ }, [runtime]);
1605
+
1606
+ // Usage (Milestone D) — context-window gauge + session cost + an event-
1607
+ // driven single-window rate-limit banner. Per-chat: each ChatThread has its
1608
+ // own connection, so this is naturally scoped to the right session.
1609
+ const [rawUsage, setRawUsage] = useState(null);
1610
+ useEffect(() => conn.onUsage(setRawUsage), [conn]);
1611
+ const usage = useMemo(() => parseUsage({ usage: rawUsage }), [rawUsage]);
1612
+ const [bannerDismissedFor, setBannerDismissedFor] = useState(null);
1613
+ const showRateLimitBanner =
1614
+ usage.rateLimit &&
1615
+ (usage.rateLimit.status === 'allowed_warning' || usage.rateLimit.status === 'rejected') &&
1616
+ bannerDismissedFor !== `${usage.rateLimit.type}:${usage.rateLimit.resetsAt}`;
1617
+ const dismissRateLimitBanner = useCallback(() => {
1618
+ if (usage.rateLimit) setBannerDismissedFor(`${usage.rateLimit.type}:${usage.rateLimit.resetsAt}`);
1619
+ }, [usage.rateLimit]);
1130
1620
 
1131
1621
  return (
1132
1622
  <AssistantRuntimeProvider runtime={runtime}>
1133
1623
  <ChatMediaContext.Provider value={media}>
1134
1624
  <div className="chat-panel" style={hidden ? { display: 'none' } : undefined}>
1135
- <StatusRow tools={activeTools} />
1625
+ <StatusRow
1626
+ tools={activeTools}
1627
+ transcriptView={transcriptView}
1628
+ onSetTranscriptView={onSetTranscriptView}
1629
+ />
1136
1630
  <ThreadPrimitive.Root className="chat-thread">
1137
1631
  <ThreadPrimitive.Viewport className="chat-feed" autoScroll>
1138
1632
  <ThreadPrimitive.Empty>
@@ -1141,22 +1635,36 @@ function ChatThread({
1141
1635
  <ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
1142
1636
  {/* Background work that outlived the turn — streamed here so it's not
1143
1637
  dropped (RCA F2). */}
1144
- <ContinuationBubble parts={bgParts} />
1638
+ <ContinuationBubble parts={bgParts} viewMode={transcriptView} />
1145
1639
  {/* "still working" indicator under the latest message */}
1146
1640
  <ActivityBar tools={activeTools} />
1147
1641
  </ThreadPrimitive.Viewport>
1642
+ {activePermission ? (
1643
+ <PermissionPrompt
1644
+ request={activePermission}
1645
+ onRespond={respondPermission}
1646
+ queueLength={pendingPermissions.length}
1647
+ />
1648
+ ) : activeElicitation ? (
1649
+ <ElicitationPrompt request={activeElicitation} onRespond={respondElicitation} />
1650
+ ) : (
1651
+ <ErrorCard error={turnError} onRetry={retryLastTurn} />
1652
+ )}
1148
1653
  <QuickActions />
1149
1654
  <Composer
1150
1655
  activeCanvas={activeCanvas}
1151
1656
  chatCtx={ctxDismissed ? null : chatCtx}
1152
1657
  onCtxDismiss={dismissCtx}
1153
- model={model}
1154
- setModel={setModel}
1155
- effort={effort}
1156
- setEffort={setEffort}
1658
+ picksRef={picksRef}
1659
+ caps={{ modes: caps.modes, parsedOptions }}
1660
+ onSetMode={onSetMode}
1661
+ onSetConfig={onSetConfig}
1157
1662
  conn={conn}
1158
1663
  chatId={chatId}
1159
1664
  attachmentsRef={attachmentsRef}
1665
+ usage={usage}
1666
+ showRateLimitBanner={showRateLimitBanner}
1667
+ onDismissRateLimitBanner={dismissRateLimitBanner}
1160
1668
  />
1161
1669
  </ThreadPrimitive.Root>
1162
1670
  {lightboxSrc ? (
@@ -1180,19 +1688,29 @@ export default function ChatPanel({
1180
1688
  onBusyChange,
1181
1689
  onFinished,
1182
1690
  }) {
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);
1691
+ // Model/effort/mode picks — persisted across sessions per config id (never a
1692
+ // fixed model/effort pair; see loadPersistedPicks). Read live by each open
1693
+ // chat's adapter (for a FRESH session's one-time apply) and by the picker's
1694
+ // one-time reconciliation effect.
1695
+ const [picks, setPicks] = useState(loadPersistedPicks);
1696
+ const picksRef = useRef(picks);
1188
1697
  useEffect(() => {
1189
- modelRef.current = model;
1190
- safeStorageSet('maude-acp-model', model);
1191
- }, [model]);
1698
+ picksRef.current = picks;
1699
+ savePersistedPicks(picks);
1700
+ }, [picks]);
1701
+ const setPick = useCallback((id, value) => {
1702
+ setPicks((prev) => (prev[id] === value ? prev : { ...prev, [id]: value }));
1703
+ }, []);
1704
+
1705
+ // Transcript view mode (Task C4) — panel-wide (applies to every open chat),
1706
+ // persisted, changed via the per-chat overflow menu (Task C5).
1707
+ const [transcriptView, setTranscriptView] = useState(() => {
1708
+ const saved = safeStorageGet('maude-acp-transcript-view', DEFAULT_TRANSCRIPT_VIEW);
1709
+ return TRANSCRIPT_VIEWS.includes(saved) ? saved : DEFAULT_TRANSCRIPT_VIEW;
1710
+ });
1192
1711
  useEffect(() => {
1193
- effortRef.current = effort;
1194
- safeStorageSet('maude-acp-effort', effort);
1195
- }, [effort]);
1712
+ safeStorageSet('maude-acp-transcript-view', transcriptView);
1713
+ }, [transcriptView]);
1196
1714
 
1197
1715
  // Availability is global (is claude installed) — a single probe, no connection.
1198
1716
  const [status, setStatus] = useState({ available: null, reason: undefined });
@@ -1225,10 +1743,19 @@ export default function ChatPanel({
1225
1743
 
1226
1744
  // Recents (for the switcher).
1227
1745
  const [chats, setChats] = useState([]);
1746
+ // Chat ids the server confirms have a user-set title (ChatSummary.renamed)
1747
+ // — session_info_update must never clobber those (Task C5 gotcha: "an
1748
+ // explicit rename must not be silently clobbered by an auto-generated
1749
+ // summary landing afterward"). Seeded/kept in sync from every refreshChats().
1750
+ const renamedChatIdsRef = useRef(new Set());
1228
1751
  const refreshChats = useCallback(() => {
1229
1752
  fetch('/_api/acp/chats')
1230
1753
  .then((r) => r.json())
1231
- .then((d) => Array.isArray(d) && setChats(d))
1754
+ .then((d) => {
1755
+ if (!Array.isArray(d)) return;
1756
+ setChats(d);
1757
+ renamedChatIdsRef.current = new Set(d.filter((c) => c.renamed).map((c) => c.id));
1758
+ })
1232
1759
  .catch(() => {});
1233
1760
  }, []);
1234
1761
  useEffect(() => {
@@ -1244,6 +1771,9 @@ export default function ChatPanel({
1244
1771
  const [openChatIds, setOpenChatIds] = useState([]);
1245
1772
  const [activeChatId, setActiveChatId] = useState(null);
1246
1773
  const [busyChats, setBusyChats] = useState({}); // reactive: chatId → busy (for the dot)
1774
+ // Agent-generated chat titles (`session_info_update`) — wins over the
1775
+ // client's "New chat" heuristic once the agent names the first turn.
1776
+ const [sessionTitles, setSessionTitles] = useState({});
1247
1777
  const [menuOpen, setMenuOpen] = useState(false);
1248
1778
  const cbRef = useRef({ onBusyChange, onFinished });
1249
1779
  useEffect(() => {
@@ -1256,6 +1786,14 @@ export default function ChatPanel({
1256
1786
  if (existing) return existing;
1257
1787
  const conn = createAcpConnection();
1258
1788
  connsRef.current.set(chatId, conn);
1789
+ // Agent-generated title (session_info_update — fires at turn-end, so a
1790
+ // fresh chat shows the client's "New chat" heuristic until the first
1791
+ // turn completes; expected). Read live by chatOptions below.
1792
+ conn.onSessionInfo((info) => {
1793
+ if (info.title && !renamedChatIdsRef.current.has(chatId)) {
1794
+ setSessionTitles((prev) => ({ ...prev, [chatId]: info.title }));
1795
+ }
1796
+ });
1259
1797
  // Background work (subagents) can outlive the main turn — the ACP adapter
1260
1798
  // settles the prompt at the main agent's `result` while they drain
1261
1799
  // (claude-agent-acp #773). Track live tool activity so the menubar
@@ -1345,6 +1883,12 @@ export default function ChatPanel({
1345
1883
  delete next[id];
1346
1884
  return next;
1347
1885
  });
1886
+ setSessionTitles((prev) => {
1887
+ if (!(id in prev)) return prev;
1888
+ const next = { ...prev };
1889
+ delete next[id];
1890
+ return next;
1891
+ });
1348
1892
  fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
1349
1893
  .catch(() => {})
1350
1894
  .finally(refreshChats);
@@ -1366,6 +1910,78 @@ export default function ChatPanel({
1366
1910
  [activeChatId, ensureConn, refreshChats]
1367
1911
  );
1368
1912
 
1913
+ // Rename / Archive / Copy transcript (Task C5 — per-chat overflow menu).
1914
+ // Rename/Archive PATCH the `_chat/<id>.meta.json` sidecar (acp/transcript.ts)
1915
+ // — chosen over injecting a synthetic `/rename` prompt (Task B/C research):
1916
+ // deterministic, doesn't pollute the transcript with a synthetic turn, and
1917
+ // needs no unverified assumption about how the CLI's own /rename behaves
1918
+ // over ACP. `renamedChatIdsRef` (set above) keeps a later live auto-title
1919
+ // from clobbering it.
1920
+ const patchChatMeta = useCallback((id, patch) => {
1921
+ return fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`, {
1922
+ method: 'PATCH',
1923
+ headers: { 'Content-Type': 'application/json' },
1924
+ body: JSON.stringify(patch),
1925
+ })
1926
+ .then((r) => (r.ok ? r.json() : null))
1927
+ .catch(() => null);
1928
+ }, []);
1929
+
1930
+ const [overflowChatId, setOverflowChatId] = useState(null);
1931
+ const [renamingChatId, setRenamingChatId] = useState(null);
1932
+ const [renameValue, setRenameValue] = useState('');
1933
+
1934
+ const startRename = useCallback((c) => {
1935
+ setRenamingChatId(c.id);
1936
+ setRenameValue(c.title === 'New chat' ? '' : c.title);
1937
+ setOverflowChatId(null);
1938
+ }, []);
1939
+
1940
+ const commitRename = useCallback(
1941
+ (id) => {
1942
+ const value = renameValue.trim();
1943
+ setRenamingChatId(null);
1944
+ if (!value) return;
1945
+ renamedChatIdsRef.current.add(id);
1946
+ setSessionTitles((prev) => ({ ...prev, [id]: value })); // optimistic — see gotcha above
1947
+ patchChatMeta(id, { title: value }).then(refreshChats);
1948
+ },
1949
+ [renameValue, patchChatMeta, refreshChats]
1950
+ );
1951
+
1952
+ const archiveChat = useCallback(
1953
+ (id) => {
1954
+ setOverflowChatId(null);
1955
+ patchChatMeta(id, { archived: true }).then(() => {
1956
+ refreshChats();
1957
+ // An archived chat can still be OPEN (mounted, running) — archiving
1958
+ // only hides it from recents, per the task spec; it does not close
1959
+ // the connection or force-switch away.
1960
+ });
1961
+ },
1962
+ [patchChatMeta, refreshChats]
1963
+ );
1964
+
1965
+ const copyTranscript = useCallback((id) => {
1966
+ setOverflowChatId(null);
1967
+ fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`)
1968
+ .then((r) => r.json())
1969
+ .then((msgs) => {
1970
+ const text = (Array.isArray(msgs) ? msgs : [])
1971
+ .map((m) => {
1972
+ const body = (m.parts || [])
1973
+ .map((p) =>
1974
+ p.type === 'text' ? p.text || '' : p.type === 'tool' ? `[${p.toolName || 'tool'}]` : ''
1975
+ )
1976
+ .join('');
1977
+ return `${m.role === 'user' ? 'User' : 'Claude'}: ${body}`;
1978
+ })
1979
+ .join('\n\n');
1980
+ return navigator.clipboard?.writeText(text);
1981
+ })
1982
+ .catch(() => {});
1983
+ }, []);
1984
+
1369
1985
  // Open a fresh chat on mount; close every connection on unmount.
1370
1986
  useEffect(() => {
1371
1987
  newChat();
@@ -1377,21 +1993,24 @@ export default function ChatPanel({
1377
1993
  }, []);
1378
1994
 
1379
1995
  // Switcher options — open chats first (parallel), then the rest of the recents.
1996
+ // Title precedence: the agent's own session_info_update title (live, this
1997
+ // process lifetime) > the persisted transcript's title > "New chat".
1380
1998
  const chatOptions = useMemo(() => {
1381
1999
  const seen = new Set();
1382
2000
  const list = [];
1383
2001
  for (const id of openChatIds) {
1384
2002
  if (seen.has(id)) continue;
1385
2003
  seen.add(id);
1386
- list.push({ id, title: chats.find((c) => c.id === id)?.title || 'New chat', open: true });
2004
+ const title = sessionTitles[id] || chats.find((c) => c.id === id)?.title || 'New chat';
2005
+ list.push({ id, title, open: true });
1387
2006
  }
1388
2007
  for (const c of chats) {
1389
2008
  if (seen.has(c.id)) continue;
1390
2009
  seen.add(c.id);
1391
- list.push(c);
2010
+ list.push(sessionTitles[c.id] ? { ...c, title: sessionTitles[c.id] } : c);
1392
2011
  }
1393
2012
  return list;
1394
- }, [chats, openChatIds]);
2013
+ }, [chats, openChatIds, sessionTitles]);
1395
2014
 
1396
2015
  const connected = status.available !== false;
1397
2016
 
@@ -1439,36 +2058,88 @@ export default function ChatPanel({
1439
2058
  </button>
1440
2059
  {menuOpen ? (
1441
2060
  <>
1442
- <div className="chat-menu-backdrop" onClick={() => setMenuOpen(false)} />
2061
+ <div
2062
+ className="chat-menu-backdrop"
2063
+ onClick={() => {
2064
+ setMenuOpen(false);
2065
+ setOverflowChatId(null);
2066
+ setRenamingChatId(null);
2067
+ }}
2068
+ />
1443
2069
  <div className="chat-menu" role="listbox">
1444
2070
  {chatOptions.map((c) => (
1445
2071
  <div
1446
2072
  key={c.id}
1447
2073
  className={`chat-menu-row${c.id === activeChatId ? ' is-active' : ''}`}
1448
2074
  >
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'}
2075
+ {renamingChatId === c.id ? (
2076
+ <input
2077
+ type="text"
2078
+ className="chat-menu-rename-input"
2079
+ value={renameValue}
2080
+ autoFocus
2081
+ onChange={(e) => setRenameValue(e.target.value)}
2082
+ onBlur={() => commitRename(c.id)}
2083
+ onKeyDown={(e) => {
2084
+ if (e.key === 'Enter') commitRename(c.id);
2085
+ else if (e.key === 'Escape') setRenamingChatId(null);
2086
+ }}
1460
2087
  />
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>
2088
+ ) : (
2089
+ <button
2090
+ type="button"
2091
+ className="chat-menu-open"
2092
+ onClick={() => {
2093
+ switchTo(c.id);
2094
+ setMenuOpen(false);
2095
+ }}
2096
+ >
2097
+ <span
2098
+ className={`chat-dot ${busyChats[c.id] ? 'chat-dot--busy' : c.open ? 'chat-dot--idle' : 'chat-dot--off'}`}
2099
+ title={busyChats[c.id] ? 'Running' : c.open ? 'Open' : 'Saved'}
2100
+ />
2101
+ <span className="chat-menu-title">{c.title}</span>
2102
+ </button>
2103
+ )}
2104
+ <div className="chat-menu-overflow-wrap">
2105
+ <button
2106
+ type="button"
2107
+ className="chat-menu-more"
2108
+ aria-label="More options"
2109
+ title="More options"
2110
+ aria-haspopup="menu"
2111
+ aria-expanded={overflowChatId === c.id}
2112
+ onClick={() =>
2113
+ setOverflowChatId((prev) => (prev === c.id ? null : c.id))
2114
+ }
2115
+ >
2116
+
2117
+ </button>
2118
+ {overflowChatId === c.id ? (
2119
+ <div className="chat-menu-overflow" role="menu" data-testid="chat-overflow-menu">
2120
+ <button type="button" role="menuitem" onClick={() => startRename(c)}>
2121
+ Rename
2122
+ </button>
2123
+ <button type="button" role="menuitem" onClick={() => archiveChat(c.id)}>
2124
+ Archive
2125
+ </button>
2126
+ <button type="button" role="menuitem" onClick={() => copyTranscript(c.id)}>
2127
+ Copy transcript
2128
+ </button>
2129
+ <button
2130
+ type="button"
2131
+ role="menuitem"
2132
+ className="chat-menu-overflow-danger"
2133
+ onClick={() => {
2134
+ setOverflowChatId(null);
2135
+ deleteChat(c.id);
2136
+ }}
2137
+ >
2138
+ Delete
2139
+ </button>
2140
+ </div>
2141
+ ) : null}
2142
+ </div>
1472
2143
  </div>
1473
2144
  ))}
1474
2145
  </div>
@@ -1507,15 +2178,13 @@ export default function ChatPanel({
1507
2178
  chatId={id}
1508
2179
  initialMessages={hydratedRef.current.get(id) || []}
1509
2180
  hidden={id !== activeChatId}
1510
- modelRef={modelRef}
1511
- effortRef={effortRef}
2181
+ picksRef={picksRef}
2182
+ setPick={setPick}
1512
2183
  activeCanvas={activeCanvas}
1513
2184
  selected={selected}
1514
2185
  designRel={designRel}
1515
- model={model}
1516
- setModel={setModel}
1517
- effort={effort}
1518
- setEffort={setEffort}
2186
+ transcriptView={transcriptView}
2187
+ onSetTranscriptView={setTranscriptView}
1519
2188
  />
1520
2189
  );
1521
2190
  })