@1agh/maude 0.45.0 → 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.
- package/apps/studio/acp/bridge.ts +585 -73
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +802 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +71 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +648 -11
- package/apps/studio/dist/client.bundle.js +1596 -1594
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +33 -2
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/whats-new.json +34 -0
- package/cli/commands/design.mjs +107 -2
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- 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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
{
|
|
569
|
-
|
|
570
|
-
<
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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={
|
|
577
|
-
args={
|
|
578
|
-
result={
|
|
579
|
-
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
1227
|
+
const p = picksRef.current;
|
|
1228
|
+
conn.warm(chatId, p.model, p.effort, p.mode);
|
|
834
1229
|
}
|
|
835
|
-
}, [menuOpen, conn, chatId,
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
</
|
|
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
|
-
|
|
1066
|
-
|
|
1441
|
+
picksRef,
|
|
1442
|
+
setPick,
|
|
1067
1443
|
activeCanvas,
|
|
1068
1444
|
selected,
|
|
1069
1445
|
designRel,
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
-
() =>
|
|
1107
|
-
() =>
|
|
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,
|
|
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
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
const
|
|
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
|
-
|
|
1190
|
-
|
|
1191
|
-
}, [
|
|
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
|
-
|
|
1194
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
2181
|
+
picksRef={picksRef}
|
|
2182
|
+
setPick={setPick}
|
|
1512
2183
|
activeCanvas={activeCanvas}
|
|
1513
2184
|
selected={selected}
|
|
1514
2185
|
designRel={designRel}
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
effort={effort}
|
|
1518
|
-
setEffort={setEffort}
|
|
2186
|
+
transcriptView={transcriptView}
|
|
2187
|
+
onSetTranscriptView={setTranscriptView}
|
|
1519
2188
|
/>
|
|
1520
2189
|
);
|
|
1521
2190
|
})
|