From f36b7808a788e15ebf150939a4ed3ac0585d4706 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 8 Sep 2026 14:32:42 +0100 Subject: [PATCH] feat(maestro): show image previews in user message bubbles Uploaded images now render as thumbnails in the user bubble instead of showing only the server file path as text. Co-Authored-By: Claude Opus 4.6 --- web/src/structured.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ web/src/styles.css | 9 +++++++++ 2 files changed, 52 insertions(+) diff --git a/web/src/structured.ts b/web/src/structured.ts index b8985f1..bbedacd 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -253,6 +253,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; @@ -383,6 +385,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, @@ -399,6 +404,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; } @@ -991,6 +1029,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 }) => @@ -1230,6 +1271,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 dbf0132..0d098cc 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2380,6 +2380,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);