feat(web): show attachment thumbnails on queued messages (#936)
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user