fix(webchat): improve image paste UI layout and display

- Fix preview container width (use inline-flex + fit-content)
- Fix flex layout conflict in components.css (grid -> flex column)
- Change preview thumbnail to object-fit: contain (no cropping)
- Add image rendering in sent message bubbles
- Add CSS for chat-message-images display

Improves upon #1900
This commit is contained in:
Clawd
2026-01-25 22:46:09 +03:00
committed by Peter Steinberger
parent fabdf2f6f7
commit 9ba4b1e32b
3 changed files with 98 additions and 6 deletions

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
if (b.type === "image") {
// Handle source object format (from sendChatMessage)
const source = b.source as Record<string, unknown> | 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<string, unknown> | undefined;
if (typeof imageUrl?.url === "string") {
images.push({ url: imageUrl.url });
}
}
}
}
return images;
}
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
return html`
<div class="chat-group assistant">
@@ -163,6 +205,25 @@ function isAvatarUrl(value: string): boolean {
);
}
function renderMessageImages(images: ImageBlock[]) {
if (images.length === 0) return nothing;
return html`
<div class="chat-message-images">
${images.map(
(img) => html`
<img
src=${img.url}
alt=${img.alt ?? "Attached image"}
class="chat-message-image"
@click=${() => window.open(img.url, "_blank")}
/>
`,
)}
</div>
`;
}
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`
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${renderMessageImages(images)}
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),