@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.
@@ -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
- /** Get-or-create the ACP session for a chat id (one claude context per chat). */
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
- const created = await this.conn.newSession(
196
- newSessionParams(this.opts.repoRoot, this.opts.studioBrief, this.opts.plugins)
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
  }
@@ -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 join(ctx.paths.designRoot, '_chat', `${sanitizeChatId(chatId)}.jsonl`);
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));
@@ -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 { activityLabel, createAcpConnection, makeAcpAdapter } from './acp-runtime.js';
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
- return <div className="chat-bubble">{chipNodes(text, 'ub')}</div>;
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}