diff --git a/.changeset/user-message-image-preview-tab.md b/.changeset/user-message-image-preview-tab.md new file mode 100644 index 00000000000..ec8a663a1ac --- /dev/null +++ b/.changeset/user-message-image-preview-tab.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open images attached to sent chat messages in an editor tab preview instead of a modal, matching the behavior of images attached in the prompt input. diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 108db2ea344..a4116062760 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -757,6 +757,7 @@ export function UserMessageDisplay(props: { onDelete?: () => void onFork?: () => void onRevert?: () => void + onImageClick?: (url: string, filename?: string) => boolean }) { const data = useData() const dialog = useDialog() @@ -818,6 +819,7 @@ export function UserMessageDisplay(props: { }) const openImagePreview = (url: string, alt?: string) => { + if (props.onImageClick?.(url, alt)) return dialog.show(() => ) } diff --git a/packages/kilo-vscode/src/image-preview.ts b/packages/kilo-vscode/src/image-preview.ts index c9abc2d4331..79542320801 100644 --- a/packages/kilo-vscode/src/image-preview.ts +++ b/packages/kilo-vscode/src/image-preview.ts @@ -1,4 +1,5 @@ import * as path from "path" +import { imageMime } from "./shared/image-data-url" const IMAGE_PREVIEW_ID = "imagePreview.previewEditor" const PREVIEW_DIR = "image-preview" @@ -11,14 +12,10 @@ type Preview = { } export function parseImage(dataUrl: string, filename: string): Preview | null { - const sep = dataUrl.indexOf(",") - if (sep === -1) return null - - const head = dataUrl.slice(0, sep) - const mime = head.match(/^data:(image\/[A-Za-z0-9.+-]+);base64$/)?.[1] + const mime = imageMime(dataUrl) if (!mime) return null - const data = parseBase64(dataUrl.slice(sep + 1)) + const data = parseBase64(dataUrl.slice(dataUrl.indexOf(",") + 1)) if (!data) return null const ext = getExt(mime) diff --git a/packages/kilo-vscode/src/shared/image-data-url.ts b/packages/kilo-vscode/src/shared/image-data-url.ts new file mode 100644 index 00000000000..777d530d6c1 --- /dev/null +++ b/packages/kilo-vscode/src/shared/image-data-url.ts @@ -0,0 +1,11 @@ +/** + * The exact shape of data URL the host can turn into a file on disk (see + * `parseImage` in ../image-preview.ts). Shared with the webview so a click + * is only routed to the host preview when the host can actually decode it — + * otherwise the webview keeps its own modal fallback. + */ +const PATTERN = /^data:(image\/[A-Za-z0-9.+-]+);base64,/ + +export function imageMime(url: string): string | undefined { + return url.match(PATTERN)?.[1] +} diff --git a/packages/kilo-vscode/tests/unit/image-preview.test.ts b/packages/kilo-vscode/tests/unit/image-preview.test.ts index 6e52993b032..d65db6adf8e 100644 --- a/packages/kilo-vscode/tests/unit/image-preview.test.ts +++ b/packages/kilo-vscode/tests/unit/image-preview.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "../../src/image-preview" +import { imageMime } from "../../src/shared/image-data-url" describe("parseImage", () => { it("parses png data urls and preserves a clean extension", () => { @@ -28,6 +29,31 @@ describe("parseImage", () => { }) }) +// The webview decides whether to route a click to the host preview or keep +// its own modal fallback by asking imageMime, so the two must agree on every +// url: a url imageMime accepts but parseImage rejects would open nothing. +describe("imageMime", () => { + it("accepts exactly the urls parseImage can decode", () => { + const urls = [ + "data:image/png;base64,aGVsbG8=", + "data:image/svg+xml;base64,aGVsbG8=", + "data:image/png,hello", + "data:image/png;charset=utf-8;base64,aGVsbG8=", + "data:text/plain;base64,aGVsbG8=", + "https://example.com/screen.png", + "", + ] + + for (const url of urls) { + expect([url, !!imageMime(url)]).toEqual([url, parseImage(url, "screen.png") !== null]) + } + }) + + it("returns the image mime type", () => { + expect(imageMime("data:image/svg+xml;base64,aGVsbG8=")).toBe("image/svg+xml") + }) +}) + describe("buildPreviewPath", () => { it("writes previews into a dedicated storage folder", () => { expect(buildPreviewPath("screen.png", 42)).toBe("image-preview/42-screen.png") diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeUserMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeUserMessage.tsx index c4edb9b52c6..8f0eb5fd7b8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeUserMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeUserMessage.tsx @@ -1,10 +1,12 @@ import { createMemo, Show, type Component } from "solid-js" import { UserMessageDisplay } from "@kilocode/kilo-ui/message-part" import { partFeedback } from "../../../../src/shared/browser-feedback" +import { imageMime } from "../../../../src/shared/image-data-url" import type { Message, Part, TextPart } from "../../types/messages" import { BrowserReferences } from "./BrowserReferences" import { ReviewComments } from "./ReviewComments" import { useLanguage } from "../../context/language" +import { useVSCode } from "../../context/vscode" interface VscodeUserMessageProps { message: Message @@ -21,6 +23,7 @@ interface VscodeUserMessageProps { export const VscodeUserMessage: Component = (props) => { const language = useLanguage() + const vscode = useVSCode() const text = createMemo(() => props.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)) const feedback = createMemo(() => { const part = text() @@ -60,6 +63,14 @@ export const VscodeUserMessage: Component = (props) => { onDelete={props.onDelete} onFork={props.onFork} onRevert={props.onRevert} + onImageClick={(dataUrl, filename) => { + // Only claim the click when the host can decode the image; anything + // else (remote URLs, non-base64 data URLs) keeps the modal fallback + // rather than opening nothing at all. + if (!imageMime(dataUrl)) return false + vscode.postMessage({ type: "previewImage", dataUrl, filename: filename || "image" }) + return true + }} /> ) }