diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 951266a98..e11fedb71 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -113,13 +113,16 @@ /* Image attachments preview */ .chat-attachments { - display: flex; + display: inline-flex; flex-wrap: wrap; gap: 8px; padding: 8px; background: var(--panel); border-radius: 8px; border: 1px solid var(--border); + width: fit-content; + max-width: 100%; + align-self: flex-start; /* Don't stretch in flex column parent */ } .chat-attachment { @@ -135,7 +138,7 @@ .chat-attachment__img { width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; } .chat-attachment__remove { @@ -189,6 +192,32 @@ background: rgba(0, 0, 0, 0.6); } +/* Message images (sent images displayed in chat) */ +.chat-message-images { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.chat-message-image { + max-width: 300px; + max-height: 200px; + border-radius: 8px; + object-fit: contain; + cursor: pointer; + transition: transform 150ms ease-out; +} + +.chat-message-image:hover { + transform: scale(1.02); +} + +/* User message images align right */ +.chat-group.user .chat-message-images { + justify-content: flex-end; +} + /* Compose input row - horizontal layout */ .chat-compose__row { display: flex; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index a78e0ef0a..27dfe62d1 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1303,9 +1303,8 @@ /* Chat compose */ .chat-compose { margin-top: 12px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; + display: flex; + flex-direction: column; gap: 10px; } diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index ea1c7ffda..4a9ccec14 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -13,6 +13,48 @@ import { } from "./message-extract"; import { extractToolCards, renderToolCardSidebar } from "./tool-cards"; +type ImageBlock = { + url: string; + alt?: string; +}; + +function extractImages(message: unknown): ImageBlock[] { + const m = message as Record; + const content = m.content; + const images: ImageBlock[] = []; + + if (Array.isArray(content)) { + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const b = block as Record; + + if (b.type === "image") { + // Handle source object format (from sendChatMessage) + const source = b.source as Record | undefined; + if (source?.type === "base64" && typeof source.data === "string") { + const data = source.data as string; + const mediaType = (source.media_type as string) || "image/png"; + // If data is already a data URL, use it directly + const url = data.startsWith("data:") + ? data + : `data:${mediaType};base64,${data}`; + images.push({ url }); + } else if (typeof b.url === "string") { + images.push({ url: b.url }); + } + } else if (b.type === "image_url") { + // OpenAI format + const imageUrl = b.image_url as Record | undefined; + if (typeof imageUrl?.url === "string") { + images.push({ url: imageUrl.url }); + } + } + } + } + + return images; +} + export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) { return html`
@@ -163,6 +205,25 @@ function isAvatarUrl(value: string): boolean { ); } +function renderMessageImages(images: ImageBlock[]) { + if (images.length === 0) return nothing; + + return html` +
+ ${images.map( + (img) => html` + ${img.alt window.open(img.url, "_blank")} + /> + `, + )} +
+ `; +} + function renderGroupedMessage( message: unknown, opts: { isStreaming: boolean; showReasoning: boolean }, @@ -179,6 +240,8 @@ function renderGroupedMessage( const toolCards = extractToolCards(message); const hasToolCards = toolCards.length > 0; + const images = extractImages(message); + const hasImages = images.length > 0; const extractedText = extractTextCached(message); const extractedThinking = @@ -207,11 +270,12 @@ function renderGroupedMessage( )}`; } - if (!markdown && !hasToolCards) return nothing; + if (!markdown && !hasToolCards && !hasImages) return nothing; return html`
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} + ${renderMessageImages(images)} ${reasoningMarkdown ? html`
${unsafeHTML( toSanitizedMarkdownHtml(reasoningMarkdown),