summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-22 11:24:52 -0600
committerAdam <[email protected]>2026-01-22 12:22:10 -0600
commit496bbd70f4afcd2b1fd580e7fdbc5947c257d7a2 (patch)
treea56aae966eea61e5945ea7681889c9ab52320b1c
parent93044cc7d11d406a661278ed7f05065d85cda60a (diff)
downloadopencode-496bbd70f4afcd2b1fd580e7fdbc5947c257d7a2.tar.gz
opencode-496bbd70f4afcd2b1fd580e7fdbc5947c257d7a2.zip
feat(app): render images in session review
-rw-r--r--packages/app/src/pages/session.tsx10
-rw-r--r--packages/ui/src/components/session-review.css26
-rw-r--r--packages/ui/src/components/session-review.tsx216
3 files changed, 202 insertions, 50 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 3a14cf401..719e13f00 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -93,6 +93,15 @@ function SessionReviewTab(props: SessionReviewTabProps) {
let frame: number | undefined
let pending: { x: number; y: number } | undefined
+ const sdk = useSDK()
+
+ const readFile = (path: string) => {
+ return sdk.client.file
+ .read({ path })
+ .then((x) => x.data)
+ .catch(() => undefined)
+ }
+
const restoreScroll = (retries = 0) => {
const el = scroll
if (!el) return
@@ -161,6 +170,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
onViewFile={props.onViewFile}
+ readFile={readFile}
/>
)
}
diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css
index eb6ddb441..e682c19b4 100644
--- a/packages/ui/src/components/session-review.css
+++ b/packages/ui/src/components/session-review.css
@@ -137,4 +137,30 @@
align-items: center;
justify-content: flex-end;
}
+
+ [data-slot="session-review-file-container"] {
+ padding: 0;
+ }
+
+ [data-slot="session-review-image-container"] {
+ padding: 12px;
+ display: flex;
+ justify-content: center;
+ background: var(--background-stronger);
+ }
+
+ [data-slot="session-review-image"] {
+ max-width: 100%;
+ max-height: 60vh;
+ object-fit: contain;
+ border-radius: 8px;
+ border: 1px solid var(--border-weak-base);
+ background: var(--background-base);
+ }
+
+ [data-slot="session-review-image-placeholder"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ color: var(--text-weak);
+ }
}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 27c033f77..4d2123ff0 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -5,12 +5,14 @@ import { DiffChanges } from "./diff-changes"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { StickyAccordionHeader } from "./sticky-accordion-header"
+import { useCodeComponent } from "../context/code"
import { useDiffComponent } from "../context/diff"
import { useI18n } from "../context/i18n"
+import { checksum } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { For, Match, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
-import { type FileDiff } from "@opencode-ai/sdk/v2"
+import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { Dynamic } from "solid-js/web"
@@ -30,11 +32,52 @@ export interface SessionReviewProps {
actions?: JSX.Element
diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> })[]
onViewFile?: (file: string) => void
+ readFile?: (path: string) => Promise<FileContent | undefined>
+}
+
+const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
+
+function getExtension(file: string): string {
+ const idx = file.lastIndexOf(".")
+ if (idx === -1) return ""
+ return file.slice(idx + 1).toLowerCase()
+}
+
+function isImageFile(file: string): boolean {
+ return imageExtensions.has(getExtension(file))
+}
+
+function dataUrl(content: FileContent | undefined): string | undefined {
+ if (!content) return
+ if (content.encoding !== "base64") return
+ const mime = content.mimeType ?? ""
+ if (!mime.startsWith("image/")) return
+ return `data:${mime};base64,${content.content}`
+}
+
+function dataUrlFromValue(value: unknown): string | undefined {
+ if (typeof value === "string") {
+ if (value.startsWith("data:image/")) return value
+ return
+ }
+ if (!value || typeof value !== "object") return
+
+ const content = (value as { content?: unknown }).content
+ const encoding = (value as { encoding?: unknown }).encoding
+ const mimeType = (value as { mimeType?: unknown }).mimeType
+
+ if (typeof content !== "string") return
+ if (encoding !== "base64") return
+ if (typeof mimeType !== "string") return
+ if (!mimeType.startsWith("image/")) return
+
+ return `data:${mimeType};base64,${content}`
}
export const SessionReview = (props: SessionReviewProps) => {
const i18n = useI18n()
const diffComponent = useDiffComponent()
+ const codeComponent = useCodeComponent()
const [store, setStore] = createStore({
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
})
@@ -100,56 +143,129 @@ export const SessionReview = (props: SessionReviewProps) => {
>
<Accordion multiple value={open()} onChange={handleChange}>
<For each={props.diffs}>
- {(diff) => (
- <Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
- <StickyAccordionHeader>
- <Accordion.Trigger>
- <div data-slot="session-review-trigger-content">
- <div data-slot="session-review-file-info">
- <FileIcon node={{ path: diff.file, type: "file" }} />
- <div data-slot="session-review-file-name-container">
- <Show when={diff.file.includes("/")}>
- <span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
- </Show>
- <span data-slot="session-review-filename">{getFilename(diff.file)}</span>
- <Show when={props.onViewFile}>
- <button
- data-slot="session-review-view-button"
- type="button"
- onClick={(e) => {
- e.stopPropagation()
- props.onViewFile?.(diff.file)
- }}
- >
- <Icon name="eye" size="small" />
- </button>
- </Show>
+ {(diff) => {
+ const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
+ const afterText = () => (typeof diff.after === "string" ? diff.after : "")
+
+ const isAdded = () => beforeText().length === 0 && afterText().length > 0
+ const isDeleted = () => afterText().length === 0 && beforeText().length > 0
+ const isImage = () => isImageFile(diff.file)
+
+ const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
+ const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
+ const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
+
+ createEffect(() => {
+ if (!open().includes(diff.file)) return
+ if (!isImage()) return
+ if (imageSrc()) return
+ if (imageStatus() !== "idle") return
+
+ const reader = props.readFile
+ if (!reader) return
+
+ setImageStatus("loading")
+ reader(diff.file)
+ .then((result) => {
+ const src = dataUrl(result)
+ if (!src) {
+ setImageStatus("error")
+ return
+ }
+ setImageSrc(src)
+ setImageStatus("idle")
+ })
+ .catch(() => {
+ setImageStatus("error")
+ })
+ })
+
+ const fileForCode = () => {
+ const contents = afterText() || beforeText()
+ return {
+ name: diff.file,
+ contents,
+ cacheKey: checksum(contents),
+ }
+ }
+
+ return (
+ <Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
+ <StickyAccordionHeader>
+ <Accordion.Trigger>
+ <div data-slot="session-review-trigger-content">
+ <div data-slot="session-review-file-info">
+ <FileIcon node={{ path: diff.file, type: "file" }} />
+ <div data-slot="session-review-file-name-container">
+ <Show when={diff.file.includes("/")}>
+ <span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
+ </Show>
+ <span data-slot="session-review-filename">{getFilename(diff.file)}</span>
+ <Show when={props.onViewFile}>
+ <button
+ data-slot="session-review-view-button"
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation()
+ props.onViewFile?.(diff.file)
+ }}
+ >
+ <Icon name="eye" size="small" />
+ </button>
+ </Show>
+ </div>
+ </div>
+ <div data-slot="session-review-trigger-actions">
+ <DiffChanges changes={diff} />
+ <Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
- <div data-slot="session-review-trigger-actions">
- <DiffChanges changes={diff} />
- <Icon name="chevron-grabber-vertical" size="small" />
- </div>
- </div>
- </Accordion.Trigger>
- </StickyAccordionHeader>
- <Accordion.Content data-slot="session-review-accordion-content">
- <Dynamic
- component={diffComponent}
- preloadedDiff={diff.preloaded}
- diffStyle={diffStyle()}
- before={{
- name: diff.file!,
- contents: typeof diff.before === "string" ? diff.before : "",
- }}
- after={{
- name: diff.file!,
- contents: typeof diff.after === "string" ? diff.after : "",
- }}
- />
- </Accordion.Content>
- </Accordion.Item>
- )}
+ </Accordion.Trigger>
+ </StickyAccordionHeader>
+ <Accordion.Content data-slot="session-review-accordion-content">
+ <Switch>
+ <Match when={isImage()}>
+ <div data-slot="session-review-image-container">
+ <Show
+ when={imageSrc()}
+ fallback={
+ <div data-slot="session-review-image-placeholder">
+ <Switch>
+ <Match when={imageStatus() === "loading"}>Loading image...</Match>
+ <Match when={true}>Image preview unavailable</Match>
+ </Switch>
+ </div>
+ }
+ >
+ <img data-slot="session-review-image" src={imageSrc()!} alt={getFilename(diff.file)} />
+ </Show>
+ </div>
+ </Match>
+ <Match when={isAdded() || isDeleted()}>
+ <div data-slot="session-review-file-container">
+ <Dynamic component={codeComponent} file={fileForCode()} overflow="scroll" />
+ </div>
+ </Match>
+ <Match when={true}>
+ <Dynamic
+ component={diffComponent}
+ preloadedDiff={diff.preloaded}
+ diffStyle={diffStyle()}
+ before={{
+ name: diff.file!,
+ contents: beforeText(),
+ }}
+ after={{
+ name: diff.file!,
+ contents: afterText(),
+ }}
+ />
+ </Match>
+ </Switch>
+ </Accordion.Content>
+ </Accordion.Item>
+ )
+ }}
</For>
</Accordion>
</div>