diff --git a/turnstone/console/static/coordinator/coordinator.js b/turnstone/console/static/coordinator/coordinator.js index ff39b69e..d85cecfa 100644 --- a/turnstone/console/static/coordinator/coordinator.js +++ b/turnstone/console/static/coordinator/coordinator.js @@ -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); diff --git a/turnstone/shared_static/chat.css b/turnstone/shared_static/chat.css index aae2ccb9..fd00649c 100644 --- a/turnstone/shared_static/chat.css +++ b/turnstone/shared_static/chat.css @@ -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%); diff --git a/turnstone/shared_static/composer_attachments.js b/turnstone/shared_static/composer_attachments.js index e3961329..7b954c00 100644 --- a/turnstone/shared_static/composer_attachments.js +++ b/turnstone/shared_static/composer_attachments.js @@ -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; diff --git a/turnstone/shared_static/interactive.js b/turnstone/shared_static/interactive.js index f154f30d..a45b2c93 100644 --- a/turnstone/shared_static/interactive.js +++ b/turnstone/shared_static/interactive.js @@ -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";