# PR Title Implement shared compact split and unified tool-call diff layout --- Fixes #268 # PR Description ## Summary This PR makes tool-call diffs more compact in both `Unified` and `Split` views by reducing wasted horizontal space in line-number gutters and content indentation. ## What changed - introduced a shared compact-diff framework for tool-call diffs - kept mobile-specific policy limited to: - forcing unified mode below the breakpoint - enabling wrap only in mobile unified mode - added mode-specific compact applicators in the diff viewer: - unified applicator - split applicator - reduced gutter width waste by measuring rendered line-number text and tightening column width around it - removed unnecessary right-side content padding - aligned `+` / `-` markers closer to the left edge across both views - simplified cleanup after gatekeeper review by removing extra plumbing and residue ## Screenshots ### Before <img width="581" height="341" alt="image" src="https://github.com/user-attachments/assets/ec47b256-749a-4afc-8879-aaf33f0b46b6" /> ### After <img width="470" height="586" alt="image" src="https://github.com/user-attachments/assets/7258a5a2-47c4-408d-84bc-1b497761c7ad" /> ## Architectural approach This change intentionally uses: - shared policy in `packages/ui/src/components/tool-call/diff-render.tsx` - shared helper/measurement logic in `packages/ui/src/components/diff-viewer.tsx` - mode-specific applicators where unified and split DOM differ - CSS for shared visual spacing and alignment cleanup The goal was to keep the implementation architecturally clean and avoid building separate duplicated compact-diff features for: - mobile vs desktop - unified vs split Instead, the feature shares one compact-diff concept and only diverges where the upstream diff DOM requires separate handling. ## Files changed - `packages/ui/src/components/tool-call/diff-render.tsx` - `packages/ui/src/components/diff-viewer.tsx` - `packages/ui/src/styles/messaging/tool-call.css` - `packages/ui/src/types/message.ts` ## Validation Manual validation was performed in the running UI. Verified manually: - compact unified gutters on mobile - compact unified gutters on desktop - compact split gutters on desktop - tighter operator alignment in both modes Also verified: - `npm run typecheck` passes ## Notes - This PR is intended to address the compact diff layout problem described in the related issue. - Diff-specific CSS still lives in `tool-call.css`; future extraction into a smaller dedicated stylesheet is possible but not required for this change. --------- Co-authored-by: Shantur Rathore <i@shantur.com>
199 lines
7.3 KiB
TypeScript
199 lines
7.3 KiB
TypeScript
import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
|
import { AlignJustify, Copy, Split, WrapText } from "lucide-solid"
|
|
import type { RenderCache } from "../../types/message"
|
|
import type { DiffViewMode } from "../../stores/preferences"
|
|
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
|
import { getRelativePath } from "./utils"
|
|
import { getCacheEntry } from "../../lib/global-cache"
|
|
import { copyToClipboard } from "../../lib/clipboard"
|
|
|
|
const LazyToolCallDiffViewer = lazy(() =>
|
|
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
|
|
)
|
|
|
|
function CachedDiffMarkup(props: { html: string; onRendered?: () => void }) {
|
|
onMount(() => {
|
|
props.onRendered?.()
|
|
})
|
|
|
|
return (
|
|
<div class="tool-call-diff-viewer">
|
|
<div innerHTML={props.html} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type CacheHandle = {
|
|
get<T>(): T | undefined
|
|
params(): unknown
|
|
}
|
|
|
|
type DiffPrefs = {
|
|
diffViewMode?: DiffViewMode
|
|
}
|
|
|
|
export function createDiffContentRenderer(params: {
|
|
toolState: Accessor<ToolState | undefined>
|
|
preferences: Accessor<DiffPrefs>
|
|
setDiffViewMode: (mode: DiffViewMode) => void
|
|
isDark: Accessor<boolean>
|
|
t: (key: string, params?: Record<string, unknown>) => string
|
|
diffCache: CacheHandle
|
|
permissionDiffCache: CacheHandle
|
|
scrollHelpers: ToolScrollHelpers
|
|
handleScrollRendered: () => void
|
|
onContentRendered?: () => void
|
|
}) {
|
|
const compactDiffQuery = useMediaQuery("(max-width: 640px)")
|
|
const [mobileModeOverride, setMobileModeOverride] = createSignal<DiffViewMode | undefined>(undefined)
|
|
const [wordWrapEnabled, setWordWrapEnabled] = createSignal(true)
|
|
|
|
createEffect(() => {
|
|
if (!compactDiffQuery()) {
|
|
setMobileModeOverride(undefined)
|
|
}
|
|
})
|
|
|
|
const registerTracked = (element: HTMLDivElement | null) => {
|
|
params.scrollHelpers.registerContainer(element)
|
|
}
|
|
|
|
const registerUntracked = (element: HTMLDivElement | null) => {
|
|
params.scrollHelpers.registerContainer(element, { disableTracking: true })
|
|
}
|
|
|
|
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null {
|
|
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
|
const toolbarLabel = options?.label || (relativePath
|
|
? params.t("toolCall.diff.label.withPath", { path: relativePath })
|
|
: params.t("toolCall.diff.label"))
|
|
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
|
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
|
const preferredMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
|
const effectiveMode = () => {
|
|
if (!compactDiffQuery()) return preferredMode()
|
|
return mobileModeOverride() || "unified"
|
|
}
|
|
const shouldWrap = () => wordWrapEnabled()
|
|
const themeKey = params.isDark() ? "dark" : "light"
|
|
const state = params.toolState()
|
|
const disableScrollTracking = Boolean(
|
|
options?.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending"),
|
|
)
|
|
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
|
|
|
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}`,
|
|
}
|
|
})()
|
|
|
|
const currentMode = createMemo(() => effectiveMode())
|
|
const currentWrap = createMemo(() => shouldWrap())
|
|
const cachedHtml = createMemo(() => {
|
|
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
|
|
if (
|
|
cached
|
|
&& cached.text === payload.diffText
|
|
&& cached.theme === themeKey
|
|
&& cached.mode === currentMode()
|
|
&& cached.wrap === currentWrap()
|
|
) {
|
|
return cached.html
|
|
}
|
|
return undefined
|
|
})
|
|
|
|
const handleModeChange = (mode: DiffViewMode) => {
|
|
if (compactDiffQuery()) {
|
|
setMobileModeOverride(mode)
|
|
}
|
|
params.setDiffViewMode(mode)
|
|
}
|
|
|
|
const nextViewMode = (): DiffViewMode => (currentMode() === "split" ? "unified" : "split")
|
|
const viewModeTitle = () =>
|
|
nextViewMode() === "split"
|
|
? params.t("toolCall.diff.switchToSplit")
|
|
: params.t("toolCall.diff.switchToUnified")
|
|
const wordWrapTitle = () =>
|
|
wordWrapEnabled()
|
|
? params.t("toolCall.diff.disableWordWrap")
|
|
: params.t("toolCall.diff.enableWordWrap")
|
|
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
|
|
|
|
const handleDiffRendered = () => {
|
|
if (!disableScrollTracking) {
|
|
params.handleScrollRendered()
|
|
}
|
|
params.onContentRendered?.()
|
|
}
|
|
|
|
return (
|
|
<div
|
|
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
|
data-diff-mode={currentMode()}
|
|
ref={registerRef}
|
|
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
|
>
|
|
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
|
|
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
|
<div class="file-viewer-toolbar">
|
|
<button
|
|
type="button"
|
|
class="file-viewer-toolbar-icon-button"
|
|
onClick={() => void copyToClipboard(payload.diffText)}
|
|
aria-label={copyPatchTitle()}
|
|
title={copyPatchTitle()}
|
|
>
|
|
<Copy class="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="file-viewer-toolbar-icon-button"
|
|
onClick={() => handleModeChange(nextViewMode())}
|
|
aria-label={viewModeTitle()}
|
|
title={viewModeTitle()}
|
|
>
|
|
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class={`file-viewer-toolbar-icon-button${wordWrapEnabled() ? " active" : ""}`}
|
|
onClick={() => setWordWrapEnabled((enabled) => !enabled)}
|
|
aria-label={wordWrapTitle()}
|
|
title={wordWrapTitle()}
|
|
>
|
|
<WrapText class="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{cachedHtml() ? (
|
|
<CachedDiffMarkup html={cachedHtml()!} onRendered={handleDiffRendered} />
|
|
) : (
|
|
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
|
<LazyToolCallDiffViewer
|
|
diffText={payload.diffText}
|
|
filePath={payload.filePath}
|
|
theme={themeKey}
|
|
mode={currentMode()}
|
|
wrap={currentWrap()}
|
|
cacheEntryParams={cacheEntryParams as any}
|
|
onRendered={handleDiffRendered}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return { renderDiffContent }
|
|
}
|