fix(attachments): unify kind-icon, fix coordinator audio pill + thumbnail-error gap

Three copies of the kind->glyph mapping had drifted: the coordinator pill rendered audio as the document glyph (not the audio note) and showed no inline preview, diverging from the interactive pane.

Export kindIcon() from composer_attachments.js (+ window bridge) as the single source of truth; the interactive pane imports it and the coordinator pill uses it. Wire the coordinator pill to buildAttachmentPreview too (image/pdf thumbnail, audio player), gracefully no-oping on history replay (which omits attachment_id), matching interactive.

Also fix buildAttachmentPreview's thumbnail-error handler: it called img.remove(), but the caller has already replaced the icon span with the img, so a failed thumbnail left a blank gap. Swap in the kind glyph instead (.attach-preview-icon, sized to the thumbnail slot).
This commit is contained in:
Patrick Buckley
2026-06-15 20:46:33 -07:00
parent 797a8e0404
commit 8ca20fecd4
4 changed files with 65 additions and 14 deletions
@@ -778,13 +778,38 @@ function createCoordinatorPane(root, wsId, opts) {
const icon = document.createElement("span");
icon.className = "msg-user-attach-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = kind === "image" ? "🖼" : "📄";
icon.textContent =
typeof window.kindIcon === "function"
? window.kindIcon(kind)
: kind === "image"
? "🖼"
: kind === "audio"
? "🎵"
: "📄";
pill.appendChild(icon);
const name = document.createElement("span");
name.className = "msg-user-attach-name";
name.textContent =
(a && a.filename) || (kind === "image" ? "image" : "document");
(a && a.filename) ||
(kind === "image" ? "image" : kind === "audio" ? "audio" : "document");
pill.appendChild(name);
// Inline preview (image/pdf thumbnail, audio player) — the same affordance
// the interactive pane renders, shared via the buildAttachmentPreview
// window bridge composer_attachments.js installs. No-ops on history
// replay (the /history projection omits attachment_id), matching interactive.
const prev =
typeof window.buildAttachmentPreview === "function"
? window.buildAttachmentPreview({
kind: kind,
wsId: wsId,
attachmentId: a && a.attachment_id,
filename: a && a.filename,
})
: null;
if (prev) {
if (kind === "image" || kind === "pdf") icon.replaceWith(prev);
else pill.appendChild(prev);
}
pills.appendChild(pill);
});
el.appendChild(pills);
+17
View File
@@ -88,6 +88,18 @@
background: var(--bg-surface);
flex: 0 0 auto;
}
/* Fallback when a thumbnail fails to load: the kind glyph in the same slot the
thumbnail would have occupied (so layout doesn't jump and no blank gap). */
.attach-preview-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: 0 0 auto;
font-size: 16px;
line-height: 1;
}
.attach-preview-audio {
height: 30px;
max-width: min(240px, 100%);
@@ -114,6 +126,11 @@
width: 44px;
height: 44px;
}
.msg-user-attach-pill .attach-preview-icon {
width: 44px;
height: 44px;
font-size: 22px;
}
.msg-user-attach-pill .attach-preview-audio {
height: 32px;
max-width: min(280px, 100%);
@@ -57,7 +57,11 @@ function _attachUrl(wsId, id, suffix) {
);
}
function _kindIcon(kind) {
// Canonical kind → glyph mapping. Exported (and mirrored on `window` below) so
// the interactive pane and the coordinator pill builder share one source of
// truth instead of re-deriving it — the audio (🎵) case in particular has
// drifted before.
export function kindIcon(kind) {
if (kind === "image") return "🖼";
if (kind === "audio") return "🎵";
return "📄"; // pdf + text
@@ -78,9 +82,15 @@ export function buildAttachmentPreview(opts) {
img.decoding = "async";
img.alt = "";
img.src = _attachUrl(wsId, id, "/thumbnail");
// Drop the node if the thumbnail can't render, so the icon shows instead.
// If the thumbnail can't render, swap in the kind glyph rather than removing
// the node: the caller has already replaced the original icon span with this
// img, so a bare remove() would leave a blank gap (no icon at all).
img.addEventListener("error", function () {
img.remove();
var fallback = document.createElement("span");
fallback.className = "attach-preview attach-preview-icon";
fallback.setAttribute("aria-hidden", "true");
fallback.textContent = kindIcon(kind);
img.replaceWith(fallback);
});
return img;
}
@@ -175,7 +185,7 @@ export function createAttachmentController(opts) {
var icon = document.createElement("span");
icon.className = "composer-chip-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = _kindIcon(info.kind);
icon.textContent = kindIcon(info.kind);
chip.appendChild(icon);
var name = document.createElement("span");
@@ -279,7 +289,7 @@ export function createAttachmentController(opts) {
// classify_upload on the server is authoritative — adopt its kind for the
// chip icon, then render the preview now there's a real id.
var swapIcon = chip.querySelector(".composer-chip-icon");
if (swapIcon) swapIcon.textContent = _kindIcon(info.kind);
if (swapIcon) swapIcon.textContent = kindIcon(info.kind);
var name = chip.querySelector(".composer-chip-name");
if (name) {
name.textContent = info.filename || "(unnamed)";
@@ -438,3 +448,4 @@ export function createAttachmentController(opts) {
// this deferred module evaluated). New module code imports instead.
window.createAttachmentController = createAttachmentController;
window.buildAttachmentPreview = buildAttachmentPreview;
window.kindIcon = kindIcon;
+5 -7
View File
@@ -37,7 +37,10 @@ import {
import { authFetch } from "./auth.js";
import { showToast } from "./toast.js";
import { Composer } from "./composer.js";
import { createAttachmentController } from "./composer_attachments.js";
import {
createAttachmentController,
kindIcon,
} from "./composer_attachments.js";
import { createQueueController } from "./composer_queue.js";
import { StatusBar } from "./status_bar.js";
import { streamingRender, streamingRenderFinalize } from "./renderer.js";
@@ -373,12 +376,7 @@ class Pane {
const icon = document.createElement("span");
icon.className = "msg-user-attach-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent =
a.kind === "image"
? "\ud83d\uddbc"
: a.kind === "audio"
? "\ud83c\udfb5"
: "\ud83d\udcc4";
icon.textContent = kindIcon(a.kind);
pill.appendChild(icon);
const nameEl = document.createElement("span");
nameEl.className = "msg-user-attach-name";