feat(web): show attachment thumbnails on queued messages (#936)

This commit is contained in:
Carlos Florêncio
2026-05-18 20:02:30 +01:00
committed by GitHub
parent af68c0a8f5
commit b3e8dcebf8
5 changed files with 254 additions and 13 deletions
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { processFile } from "./file-attachment";
import { openImageInWindow, processFile } from "./file-attachment";
const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -59,3 +59,25 @@ describe("processFile in insecure context (HTTP, no crypto.randomUUID)", () => {
expect(attachment!.preview).toMatch(/^data:image\/png;base64,/);
});
});
describe("openImageInWindow", () => {
it("falls back to image/png when the MIME type is not in the safe allowlist", () => {
let written = "";
const fakeWin = { document: { write: (s: string) => (written = s) } };
const openSpy = vi.spyOn(window, "open").mockReturnValue(fakeWin as unknown as Window);
openImageInWindow('image/png" onerror="alert(1)', "AAAA");
expect(openSpy).toHaveBeenCalledWith("", "_blank", "noopener,noreferrer");
expect(written).toContain("data:image/png;base64,AAAA");
expect(written).not.toContain("onerror");
openSpy.mockRestore();
});
it("passes through a safe MIME type unchanged", () => {
let written = "";
const fakeWin = { document: { write: (s: string) => (written = s) } };
const openSpy = vi.spyOn(window, "open").mockReturnValue(fakeWin as unknown as Window);
openImageInWindow("image/jpeg", "ZZZ");
expect(written).toContain("data:image/jpeg;base64,ZZZ");
openSpy.mockRestore();
});
});
@@ -29,6 +29,22 @@ export function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
/**
* Open a base64-encoded image in a new browser window for full-size viewing.
* MIME type is constrained to PREVIEWABLE_IMAGE_TYPES so a crafted value can't
* break out of the `src` attribute in document.write. `noopener` keeps the
* opened window from accessing window.opener.
*/
export function openImageInWindow(mimeType: string, data: string): void {
const safeMime = PREVIEWABLE_IMAGE_TYPES.includes(mimeType) ? mimeType : "image/png";
const win = window.open("", "_blank", "noopener,noreferrer");
if (win) {
win.document.write(
`<img src="data:${safeMime};base64,${data}" style="max-width:100%;height:auto;" />`,
);
}
}
function isPreviewableImage(mimeType: string): boolean {
return PREVIEWABLE_IMAGE_TYPES.includes(mimeType);
}
@@ -13,6 +13,7 @@ import { useMessageNavigation } from "@/hooks/use-message-navigation";
import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id";
import { linkToTask } from "@/lib/links";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import { openImageInWindow } from "@/components/task/chat/file-attachment";
type ChatMessageProps = {
comment: Message;
@@ -234,15 +235,6 @@ function UserContextBadges({
);
}
function openImageInWindow(mimeType: string, data: string) {
const win = window.open();
if (win) {
win.document.write(
`<img src="data:${mimeType};base64,${data}" style="max-width:100%;height:auto;" />`,
);
}
}
function UserMessageContent({
comment,
showRaw,
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueuedGhostMessage } from "./queued-ghost-message";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
afterEach(() => {
cleanup();
});
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
const ATTACHMENT_1_ALT = "Attachment 1";
function entry(overrides: Partial<QueuedMessage> = {}): QueuedMessage {
return {
id: "q-1",
session_id: "sess-1",
task_id: "task-1",
content: "hello",
plan_mode: false,
queued_at: "2026-05-18T00:00:00Z",
queued_by: "user-1",
...overrides,
};
}
describe("QueuedGhostMessage attachment thumbnails", () => {
it("renders an image thumbnail for image attachments", () => {
render(
<QueuedGhostMessage
entry={entry({
attachments: [{ type: "image", data: PNG_BASE64, mime_type: "image/png" }],
})}
canEdit
onSave={async () => {}}
onRemove={() => {}}
/>,
);
const img = screen.getByAltText(ATTACHMENT_1_ALT) as HTMLImageElement;
expect(img.src).toBe(`data:image/png;base64,${PNG_BASE64}`);
expect(img.className).toContain("cursor-pointer");
});
it("renders a file chip for non-image (resource) attachments", () => {
render(
<QueuedGhostMessage
entry={entry({
content: "",
attachments: [{ type: "resource", data: "ZmlsZQ==", mime_type: "text/plain" }],
})}
canEdit
onSave={async () => {}}
onRemove={() => {}}
/>,
);
expect(screen.getByText("Attachment")).toBeTruthy();
});
it("opens the image via keyboard (Enter) when focused in display mode", () => {
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
render(
<QueuedGhostMessage
entry={entry({
attachments: [{ type: "image", data: PNG_BASE64, mime_type: "image/png" }],
})}
canEdit
onSave={async () => {}}
onRemove={() => {}}
/>,
);
const img = screen.getByAltText(ATTACHMENT_1_ALT) as HTMLImageElement;
expect(img.getAttribute("role")).toBe("button");
expect(img.getAttribute("tabindex")).toBe("0");
fireEvent.keyDown(img, { key: "Enter" });
expect(openSpy).toHaveBeenCalledOnce();
openSpy.mockRestore();
});
it("opens the image in a new window when clicked in display mode", () => {
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
render(
<QueuedGhostMessage
entry={entry({
attachments: [{ type: "image", data: PNG_BASE64, mime_type: "image/png" }],
})}
canEdit
onSave={async () => {}}
onRemove={() => {}}
/>,
);
fireEvent.click(screen.getByAltText(ATTACHMENT_1_ALT));
expect(openSpy).toHaveBeenCalledOnce();
openSpy.mockRestore();
});
it("renders thumbnails read-only in edit mode (no click handler, no cursor-pointer)", () => {
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
render(
<QueuedGhostMessage
entry={entry({
attachments: [{ type: "image", data: PNG_BASE64, mime_type: "image/png" }],
})}
canEdit
onSave={async () => {}}
onRemove={() => {}}
/>,
);
fireEvent.click(screen.getByTitle("Edit queued message"));
const img = screen.getByAltText(ATTACHMENT_1_ALT) as HTMLImageElement;
expect(img.className).not.toContain("cursor-pointer");
fireEvent.click(img);
expect(openSpy).not.toHaveBeenCalled();
openSpy.mockRestore();
});
it("renders no thumbnail row when there are no attachments", () => {
const { container } = render(
<QueuedGhostMessage entry={entry()} canEdit onSave={async () => {}} onRemove={() => {}} />,
);
expect(container.querySelector("img")).toBeNull();
});
});
@@ -1,15 +1,79 @@
"use client";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { IconCheck, IconClock, IconEdit, IconRobot, IconUser, IconX } from "@tabler/icons-react";
import {
IconCheck,
IconClock,
IconEdit,
IconFile,
IconRobot,
IconUser,
IconX,
} from "@tabler/icons-react";
import { toast } from "sonner";
import { Button } from "@kandev/ui";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { QueueEntryNotFoundError } from "@/lib/api/domains/queue-api";
import { openImageInWindow } from "@/components/task/chat/file-attachment";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
type QueuedAttachment = NonNullable<QueuedMessage["attachments"]>[number];
type AttachmentRowProps = {
attachments: QueuedAttachment[];
interactive: boolean;
};
/**
* Renders queued-message attachments as compact thumbnails (images) and chips
* (other resources). Used in both display and edit views; `interactive=false`
* disables the click-to-open behavior so it stays a passive context cue while
* editing the message text.
*/
function AttachmentRow({ attachments, interactive }: AttachmentRowProps) {
if (attachments.length === 0) return null;
const images = attachments.filter((a) => a.type === "image");
const files = attachments.filter((a) => a.type !== "image");
const onImageKeyDown = (att: QueuedAttachment) => (e: React.KeyboardEvent<HTMLImageElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openImageInWindow(att.mime_type, att.data);
}
};
return (
<div className="flex flex-wrap items-center gap-1.5">
{images.map((att, i) => (
/* eslint-disable-next-line @next/next/no-img-element -- base64 data URLs are not compatible with next/image */
<img
key={`img-${i}`}
src={`data:${att.mime_type};base64,${att.data}`}
alt={`Attachment ${i + 1}`}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
className={cn(
"h-10 w-10 rounded-md border border-border object-cover",
interactive &&
"cursor-pointer hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-primary",
)}
onClick={interactive ? () => openImageInWindow(att.mime_type, att.data) : undefined}
onKeyDown={interactive ? onImageKeyDown(att) : undefined}
/>
))}
{files.map((_, i) => (
<span
key={`file-${i}`}
className="inline-flex items-center gap-1.5 rounded-full bg-muted/60 px-2 py-0.5 text-xs text-muted-foreground"
>
<IconFile className="h-3 w-3" />
Attachment
</span>
))}
</div>
);
}
/** Strip internal <kandev-system>...</kandev-system> blocks from display text. */
function stripSystemTags(text: string): string {
return text.replace(/<kandev-system>[\s\S]*?<\/kandev-system>/g, "").trim();
@@ -63,13 +127,22 @@ function SenderChip({ entry }: SenderChipProps) {
type EditViewProps = {
value: string;
saving: boolean;
attachments: QueuedAttachment[];
onChange: (v: string) => void;
onSave: () => void;
onCancel: () => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
};
function EditView({ value, saving, onChange, onSave, onCancel, textareaRef }: EditViewProps) {
function EditView({
value,
saving,
attachments,
onChange,
onSave,
onCancel,
textareaRef,
}: EditViewProps) {
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Escape") {
event.preventDefault();
@@ -81,6 +154,7 @@ function EditView({ value, saving, onChange, onSave, onCancel, textareaRef }: Ed
};
return (
<div className="space-y-2 p-2">
<AttachmentRow attachments={attachments} interactive={false} />
<Textarea
ref={textareaRef}
data-testid="queue-edit-textarea"
@@ -131,6 +205,7 @@ type DisplayViewProps = {
function DisplayView({ entry, canEdit, onStartEdit, onRemove }: DisplayViewProps) {
const visible = stripSystemTags(entry.content);
const display = visible.length > 200 ? visible.slice(0, 200) + "..." : visible;
const attachments = (entry.attachments ?? []) as QueuedAttachment[];
return (
<div className="flex items-start gap-2 px-3 py-2">
<Tooltip>
@@ -143,7 +218,12 @@ function DisplayView({ entry, canEdit, onStartEdit, onRemove }: DisplayViewProps
</Tooltip>
<div className="flex-1 min-w-0 space-y-1">
<SenderChip entry={entry} />
<div className="text-sm text-foreground/80 whitespace-pre-wrap break-words">{display}</div>
<AttachmentRow attachments={attachments} interactive={true} />
{display && (
<div className="text-sm text-foreground/80 whitespace-pre-wrap break-words">
{display}
</div>
)}
</div>
<div className="flex items-center gap-0.5 flex-shrink-0">
{canEdit && (
@@ -259,6 +339,7 @@ export const QueuedGhostMessage = forwardRef<QueuedGhostMessageHandle, QueuedGho
<EditView
value={value}
saving={saving}
attachments={(entry.attachments ?? []) as QueuedAttachment[]}
onChange={setValue}
onSave={handleSave}
onCancel={handleCancel}