From 4ea710c7356bc61ec010547dc3e84fb300c06462 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 22 Jan 2026 22:32:03 +0000 Subject: [PATCH] feat(ui): render apply_patch multi-file diffs --- .../src/components/tool-call/diff-render.tsx | 15 +- .../tool-call/renderers/apply-patch.tsx | 197 ++++++++++++++++++ .../components/tool-call/renderers/index.ts | 2 + .../ui/src/components/tool-call/tool-title.ts | 2 + packages/ui/src/components/tool-call/types.ts | 5 + packages/ui/src/components/tool-call/utils.ts | 6 + .../ui/src/styles/messaging/tool-call.css | 23 ++ 7 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/tool-call/renderers/apply-patch.tsx diff --git a/packages/ui/src/components/tool-call/diff-render.tsx b/packages/ui/src/components/tool-call/diff-render.tsx index 22679733..b76737a7 100644 --- a/packages/ui/src/components/tool-call/diff-render.tsx +++ b/packages/ui/src/components/tool-call/diff-render.tsx @@ -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 | 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() + const cached = getCacheEntry(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 })} diff --git a/packages/ui/src/components/tool-call/renderers/apply-patch.tsx b/packages/ui/src/components/tool-call/renderers/apply-patch.tsx new file mode 100644 index 00000000..acfe349b --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/apply-patch.tsx @@ -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, + 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, + 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 ( + 0}> +
+
+
+ + {(entry) => ( +
+ + {entry.icon} + {entry.label} + + + {entry.displayPath} + :L{entry.line || "-"}:C{entry.column || "-"} + + {entry.message} +
+ )} +
+
+
+
+
+ ) +} + +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) : {} + }) + + 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 ( +
+ + {(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 ( +
+ 0}> + {renderDiff( + { diffText, filePath }, + { + label: `Diff ยท ${getRelativePath(labelBase)}`, + cacheKey: `apply_patch:${labelBase}:${index()}`, + }, + )} + + +
+ ) + }} +
+
+ ) + }, +} diff --git a/packages/ui/src/components/tool-call/renderers/index.ts b/packages/ui/src/components/tool-call/renderers/index.ts index c261a1bb..38807a58 100644 --- a/packages/ui/src/components/tool-call/renderers/index.ts +++ b/packages/ui/src/components/tool-call/renderers/index.ts @@ -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, diff --git a/packages/ui/src/components/tool-call/tool-title.ts b/packages/ui/src/components/tool-call/tool-title.ts index a87d22fb..be983386 100644 --- a/packages/ui/src/components/tool-call/tool-title.ts +++ b/packages/ui/src/components/tool-call/tool-title.ts @@ -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 = { read: readRenderer, write: writeRenderer, edit: editRenderer, + apply_patch: applyPatchRenderer, patch: patchRenderer, webfetch: webfetchRenderer, todowrite: todoRenderer, diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index aa2e9957..22db6903 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -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 { diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts index ac32ba60..6c3510e6 100644 --- a/packages/ui/src/components/tool-call/utils.ts +++ b/packages/ui/src/components/tool-call/utils.ts @@ -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..." } diff --git a/packages/ui/src/styles/messaging/tool-call.css b/packages/ui/src/styles/messaging/tool-call.css index a88b1f0f..c0affd09 100644 --- a/packages/ui/src/styles/messaging/tool-call.css +++ b/packages/ui/src/styles/messaging/tool-call.css @@ -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);