feat(ui): render apply_patch multi-file diffs

This commit is contained in:
Shantur Rathore
2026-01-22 22:32:03 +00:00
parent f5d4cb6917
commit 4ea710c735
7 changed files with 248 additions and 2 deletions

View File

@@ -4,6 +4,7 @@ import type { DiffViewMode } from "../../stores/preferences"
import { ToolCallDiffViewer } from "../diff-viewer"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
import { getRelativePath } from "./utils"
import { getCacheEntry } from "../../lib/global-cache"
type CacheHandle = {
get<T>(): T | undefined
@@ -32,8 +33,18 @@ export function createDiffContentRenderer(params: {
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
const themeKey = params.isDark() ? "dark" : "light"
const baseEntryParams = cacheHandle.params() as any
const cacheEntryParams = (() => {
const suffix = typeof options?.cacheKey === "string" ? options.cacheKey.trim() : ""
if (!suffix) return baseEntryParams
return {
...baseEntryParams,
cacheId: `${baseEntryParams.cacheId}:${suffix}`,
}
})()
let cachedHtml: string | undefined
const cached = cacheHandle.get<RenderCache>()
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
const currentMode = diffMode()
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
cachedHtml = cached.html
@@ -83,7 +94,7 @@ export function createDiffContentRenderer(params: {
theme={themeKey}
mode={diffMode()}
cachedHtml={cachedHtml}
cacheEntryParams={cacheHandle.params() as any}
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}

View File

@@ -0,0 +1,197 @@
import { For, Show, createMemo } from "solid-js"
import type { ToolRenderer } from "../types"
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
import type { DiagnosticEntry } from "../diagnostics"
type LspRangePosition = {
line?: number
character?: number
}
type LspRange = {
start?: LspRangePosition
}
type LspDiagnostic = {
message?: string
severity?: number
range?: LspRange
}
type ApplyPatchFile = {
filePath?: string
relativePath?: string
type?: string
diff?: string
}
function normalizePath(value: string): string {
return value.replace(/\\/g, "/")
}
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
if (severity === 1) return "error"
if (severity === 2) return "warning"
return "info"
}
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
return { label: "INFO", icon: "i", rank: 2 }
}
function resolveDiagnosticsKey(
diagnostics: Record<string, LspDiagnostic[] | undefined>,
file: ApplyPatchFile,
): string | undefined {
const absolute = typeof file.filePath === "string" ? normalizePath(file.filePath) : ""
const relative = typeof file.relativePath === "string" ? normalizePath(file.relativePath) : ""
if (absolute && diagnostics[absolute]) return absolute
if (relative && diagnostics[relative]) return relative
if (absolute) {
const direct = Object.keys(diagnostics).find((key) => normalizePath(key) === absolute)
if (direct) return direct
}
if (relative) {
const suffixMatch = Object.keys(diagnostics).find((key) => {
const normalized = normalizePath(key)
return normalized === relative || normalized.endsWith("/" + relative)
})
if (suffixMatch) return suffixMatch
}
return undefined
}
function buildDiagnostics(
diagnostics: Record<string, LspDiagnostic[] | undefined>,
file: ApplyPatchFile,
): DiagnosticEntry[] {
const key = resolveDiagnosticsKey(diagnostics, file)
if (!key) return []
const list = diagnostics[key]
if (!Array.isArray(list) || list.length === 0) return []
const normalizedKey = normalizePath(key)
const entries: DiagnosticEntry[] = []
for (let index = 0; index < list.length; index++) {
const diagnostic = list[index]
if (!diagnostic || typeof diagnostic.message !== "string") continue
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
const severityMeta = getSeverityMeta(tone)
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
entries.push({
id: `${normalizedKey}-${index}-${diagnostic.message}`,
severity: severityMeta.rank,
tone,
label: severityMeta.label,
icon: severityMeta.icon,
message: diagnostic.message,
filePath: normalizedKey,
displayPath: getRelativePath(normalizedKey),
line,
column,
})
}
return entries.sort((a, b) => a.severity - b.severity)
}
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string }) {
return (
<Show when={props.entries.length > 0}>
<div class="tool-call-diagnostics-wrapper">
<div class="tool-call-diagnostics" role="region" aria-label={`Diagnostics ${props.label}`}
>
<div class="tool-call-diagnostics-body" role="list">
<For each={props.entries}>
{(entry) => (
<div class="tool-call-diagnostic-row" role="listitem">
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
<span>{entry.label}</span>
</span>
<span class="tool-call-diagnostic-path" title={entry.filePath}>
{entry.displayPath}
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
</span>
<span class="tool-call-diagnostic-message">{entry.message}</span>
</div>
)}
</For>
</div>
</div>
</div>
</Show>
)
}
export const applyPatchRenderer: ToolRenderer = {
tools: ["apply_patch"],
getAction: () => "Preparing apply_patch...",
getTitle({ toolState }) {
const state = toolState()
if (!state) return undefined
if (state.status === "pending") return getToolName("apply_patch")
const { metadata } = readToolStatePayload(state)
const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as ApplyPatchFile[]) : []
if (files.length > 0) {
return `${getToolName("apply_patch")} (${files.length} file${files.length === 1 ? "" : "s"})`
}
return getToolName("apply_patch")
},
renderBody({ toolState, renderDiff, renderMarkdown }) {
const state = toolState()
if (!state || state.status === "pending") return null
const payload = readToolStatePayload(state)
const files = createMemo(() => {
const list = (payload.metadata as any).files
return Array.isArray(list) ? (list as ApplyPatchFile[]) : []
})
const diagnosticsMap = createMemo(() => {
const value = (payload.metadata as any).diagnostics
return value && typeof value === "object" ? (value as Record<string, LspDiagnostic[] | undefined>) : {}
})
if (files().length === 0) {
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
if (!fallback) return null
return renderMarkdown({ content: fallback, size: "large", disableHighlight: state.status === "running" })
}
return (
<div class="tool-call-apply-patch">
<For each={files()}>
{(file, index) => {
const labelBase = file.relativePath || file.filePath || `File ${index() + 1}`
const diffText = typeof file.diff === "string" ? file.diff : ""
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file))
return (
<div class="tool-call-apply-patch-file">
<Show when={diffText.trim().length > 0}>
{renderDiff(
{ diffText, filePath },
{
label: `Diff · ${getRelativePath(labelBase)}`,
cacheKey: `apply_patch:${labelBase}:${index()}`,
},
)}
</Show>
<DiagnosticsInline entries={entries()} label={labelBase} />
</div>
)
}}
</For>
</div>
)
},
}

View File

@@ -2,6 +2,7 @@ import type { ToolRenderer } from "../types"
import { bashRenderer } from "./bash"
import { defaultRenderer } from "./default"
import { editRenderer } from "./edit"
import { applyPatchRenderer } from "./apply-patch"
import { patchRenderer } from "./patch"
import { readRenderer } from "./read"
import { taskRenderer } from "./task"
@@ -16,6 +17,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [
readRenderer,
writeRenderer,
editRenderer,
applyPatchRenderer,
patchRenderer,
webfetchRenderer,
todoRenderer,

View File

@@ -6,6 +6,7 @@ import { bashRenderer } from "./renderers/bash"
import { readRenderer } from "./renderers/read"
import { writeRenderer } from "./renderers/write"
import { editRenderer } from "./renderers/edit"
import { applyPatchRenderer } from "./renderers/apply-patch"
import { patchRenderer } from "./renderers/patch"
import { webfetchRenderer } from "./renderers/webfetch"
import { todoRenderer } from "./renderers/todo"
@@ -16,6 +17,7 @@ const TITLE_RENDERERS: Record<string, ToolRenderer> = {
read: readRenderer,
write: writeRenderer,
edit: editRenderer,
apply_patch: applyPatchRenderer,
patch: patchRenderer,
webfetch: webfetchRenderer,
todowrite: todoRenderer,

View File

@@ -26,6 +26,11 @@ export interface DiffRenderOptions {
variant?: string
disableScrollTracking?: boolean
label?: string
/**
* Optional cache key suffix to avoid collisions when rendering multiple diffs
* within the same tool call (e.g. apply_patch).
*/
cacheKey?: string
}
export interface ToolScrollHelpers {

View File

@@ -51,6 +51,8 @@ export function getToolIcon(tool: string): string {
return "📁"
case "patch":
return "🔧"
case "apply_patch":
return "🔧"
default:
return "🔧"
}
@@ -67,6 +69,8 @@ export function getToolName(tool: string): string {
case "todowrite":
case "todoread":
return "Plan"
case "apply_patch":
return "Apply patch"
default: {
const normalized = tool.replace(/^opencode_/, "")
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
@@ -220,6 +224,8 @@ export function getDefaultToolAction(toolName: string) {
return "Planning..."
case "patch":
return "Preparing patch..."
case "apply_patch":
return "Preparing apply_patch..."
default:
return "Working..."
}

View File

@@ -217,6 +217,16 @@
@apply flex items-center justify-between gap-3 px-3 py-2;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
position: sticky;
top: 0;
z-index: 2;
}
/* Diff shell already provides the scroll container.
Avoid nested scroll areas inside the diff viewer. */
.tool-call-diff-shell .tool-call-diff-viewer {
max-height: none;
overflow: visible;
}
.tool-call-diff-toolbar-label {
@@ -423,6 +433,19 @@
background-clip: padding-box;
}
/* apply_patch multi-file layout */
.tool-call-apply-patch {
@apply flex flex-col;
}
.tool-call-apply-patch-file {
margin-top: 0.75rem;
}
.tool-call-apply-patch-file:first-child {
margin-top: 0;
}
.tool-call-section h4 {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);