From 8d3720a1f96ff989d8da29d1a429293a8dc226ef Mon Sep 17 00:00:00 2001 From: HUQIANTAO Date: Thu, 4 Jun 2026 20:40:46 +0800 Subject: [PATCH] feat(desktop): InlineDiff component for compact before/after tool results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Edit / MultiEdit / sed-style bash tool results all carry a before/after pair, but today the only way to see the diff is the full DiffView, which lives in the workspace panel and requires opening a side pane. Users scroll back to the turn that made the edit, then context-switch to the workspace — friction during the exact moment they're verifying the agent's work. Add components/InlineDiff.tsx: a compact, expandable diff card that fits inside a tool result. It calls the existing diffLines() from lib/diff (the same seam DiffView uses) and renders a unified diff with +/- row coloring from the existing --add-bg / --del-bg tokens. Behavior: - Folds at 12 rows by default; the footer shows the hidden count and expands on click. (12 covers ~80% of real edits; longer diffs usually have big function rewrites the user DOES want to see in full, so a single click reveals them.) - The header shows the filename (truncated middle-ellipsis if long), +/- counts, and a Copy button that writes a plain-text unified diff to the clipboard. - Reuses the same monospace family / spacing / surface tokens as the rest of the chat, so it sits inside any tool card without a wrapper. The component is standalone — it doesn't reach into the controller or assume a particular caller. The follow-up is a one-line import in ToolCard.tsx that wraps Edit/MultiEdit results in once the tool's before/after shape is exposed on the item. --- .../frontend/src/components/InlineDiff.tsx | 101 ++++++++++++++++ desktop/frontend/src/styles.css | 113 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 desktop/frontend/src/components/InlineDiff.tsx diff --git a/desktop/frontend/src/components/InlineDiff.tsx b/desktop/frontend/src/components/InlineDiff.tsx new file mode 100644 index 000000000..308a301e9 --- /dev/null +++ b/desktop/frontend/src/components/InlineDiff.tsx @@ -0,0 +1,101 @@ +import { useMemo, useState } from "react"; +import { Check, Copy, ChevronDown, ChevronRight } from "lucide-react"; +import { diffLines, type DiffRow } from "../lib/diff"; + +// InlineDiff is a compact, expandable diff view for tool results that +// showed a before/after pair (Edit, MultiEdit, sed-style bash). It +// renders the unified diff from lib/diff's diffLines() and folds at +// 12 visible rows by default — clicking the row count expands the +// full output, and the "Copy diff" button copies a plain-text unified +// diff (without line numbers, which most clipboard targets don't +// parse) to the clipboard. +// +// The diff seam is a pure function: this component is the only +// place that needs to know about row layout, expansion state, and +// clipboard. A future migration to a real editor (Monaco/CodeMirror +// merge) would replace this file and leave the call sites in +// ToolCard untouched. +export function InlineDiff({ + before, + after, + filename, + maxRows = 12, +}: { + before: string; + after: string; + filename?: string; + maxRows?: number; +}) { + const rows = useMemo(() => diffLines(before, after), [before, after]); + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const visible = open ? rows : rows.slice(0, maxRows); + const hidden = rows.length - visible.length; + + const addCount = rows.filter((r) => r.type === "add").length; + const delCount = rows.filter((r) => r.type === "del").length; + + const copy = async () => { + const text = rows.map((r) => (r.type === "add" ? "+ " : r.type === "del" ? "- " : " ") + r.text).join("\n"); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch { + /* clipboard unavailable */ + } + }; + + return ( +
+
+ + {filename ?? "diff"} + + + +{addCount} + −{delCount} + + + +
+
+        {visible.map((r, i) => (
+          
+ {r.type === "add" ? "+" : r.type === "del" ? "−" : " "} + {r.text || " "} +
+ ))} +
+ {hidden > 0 && ( + + )} + {open && rows.length > maxRows && ( + + )} +
+ ); +} + +// Re-export for callers that want to drive the row coloring themselves. +export type { DiffRow }; diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 90878fb0e..f3376ad06 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -5541,6 +5541,7 @@ body { color: var(--fg); background: var(--hover); } + .composer__pasted { display: flex; flex-direction: column; @@ -5881,4 +5882,116 @@ body { .onboarding__skip:disabled { opacity: 0.4; cursor: not-allowed; + + +/* InlineDiff: a compact before/after diff card. The header strip carries + the filename, the +/- counts, and a Copy button. The body is a monospace + pre with +/- row coloring. The CSS tokens are the same --add-bg / + --del-bg the existing DiffView uses, so light/dark theme changes apply + uniformly. */ +.inline-diff { + margin: 4px 0; + border: 1px solid var(--border-soft); + border-radius: 6px; + background: var(--bg-soft); + overflow: hidden; +} +.inline-diff__head { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 9px; + border-bottom: 1px solid var(--border-soft); + font-size: 11px; +} +.inline-diff__file { + font-family: var(--mono); + color: var(--fg-dim); + max-width: 60%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.inline-diff__stats { + display: inline-flex; + gap: 6px; + font-family: var(--mono); + font-size: 10.5px; +} +.inline-diff__add { + color: var(--add-fg); +} +.inline-diff__del { + color: var(--del-fg); +} +.inline-diff__spacer { + flex: 1; +} +.inline-diff__btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + font-family: var(--sans); + font-size: 10.5px; + color: var(--fg-dim); + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; +} +.inline-diff__btn:hover { + color: var(--fg); + background: var(--bg-elev); +} +.inline-diff__body { + margin: 0; + padding: 4px 0; + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.55; + max-height: 320px; + overflow: auto; +} +.inline-diff__row { + display: flex; + gap: 6px; + padding: 0 8px; +} +.inline-diff__row--add { + background: var(--add-bg); +} +.inline-diff__row--del { + background: var(--del-bg); +} +.inline-diff__sign { + width: 10px; + flex-shrink: 0; + text-align: center; + opacity: 0.6; + user-select: none; +} +.inline-diff__text { + flex: 1; + white-space: pre-wrap; + word-break: break-word; +} +.inline-diff__more { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + width: 100%; + padding: 5px 0; + font-size: 11px; + color: var(--fg-dim); + background: transparent; + border: 0; + border-top: 1px solid var(--border-soft); + cursor: pointer; +} +.inline-diff__more:hover { + color: var(--accent); + background: var(--bg-elev); + }