@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.
- package/apps/studio/api.ts +21 -0
- package/apps/studio/bin/_ensure-browser.mjs +12 -1
- 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-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/whats-new.json +9 -0
- package/package.json +8 -8
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,
|
|
@@ -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
|
-
|
|
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 {
|
|
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}
|
|
@@ -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
|
|