Compare commits

..
Author SHA1 Message Date
Miguel Palhas e9c9e1c647 Merge remote-tracking branch 'origin/main' into staccato-2 2026-09-08 14:33:26 +01:00
Miguel PalhasandClaude Opus 4.6 4167b21fe9 feat(maestro): render queued messages as blurred chat bubbles
Queued prompts now appear inline in the transcript as faded user bubbles
instead of a pill badge above the composer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 14:33:22 +01:00
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
3 changed files with 106 additions and 64 deletions
+5 -3
View File
@@ -824,7 +824,9 @@ describe("structured connection: turn/composer gating", () => {
expect(send.disabled).toBe(false);
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
const queued = conn.el.querySelectorAll(".st-row-queued");
expect(queued).toHaveLength(1);
expect(queued[0].textContent).toBe("queued next");
conn.dispose();
});
@@ -844,7 +846,7 @@ describe("structured connection: turn/composer gating", () => {
submit("second");
submit("third");
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(2);
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
@@ -853,7 +855,7 @@ describe("structured connection: turn/composer gating", () => {
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(0);
conn.dispose();
});
+76 -49
View File
@@ -117,7 +117,6 @@ export function createStructuredConnection(
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
</button>
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
</form>
</div>
</div>
@@ -139,7 +138,6 @@ export function createStructuredConnection(
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
const queueEl = el.querySelector(".structured-queue") as HTMLSpanElement;
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
@@ -253,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<string, string>();
// Within 64px, wheel and touch imprecision should still count as following.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -383,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,
@@ -399,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;
}
@@ -486,7 +522,6 @@ export function createStructuredConnection(
const liveGroupIds = new Set([...groups.keys()]);
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
const prevGroupEls = new Map(groupEls);
rowEls.clear();
groupEls.clear();
const children: HTMLElement[] = [];
@@ -505,37 +540,23 @@ export function createStructuredConnection(
rowEls.set(memberRow.id, memberEl);
return [memberEl];
});
// Reuse existing group element when possible — avoids tearing down
// and recreating the wrapper, which restarts CSS transitions (visible
// flash) and destroys keyboard/focus state.
const existing = prevGroupEls.get(groupId);
if (existing) {
const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title");
if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`;
const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner");
if (inner) inner.replaceChildren(...memberEls);
groupEls.set(groupId, existing);
children.push(existing);
} else {
const groupEl = buildToolGroup(
groupId,
group.rows,
memberEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
group.toolCount,
);
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
const groupEl = buildToolGroup(
groupId,
group.rows,
memberEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
settleDisclosureLayout();
},
group.toolCount,
);
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
groupEl.classList.add("st-row-enter");
}
groupEls.set(groupId, groupEl);
children.push(groupEl);
continue;
}
// Resolved approvals not absorbed into a tool group are hidden — the
@@ -650,14 +671,12 @@ export function createStructuredConnection(
}
}
let configDirty = false;
function renderConfig(): void {
const options = sessionConfigOptions();
configEl.hidden = options.length === 0;
if (options.length === 0) {
configMenu.hidden = true;
configTrigger.setAttribute("aria-expanded", "false");
configDirty = false;
return;
}
const ordered = [...options].sort((a, b) => {
@@ -671,15 +690,6 @@ export function createStructuredConnection(
configTrigger.title = summary;
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
configTrigger.disabled = configBusy;
// When the menu is open and the user is interacting (not mid-change),
// skip the DOM rebuild so open <select> dropdowns aren't destroyed.
// Mark dirty so the next open rebuilds with fresh data.
if (!configMenu.hidden && !configBusy) {
configDirty = true;
return;
}
configDirty = false;
configMenu.replaceChildren();
for (const option of ordered) {
const row = document.createElement("label");
@@ -718,6 +728,22 @@ export function createStructuredConnection(
}
}
function renderQueuedPrompts(): void {
for (const old of rowsEl.querySelectorAll(".st-row-queued")) old.remove();
for (const queued of promptQueue) {
const wrap = document.createElement("div");
wrap.className = "st-row st-row-queued";
const body = document.createElement("div");
body.className = "st-body";
const line = document.createElement("div");
line.className = "st-user-text";
line.textContent = queued.text;
body.appendChild(line);
wrap.appendChild(body);
rowsEl.appendChild(wrap);
}
}
function renderComposerState(): void {
const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = false;
@@ -728,10 +754,7 @@ export function createStructuredConnection(
sendBtn.hidden = false;
sendLabel.textContent = "Send";
cancelBtn.hidden = !projectedActive;
queueEl.hidden = promptQueue.length === 0;
queueEl.textContent = promptQueue.length === 1
? "1 message queued"
: `${promptQueue.length} messages queued`;
renderQueuedPrompts();
renderActivity();
}
@@ -811,7 +834,6 @@ export function createStructuredConnection(
configTrigger.addEventListener("click", () => {
const opening = configMenu.hidden;
if (opening && configDirty) renderConfig();
configMenu.hidden = !opening;
configTrigger.setAttribute("aria-expanded", String(opening));
});
@@ -1018,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 }) =>
@@ -1257,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);
+25 -12
View File
@@ -2375,11 +2375,36 @@ input.leaf-rename:focus {
background: var(--surface-2);
}
/* Queued messages: optimistic user bubbles, not yet sent. */
.st-row-queued {
justify-content: flex-end;
pointer-events: none;
}
.st-row-queued .st-body {
flex: 0 1 auto;
max-width: min(86%, 72ch);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-2);
background: var(--surface-3);
opacity: 0.45;
filter: blur(0.5px);
}
.st-user-text {
white-space: pre-wrap;
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);
@@ -3112,18 +3137,6 @@ input.leaf-rename:focus {
transition: border-color var(--dur-fast) var(--ease-out);
}
.structured-queue {
position: absolute;
right: var(--sp-2);
bottom: calc(100% + var(--sp-1));
padding: 3px var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-pill);
background: var(--surface);
color: var(--muted);
font-size: var(--fs-11);
box-shadow: var(--shadow-1);
}
.structured-composer:focus-within {
border-top-color: var(--border);