@1agh/maude 0.38.1 → 0.39.1
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 +144 -5
- package/apps/studio/acp/index.ts +11 -1
- package/apps/studio/api.ts +21 -0
- package/apps/studio/client/app.jsx +1 -0
- package/apps/studio/client/panels/ChatPanel.jsx +190 -4
- package/apps/studio/client/panels/acp-runtime.js +108 -0
- package/apps/studio/client/styles/6-acp-chat.css +123 -1
- package/apps/studio/dist/client.bundle.js +14 -14
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +23 -3
- package/apps/studio/test/acp-attachment-serve.test.ts +74 -0
- package/apps/studio/test/acp-bridge.test.ts +132 -0
- package/apps/studio/test/acp-commands.test.ts +8 -3
- package/apps/studio/test/acp-origin-gate.test.ts +6 -4
- package/apps/studio/test/canvas-origin-gate.test.ts +5 -0
- package/apps/studio/test/chat-attachments.test.ts +117 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
- package/apps/studio/whats-new.json +18 -0
- package/package.json +8 -8
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// (env.ts): the child inherits the environment MINUS `ANTHROPIC_API_KEY`, so
|
|
9
9
|
// auth precedence falls through to the user's Pro/Max subscription.
|
|
10
10
|
|
|
11
|
-
import { appendFile, mkdir } from 'node:fs/promises';
|
|
11
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
|
|
14
14
|
import {
|
|
@@ -71,6 +71,29 @@ const EFFORT_THINKING_TOKENS: Record<string, number | null> = {
|
|
|
71
71
|
|
|
72
72
|
export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
|
|
73
73
|
|
|
74
|
+
// Real sessionIds are adapter-generated `randomUUID()`s. A persisted value that
|
|
75
|
+
// doesn't look like one (corrupt sidecar, or a tracked file a cloned repo
|
|
76
|
+
// shipped despite `_chat/` being gitignored — DDR-115) is rejected rather than
|
|
77
|
+
// forwarded into the privileged `loadSession` ACP call.
|
|
78
|
+
const VALID_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
79
|
+
|
|
80
|
+
// `loadSession`'s replay can, in principle, never settle if the underlying
|
|
81
|
+
// transport dies mid-call (adapter crash, a concurrent `stop()` from another
|
|
82
|
+
// chat sharing this bridge). Bound it so `replaying` always resets and a
|
|
83
|
+
// resume attempt always falls back to `newSession` instead of wedging the
|
|
84
|
+
// bridge silent forever. Mirrors the `withTimeout`/`TIMED_OUT` pattern already
|
|
85
|
+
// used for network calls in `apps/studio/git/service.ts`.
|
|
86
|
+
const LOAD_SESSION_TIMEOUT_MS = 15_000;
|
|
87
|
+
const TIMED_OUT = Symbol('maude-acp-load-session-timeout');
|
|
88
|
+
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
|
|
89
|
+
p.catch(() => {});
|
|
90
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
91
|
+
const t = new Promise<typeof TIMED_OUT>((res) => {
|
|
92
|
+
timer = setTimeout(() => res(TIMED_OUT), ms);
|
|
93
|
+
});
|
|
94
|
+
return Promise.race([p, t]).finally(() => clearTimeout(timer));
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
/**
|
|
75
98
|
* Build the `session/new` params, carrying TWO adapter-internal `_meta` payloads
|
|
76
99
|
* (both spread by the installed `claude-agent-acp@0.49.x` `newSession`):
|
|
@@ -90,6 +113,10 @@ export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
|
|
|
90
113
|
* must fail the presence tests LOUDLY (acp-bootstrap-brief.test.ts +
|
|
91
114
|
* acp-session-plugins.test.ts), not silently un-brief / un-plugin every session.
|
|
92
115
|
* Exported for those tests.
|
|
116
|
+
*
|
|
117
|
+
* `cwd`/`mcpServers`/`_meta` are also exactly the shared fields of a
|
|
118
|
+
* `LoadSessionRequest` (schema/types.gen.d.ts) — `sessionFor`'s resume path
|
|
119
|
+
* spreads this same object and adds `sessionId` rather than duplicating it.
|
|
93
120
|
*/
|
|
94
121
|
export function newSessionParams(
|
|
95
122
|
repoRoot: string,
|
|
@@ -136,12 +163,21 @@ export class AcpBridge {
|
|
|
136
163
|
// context. The adapter (one subprocess) holds them all; switching chats reuses
|
|
137
164
|
// the session, so claude remembers that chat while the app is open.
|
|
138
165
|
private sessions = new Map<string, string>(); // chatId → sessionId
|
|
166
|
+
// In-flight sessionFor() calls, keyed by chatId — lets a `warm` and a `prompt`
|
|
167
|
+
// racing for the same chat share one resume/create attempt instead of each
|
|
168
|
+
// running establishSession() and stomping the single shared `replaying` flag.
|
|
169
|
+
private sessionPromises = new Map<string, Promise<string>>();
|
|
139
170
|
private currentSession: string | null = null; // the in-flight prompt's session
|
|
140
171
|
/** Sessions whose bootstrap brief already hit the transcript (audit record). */
|
|
141
172
|
private briefLogged = new Set<string>();
|
|
142
173
|
private starting: Promise<void> | null = null;
|
|
143
174
|
/** Per-chat transcript file (`_chat/<id>.jsonl`); set per prompt. */
|
|
144
175
|
private transcriptPath: string | null = null;
|
|
176
|
+
/** Sidecar persisting this chat's ACP sessionId across restarts (`_chat/<id>.session.json`). */
|
|
177
|
+
private sessionStorePath: string | null = null;
|
|
178
|
+
/** True while `conn.loadSession()` is replaying a resumed session's history
|
|
179
|
+
* back through the `sessionUpdate` client callback — see the guard in `start()`. */
|
|
180
|
+
private replaying = false;
|
|
145
181
|
// Model + effort are env-at-spawn (ANTHROPIC_MODEL / MAX_THINKING_TOKENS), so a
|
|
146
182
|
// change re-spawns the adapter. `desired*` is what the UI asked for; `active*`
|
|
147
183
|
// is what the running session was spawned with.
|
|
@@ -165,6 +201,10 @@ export class AcpBridge {
|
|
|
165
201
|
this.transcriptPath = path;
|
|
166
202
|
}
|
|
167
203
|
|
|
204
|
+
setSessionStorePath(path: string | null): void {
|
|
205
|
+
this.sessionStorePath = path;
|
|
206
|
+
}
|
|
207
|
+
|
|
168
208
|
/** Desired model (alias/id, or null for the user's default) + effort. Applied
|
|
169
209
|
* on the next prompt — re-spawning the adapter only if it actually changed. */
|
|
170
210
|
setConfig(model: string | null, effort: AcpEffort): void {
|
|
@@ -187,18 +227,104 @@ export class AcpBridge {
|
|
|
187
227
|
return this.starting;
|
|
188
228
|
}
|
|
189
229
|
|
|
190
|
-
/**
|
|
230
|
+
/**
|
|
231
|
+
* Get-or-create the ACP session for a chat id (one claude context per chat).
|
|
232
|
+
* `warm` and `prompt` can both reach this for the same chat close together
|
|
233
|
+
* (composer autocomplete warm-up racing the user hitting send) — a second
|
|
234
|
+
* concurrent call for the same chatId shares the FIRST call's in-flight
|
|
235
|
+
* promise (mirrors `ensureStarted`'s `this.starting` pattern) rather than
|
|
236
|
+
* re-entering resume/create and stomping the single shared `replaying` flag.
|
|
237
|
+
*/
|
|
191
238
|
private async sessionFor(chatId: string): Promise<string> {
|
|
192
239
|
const existing = this.sessions.get(chatId);
|
|
193
240
|
if (existing) return existing;
|
|
241
|
+
const inFlight = this.sessionPromises.get(chatId);
|
|
242
|
+
if (inFlight) return inFlight;
|
|
243
|
+
|
|
244
|
+
const promise = this.establishSession(chatId).finally(() => {
|
|
245
|
+
this.sessionPromises.delete(chatId);
|
|
246
|
+
});
|
|
247
|
+
this.sessionPromises.set(chatId, promise);
|
|
248
|
+
return promise;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resumes a session persisted from a PRIOR app/dev-server lifetime (the
|
|
253
|
+
* cross-restart memory gap tracked in DDR-125) via the adapter's `loadSession`
|
|
254
|
+
* before falling back to a brand-new `newSession` — either because this chat
|
|
255
|
+
* has never had a session, or because the resume attempt failed (e.g. the
|
|
256
|
+
* underlying claude session was pruned, `claude` was reinstalled, or the
|
|
257
|
+
* adapter's response never arrives — `loadSession` is time-boxed so a dead
|
|
258
|
+
* transport can't wedge `replaying` true forever and silently black-hole
|
|
259
|
+
* every future turn on this bridge).
|
|
260
|
+
*/
|
|
261
|
+
private async establishSession(chatId: string): Promise<string> {
|
|
194
262
|
if (!this.conn) throw new Error('ACP adapter not started');
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
);
|
|
263
|
+
|
|
264
|
+
const params = newSessionParams(this.opts.repoRoot, this.opts.studioBrief, this.opts.plugins);
|
|
265
|
+
const persistedId = await this.readPersistedSessionId();
|
|
266
|
+
if (persistedId) {
|
|
267
|
+
try {
|
|
268
|
+
this.replaying = true;
|
|
269
|
+
const result = await withTimeout(
|
|
270
|
+
this.conn.loadSession({ ...params, sessionId: persistedId }),
|
|
271
|
+
LOAD_SESSION_TIMEOUT_MS
|
|
272
|
+
);
|
|
273
|
+
if (result === TIMED_OUT) {
|
|
274
|
+
throw new Error(`loadSession timed out after ${LOAD_SESSION_TIMEOUT_MS}ms`);
|
|
275
|
+
}
|
|
276
|
+
this.sessions.set(chatId, persistedId);
|
|
277
|
+
return persistedId;
|
|
278
|
+
} catch (err) {
|
|
279
|
+
await this.appendTranscript({
|
|
280
|
+
role: 'bootstrap',
|
|
281
|
+
kind: 'resume-failed',
|
|
282
|
+
error: err instanceof Error ? err.message : String(err),
|
|
283
|
+
});
|
|
284
|
+
} finally {
|
|
285
|
+
this.replaying = false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const created = await this.conn.newSession(params);
|
|
198
290
|
this.sessions.set(chatId, created.sessionId);
|
|
291
|
+
await this.writePersistedSessionId(created.sessionId);
|
|
199
292
|
return created.sessionId;
|
|
200
293
|
}
|
|
201
294
|
|
|
295
|
+
/** Read the sessionId persisted for this chat by a prior bridge lifetime.
|
|
296
|
+
* Null when there's no sidecar wired (e.g. warm-up before any prompt), the
|
|
297
|
+
* file doesn't exist yet (first-ever turn), it's unreadable/corrupt, or its
|
|
298
|
+
* `sessionId` doesn't look like a real one (defense-in-depth — this file's
|
|
299
|
+
* directory is per-machine/gitignored per DDR-115, but a cloned repo could
|
|
300
|
+
* still ship a tracked file there, so bound what we'll forward into the
|
|
301
|
+
* privileged `loadSession` ACP call rather than trusting its shape blindly). */
|
|
302
|
+
private async readPersistedSessionId(): Promise<string | null> {
|
|
303
|
+
if (!this.sessionStorePath) return null;
|
|
304
|
+
try {
|
|
305
|
+
const raw = await readFile(this.sessionStorePath, 'utf8');
|
|
306
|
+
const data = JSON.parse(raw) as { sessionId?: unknown };
|
|
307
|
+
const id = data.sessionId;
|
|
308
|
+
return typeof id === 'string' && VALID_SESSION_ID.test(id) ? id : null;
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Persist a freshly-created sessionId so the NEXT bridge lifetime (app
|
|
315
|
+
* restart, dev-server restart) can resume this chat instead of starting
|
|
316
|
+
* fresh. Best-effort, like `appendTranscript` — a failed write just means
|
|
317
|
+
* the next restart falls back to a new session. */
|
|
318
|
+
private async writePersistedSessionId(sessionId: string): Promise<void> {
|
|
319
|
+
if (!this.sessionStorePath) return;
|
|
320
|
+
try {
|
|
321
|
+
await mkdir(dirname(this.sessionStorePath), { recursive: true });
|
|
322
|
+
await writeFile(this.sessionStorePath, JSON.stringify({ sessionId, updatedAt: Date.now() }));
|
|
323
|
+
} catch {
|
|
324
|
+
/* best-effort — see doc comment above */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
202
328
|
private async start(): Promise<void> {
|
|
203
329
|
const adapterEntry = resolveAdapterEntry();
|
|
204
330
|
if (!adapterEntry) {
|
|
@@ -279,6 +405,12 @@ export class AcpBridge {
|
|
|
279
405
|
this.opts.onCommands?.(params.update.availableCommands ?? []);
|
|
280
406
|
return;
|
|
281
407
|
}
|
|
408
|
+
// `loadSession` replays the resumed session's entire prior history back
|
|
409
|
+
// through this SAME callback (claude-agent-acp's replaySessionHistory) to
|
|
410
|
+
// prime its own in-adapter state. That history is already on disk in the
|
|
411
|
+
// transcript and already rendered client-side, so forwarding/re-appending
|
|
412
|
+
// it here would duplicate every message in the panel and the jsonl file.
|
|
413
|
+
if (this.replaying) return;
|
|
282
414
|
this.opts.onUpdate(params.update);
|
|
283
415
|
void this.appendTranscript({ role: 'agent', update: params.update });
|
|
284
416
|
},
|
|
@@ -388,6 +520,13 @@ export class AcpBridge {
|
|
|
388
520
|
this.proc = null;
|
|
389
521
|
this.conn = null;
|
|
390
522
|
this.sessions.clear();
|
|
523
|
+
// Drop any in-flight sessionFor() promises too — they were bound to the
|
|
524
|
+
// now-dead `conn`; a subsequent sessionFor() for the same chatId must
|
|
525
|
+
// establish fresh against the respawned connection, not await a stale
|
|
526
|
+
// reference (each entry's own .finally() would eventually clear it once its
|
|
527
|
+
// bounded loadSession timeout fires, but a call landing before then would
|
|
528
|
+
// otherwise get back a result tied to the connection we just tore down).
|
|
529
|
+
this.sessionPromises.clear();
|
|
391
530
|
this.briefLogged.clear();
|
|
392
531
|
this.currentSession = null;
|
|
393
532
|
}
|
package/apps/studio/acp/index.ts
CHANGED
|
@@ -185,8 +185,16 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
185
185
|
const safe = id.replace(/[^a-z0-9_-]/gi, '').slice(0, 64);
|
|
186
186
|
return safe || 'default';
|
|
187
187
|
}
|
|
188
|
+
function chatFilePathFor(chatId: string, suffix: string): string {
|
|
189
|
+
return join(ctx.paths.designRoot, '_chat', `${sanitizeChatId(chatId)}${suffix}`);
|
|
190
|
+
}
|
|
188
191
|
function transcriptPathFor(chatId: string): string {
|
|
189
|
-
return
|
|
192
|
+
return chatFilePathFor(chatId, '.jsonl');
|
|
193
|
+
}
|
|
194
|
+
// Sidecar persisting this chat's ACP sessionId across restarts (bridge.ts
|
|
195
|
+
// sessionFor's resume path) — the cross-restart memory gap tracked in DDR-125.
|
|
196
|
+
function sessionStorePathFor(chatId: string): string {
|
|
197
|
+
return chatFilePathFor(chatId, '.session.json');
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
async function handlePrompt(
|
|
@@ -198,6 +206,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
198
206
|
): Promise<void> {
|
|
199
207
|
const bridge = getOrCreateBridge(ws);
|
|
200
208
|
bridge.setTranscriptPath(transcriptPathFor(chatId));
|
|
209
|
+
bridge.setSessionStorePath(sessionStorePathFor(chatId));
|
|
201
210
|
bridge.setConfig(model, effort);
|
|
202
211
|
try {
|
|
203
212
|
await bridge.ensureStarted();
|
|
@@ -226,6 +235,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
226
235
|
effort: AcpEffort
|
|
227
236
|
): Promise<void> {
|
|
228
237
|
const bridge = getOrCreateBridge(ws);
|
|
238
|
+
bridge.setSessionStorePath(sessionStorePathFor(chatId));
|
|
229
239
|
bridge.setConfig(model, effort);
|
|
230
240
|
try {
|
|
231
241
|
await bridge.warmUp(sanitizeChatId(chatId));
|
package/apps/studio/api.ts
CHANGED
|
@@ -242,6 +242,9 @@ export interface Api {
|
|
|
242
242
|
// Persist a clipboard-pasted ACP composer image → runtime `_chat/attachments/`,
|
|
243
243
|
// returns an absolute path (Phase 31 follow-up — POST /_api/acp/attachment).
|
|
244
244
|
saveChatAttachment(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
245
|
+
// Resolve a content-addressed attachment name (`<sha8>.<ext>`) to its absolute
|
|
246
|
+
// path, or null (GET /_api/acp/attachment — the read side of the pair above).
|
|
247
|
+
resolveChatAttachment(name: unknown): Promise<string | null>;
|
|
245
248
|
// Create a blank brief board from the browser (Phase 22 — POST /_api/canvas)
|
|
246
249
|
createCanvas(input: {
|
|
247
250
|
name?: unknown;
|
|
@@ -1081,6 +1084,23 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1081
1084
|
return { ok: true, path: fileAbs };
|
|
1082
1085
|
}
|
|
1083
1086
|
|
|
1087
|
+
// Read side of saveChatAttachment — resolve a content-addressed attachment
|
|
1088
|
+
// name back to its absolute path under `_chat/attachments/`, or null. The
|
|
1089
|
+
// name is the ONLY input and must match our own `<sha8>.<ext>` shape, so
|
|
1090
|
+
// traversal is impossible by construction; the resolve() assert mirrors the
|
|
1091
|
+
// write side's containment backstop. MAIN-ORIGIN ONLY at the route layer
|
|
1092
|
+
// (the untrusted canvas origin never reaches the serving route).
|
|
1093
|
+
async function resolveChatAttachment(name: unknown): Promise<string | null> {
|
|
1094
|
+
if (typeof name !== 'string' || !/^[0-9a-f]{8}\.(?:png|jpe?g|gif|webp)$/.test(name)) {
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
const dir = path.join(paths.designRoot, '_chat', 'attachments');
|
|
1098
|
+
const fileAbs = path.join(dir, name);
|
|
1099
|
+
if (path.resolve(fileAbs) !== path.join(path.resolve(dir), name)) return null;
|
|
1100
|
+
if (!(await Bun.file(fileAbs).exists())) return null;
|
|
1101
|
+
return fileAbs;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1084
1104
|
// Phase 22 — create a blank brief board from the browser file tree. Wired ONLY
|
|
1085
1105
|
// on the main origin (server.ts startMainServer); the segregated canvas origin
|
|
1086
1106
|
// (DDR-054) never exposes this — an untrusted canvas iframe must not be able to
|
|
@@ -1994,6 +2014,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1994
2014
|
saveAnnotations,
|
|
1995
2015
|
saveAsset,
|
|
1996
2016
|
saveChatAttachment,
|
|
2017
|
+
resolveChatAttachment,
|
|
1997
2018
|
createCanvas,
|
|
1998
2019
|
deleteCanvas,
|
|
1999
2020
|
editCss,
|
|
@@ -8210,6 +8210,7 @@ function App() {
|
|
|
8210
8210
|
: null
|
|
8211
8211
|
}
|
|
8212
8212
|
selected={selected}
|
|
8213
|
+
designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
|
|
8213
8214
|
width={rpSize.w}
|
|
8214
8215
|
resizing={dragSide === 'rp'}
|
|
8215
8216
|
onClose={() => setAssistantOpen(false)}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Native-app only — app.jsx mounts this gated on isNativeApp().
|
|
12
12
|
|
|
13
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
AssistantRuntimeProvider,
|
|
@@ -23,7 +23,14 @@ import {
|
|
|
23
23
|
useThread,
|
|
24
24
|
} from '@assistant-ui/react';
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
activityLabel,
|
|
28
|
+
attachmentName,
|
|
29
|
+
createAcpConnection,
|
|
30
|
+
designImageRefs,
|
|
31
|
+
extractAttachmentRefs,
|
|
32
|
+
makeAcpAdapter,
|
|
33
|
+
} from './acp-runtime.js';
|
|
27
34
|
import { buildChatContext } from './chat-context.js';
|
|
28
35
|
import { Markdown } from './chat-markdown.jsx';
|
|
29
36
|
import ReadinessList, { useReadiness } from './ReadinessList.jsx';
|
|
@@ -243,10 +250,23 @@ async function uploadChatImage(file) {
|
|
|
243
250
|
}
|
|
244
251
|
|
|
245
252
|
// ── message-part renderers ──
|
|
253
|
+
// Assistant text: markdown, plus a thumbnail strip for any designRoot image
|
|
254
|
+
// path the agent mentioned (e.g. "/design:screenshot → Saved to: .design/…png"
|
|
255
|
+
// — DDR-145). The path stays visible in the text; the strip adds the preview.
|
|
246
256
|
function ChatText({ text }) {
|
|
257
|
+
const media = useContext(ChatMediaContext);
|
|
258
|
+
const refs = designImageRefs(text, media?.designRel);
|
|
247
259
|
return (
|
|
248
260
|
<div className="chat-bubble">
|
|
249
261
|
<Markdown text={text} />
|
|
262
|
+
{refs.length ? (
|
|
263
|
+
<div className="chat-thumbrow">
|
|
264
|
+
{refs.map((src) => {
|
|
265
|
+
const file = src.split('/').pop();
|
|
266
|
+
return <ChatThumb key={src} src={src} label={`Open ${file}`} caption={file} />;
|
|
267
|
+
})}
|
|
268
|
+
</div>
|
|
269
|
+
) : null}
|
|
250
270
|
</div>
|
|
251
271
|
);
|
|
252
272
|
}
|
|
@@ -291,10 +311,157 @@ function ChatToolCard({ toolName, args, result, isError }) {
|
|
|
291
311
|
);
|
|
292
312
|
}
|
|
293
313
|
|
|
314
|
+
// ── chat media (image thumbnails + lightbox) ──
|
|
315
|
+
// Per-thread context so deeply-nested bubbles reach the single lightbox + the
|
|
316
|
+
// paste-attachments map without prop-drilling through assistant-ui's renderers.
|
|
317
|
+
// `chipName(token)` resolves a live chip ([image-1]) to its content-addressed
|
|
318
|
+
// name via the per-chat map; `openLightbox(src)` opens the one overlay.
|
|
319
|
+
const ChatMediaContext = createContext(null);
|
|
320
|
+
|
|
321
|
+
function attachmentSrc(name) {
|
|
322
|
+
return `/_api/acp/attachment?name=${encodeURIComponent(name)}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Thumbnail — a real focusable button (Enter/Space open) wrapping the served
|
|
326
|
+
// image; clicking opens the shared lightbox. `src` is always one of our own
|
|
327
|
+
// same-origin serve lanes (attachment route or designRoot static).
|
|
328
|
+
//
|
|
329
|
+
// `caption` marks a REFERENCED path (an image an assistant message merely named,
|
|
330
|
+
// DDR-145) as distinct from first-class user/agent media — it prints the
|
|
331
|
+
// filename under the thumb so an injected assistant can't fully borrow the
|
|
332
|
+
// feed's authority by rendering an arbitrary designRoot image as if it produced
|
|
333
|
+
// it (attacker F2). Pasted-image thumbs pass no caption.
|
|
334
|
+
function ChatThumb({ src, label = 'Open image', caption }) {
|
|
335
|
+
const media = useContext(ChatMediaContext);
|
|
336
|
+
const btn = (
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
className="chat-thumb-btn"
|
|
340
|
+
aria-label={label}
|
|
341
|
+
onClick={() => media?.openLightbox(src)}
|
|
342
|
+
>
|
|
343
|
+
<img className="chat-thumb" src={src} alt="" loading="lazy" />
|
|
344
|
+
</button>
|
|
345
|
+
);
|
|
346
|
+
if (!caption) return btn;
|
|
347
|
+
return (
|
|
348
|
+
<figure className="chat-thumb-fig">
|
|
349
|
+
{btn}
|
|
350
|
+
<figcaption className="chat-thumb-cap" title={caption}>
|
|
351
|
+
{caption}
|
|
352
|
+
</figcaption>
|
|
353
|
+
</figure>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// One live-bubble chip: an image chip whose upload has resolved renders as a
|
|
358
|
+
// thumbnail; file/link chips (and a still-pending image upload) keep the text
|
|
359
|
+
// badge. The map entry can fill AFTER the bubble first renders (paste + Enter
|
|
360
|
+
// immediately), so poll briefly until it resolves — the adapter awaits the
|
|
361
|
+
// upload before sending, so this settles within the same turn.
|
|
362
|
+
function ChipOrThumb({ token, kind }) {
|
|
363
|
+
const media = useContext(ChatMediaContext);
|
|
364
|
+
const [name, setName] = useState(() => (kind === 'image' ? media?.chipName(token) : null));
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
if (kind !== 'image' || name || !media) return undefined;
|
|
367
|
+
let tries = 0;
|
|
368
|
+
const id = setInterval(() => {
|
|
369
|
+
const n = media.chipName(token);
|
|
370
|
+
if (n) {
|
|
371
|
+
setName(n);
|
|
372
|
+
clearInterval(id);
|
|
373
|
+
} else if (++tries > 20) {
|
|
374
|
+
clearInterval(id);
|
|
375
|
+
}
|
|
376
|
+
}, 250);
|
|
377
|
+
return () => clearInterval(id);
|
|
378
|
+
}, [kind, name, media, token]);
|
|
379
|
+
if (!name) return <span className="chat-paste-chip">{token}</span>;
|
|
380
|
+
return <ChatThumb src={attachmentSrc(name)} label="Open pasted image" />;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Single fixed overlay for the enlarged image — ESC / backdrop / × close,
|
|
384
|
+
// role="dialog", focus moves to the close button on open and returns to the
|
|
385
|
+
// invoking thumbnail on close (mirrors tour/overlay.jsx). The capture-phase
|
|
386
|
+
// key listener runs only while open, so it never collides with the composer's
|
|
387
|
+
// onKeyDownCapture.
|
|
388
|
+
function ChatLightbox({ src, onClose }) {
|
|
389
|
+
const closeRef = useRef(null);
|
|
390
|
+
const prevFocus = useRef(null);
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
prevFocus.current = document.activeElement;
|
|
393
|
+
closeRef.current?.focus();
|
|
394
|
+
function onKey(e) {
|
|
395
|
+
if (e.key === 'Escape') {
|
|
396
|
+
e.preventDefault();
|
|
397
|
+
e.stopPropagation();
|
|
398
|
+
onClose();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
window.addEventListener('keydown', onKey, true);
|
|
402
|
+
return () => {
|
|
403
|
+
window.removeEventListener('keydown', onKey, true);
|
|
404
|
+
try {
|
|
405
|
+
prevFocus.current?.focus?.();
|
|
406
|
+
} catch {
|
|
407
|
+
/* trigger unmounted — non-fatal */
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}, [onClose]);
|
|
411
|
+
return (
|
|
412
|
+
<div
|
|
413
|
+
className="chat-lightbox"
|
|
414
|
+
role="dialog"
|
|
415
|
+
aria-modal="true"
|
|
416
|
+
aria-label="Pasted image"
|
|
417
|
+
onClick={onClose}
|
|
418
|
+
>
|
|
419
|
+
<img src={src} alt="pasted image, enlarged" onClick={(e) => e.stopPropagation()} />
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
ref={closeRef}
|
|
423
|
+
className="chat-lightbox-close"
|
|
424
|
+
aria-label="Close image"
|
|
425
|
+
title="Close image"
|
|
426
|
+
onClick={onClose}
|
|
427
|
+
>
|
|
428
|
+
×
|
|
429
|
+
</button>
|
|
430
|
+
</div>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
294
434
|
// User bubble keeps the collapsed chips in the transcript (Claude Code shows the
|
|
295
|
-
// placeholder in history too — the real path went to the agent, not the log)
|
|
435
|
+
// placeholder in history too — the real path went to the agent, not the log),
|
|
436
|
+
// EXCEPT images, which render as clickable thumbnails in a strip UNDER the text
|
|
437
|
+
// (inline they'd break the message's reading flow). Two spellings resolve to the
|
|
438
|
+
// same thumbnail: a live chip ([image-1] → per-chat map) and the reloaded
|
|
439
|
+
// transcript's expanded `_chat/attachments/` path (never printed raw).
|
|
296
440
|
function UserBubble({ text }) {
|
|
297
|
-
|
|
441
|
+
const segs = extractAttachmentRefs(text);
|
|
442
|
+
const inline = [];
|
|
443
|
+
const images = [];
|
|
444
|
+
segs.forEach((seg, i) => {
|
|
445
|
+
if (seg.type === 'text') inline.push(<span key={`t-${i}`}>{seg.text}</span>);
|
|
446
|
+
else if (seg.type === 'chip' && seg.kind !== 'image')
|
|
447
|
+
inline.push(
|
|
448
|
+
<span className="chat-paste-chip" key={`c-${i}`}>
|
|
449
|
+
{seg.token}
|
|
450
|
+
</span>
|
|
451
|
+
);
|
|
452
|
+
else if (seg.type === 'chip')
|
|
453
|
+
images.push(<ChipOrThumb key={`c-${i}`} token={seg.token} kind={seg.kind} />);
|
|
454
|
+
else
|
|
455
|
+
images.push(
|
|
456
|
+
<ChatThumb key={`a-${i}`} src={attachmentSrc(seg.name)} label="Open pasted image" />
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
return (
|
|
460
|
+
<div className="chat-bubble">
|
|
461
|
+
{inline}
|
|
462
|
+
{images.length ? <div className="chat-thumbrow">{images}</div> : null}
|
|
463
|
+
</div>
|
|
464
|
+
);
|
|
298
465
|
}
|
|
299
466
|
|
|
300
467
|
function UserMessage() {
|
|
@@ -904,6 +1071,7 @@ function ChatThread({
|
|
|
904
1071
|
effortRef,
|
|
905
1072
|
activeCanvas,
|
|
906
1073
|
selected,
|
|
1074
|
+
designRel,
|
|
907
1075
|
model,
|
|
908
1076
|
setModel,
|
|
909
1077
|
effort,
|
|
@@ -948,6 +1116,17 @@ function ChatThread({
|
|
|
948
1116
|
[conn, chatId, modelRef, effortRef]
|
|
949
1117
|
);
|
|
950
1118
|
const runtime = useLocalRuntime(adapter, { initialMessages });
|
|
1119
|
+
// Image thumbnails + lightbox — one overlay per thread; bubbles reach it (and
|
|
1120
|
+
// the chip → attachment-name resolution) through ChatMediaContext.
|
|
1121
|
+
const [lightboxSrc, setLightboxSrc] = useState(null);
|
|
1122
|
+
const media = useMemo(
|
|
1123
|
+
() => ({
|
|
1124
|
+
chipName: (token) => attachmentName(attachmentsRef.current.map.get(token) || ''),
|
|
1125
|
+
openLightbox: setLightboxSrc,
|
|
1126
|
+
designRel,
|
|
1127
|
+
}),
|
|
1128
|
+
[designRel]
|
|
1129
|
+
);
|
|
951
1130
|
const [activeTools, setActiveTools] = useState([]);
|
|
952
1131
|
useEffect(() => conn.onActivity(setActiveTools), [conn]);
|
|
953
1132
|
// Post-turn-end continuation (the tail the client used to drop — RCA F2).
|
|
@@ -956,6 +1135,7 @@ function ChatThread({
|
|
|
956
1135
|
|
|
957
1136
|
return (
|
|
958
1137
|
<AssistantRuntimeProvider runtime={runtime}>
|
|
1138
|
+
<ChatMediaContext.Provider value={media}>
|
|
959
1139
|
<div className="chat-panel" style={hidden ? { display: 'none' } : undefined}>
|
|
960
1140
|
<StatusRow tools={activeTools} />
|
|
961
1141
|
<ThreadPrimitive.Root className="chat-thread">
|
|
@@ -984,7 +1164,11 @@ function ChatThread({
|
|
|
984
1164
|
attachmentsRef={attachmentsRef}
|
|
985
1165
|
/>
|
|
986
1166
|
</ThreadPrimitive.Root>
|
|
1167
|
+
{lightboxSrc ? (
|
|
1168
|
+
<ChatLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
|
1169
|
+
) : null}
|
|
987
1170
|
</div>
|
|
1171
|
+
</ChatMediaContext.Provider>
|
|
988
1172
|
</AssistantRuntimeProvider>
|
|
989
1173
|
);
|
|
990
1174
|
}
|
|
@@ -993,6 +1177,7 @@ function ChatThread({
|
|
|
993
1177
|
export default function ChatPanel({
|
|
994
1178
|
activeCanvas,
|
|
995
1179
|
selected,
|
|
1180
|
+
designRel,
|
|
996
1181
|
width,
|
|
997
1182
|
resizing,
|
|
998
1183
|
onClose,
|
|
@@ -1344,6 +1529,7 @@ export default function ChatPanel({
|
|
|
1344
1529
|
effortRef={effortRef}
|
|
1345
1530
|
activeCanvas={activeCanvas}
|
|
1346
1531
|
selected={selected}
|
|
1532
|
+
designRel={designRel}
|
|
1347
1533
|
model={model}
|
|
1348
1534
|
setModel={setModel}
|
|
1349
1535
|
effort={effort}
|