diff --git a/web/src/structured.ts b/web/src/structured.ts index 8c9d7eb..288d8b9 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -251,6 +251,8 @@ export function createStructuredConnection( let activityTimer: number | null = null; const promptQueue: QueuedPrompt[] = []; let promptRequestPending = false; + /** Server path → object URL for uploaded images, so user bubbles can show previews. */ + const imageObjectUrls = new Map(); // Within 64px, wheel and touch imprecision should still count as following. const FOLLOW_BOTTOM_THRESHOLD_PX = 64; @@ -381,6 +383,9 @@ export function createStructuredConnection( } } + /** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */ + const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g; + function buildRowElement(row: TranscriptRow): HTMLElement | null { const el = buildTranscriptRow( row, @@ -397,6 +402,39 @@ export function createStructuredConnection( if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") { expandedRowIds.delete(row.id); } + // Inject image previews into user message bubbles and strip the path text. + if (row.kind === "user" && imageObjectUrls.size > 0) { + const data = row.data as { content?: { type: string; text?: string } } | undefined; + const text = data?.content?.type === "text" ? data.content.text ?? "" : ""; + const body = el.querySelector(".st-body"); + if (body && text) { + const images: string[] = []; + attachedFileRe.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = attachedFileRe.exec(text)) !== null) { + const serverPath = JSON.parse(`"${match[1]}"`); + const objectUrl = imageObjectUrls.get(serverPath); + if (objectUrl) images.push(objectUrl); + } + if (images.length > 0) { + // Strip attachment reference lines from the displayed text. + const userText = body.querySelector(".st-user-text"); + if (userText) { + const cleaned = text.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim(); + userText.textContent = cleaned; + if (!cleaned) userText.remove(); + } + for (const src of images) { + const img = document.createElement("img"); + img.className = "st-user-image"; + img.src = src; + img.alt = "Uploaded image"; + img.loading = "lazy"; + body.appendChild(img); + } + } + } + } return el; } @@ -1002,6 +1040,9 @@ export function createStructuredConnection( for (const attachment of submittedAttachments) { if (disposed) return; if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path; + if (attachment.path && attachment.file.type.startsWith("image/")) { + imageObjectUrls.set(attachment.path, URL.createObjectURL(attachment.file)); + } } if (disposed) return; const references = submittedAttachments.map(({ file, path }) => @@ -1241,6 +1282,8 @@ export function createStructuredConnection( closeCurrentSocket(); expandedRowIds.clear(); expandedGroupIds.clear(); + for (const url of imageObjectUrls.values()) URL.revokeObjectURL(url); + imageObjectUrls.clear(); scrollEl.removeEventListener("scroll", onTranscriptScroll); scrollEl.removeEventListener("click", onTranscriptClick, true); scrollEl.removeEventListener("wheel", stopSmoothFollow); diff --git a/web/src/styles.css b/web/src/styles.css index 75a6f48..1528074 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2396,6 +2396,15 @@ input.leaf-rename:focus { overflow-wrap: anywhere; } +.st-user-image { + display: block; + max-width: min(100%, 480px); + max-height: 360px; + margin-top: var(--sp-2); + border-radius: var(--r-1); + object-fit: contain; +} + /* agent prose: no card, no background — plain text is the point */ .st-row-agent .st-body p { margin: 0 0 var(--sp-2);