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