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
+ }}
/>
)
}