Compare commits

..
1 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 f36b7808a7 feat(maestro): show image previews in user message bubbles
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
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 <noreply@anthropic.com>
2026-09-08 14:32:42 +01:00
4 changed files with 58 additions and 8 deletions
+43
View File
@@ -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<string, string>();
// 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);
+2 -2
View File
@@ -315,8 +315,8 @@ describe("buildTranscriptRow: tool diffs", () => {
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const diff = el.querySelector(".st-diff") as HTMLElement;
expect(diff).not.toBeNull();
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b");
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
});
it("gives the diff its own horizontally-scrolling container", () => {
+4 -5
View File
@@ -223,16 +223,15 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
wrap.appendChild(label);
const scroller = el("div", "st-diff");
const pre = document.createElement("pre");
const lines = computeDiffLines(diff.oldText, diff.newText);
const prefixes: Record<string, string> = { add: "+ ", del: "- ", ctx: " ", hunk: "", eof: "" };
for (const line of lines) {
for (const line of computeDiffLines(diff.oldText, diff.newText)) {
const span = document.createElement("span");
span.className = `st-ln st-ln-${line.kind}`;
span.textContent = (prefixes[line.kind] ?? "") + line.text;
span.textContent = line.text;
pre.appendChild(span);
pre.appendChild(document.createTextNode("\n"));
}
scroller.appendChild(pre);
makeCopyable(scroller, lines.map((l) => (prefixes[l.kind] ?? "") + l.text).join("\n"), "diff");
makeCopyable(scroller, pre.textContent ?? "", "diff");
wrap.appendChild(scroller);
return wrap;
}
+9 -1
View File
@@ -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);
@@ -2965,7 +2974,6 @@ input.leaf-rename:focus {
display: block;
white-space: pre;
padding: 0 var(--sp-3);
line-height: 1.4;
}
.st-ln-add {