diff --git a/apps/web/components/task/chat/file-attachment.test.ts b/apps/web/components/task/chat/file-attachment.test.ts index 29207599b2..9afa5b872d 100644 --- a/apps/web/components/task/chat/file-attachment.test.ts +++ b/apps/web/components/task/chat/file-attachment.test.ts @@ -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(); + }); +}); diff --git a/apps/web/components/task/chat/file-attachment.ts b/apps/web/components/task/chat/file-attachment.ts index 1c835378cc..6245b2453a 100644 --- a/apps/web/components/task/chat/file-attachment.ts +++ b/apps/web/components/task/chat/file-attachment.ts @@ -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( + ``, + ); + } +} + function isPreviewableImage(mimeType: string): boolean { return PREVIEWABLE_IMAGE_TYPES.includes(mimeType); } diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index b3945f6fee..9619b260f0 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -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( - ``, - ); - } -} - function UserMessageContent({ comment, showRaw, diff --git a/apps/web/components/task/chat/queued-ghost-message.test.tsx b/apps/web/components/task/chat/queued-ghost-message.test.tsx new file mode 100644 index 0000000000..fb36f9d374 --- /dev/null +++ b/apps/web/components/task/chat/queued-ghost-message.test.tsx @@ -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 { + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} onRemove={() => {}} />, + ); + expect(container.querySelector("img")).toBeNull(); + }); +}); diff --git a/apps/web/components/task/chat/queued-ghost-message.tsx b/apps/web/components/task/chat/queued-ghost-message.tsx index f44cdbfa2c..142ed07943 100644 --- a/apps/web/components/task/chat/queued-ghost-message.tsx +++ b/apps/web/components/task/chat/queued-ghost-message.tsx @@ -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[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) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openImageInWindow(att.mime_type, att.data); + } + }; + return ( +
+ {images.map((att, i) => ( + /* eslint-disable-next-line @next/next/no-img-element -- base64 data URLs are not compatible with next/image */ + {`Attachment openImageInWindow(att.mime_type, att.data) : undefined} + onKeyDown={interactive ? onImageKeyDown(att) : undefined} + /> + ))} + {files.map((_, i) => ( + + + Attachment + + ))} +
+ ); +} + /** Strip internal ... blocks from display text. */ function stripSystemTags(text: string): string { return text.replace(/[\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; }; -function EditView({ value, saving, onChange, onSave, onCancel, textareaRef }: EditViewProps) { +function EditView({ + value, + saving, + attachments, + onChange, + onSave, + onCancel, + textareaRef, +}: EditViewProps) { const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); @@ -81,6 +154,7 @@ function EditView({ value, saving, onChange, onSave, onCancel, textareaRef }: Ed }; return (
+