diff --git a/ui/src/e2e/chat-session-diff.e2e.test.ts b/ui/src/e2e/chat-session-diff.e2e.test.ts index 76f9e4ce9521..281f03a5cdab 100644 --- a/ui/src/e2e/chat-session-diff.e2e.test.ts +++ b/ui/src/e2e/chat-session-diff.e2e.test.ts @@ -40,11 +40,21 @@ const APP_PATCH = [ "index 1111111..2222222 100644", "--- a/src/app.ts", "+++ b/src/app.ts", - "@@ -10,3 +10,4 @@", + "@@ -30,3 +30,4 @@", " context line", "-removed line", "+replacement line", "+extra line", + " trailing context", + "", +].join("\n"); + +const APP_FILE_TEXT = [ + ...Array.from({ length: 29 }, (_, index) => `unchanged line ${index + 1}`), + "context line", + "replacement line", + "extra line", + "trailing context", "", ].join("\n"); @@ -93,8 +103,22 @@ describeControlUiE2e("session diff panel", () => { mergeBase: { sha: "0011223", subject: "Initial commit" }, }; const gateway = await installMockGateway(page, { - featureMethods: ["chat.metadata", "chat.startup", "sessions.diff"], + featureMethods: ["chat.metadata", "chat.startup", "sessions.diff", "sessions.files.get"], methodResponses: { + "sessions.files.get": { + sessionKey: "main", + root: "/tmp/checkout", + file: { + path: "src/app.ts", + workspacePath: "src/app.ts", + name: "app.ts", + kind: "modified", + missing: false, + previewKind: "text", + contentEncoding: "utf8", + content: APP_FILE_TEXT, + }, + }, "sessions.diff": { cases: [ { @@ -206,14 +230,30 @@ describeControlUiE2e("session diff panel", () => { .poll(() => modified.locator(".session-diff__filename").textContent()) .toBe("app.ts"); await expect.poll(() => modified.locator(".session-diff__directory").textContent()).toBe("src"); - // Hunk starting at old line 10 renders a leading gap marker. + // Hunk starting at old line 30 renders a leading expandable gap marker. await expect .poll(() => modified.locator(".chat-diff__row--skip").first().textContent()) - .toContain("9 unmodified lines"); + .toContain("29 unmodified lines"); await expect .poll(() => modified.locator(".chat-diff__row--add").first().textContent()) .toContain("replacement line"); + await modified.getByRole("button", { name: "Show next 20 unmodified lines" }).click(); + await expect.poll(async () => (await gateway.getRequests("sessions.diff")).length).toBe(2); + await expect + .poll(async () => (await gateway.getRequests("sessions.files.get"))[0]?.params) + .toMatchObject({ path: "src/app.ts" }); + await expect + .poll(() => modified.locator(".chat-diff__row").first().textContent()) + .toContain("unchanged line 1"); + await expect + .poll(() => modified.locator(".chat-diff__row--skip").first().textContent()) + .toContain("9 unmodified lines"); + await modified.getByRole("button", { name: "Show previous 9 unmodified lines" }).click(); + await expect.poll(() => modified.locator(".chat-diff__row--skip").count()).toBe(0); + await expect.poll(async () => (await gateway.getRequests("sessions.files.get")).length).toBe(1); + await expect.poll(async () => (await gateway.getRequests("sessions.diff")).length).toBe(3); + const untracked = files.nth(1); await expect .poll(() => untracked.locator(".session-diff__badge").textContent()) @@ -230,41 +270,43 @@ describeControlUiE2e("session diff panel", () => { await panel.getByRole("button", { name: "Change view options" }).click(); await page.getByRole("menuitem", { name: "Switch to Unified Diff" }).click(); await expect.poll(() => modified.locator(".chat-diff").count()).toBe(1); - // View-only toggles reuse parsed patches and do not refetch the RPC. - await expect.poll(async () => (await gateway.getRequests("sessions.diff")).length).toBe(1); + // View-only toggles reuse parsed patches after the expansion revalidations. + await expect.poll(async () => (await gateway.getRequests("sessions.diff")).length).toBe(3); // Collapsing a file hides its diff body. await modified.locator(".session-diff__file-toggle").click(); await expect.poll(() => modified.locator(".chat-diff").count()).toBe(0); await panel.getByRole("button", { name: "Refresh changes" }).click(); - await expect.poll(async () => (await gateway.getRequests("sessions.diff")).length).toBe(2); + await expect.poll(async () => (await gateway.getRequests("sessions.diff")).length).toBe(4); // Refresh keeps the current collapse state instead of expanding every file. await expect.poll(() => modified.locator(".chat-diff").count()).toBe(0); await modified.locator(".session-diff__file-toggle").click(); await expect.poll(() => modified.locator(".chat-diff").count()).toBe(1); - await panel.getByRole("button", { name: "Choose change scope" }).click(); + // The section-title button opens the same scope menu as the footer. + await panel.locator(".session-diff__section-title").click(); await page .locator('openclaw-session-diff-menu wa-dropdown-item[value="scope:uncommitted"]') .click(); await expect - .poll(() => panel.locator(".session-diff__section-title").textContent()) + .poll(() => panel.locator(".session-diff__section-title span").textContent()) .toBe("Uncommitted"); await expect.poll(() => panel.locator(".session-diff__file").count()).toBe(1); await expect .poll(async () => (await gateway.getRequests("sessions.diff")).at(-1)?.params) .toMatchObject({ scope: "uncommitted" }); - await panel.getByRole("button", { name: "Choose change scope" }).click(); + await panel.locator(".session-diff__footer").click(); await page .locator('openclaw-session-diff-menu wa-dropdown-item[value="scope:commit:abc1234"]') .click(); await expect - .poll(() => panel.locator(".session-diff__section-title").textContent()) + .poll(() => panel.locator(".session-diff__section-title span").textContent()) .toBe("abc1234 First feature change"); await expect .poll(async () => (await gateway.getRequests("sessions.diff")).at(-1)?.params) .toMatchObject({ scope: "commit", commit: "abc1234" }); + await expect.poll(() => panel.locator(".session-diff__gap-controls").count()).toBe(0); }); it("hides the diff toggle until the workspace becomes a git checkout", async () => { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index d9c987c29673..a903008bed62 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5462,6 +5462,9 @@ export const en: TranslationMap = { openInEditor: "Open in Editor", revealInFileTree: "Reveal in File Tree", unmodifiedLines: "{count} unmodified lines", + expandPreviousLines: "Show previous {count} unmodified lines", + expandNextLines: "Show next {count} unmodified lines", + expandAllLines: "Show all {count} unmodified lines", binaryFile: "Binary file", untracked: "untracked", tooLarge: "Diff too large to display.", diff --git a/ui/src/lib/chat/session-diff-gaps.test.ts b/ui/src/lib/chat/session-diff-gaps.test.ts new file mode 100644 index 000000000000..67dcc23e4433 --- /dev/null +++ b/ui/src/lib/chat/session-diff-gaps.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { expandSessionDiffGap } from "./session-diff-gaps.ts"; +import type { DiffLine, DiffLineGap } from "./tool-call-diff.ts"; + +const formatGap = (count: number) => `${count} unmodified lines`; +const gap: DiffLineGap = { oldStart: 1, newStart: 1, count: 50 }; +const fileLines = Array.from({ length: 52 }, (_, index) => `line ${index + 1}`); +const lines: DiffLine[] = [ + { kind: "skip", text: formatGap(gap.count), gap }, + { kind: "ctx", lineNo: 51, text: "line 51" }, + { kind: "add", lineNo: 52, text: "line 52" }, +]; + +describe("expandSessionDiffGap", () => { + it.each([ + { + direction: "down" as const, + contextStart: 1, + contextEnd: 20, + marker: { oldStart: 21, newStart: 21, count: 30 }, + markerIndex: 20, + }, + { + direction: "up" as const, + contextStart: 31, + contextEnd: 50, + marker: { oldStart: 1, newStart: 1, count: 30 }, + markerIndex: 0, + }, + ])( + "reveals a continuous $direction chunk and shrinks the marker", + ({ direction, contextStart, contextEnd, marker, markerIndex }) => { + const expanded = expandSessionDiffGap(lines, gap, fileLines, direction, formatGap); + + expect(expanded?.[markerIndex]).toMatchObject({ kind: "skip", gap: marker }); + const context = expanded?.filter((line) => line.kind === "ctx" && (line.lineNo ?? 0) <= 50); + expect(context?.at(0)).toEqual({ + kind: "ctx", + lineNo: contextStart, + text: `line ${contextStart}`, + }); + expect(context?.at(-1)).toEqual({ + kind: "ctx", + lineNo: contextEnd, + text: `line ${contextEnd}`, + }); + }, + ); + + it("reveals the full gap with continuous new-side line numbers", () => { + const expanded = expandSessionDiffGap(lines, gap, fileLines, "all", formatGap); + + expect(expanded?.some((line) => line.kind === "skip")).toBe(false); + expect(expanded?.map((line) => line.lineNo)).toEqual( + Array.from({ length: 52 }, (_, index) => index + 1), + ); + }); + + it.each(["down", "up", "all"] as const)( + "reveals a gap of 25 lines or fewer in one %s click", + (direction) => { + const smallGap: DiffLineGap = { oldStart: 1, newStart: 1, count: 25 }; + const smallLines: DiffLine[] = [ + { kind: "skip", text: formatGap(smallGap.count), gap: smallGap }, + ]; + const expanded = expandSessionDiffGap(smallLines, smallGap, fileLines, direction, formatGap); + + expect(expanded).toHaveLength(25); + expect(expanded?.some((line) => line.kind === "skip")).toBe(false); + }, + ); + + it("leaves the marker unchanged when working-tree content no longer matches the patch", () => { + const staleLines = fileLines.with(50, "changed since diff"); + expect(expandSessionDiffGap(lines, gap, staleLines, "down", formatGap)).toBeNull(); + }); +}); diff --git a/ui/src/lib/chat/session-diff-gaps.ts b/ui/src/lib/chat/session-diff-gaps.ts new file mode 100644 index 000000000000..8e79b95e6e8e --- /dev/null +++ b/ui/src/lib/chat/session-diff-gaps.ts @@ -0,0 +1,85 @@ +import type { DiffLine, DiffLineGap } from "./tool-call-diff.ts"; + +export type SessionDiffGapDirection = "down" | "up" | "all"; + +const GAP_CHUNK_SIZE = 20; +const EXPAND_WHOLE_GAP_MAX = 25; + +export function splitSessionDiffFileText(text: string): string[] { + const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + if (lines.at(-1) === "") { + lines.pop(); + } + return lines; +} + +function fileMatchesPatch(lines: readonly DiffLine[], fileLines: readonly string[]): boolean { + for (const line of lines) { + if ( + (line.kind === "add" || line.kind === "ctx") && + line.lineNo !== undefined && + fileLines[line.lineNo - 1] !== line.text + ) { + return false; + } + } + return true; +} + +function contextRows(fileLines: readonly string[], start: number, count: number): DiffLine[] { + return Array.from({ length: count }, (_, index) => ({ + kind: "ctx" as const, + lineNo: start + index, + text: fileLines[start + index - 1]!, + })); +} + +/** Replaces part or all of one unchanged-lines marker with working-tree context. */ +export function expandSessionDiffGap( + lines: readonly DiffLine[], + target: DiffLineGap, + fileLines: readonly string[], + direction: SessionDiffGapDirection, + formatGap: (count: number) => string, +): DiffLine[] | null { + const index = lines.findIndex((line) => line.kind === "skip" && line.gap === target); + const gapEnd = target.newStart + target.count - 1; + if ( + index < 0 || + target.newStart < 1 || + target.count < 1 || + gapEnd > fileLines.length || + !fileMatchesPatch(lines, fileLines) + ) { + return null; + } + + const revealCount = + direction === "all" || target.count <= EXPAND_WHOLE_GAP_MAX + ? target.count + : Math.min(GAP_CHUNK_SIZE, target.count); + const remainingCount = target.count - revealCount; + const revealStart = direction === "up" ? target.newStart + remainingCount : target.newStart; + const revealed = contextRows(fileLines, revealStart, revealCount); + const replacement: DiffLine[] = []; + if (direction === "up" && remainingCount > 0) { + replacement.push({ + kind: "skip", + text: formatGap(remainingCount), + gap: { ...target, count: remainingCount }, + }); + } + replacement.push(...revealed); + if (direction !== "up" && remainingCount > 0) { + replacement.push({ + kind: "skip", + text: formatGap(remainingCount), + gap: { + oldStart: target.oldStart + revealCount, + newStart: target.newStart + revealCount, + count: remainingCount, + }, + }); + } + return [...lines.slice(0, index), ...replacement, ...lines.slice(index + 1)]; +} diff --git a/ui/src/lib/chat/session-diff.test.ts b/ui/src/lib/chat/session-diff.test.ts index bdc21dcac5e1..4c4e3c99e88e 100644 --- a/ui/src/lib/chat/session-diff.test.ts +++ b/ui/src/lib/chat/session-diff.test.ts @@ -26,13 +26,21 @@ describe("parseSessionDiffPatch", () => { const { lines, truncated } = parseSessionDiffPatch(PATCH, gap); expect(truncated).toBe(false); // Leading gap: first hunk starts at old line 3. - expect(lines[0]).toEqual({ kind: "skip", text: "2 unmodified lines" }); + expect(lines[0]).toEqual({ + kind: "skip", + text: "2 unmodified lines", + gap: { oldStart: 1, newStart: 1, count: 2 }, + }); expect(lines[1]).toEqual({ kind: "ctx", lineNo: 3, text: "context" }); expect(lines[2]).toEqual({ kind: "del", lineNo: 4, text: "old line" }); expect(lines[3]).toEqual({ kind: "add", lineNo: 4, text: "new line" }); expect(lines[4]).toEqual({ kind: "add", lineNo: 5, text: "added line" }); // Gap between hunk 1 (old lines 3-4 consumed) and hunk 2 (old line 160). - expect(lines[5]).toEqual({ kind: "skip", text: "155 unmodified lines" }); + expect(lines[5]).toEqual({ + kind: "skip", + text: "155 unmodified lines", + gap: { oldStart: 5, newStart: 6, count: 155 }, + }); expect(lines[6]).toEqual({ kind: "ctx", lineNo: 161, text: "more context" }); expect(lines[7]).toEqual({ kind: "del", lineNo: 161, text: "tail old" }); expect(lines).toHaveLength(8); diff --git a/ui/src/lib/chat/session-diff.ts b/ui/src/lib/chat/session-diff.ts index 6c5d510f2aeb..a5caa3c9ae4f 100644 --- a/ui/src/lib/chat/session-diff.ts +++ b/ui/src/lib/chat/session-diff.ts @@ -29,8 +29,9 @@ export function parseSessionDiffPatch( let inHunk = false; let oldNo = 0; let newNo = 0; - // Next expected old-file line after the previous hunk; drives gap counts. + // Next expected lines after the previous hunk; drive inter-hunk gap coordinates. let oldNext: number | undefined; + let newNext: number | undefined; const rawLines = patch.replace(/\r\n/g, "\n").split("\n"); if (rawLines.at(-1) === "") { rawLines.pop(); @@ -42,7 +43,15 @@ export function parseSessionDiffPatch( const newStart = Number.parseInt(hunk[2] ?? "", 10); const gap = oldNext === undefined ? oldStart - 1 : oldStart - oldNext; if (gap > 0) { - lines.push({ kind: "skip", text: formatGap(gap) }); + lines.push({ + kind: "skip", + text: formatGap(gap), + gap: { + oldStart: oldNext ?? oldStart - gap, + newStart: newNext ?? newStart - gap, + count: gap, + }, + }); } oldNo = oldStart; newNo = newStart; @@ -69,6 +78,7 @@ export function parseSessionDiffPatch( newNo += 1; } oldNext = oldNo; + newNext = newNo; } return { lines, truncated }; } diff --git a/ui/src/lib/chat/tool-call-diff.ts b/ui/src/lib/chat/tool-call-diff.ts index 1e1810bda113..2339c6bb327d 100644 --- a/ui/src/lib/chat/tool-call-diff.ts +++ b/ui/src/lib/chat/tool-call-diff.ts @@ -9,10 +9,18 @@ export type DiffLineKind = "add" | "del" | "ctx" | "file" | "skip"; +export type DiffLineGap = { + oldStart: number; + newStart: number; + count: number; +}; + export type DiffLine = { kind: DiffLineKind; /** 1-based line number in the file (new file for adds/ctx, old file for dels). */ lineNo?: number; + /** Session-diff coordinates for an expandable unchanged-lines marker. */ + gap?: DiffLineGap; text: string; }; diff --git a/ui/src/pages/chat/components/chat-diff-render.ts b/ui/src/pages/chat/components/chat-diff-render.ts index 5a4ee6c19e25..f52c430c3356 100644 --- a/ui/src/pages/chat/components/chat-diff-render.ts +++ b/ui/src/pages/chat/components/chat-diff-render.ts @@ -30,6 +30,7 @@ export function renderDiffStatChips(stat: DiffStat & { modified?: number }) { export function renderDiffBlock( lines: readonly DiffLine[], outcome: ToolCardOutcome = "succeeded", + renderSkip?: (line: DiffLine) => unknown, ) { const hasLineNumbers = lines.some((line) => line.lineNo !== undefined); return html` @@ -47,7 +48,7 @@ export function renderDiffBlock( return html`
${hasLineNumbers ? html`` : nothing} - ${line.text || "⋯"} + ${(renderSkip?.(line) ?? line.text) || "⋯"}
`; } const kindClass = diff --git a/ui/src/pages/chat/components/chat-session-workspace.ts b/ui/src/pages/chat/components/chat-session-workspace.ts index 911f242fa066..7d895d338f42 100644 --- a/ui/src/pages/chat/components/chat-session-workspace.ts +++ b/ui/src/pages/chat/components/chat-session-workspace.ts @@ -835,6 +835,8 @@ export function createSessionWorkspaceProps( /** Sidebar payload whose loader refetches sessions.diff for the pane's session. */ function buildSessionDiffSidebarContent(state: SessionWorkspaceHost): SidebarContent { const sessionKey = state.sessionKey; + const canLoadFileText = + isGatewayMethodAdvertised(state, "sessions.files.get") === true && Boolean(state.client); return { kind: "session-diff", load: async (scope) => { @@ -847,6 +849,27 @@ function buildSessionDiffSidebarContent(state: SessionWorkspaceHost): SidebarCon ...scope, }); }, + loadFileText: canLoadFileText + ? async (path) => { + try { + const result = await state.sessions.getFile(sessionKey, path, { + agentId: scopedAgentParamsForSession(state, sessionKey).agentId, + }); + const file = result?.file; + if ( + !file || + (file.previewKind !== undefined && file.previewKind !== "text") || + (file.contentEncoding !== undefined && file.contentEncoding !== "utf8") || + typeof file.content !== "string" + ) { + return null; + } + return file.content; + } catch { + return null; + } + } + : undefined, openFile: (path) => openFile(state, getWorkspaceState(state), path), revealFile: (path) => revealSessionWorkspaceFile(state, path), }; diff --git a/ui/src/pages/chat/components/chat-sidebar.ts b/ui/src/pages/chat/components/chat-sidebar.ts index 52006938c0f3..75a6eec7b06f 100644 --- a/ui/src/pages/chat/components/chat-sidebar.ts +++ b/ui/src/pages/chat/components/chat-sidebar.ts @@ -26,7 +26,7 @@ import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts"; import "./session-diff-panel.ts"; import { renderChatSidebarEditorMenu } from "./chat-sidebar-editor-menu.ts"; import type { FileEditorViewHandle } from "./file-editor-view.ts"; -import type { SessionDiffLoader } from "./session-diff-panel.ts"; +import type { SessionDiffFileTextLoader, SessionDiffLoader } from "./session-diff-panel.ts"; type DetailUnavailableReason = "not_found" | "oversized" | "not_visible"; type DetailFullMessageResult = { @@ -81,6 +81,7 @@ type SessionDiffSidebarContent = { kind: "session-diff"; /** Fetches a fresh sessions.diff snapshot; the panel refetches on refresh. */ load: SessionDiffLoader; + loadFileText?: SessionDiffFileTextLoader; openFile?: (path: string) => void; revealFile?: (path: string) => void; rawText?: string | null; @@ -594,6 +595,7 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) { : content.kind === "session-diff" ? html`` diff --git a/ui/src/pages/chat/components/session-diff-menus.ts b/ui/src/pages/chat/components/session-diff-menus.ts index 55a35bd1021c..f75963c39250 100644 --- a/ui/src/pages/chat/components/session-diff-menus.ts +++ b/ui/src/pages/chat/components/session-diff-menus.ts @@ -32,6 +32,7 @@ export type SessionDiffMenuData = trigger: HTMLElement; active: SessionDiffScope; result: SessionsDiffResult; + placement?: "top-start" | "bottom-start"; } | { kind: "sync"; @@ -281,7 +282,7 @@ class SessionDiffMenu extends OpenClawLightDomElement { if (!menu) { return nothing; } - const placement = menu.kind === "scope" ? "top-start" : "bottom-end"; + const placement = menu.kind === "scope" ? (menu.placement ?? "top-start") : "bottom-end"; const width = menu.kind === "sync" ? 360 : menu.kind === "scope" ? 340 : 240; const menuLabel = menu.kind === "file" diff --git a/ui/src/pages/chat/components/session-diff-panel.test.ts b/ui/src/pages/chat/components/session-diff-panel.test.ts index 08fd4a6e31be..058848b14f83 100644 --- a/ui/src/pages/chat/components/session-diff-panel.test.ts +++ b/ui/src/pages/chat/components/session-diff-panel.test.ts @@ -2,10 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { SessionsDiffResult } from "../../../../../packages/gateway-protocol/src/index.js"; -import type { SessionDiffLoader } from "./session-diff-panel.ts"; +import type { SessionDiffFileTextLoader, SessionDiffLoader } from "./session-diff-panel.ts"; import "./session-diff-panel.ts"; type SessionDiffElement = HTMLElement & { + loadFileText: SessionDiffFileTextLoader | null; loader: SessionDiffLoader | null; readonly updateComplete: Promise; }; @@ -29,6 +30,43 @@ function result(branch: string): SessionsDiffResult { }; } +const SNAPSHOT_PATCH = [ + "--- a/example.txt", + "+++ b/example.txt", + "@@ -3 +3 @@", + "-before", + "+snapshot line", +].join("\n"); + +const FRESH_PATCH = [ + "--- a/example.txt", + "+++ b/example.txt", + "@@ -1,3 +1,3 @@", + " fresh gap edit", + " context", + "-before", + "+fresh snapshot line", +].join("\n"); + +function fileResult(patch: string): SessionsDiffResult { + return { + sessionKey: "agent:main:test", + branch: "feature/test", + baseRef: "main", + files: [ + { + path: "example.txt", + status: "modified", + additions: 1, + deletions: 1, + patch, + }, + ], + additions: 1, + deletions: 1, + }; +} + afterEach(() => { document.body.replaceChildren(); }); @@ -56,4 +94,28 @@ describe("SessionDiffPanel", () => { expect(panel.textContent).toContain("feature/latest"); expect(panel.textContent).not.toContain("feature/stale"); }); + + it("refreshes the diff instead of expanding file text from a stale gap snapshot", async () => { + const loader = vi + .fn() + .mockResolvedValueOnce(fileResult(SNAPSHOT_PATCH)) + .mockResolvedValueOnce(fileResult(FRESH_PATCH)); + const loadFileText = vi + .fn() + .mockResolvedValue(["expanded current file line", "context", "snapshot line"].join("\n")); + const panel = document.createElement("openclaw-session-diff") as SessionDiffElement; + panel.loader = loader; + panel.loadFileText = loadFileText; + document.body.append(panel); + + await vi.waitFor(() => expect(panel.querySelector(".session-diff__gap-count")).not.toBeNull()); + (panel.querySelector(".session-diff__gap-count") as HTMLButtonElement).click(); + + await vi.waitFor(() => expect(panel.textContent).toContain("fresh snapshot line")); + expect(loader).toHaveBeenCalledTimes(2); + expect(loader).toHaveBeenNthCalledWith(2, { scope: "all" }); + expect(loadFileText).not.toHaveBeenCalled(); + expect(panel.textContent).not.toContain("expanded current file line"); + expect(panel.querySelector(".session-diff__gap-controls")).toBeNull(); + }); }); diff --git a/ui/src/pages/chat/components/session-diff-panel.ts b/ui/src/pages/chat/components/session-diff-panel.ts index 7db54f9d1ffd..57fbfde6043a 100644 --- a/ui/src/pages/chat/components/session-diff-panel.ts +++ b/ui/src/pages/chat/components/session-diff-panel.ts @@ -10,11 +10,17 @@ import type { import { icons } from "../../../components/icons.ts"; import "../../../components/tooltip.ts"; import { t } from "../../../i18n/index.ts"; +import { + expandSessionDiffGap, + splitSessionDiffFileText, + type SessionDiffGapDirection, +} from "../../../lib/chat/session-diff-gaps.ts"; import { pairSessionDiffLines, type SessionSplitDiffRow, } from "../../../lib/chat/session-diff-split.ts"; import { parseSessionDiffPatch, type ParsedFilePatch } from "../../../lib/chat/session-diff.ts"; +import type { DiffLine } from "../../../lib/chat/tool-call-diff.ts"; import { copyToClipboard } from "../../../lib/clipboard.ts"; import { openEditor } from "../../../lib/editor-links.ts"; import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts"; @@ -30,6 +36,7 @@ import "./session-diff-menus.ts"; import { renderSessionSplitDiff } from "./session-diff-render.ts"; export type SessionDiffLoader = (params: SessionDiffScope) => Promise; +export type SessionDiffFileTextLoader = (path: string) => Promise; type FileView = { file: SessionDiffFile; @@ -125,8 +132,29 @@ function shellArgument(value: string): string { return /^[A-Za-z0-9_./:@+-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`; } +function scopeParams(scope: SessionDiffScope): SessionDiffScope { + return scope.scope === "commit" + ? { scope: "commit", commit: scope.commit } + : { scope: scope.scope }; +} + +function taskResult(result: SessionsDiffResult): SessionDiffTaskResult { + return { + result, + views: result.files.map((file) => ({ + file, + parsed: file.patch + ? parseSessionDiffPatch(file.patch, (count) => + t("chat.sessionDiff.unmodifiedLines", { count: String(count) }), + ) + : null, + })), + }; +} + class SessionDiffPanel extends OpenClawLightDomElement { @property({ attribute: false }) loader: SessionDiffLoader | null = null; + @property({ attribute: false }) loadFileText: SessionDiffFileTextLoader | null = null; @property({ attribute: false }) openFile: ((path: string) => void) | null = null; @property({ attribute: false }) revealFile: ((path: string) => void) | null = null; @@ -137,6 +165,9 @@ class SessionDiffPanel extends OpenClawLightDomElement { @state() private wrap = loadPreferences().wrap; private readonly splitCache = new WeakMap(); + private readonly fileTextCache = new WeakMap>(); + private readonly unavailableFileText = new WeakSet(); + private prefetchedDiffResult: SessionsDiffResult | null = null; private readonly diffTask = new Task(this, { args: () => @@ -150,18 +181,9 @@ class SessionDiffPanel extends OpenClawLightDomElement { return null; } const params: SessionDiffScope = scope === "commit" ? { scope, commit: commit! } : { scope }; - const result = await loader(params); - return { - result, - views: result.files.map((file) => ({ - file, - parsed: file.patch - ? parseSessionDiffPatch(file.patch, (count) => - t("chat.sessionDiff.unmodifiedLines", { count: String(count) }), - ) - : null, - })), - }; + const result = this.prefetchedDiffResult ?? (await loader(params)); + this.prefetchedDiffResult = null; + return taskResult(result); }, onComplete: (value) => { const currentPaths = new Set(value?.views.map((view) => view.file.path) ?? []); @@ -189,7 +211,11 @@ class SessionDiffPanel extends OpenClawLightDomElement { this.collapsedPaths = next; } - private openAnchoredMenu(event: Event, menu: SessionDiffMenuDraft, upward = false): void { + private openAnchoredMenu( + event: Event, + menu: SessionDiffMenuDraft, + placement: "bottom-end" | "bottom-start" | "top-start" = "bottom-end", + ): void { event.stopPropagation(); const trigger = event.currentTarget; if (!(trigger instanceof HTMLElement)) { @@ -198,7 +224,11 @@ class SessionDiffPanel extends OpenClawLightDomElement { const bounds = trigger.getBoundingClientRect(); this.menu = { ...menu, - anchor: { x: upward ? bounds.left : bounds.right, y: upward ? bounds.top : bounds.bottom }, + ...(menu.kind === "scope" && placement !== "bottom-end" ? { placement } : {}), + anchor: { + x: placement.endsWith("start") ? bounds.left : bounds.right, + y: placement.startsWith("top") ? bounds.top : bounds.bottom, + }, trigger, } as SessionDiffMenuData; } @@ -310,6 +340,119 @@ class SessionDiffPanel extends OpenClawLightDomElement { return rows; } + private canExpandGaps(view: FileView): boolean { + return ( + this.scope.scope !== "commit" && + Boolean(this.loadFileText) && + view.file.binary !== true && + view.parsed !== null && + !view.parsed.truncated && + !this.unavailableFileText.has(view) + ); + } + + private loadFileLines(view: FileView): Promise { + const cached = this.fileTextCache.get(view); + if (cached) { + return cached; + } + const load = this.loadFileText; + const pending = load + ? load(view.file.path) + .then((text) => (text === null ? null : splitSessionDiffFileText(text))) + .catch(() => null) + : Promise.resolve(null); + this.fileTextCache.set(view, pending); + return pending; + } + + private async expandGap( + view: FileView, + line: DiffLine, + direction: SessionDiffGapDirection, + ): Promise { + const parsed = view.parsed; + const loader = this.loader; + if (!parsed || !line.gap || !loader || !this.canExpandGaps(view)) { + return; + } + const scope = this.scope; + let freshResult: SessionsDiffResult; + try { + freshResult = await loader(scopeParams(scope)); + } catch { + return; + } + if ( + this.loader !== loader || + this.scope !== scope || + !this.diffTask.value?.views.includes(view) + ) { + return; + } + const freshFile = freshResult.files.find((file) => file.path === view.file.path); + // The panel renders a snapshot; revalidate its patch server-side because gap-interior + // edits are invisible to row validation. The remaining diff-to-file fetch race is a few + // milliseconds and is an accepted tradeoff without shared snapshot identity. + if (!freshFile || freshFile.patch !== view.file.patch) { + this.fileTextCache.delete(view); + this.prefetchedDiffResult = freshResult; + await this.diffTask.run(); + return; + } + const fileLines = await this.loadFileLines(view); + if (!fileLines || !this.diffTask.value?.views.includes(view)) { + this.unavailableFileText.add(view); + this.requestUpdate(); + return; + } + const expanded = expandSessionDiffGap(parsed.lines, line.gap, fileLines, direction, (count) => + t("chat.sessionDiff.unmodifiedLines", { count: String(count) }), + ); + if (!expanded) { + this.unavailableFileText.add(view); + this.requestUpdate(); + return; + } + parsed.lines = expanded; + this.splitCache.delete(parsed); + this.requestUpdate(); + } + + private renderGap(view: FileView, line: DiffLine): unknown { + const gap = line.gap; + if (!gap || !this.canExpandGaps(view)) { + return line.text; + } + const chunkCount = gap.count <= 25 ? gap.count : Math.min(20, gap.count); + return html` + + + + `; + } + private renderFileBody(view: FileView): TemplateResult { const { file, parsed } = view; if (file.binary === true) { @@ -318,8 +461,11 @@ class SessionDiffPanel extends OpenClawLightDomElement { if (!parsed) { return html`
${t("chat.sessionDiff.tooLarge")}
`; } + const renderGap = (line: DiffLine) => this.renderGap(view, line); return html` - ${this.split ? renderSessionSplitDiff(this.splitRows(parsed)) : renderDiffBlock(parsed.lines)} + ${this.split + ? renderSessionSplitDiff(this.splitRows(parsed), renderGap) + : renderDiffBlock(parsed.lines, "succeeded", renderGap)} ${parsed.truncated ? html`
${t("chat.sessionDiff.truncatedFile")}
` : nothing} @@ -421,7 +567,7 @@ class SessionDiffPanel extends OpenClawLightDomElement { type="button" aria-label=${t("chat.sessionDiff.scopeMenu")} @click=${(event: Event) => - this.openAnchoredMenu(event, { kind: "scope", active: this.scope, result }, true)} + this.openAnchoredMenu(event, { kind: "scope", active: this.scope, result }, "top-start")} > ${label}${icons.chevronUp} `; @@ -447,7 +593,19 @@ class SessionDiffPanel extends OpenClawLightDomElement { } return html` ${this.renderSummary(result)} -
${this.scopeTitle(result)}
+
${result.unavailableReason === "unknown_commit" ? html`
${t("chat.sessionDiff.unknownCommit")}
` diff --git a/ui/src/pages/chat/components/session-diff-render.ts b/ui/src/pages/chat/components/session-diff-render.ts index bbcc8580dbfa..391828d97310 100644 --- a/ui/src/pages/chat/components/session-diff-render.ts +++ b/ui/src/pages/chat/components/session-diff-render.ts @@ -17,7 +17,10 @@ function renderSplitSide(line: DiffLine | undefined, side: "left" | "right") {
`; } -export function renderSessionSplitDiff(rows: readonly SessionSplitDiffRow[]) { +export function renderSessionSplitDiff( + rows: readonly SessionSplitDiffRow[], + renderSkip?: (line: DiffLine) => unknown, +) { return html`
- ${row.line.text || "⋯"} + ${(renderSkip?.(row.line) ?? row.line.text) || "⋯"}
`; } return html`
diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 77d1b203d301..9770ce81b577 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -1995,12 +1995,30 @@ openclaw-session-discussion { } .session-diff__section-title { + display: flex; + align-items: center; + gap: 4px; + width: 100%; padding: 11px 8px 6px; + border: 0; + background: transparent; color: var(--muted); font-size: var(--control-ui-text-xs); font-weight: 650; letter-spacing: 0.08em; + text-align: left; text-transform: uppercase; + cursor: var(--cursor-action); +} + +.session-diff__section-title:hover, +.session-diff__section-title:focus-visible { + color: var(--text); +} + +.session-diff__section-title svg { + width: 12px; + height: 12px; } .session-diff__files { @@ -2188,6 +2206,55 @@ openclaw-session-discussion { padding-right: 8px; } +/* Skip rows carry 18px expander buttons; the shared baseline row alignment + derives the strip's baseline from an icon-only button and shears the text + upward, so center these rows instead (tool cards keep baseline rows). */ +.session-diff__file .chat-diff__row--skip { + align-items: center; +} + +.session-diff__file .chat-diff__row--skip .chat-diff__text { + display: inline-flex; + align-items: center; +} + +.session-diff__gap-controls { + display: inline-flex; + align-items: center; + gap: 3px; +} + +/* Grid, not flex: Chromium's flex layout on