Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/user-message-image-preview-tab.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -818,6 +819,7 @@ export function UserMessageDisplay(props: {
})

const openImagePreview = (url: string, alt?: string) => {
if (props.onImageClick?.(url, alt)) return
dialog.show(() => <ImagePreview src={url} alt={alt} />)
}

Expand Down
9 changes: 3 additions & 6 deletions packages/kilo-vscode/src/image-preview.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions packages/kilo-vscode/src/shared/image-data-url.ts
Original file line number Diff line number Diff line change
@@ -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]
}
26 changes: 26 additions & 0 deletions packages/kilo-vscode/tests/unit/image-preview.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,6 +23,7 @@ interface VscodeUserMessageProps {

export const VscodeUserMessage: Component<VscodeUserMessageProps> = (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()
Expand Down Expand Up @@ -60,6 +63,14 @@ export const VscodeUserMessage: Component<VscodeUserMessageProps> = (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
Comment on lines +66 to +72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may cause a regression with certain data: URLs: we suppress the modal, but the host can't decode the image, so neither preview opens. Here we should keep the modal fallback for URLs the host doesn't support.

@sylwester-liljegren sylwester-liljegren Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 7aff213. The guard was too broad: startsWith("data:") claimed URLs that the host's parseImage rejects (non-base64 data URLs, non-image mime types), so the modal was suppressed and nothing opened at all.

I pulled the host's accept rule into src/shared/image-data-url.ts and now use it on both sides: parseImage matches against it, and the webview only claims the click when imageMime(dataUrl) is truthy. Everything else falls through to the modal as before.

Sharing one matcher instead of duplicating the check keeps the two sides from drifting apart later. tests/unit/image-preview.test.ts now asserts that imageMime accepts exactly the URLs parseImage can decode, so a future change to one without the other fails the test.

}}
/>
)
}
Loading