@1agh/maude 0.38.0 → 0.39.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.
@@ -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,
@@ -292,7 +292,18 @@ export async function resolveBrowser({ download = true } = {}) {
292
292
  }
293
293
 
294
294
  // CLI entry — only when invoked directly (not when imported by readiness.ts).
295
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
295
+ // `import.meta.main` is the reliable "am I the entry module?" flag under `bun
296
+ // --compile`: the argv/url string compare below FALSELY matched inside the
297
+ // standalone binary (embedded modules resolve to /$bunfs/root while argv[1] is
298
+ // the binary path), so this block fired on plain import and killed the server
299
+ // with a stray browser-path print + process.exit before Bun.serve ever ran —
300
+ // the v0.38.0 "Starting…" hang (DDR-045-class compiled-path divergence). Bun
301
+ // sets import.meta.main=false for imported modules; Node <24 leaves it
302
+ // undefined, so fall back to the argv compare for the `node`-fallback CLI path
303
+ // in ensure-browser.sh (a non-compiled process where that compare is correct).
304
+ const runDirectly =
305
+ import.meta.main ?? (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href);
306
+ if (runDirectly) {
296
307
  const result = await resolveBrowser({ download: !NO_DOWNLOAD });
297
308
  if (JSON_OUT) {
298
309
  process.stdout.write(
@@ -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}
@@ -390,6 +390,114 @@ function safeJson(value) {
390
390
  }
391
391
  }
392
392
 
393
+ // ── chat-attachment refs (image thumbnails + lightbox) ──
394
+ // A pasted image lives in the feed under two spellings: the LIVE bubble holds
395
+ // the collapsed chip token ([image-1] — resolved via the per-chat attachments
396
+ // map), while a RELOADED bubble (transcript) holds the already-expanded
397
+ // absolute path under `_chat/attachments/`. Both funnel into the same
398
+ // `<sha8>.<ext>` name the GET /_api/acp/attachment route serves. Pure — tested
399
+ // in test/chat-attachments.test.ts.
400
+ const ATTACHMENT_NAME = '[0-9a-f]{8}\\.(?:png|jpe?g|gif|webp)';
401
+
402
+ /**
403
+ * The content-addressed basename when the string is a `_chat/attachments/` path
404
+ * (absolute or relative), else null. Non-attachment paths never match.
405
+ */
406
+ export function attachmentName(absPathOrText) {
407
+ const m = new RegExp(`(?:^|/)_chat/attachments/(${ATTACHMENT_NAME})$`).exec(
408
+ String(absPathOrText || '').trim()
409
+ );
410
+ return m ? m[1] : null;
411
+ }
412
+
413
+ /**
414
+ * Split bubble text into ordered segments the renderer walks:
415
+ * { type:'text', text } — plain run, rendered verbatim
416
+ * { type:'chip', token, kind } — [image|file|link-N] (live bubble)
417
+ * { type:'attachment', name, raw } — expanded _chat/attachments path (reload)
418
+ */
419
+ export function extractAttachmentRefs(text) {
420
+ const s = String(text || '');
421
+ const re = new RegExp(
422
+ `\\[(image|file|link)-\\d+\\]|\\S*/_chat/attachments/(${ATTACHMENT_NAME})`,
423
+ 'g'
424
+ );
425
+ const segs = [];
426
+ let last = 0;
427
+ let m;
428
+ while ((m = re.exec(s))) {
429
+ if (m.index > last) segs.push({ type: 'text', text: s.slice(last, m.index) });
430
+ if (m[1]) segs.push({ type: 'chip', token: m[0], kind: m[1] });
431
+ else segs.push({ type: 'attachment', name: m[2], raw: m[0] });
432
+ last = m.index + m[0].length;
433
+ }
434
+ if (last < s.length) segs.push({ type: 'text', text: s.slice(last) });
435
+ return segs;
436
+ }
437
+
438
+ /**
439
+ * Image paths under the project's design root that an ASSISTANT message text
440
+ * references (e.g. `/design:screenshot` replying "Saved to: .design/_history/…/
441
+ * 001.png") → servable same-origin URLs for the thumbnail strip (DDR-145).
442
+ * Render-only: the main origin already serves designRoot statics with
443
+ * containment; nothing outside `<designRel>/` ever matches, SVG stays excluded
444
+ * (scriptable), and the per-message cap bounds a hostile/hallucinated wall of
445
+ * paths. Returns unique URLs in first-mention order.
446
+ *
447
+ * Containment is enforced client-side, NOT delegated to the server (DDR-145
448
+ * security follow-up): the assistant text is partly untrusted (tool output /
449
+ * indirect injection), so a token carrying a `..` dot-segment or ANY
450
+ * percent-encoding is rejected outright — otherwise `.design/../../etc/x.png`
451
+ * would collapse to `/etc/x.png` in the browser (and `..%2f` would decode
452
+ * server-side), silently widening the fetch surface from designRel to the whole
453
+ * repoRoot. The server's `safePathUnderRoot` stays the backstop; this makes the
454
+ * client guarantee match the docstring instead of leaning on it.
455
+ */
456
+ export function designImageRefs(text, designRel = '.design', cap = 6) {
457
+ const rel = String(designRel || '.design').replace(/^\/+|\/+$/g, '');
458
+ if (!rel) return [];
459
+ const esc = rel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
460
+ // A path token: optional abs/relative prefix, then `<rel>/…/<img>`. Boundaries
461
+ // tolerate the markdown the paths arrive wrapped in (backticks, quotes,
462
+ // parens) and shed trailing punctuation via the lookahead.
463
+ // Bounded quantifiers ({0,256}/{1,256}) keep the per-start scan constant-time —
464
+ // a long failing candidate (attacker-influenceable assistant text) can't drive
465
+ // superlinear backtracking. No real designRoot path segment run approaches 256.
466
+ const re = new RegExp(
467
+ `(?:^|[\\s\`'"(\\[])((?:[^\\s\`'"()\\[\\]]{0,256}/)?${esc}/[^\\s\`'"()\\[\\]]{1,256}?\\.(?:png|jpe?g|gif|webp))(?=[\\s\`'")\\]]|[.,:;!?]|$)`,
468
+ 'gi'
469
+ );
470
+ const urls = [];
471
+ let m;
472
+ while ((m = re.exec(String(text || ''))) && urls.length < cap) {
473
+ const raw = m[1];
474
+ const at = raw.lastIndexOf(`${rel}/`);
475
+ if (at !== 0 && raw[at - 1] !== '/') continue; // `not-.design/x.png` must not match
476
+ const relPath = raw.slice(at);
477
+ const url = `/${relPath}`;
478
+ // Traversal guard — ALLOWLIST the canonical form, don't blocklist escape
479
+ // spellings (DDR-145 security follow-up). Two lanes the browser/server
480
+ // normalize differently:
481
+ // 1. Reject ANY percent-encoding outright — a plain designRoot path in
482
+ // assistant prose never carries `%`, but `..%2f` survives the WHATWG
483
+ // `URL` parse (pathname keeps `%2F` literal) and then the SERVER's
484
+ // `safePathUnderRoot` decodes it out of designRel.
485
+ // 2. Parse exactly as the browser will (WHATWG collapses `..` dot-segments
486
+ // AND rewrites `\`→`/` for http(s)) and require the result byte-identical
487
+ // AND still under `/rel/` — closes `..`, `..\`, and mixed spellings.
488
+ if (relPath.includes('%')) continue;
489
+ let canon;
490
+ try {
491
+ canon = new URL(url, 'http://x').pathname;
492
+ } catch {
493
+ continue;
494
+ }
495
+ if (canon !== url || !canon.startsWith(`/${rel}/`)) continue;
496
+ if (!urls.includes(url)) urls.push(url);
497
+ }
498
+ return urls;
499
+ }
500
+
393
501
  // Expand collapsed paste chips ([image-1]/[file-1]/[link-1]) back to the real
394
502
  // path/URL the user pasted, so Claude receives the actual value while the chat
395
503
  // bubble keeps the compact badge. Unknown tokens (e.g. a stale one the user typed
@@ -1002,15 +1002,137 @@
1002
1002
  flex-shrink: 0;
1003
1003
  }
1004
1004
 
1005
+ /* Pasted-image thumbnail — a focusable button in the user bubble; clicking
1006
+ opens the lightbox. Sized like a chip row, never dominating the bubble. */
1007
+ .chat-thumb-btn {
1008
+ appearance: none;
1009
+ display: inline-block;
1010
+ padding: 0;
1011
+ margin: 2px 0;
1012
+ border: 0;
1013
+ background: transparent;
1014
+ border-radius: var(--radius-sm);
1015
+ cursor: zoom-in;
1016
+ line-height: 0;
1017
+ vertical-align: middle;
1018
+ }
1019
+ .chat-thumb {
1020
+ display: block;
1021
+ max-width: 180px;
1022
+ max-height: 140px;
1023
+ object-fit: cover;
1024
+ border: 1px solid var(--border-default);
1025
+ border-radius: var(--radius-sm);
1026
+ transition: border-color var(--dur-fast, 120ms) ease;
1027
+ }
1028
+ .chat-thumb-btn:hover .chat-thumb {
1029
+ border-color: var(--border-strong);
1030
+ }
1031
+ .chat-thumb-btn:focus-visible {
1032
+ outline: none;
1033
+ }
1034
+ .chat-thumb-btn:focus-visible .chat-thumb {
1035
+ border-color: var(--accent);
1036
+ box-shadow: 0 0 0 3px var(--accent-tint);
1037
+ }
1038
+
1039
+ /* Thumbnail strip under an assistant bubble — every designRoot image path the
1040
+ agent mentioned (DDR-145), previewed without leaving the chat. */
1041
+ .chat-thumbrow {
1042
+ display: flex;
1043
+ flex-wrap: wrap;
1044
+ gap: var(--space-2);
1045
+ margin-top: var(--space-2);
1046
+ }
1047
+ /* A REFERENCED image (assistant merely named the path) — captioned with its
1048
+ filename so it reads as "a file that was referenced", not first-class agent
1049
+ media (attacker F2, DDR-145). */
1050
+ .chat-thumb-fig {
1051
+ margin: 0;
1052
+ display: flex;
1053
+ flex-direction: column;
1054
+ gap: 2px;
1055
+ max-width: 180px;
1056
+ }
1057
+ .chat-thumb-cap {
1058
+ font-family: var(--font-mono);
1059
+ font-size: var(--type-xs);
1060
+ color: var(--fg-3);
1061
+ overflow: hidden;
1062
+ text-overflow: ellipsis;
1063
+ white-space: nowrap;
1064
+ }
1065
+
1066
+ /* Lightbox — single fixed overlay above the whole shell (ESC / backdrop / ×).
1067
+ Scrim mixes the base background so it tracks light/dark themes. z sits above
1068
+ the shell modal band (.gi-modal 9600) but below the tour/topmost layer
1069
+ (10050+) — an enlarged image must never bury the guided tour. */
1070
+ .chat-lightbox {
1071
+ position: fixed;
1072
+ inset: 0;
1073
+ z-index: 9700;
1074
+ display: grid;
1075
+ place-items: center;
1076
+ background: color-mix(in oklch, var(--bg-0) 80%, transparent);
1077
+ animation: chat-lightbox-in 120ms ease;
1078
+ }
1079
+ .chat-lightbox img {
1080
+ max-width: 90vw;
1081
+ max-height: 90vh;
1082
+ object-fit: contain;
1083
+ border: 1px solid var(--border-default);
1084
+ border-radius: var(--radius-sm);
1085
+ background: var(--bg-1);
1086
+ box-shadow: var(--shadow-lg);
1087
+ }
1088
+ .chat-lightbox-close {
1089
+ position: absolute;
1090
+ top: var(--space-4);
1091
+ right: var(--space-4);
1092
+ appearance: none;
1093
+ width: 32px;
1094
+ height: 32px;
1095
+ display: grid;
1096
+ place-items: center;
1097
+ border: 1px solid var(--border-default);
1098
+ border-radius: var(--radius-sm);
1099
+ background: var(--bg-2);
1100
+ color: var(--fg-1);
1101
+ font-size: var(--type-base);
1102
+ line-height: 1;
1103
+ cursor: pointer;
1104
+ }
1105
+ .chat-lightbox-close:hover {
1106
+ color: var(--fg-0);
1107
+ background: var(--bg-3);
1108
+ }
1109
+ .chat-lightbox-close:focus-visible {
1110
+ outline: none;
1111
+ border-color: var(--accent);
1112
+ box-shadow: 0 0 0 3px var(--accent-tint);
1113
+ }
1114
+ @keyframes chat-lightbox-in {
1115
+ from {
1116
+ opacity: 0;
1117
+ }
1118
+ to {
1119
+ opacity: 1;
1120
+ }
1121
+ }
1122
+
1005
1123
  @media (prefers-reduced-motion: reduce) {
1006
1124
  .chat-status-dot--working,
1007
1125
  .chat-tool-dot--run,
1008
1126
  .chat-activity-spin,
1009
1127
  .st-assistant[data-busy='true'],
1010
1128
  .chat-dot--busy,
1011
- .chat-msg {
1129
+ .chat-msg,
1130
+ .chat-lightbox {
1012
1131
  animation: none;
1013
1132
  }
1133
+ .chat-thumb {
1134
+ transition: none;
1135
+ }
1014
1136
  }
1015
1137
  }
1016
1138