feat(ui): render apply_patch multi-file diffs
This commit is contained in:
@@ -4,6 +4,7 @@ import type { DiffViewMode } from "../../stores/preferences"
|
|||||||
import { ToolCallDiffViewer } from "../diff-viewer"
|
import { ToolCallDiffViewer } from "../diff-viewer"
|
||||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
import { getRelativePath } from "./utils"
|
import { getRelativePath } from "./utils"
|
||||||
|
import { getCacheEntry } from "../../lib/global-cache"
|
||||||
|
|
||||||
type CacheHandle = {
|
type CacheHandle = {
|
||||||
get<T>(): T | undefined
|
get<T>(): T | undefined
|
||||||
@@ -32,8 +33,18 @@ export function createDiffContentRenderer(params: {
|
|||||||
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
||||||
const themeKey = params.isDark() ? "dark" : "light"
|
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
|
let cachedHtml: string | undefined
|
||||||
const cached = cacheHandle.get<RenderCache>()
|
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
|
||||||
const currentMode = diffMode()
|
const currentMode = diffMode()
|
||||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
||||||
cachedHtml = cached.html
|
cachedHtml = cached.html
|
||||||
@@ -83,7 +94,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
theme={themeKey}
|
theme={themeKey}
|
||||||
mode={diffMode()}
|
mode={diffMode()}
|
||||||
cachedHtml={cachedHtml}
|
cachedHtml={cachedHtml}
|
||||||
cacheEntryParams={cacheHandle.params() as any}
|
cacheEntryParams={cacheEntryParams as any}
|
||||||
onRendered={handleDiffRendered}
|
onRendered={handleDiffRendered}
|
||||||
/>
|
/>
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
||||||
|
|||||||
197
packages/ui/src/components/tool-call/renderers/apply-patch.tsx
Normal file
197
packages/ui/src/components/tool-call/renderers/apply-patch.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { ToolRenderer } from "../types"
|
|||||||
import { bashRenderer } from "./bash"
|
import { bashRenderer } from "./bash"
|
||||||
import { defaultRenderer } from "./default"
|
import { defaultRenderer } from "./default"
|
||||||
import { editRenderer } from "./edit"
|
import { editRenderer } from "./edit"
|
||||||
|
import { applyPatchRenderer } from "./apply-patch"
|
||||||
import { patchRenderer } from "./patch"
|
import { patchRenderer } from "./patch"
|
||||||
import { readRenderer } from "./read"
|
import { readRenderer } from "./read"
|
||||||
import { taskRenderer } from "./task"
|
import { taskRenderer } from "./task"
|
||||||
@@ -16,6 +17,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [
|
|||||||
readRenderer,
|
readRenderer,
|
||||||
writeRenderer,
|
writeRenderer,
|
||||||
editRenderer,
|
editRenderer,
|
||||||
|
applyPatchRenderer,
|
||||||
patchRenderer,
|
patchRenderer,
|
||||||
webfetchRenderer,
|
webfetchRenderer,
|
||||||
todoRenderer,
|
todoRenderer,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { bashRenderer } from "./renderers/bash"
|
|||||||
import { readRenderer } from "./renderers/read"
|
import { readRenderer } from "./renderers/read"
|
||||||
import { writeRenderer } from "./renderers/write"
|
import { writeRenderer } from "./renderers/write"
|
||||||
import { editRenderer } from "./renderers/edit"
|
import { editRenderer } from "./renderers/edit"
|
||||||
|
import { applyPatchRenderer } from "./renderers/apply-patch"
|
||||||
import { patchRenderer } from "./renderers/patch"
|
import { patchRenderer } from "./renderers/patch"
|
||||||
import { webfetchRenderer } from "./renderers/webfetch"
|
import { webfetchRenderer } from "./renderers/webfetch"
|
||||||
import { todoRenderer } from "./renderers/todo"
|
import { todoRenderer } from "./renderers/todo"
|
||||||
@@ -16,6 +17,7 @@ const TITLE_RENDERERS: Record<string, ToolRenderer> = {
|
|||||||
read: readRenderer,
|
read: readRenderer,
|
||||||
write: writeRenderer,
|
write: writeRenderer,
|
||||||
edit: editRenderer,
|
edit: editRenderer,
|
||||||
|
apply_patch: applyPatchRenderer,
|
||||||
patch: patchRenderer,
|
patch: patchRenderer,
|
||||||
webfetch: webfetchRenderer,
|
webfetch: webfetchRenderer,
|
||||||
todowrite: todoRenderer,
|
todowrite: todoRenderer,
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export interface DiffRenderOptions {
|
|||||||
variant?: string
|
variant?: string
|
||||||
disableScrollTracking?: boolean
|
disableScrollTracking?: boolean
|
||||||
label?: string
|
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 {
|
export interface ToolScrollHelpers {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export function getToolIcon(tool: string): string {
|
|||||||
return "📁"
|
return "📁"
|
||||||
case "patch":
|
case "patch":
|
||||||
return "🔧"
|
return "🔧"
|
||||||
|
case "apply_patch":
|
||||||
|
return "🔧"
|
||||||
default:
|
default:
|
||||||
return "🔧"
|
return "🔧"
|
||||||
}
|
}
|
||||||
@@ -67,6 +69,8 @@ export function getToolName(tool: string): string {
|
|||||||
case "todowrite":
|
case "todowrite":
|
||||||
case "todoread":
|
case "todoread":
|
||||||
return "Plan"
|
return "Plan"
|
||||||
|
case "apply_patch":
|
||||||
|
return "Apply patch"
|
||||||
default: {
|
default: {
|
||||||
const normalized = tool.replace(/^opencode_/, "")
|
const normalized = tool.replace(/^opencode_/, "")
|
||||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||||
@@ -220,6 +224,8 @@ export function getDefaultToolAction(toolName: string) {
|
|||||||
return "Planning..."
|
return "Planning..."
|
||||||
case "patch":
|
case "patch":
|
||||||
return "Preparing patch..."
|
return "Preparing patch..."
|
||||||
|
case "apply_patch":
|
||||||
|
return "Preparing apply_patch..."
|
||||||
default:
|
default:
|
||||||
return "Working..."
|
return "Working..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,16 @@
|
|||||||
@apply flex items-center justify-between gap-3 px-3 py-2;
|
@apply flex items-center justify-between gap-3 px-3 py-2;
|
||||||
background-color: var(--surface-secondary);
|
background-color: var(--surface-secondary);
|
||||||
border-bottom: 1px solid var(--border-base);
|
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 {
|
.tool-call-diff-toolbar-label {
|
||||||
@@ -423,6 +433,19 @@
|
|||||||
background-clip: padding-box;
|
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 {
|
.tool-call-section h4 {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|||||||
Reference in New Issue
Block a user