diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx
index cd873cc289..0b9c45ccb6 100644
--- a/packages/app/src/components/git-diff-pane.tsx
+++ b/packages/app/src/components/git-diff-pane.tsx
@@ -37,6 +37,12 @@ import {
type DiffLine,
type HighlightToken,
} from "@/hooks/use-checkout-diff-query";
+import {
+ useCheckoutDiffMetadataQuery,
+ type DiffFileMetadata,
+} from "@/hooks/use-checkout-diff-metadata-query";
+import { useFileHunks } from "@/hooks/use-file-hunks";
+import { useSessionForServer } from "@/hooks/use-session-directory";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
@@ -130,8 +136,16 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
);
}
+interface DiffFileHeaderFields {
+ path: string;
+ isNew: boolean;
+ isDeleted: boolean;
+ additions: number;
+ deletions: number;
+}
+
interface DiffFileSectionProps {
- file: ParsedDiffFile;
+ file: DiffFileHeaderFields;
isExpanded: boolean;
onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
@@ -617,6 +631,109 @@ function DiffFileBody({
);
}
+interface LazyDiffFileBodyProps {
+ file: DiffFileMetadata;
+ serverId: string;
+ cwd: string;
+ mode: "uncommitted" | "base";
+ baseRef?: string;
+ ignoreWhitespace?: boolean;
+ layout: "unified" | "split";
+ wrapLines: boolean;
+ onBodyHeightChange?: (path: string, height: number) => void;
+ testID?: string;
+}
+
+function LazyDiffFileBody({
+ file,
+ serverId,
+ cwd,
+ mode,
+ baseRef,
+ ignoreWhitespace,
+ layout,
+ wrapLines,
+ onBodyHeightChange,
+ testID,
+}: LazyDiffFileBodyProps) {
+ const {
+ file: resolvedFile,
+ isLoading,
+ isError,
+ error,
+ } = useFileHunks({
+ serverId,
+ cwd,
+ mode,
+ baseRef,
+ ignoreWhitespace,
+ file,
+ enabled: true,
+ });
+
+ if (file.status === "binary" || file.status === "too_large") {
+ return (
+ {
+ onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
+ }}
+ testID={testID}
+ >
+
+
+ {file.status === "binary" ? "Binary file" : "Diff too large to display"}
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+ {
+ onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
+ }}
+ testID={testID}
+ >
+
+
+
+
+ );
+ }
+
+ if (isError || !resolvedFile) {
+ return (
+ {
+ onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
+ }}
+ testID={testID}
+ >
+
+
+ {error instanceof Error ? error.message : "Failed to load diff"}
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
interface GitDiffPaneProps {
serverId: string;
workspaceId?: string | null;
@@ -625,8 +742,9 @@ interface GitDiffPaneProps {
}
type DiffFlatItem =
- | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
- | { type: "body"; file: ParsedDiffFile; fileIndex: number };
+ | { type: "header"; file: DiffFileHeaderFields; fileIndex: number; isExpanded: boolean }
+ | { type: "body"; file: ParsedDiffFile; fileIndex: number }
+ | { type: "lazy-body"; file: DiffFileMetadata; fileIndex: number };
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles();
@@ -643,6 +761,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const wrapLines = changesPreferences.wrapLines;
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
+ // Feature detection: use V2 lazy diff when daemon supports it
+ const useLazyDiff =
+ useSessionForServer(
+ serverId,
+ (session) => session?.serverInfo?.features?.lazyDiffRpcs === true,
+ ) ?? false;
+
const handleToggleWrapLines = useCallback(() => {
void updateChangesPreferences({ wrapLines: !wrapLines });
}, [updateChangesPreferences, wrapLines]);
@@ -679,22 +804,50 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base";
const diffMode = diffModeOverride ?? autoDiffMode;
+ // V1: full diff (hunks included) — used when daemon doesn't support lazy diff
const {
- files,
- payloadError: diffPayloadError,
- isLoading: isDiffLoading,
- isFetching: isDiffFetching,
- isError: isDiffError,
- error: diffError,
- refresh: refreshDiff,
+ files: v1Files,
+ payloadError: v1DiffPayloadError,
+ isLoading: isV1DiffLoading,
+ isFetching: isV1DiffFetching,
+ isError: isV1DiffError,
+ error: v1DiffError,
+ refresh: refreshV1Diff,
} = useCheckoutDiffQuery({
serverId,
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
- enabled: isGit,
+ enabled: isGit && !useLazyDiff,
});
+
+ // V2: metadata-only (hunks fetched on demand per file)
+ const {
+ files: v2Files,
+ payloadError: v2DiffPayloadError,
+ isLoading: isV2DiffLoading,
+ isFetching: isV2DiffFetching,
+ isError: isV2DiffError,
+ error: v2DiffError,
+ refresh: refreshV2Diff,
+ } = useCheckoutDiffMetadataQuery({
+ serverId,
+ cwd,
+ mode: diffMode,
+ baseRef,
+ ignoreWhitespace: changesPreferences.hideWhitespace,
+ enabled: isGit && useLazyDiff,
+ });
+
+ // Unified interface for both V1 and V2
+ const files: DiffFileHeaderFields[] = useLazyDiff ? v2Files : v1Files;
+ const diffPayloadError = useLazyDiff ? v2DiffPayloadError : v1DiffPayloadError;
+ const isDiffLoading = useLazyDiff ? isV2DiffLoading : isV1DiffLoading;
+ const isDiffFetching = useLazyDiff ? isV2DiffFetching : isV1DiffFetching;
+ const isDiffError = useLazyDiff ? isV2DiffError : isV1DiffError;
+ const diffError = useLazyDiff ? v2DiffError : v1DiffError;
+ const refreshDiff = useLazyDiff ? refreshV2Diff : refreshV1Diff;
const {
status: prStatus,
githubFeaturesEnabled,
@@ -788,11 +941,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
stickyIndices.push(items.length - 1);
}
if (isExpanded) {
- items.push({ type: "body", file, fileIndex: i });
+ if (useLazyDiff) {
+ items.push({ type: "lazy-body", file: file as DiffFileMetadata, fileIndex: i });
+ } else {
+ items.push({ type: "body", file: file as ParsedDiffFile, fileIndex: i });
+ }
}
}
return { flatItems: items, stickyHeaderIndices: stickyIndices };
- }, [expandedPaths, files]);
+ }, [expandedPaths, files, useLazyDiff]);
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) {
@@ -1067,6 +1224,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
/>
);
}
+ if (item.type === "lazy-body") {
+ return (
+
+ );
+ }
return (
(files: T[]): T[] {
if (files.length < 2) {
return files;
}
diff --git a/packages/app/src/hooks/use-checkout-diff-metadata-query.test.ts b/packages/app/src/hooks/use-checkout-diff-metadata-query.test.ts
new file mode 100644
index 0000000000..bf85d8dcbb
--- /dev/null
+++ b/packages/app/src/hooks/use-checkout-diff-metadata-query.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
+import { orderCheckoutDiffFiles } from "./checkout-diff-order";
+
+function createMetadata(path: string, fingerprint = "fp1"): DiffFileMetadata {
+ return {
+ path,
+ isNew: false,
+ isDeleted: false,
+ additions: 1,
+ deletions: 0,
+ fingerprint,
+ };
+}
+
+describe("useCheckoutDiffMetadataQuery — cache replacement behavior", () => {
+ it("orderCheckoutDiffFiles works with DiffFileMetadata arrays", () => {
+ const files: DiffFileMetadata[] = [
+ createMetadata("zeta.ts"),
+ createMetadata("alpha.ts"),
+ createMetadata("beta.ts"),
+ ];
+ const ordered = orderCheckoutDiffFiles(files);
+ expect(ordered.map((f) => f.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]);
+ });
+
+ it("replaces full list on snapshot — does not merge with previous", () => {
+ const initial: DiffFileMetadata[] = [
+ createMetadata("a.ts", "fp1"),
+ createMetadata("b.ts", "fp2"),
+ createMetadata("c.ts", "fp3"),
+ ];
+ // Simulate a new snapshot that removes b.ts and changes c.ts fingerprint
+ const updated: DiffFileMetadata[] = [
+ createMetadata("a.ts", "fp1"),
+ createMetadata("c.ts", "fp4"),
+ ];
+
+ // Full replacement: the result is exactly the updated list, not a merge
+ const result = orderCheckoutDiffFiles(updated);
+ expect(result).toHaveLength(2);
+ expect(result.map((f) => f.path)).toEqual(["a.ts", "c.ts"]);
+ expect(result.find((f) => f.path === "c.ts")?.fingerprint).toBe("fp4");
+ // b.ts is gone — no remnant from initial
+ expect(result.find((f) => f.path === "b.ts")).toBeUndefined();
+
+ // Initial is unchanged (no mutation)
+ expect(initial).toHaveLength(3);
+ });
+
+ it("preserves fingerprint and status on metadata items", () => {
+ const file: DiffFileMetadata = {
+ path: "large.bin",
+ isNew: false,
+ isDeleted: false,
+ additions: 0,
+ deletions: 0,
+ status: "binary",
+ fingerprint: "abc123",
+ };
+ const ordered = orderCheckoutDiffFiles([file]);
+ expect(ordered[0].fingerprint).toBe("abc123");
+ expect(ordered[0].status).toBe("binary");
+ });
+});
diff --git a/packages/app/src/hooks/use-checkout-diff-metadata-query.ts b/packages/app/src/hooks/use-checkout-diff-metadata-query.ts
new file mode 100644
index 0000000000..4c19786794
--- /dev/null
+++ b/packages/app/src/hooks/use-checkout-diff-metadata-query.ts
@@ -0,0 +1,214 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback, useEffect, useId, useMemo } from "react";
+import { useIsCompactFormFactor } from "@/constants/layout";
+import { usePanelStore } from "@/stores/panel-store";
+import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
+import type { CheckoutDiffSnapshot } from "@server/shared/messages";
+import { orderCheckoutDiffFiles } from "./checkout-diff-order";
+
+const CHECKOUT_DIFF_METADATA_STALE_TIME = 30_000;
+
+export type DiffFileMetadata = CheckoutDiffSnapshot["payload"]["files"][number];
+
+type CheckoutDiffMetadataPayload = Omit;
+
+function checkoutDiffMetadataQueryKey(
+ serverId: string,
+ cwd: string,
+ mode: "uncommitted" | "base",
+ baseRef?: string,
+ ignoreWhitespace?: boolean,
+) {
+ return [
+ "checkoutDiffMetadata",
+ serverId,
+ cwd,
+ mode,
+ baseRef ?? "",
+ ignoreWhitespace === true,
+ ] as const;
+}
+
+interface UseCheckoutDiffMetadataQueryOptions {
+ serverId: string;
+ cwd: string;
+ mode: "uncommitted" | "base";
+ baseRef?: string;
+ ignoreWhitespace?: boolean;
+ enabled?: boolean;
+}
+
+function normalizeCheckoutDiffCompare(compare: {
+ mode: "uncommitted" | "base";
+ baseRef?: string;
+ ignoreWhitespace?: boolean;
+}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
+ const ignoreWhitespace = compare.ignoreWhitespace === true;
+ if (compare.mode === "uncommitted") {
+ return { mode: "uncommitted", ignoreWhitespace };
+ }
+ const trimmedBaseRef = compare.baseRef?.trim();
+ return trimmedBaseRef
+ ? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
+ : { mode: "base", ignoreWhitespace };
+}
+
+export function useCheckoutDiffMetadataQuery({
+ serverId,
+ cwd,
+ mode,
+ baseRef,
+ ignoreWhitespace,
+ enabled = true,
+}: UseCheckoutDiffMetadataQueryOptions) {
+ const queryClient = useQueryClient();
+ const client = useHostRuntimeClient(serverId);
+ const isConnected = useHostRuntimeIsConnected(serverId);
+ const isMobile = useIsCompactFormFactor();
+ const mobileView = usePanelStore((state) => state.mobileView);
+ const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
+ const explorerTab = usePanelStore((state) => state.explorerTab);
+ const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
+ const hookInstanceId = useId();
+ const normalizedCompare = useMemo(
+ () => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
+ [mode, baseRef, ignoreWhitespace],
+ );
+ const compareMode = normalizedCompare.mode;
+ const compareBaseRef = normalizedCompare.baseRef;
+ const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
+ const queryKey = useMemo(
+ () => checkoutDiffMetadataQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
+ [serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
+ );
+
+ const query = useQuery({
+ queryKey,
+ queryFn: async () => {
+ if (!client) {
+ throw new Error("Daemon client not available");
+ }
+ const payload = await client.getCheckoutDiffMetadata(cwd, {
+ mode: compareMode,
+ baseRef: compareBaseRef,
+ ignoreWhitespace: compareIgnoreWhitespace,
+ });
+ return {
+ cwd: payload.cwd,
+ files: orderCheckoutDiffFiles(payload.files),
+ error: payload.error,
+ requestId: payload.requestId,
+ };
+ },
+ enabled: !!client && isConnected && !!cwd && enabled,
+ staleTime: CHECKOUT_DIFF_METADATA_STALE_TIME,
+ });
+
+ useEffect(() => {
+ if (!client || !isConnected || !cwd || !enabled) {
+ return;
+ }
+ if (!isOpen || explorerTab !== "changes") {
+ return;
+ }
+
+ const subscriptionId = [
+ "checkoutDiffMetadata",
+ hookInstanceId,
+ serverId,
+ cwd,
+ compareMode,
+ compareBaseRef ?? "",
+ compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
+ ].join(":");
+ let cancelled = false;
+
+ const unsubscribeSnapshot = client.on("checkout/diff_snapshot", (message) => {
+ if (message.type !== "checkout/diff_snapshot") {
+ return;
+ }
+ if (message.payload.subscriptionId !== subscriptionId) {
+ return;
+ }
+ queryClient.setQueryData(queryKey, {
+ cwd: message.payload.cwd,
+ files: orderCheckoutDiffFiles(message.payload.files),
+ error: message.payload.error,
+ requestId: message.payload.requestId,
+ });
+ });
+
+ void client
+ .subscribeCheckoutDiffLazy(
+ cwd,
+ {
+ mode: compareMode,
+ baseRef: compareBaseRef,
+ ignoreWhitespace: compareIgnoreWhitespace,
+ },
+ { subscriptionId },
+ )
+ .then((payload) => {
+ if (cancelled) {
+ return;
+ }
+ queryClient.setQueryData(queryKey, {
+ cwd: payload.cwd,
+ files: orderCheckoutDiffFiles(payload.files),
+ error: payload.error,
+ requestId: payload.requestId,
+ });
+ })
+ .catch((error) => {
+ if (cancelled) {
+ return;
+ }
+ console.error("[useCheckoutDiffMetadataQuery] subscribeCheckoutDiffLazy failed", {
+ serverId,
+ cwd,
+ error,
+ });
+ });
+
+ return () => {
+ cancelled = true;
+ unsubscribeSnapshot();
+ try {
+ client.unsubscribeCheckoutDiffLazy(subscriptionId);
+ } catch {
+ // Ignore disconnect race during effect cleanup.
+ }
+ };
+ }, [
+ client,
+ isConnected,
+ cwd,
+ enabled,
+ isOpen,
+ explorerTab,
+ hookInstanceId,
+ serverId,
+ compareMode,
+ compareBaseRef,
+ compareIgnoreWhitespace,
+ queryKey,
+ queryClient,
+ ]);
+
+ const refresh = useCallback(() => {
+ return query.refetch();
+ }, [query]);
+
+ const payload = query.data ?? null;
+ const payloadError = payload?.error ?? null;
+
+ return {
+ files: payload?.files ?? [],
+ payloadError,
+ isLoading: query.isLoading,
+ isFetching: query.isFetching,
+ isError: query.isError || Boolean(payloadError),
+ error: query.error,
+ refresh,
+ };
+}
diff --git a/packages/app/src/hooks/use-file-hunks.test.ts b/packages/app/src/hooks/use-file-hunks.test.ts
new file mode 100644
index 0000000000..e6932ac46b
--- /dev/null
+++ b/packages/app/src/hooks/use-file-hunks.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
+
+/**
+ * Tests for use-file-hunks hook logic.
+ *
+ * The hook itself is a thin React Query wrapper, so we test the key properties:
+ * 1. Query key includes fingerprint — fingerprint change = automatic refetch
+ * 2. Metadata fields are passed through to the merged result
+ */
+
+function fileHunksQueryKey(
+ serverId: string,
+ cwd: string,
+ mode: "uncommitted" | "base",
+ baseRef: string | undefined,
+ ignoreWhitespace: boolean | undefined,
+ path: string,
+ fingerprint: string,
+) {
+ return [
+ "checkoutFileHunks",
+ serverId,
+ cwd,
+ mode,
+ baseRef ?? "",
+ ignoreWhitespace === true,
+ path,
+ fingerprint,
+ ] as const;
+}
+
+describe("use-file-hunks query key", () => {
+ it("includes fingerprint in query key", () => {
+ const key = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-abc");
+ expect(key).toContain("fp-abc");
+ expect(key).toContain("foo.ts");
+ });
+
+ it("different fingerprints produce different query keys", () => {
+ const key1 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-1");
+ const key2 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-2");
+ expect(key1).not.toEqual(key2);
+ // Only the fingerprint element differs
+ expect(key1.slice(0, -1)).toEqual(key2.slice(0, -1));
+ });
+
+ it("same fingerprint + same params produces identical key", () => {
+ const key1 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
+ const key2 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
+ expect(key1).toEqual(key2);
+ });
+
+ it("normalizes missing baseRef to empty string", () => {
+ const key = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, false, "a.ts", "fp");
+ expect(key[4]).toBe("");
+ });
+
+ it("normalizes ignoreWhitespace to boolean", () => {
+ const key = fileHunksQueryKey("s1", "/repo", "base", "main", undefined, "a.ts", "fp");
+ expect(key[5]).toBe(false);
+ });
+});
+
+describe("use-file-hunks metadata merge", () => {
+ it("metadata fields carry through to merged ParsedDiffFile shape", () => {
+ const meta: DiffFileMetadata = {
+ path: "src/index.ts",
+ isNew: true,
+ isDeleted: false,
+ additions: 10,
+ deletions: 2,
+ status: "ok",
+ fingerprint: "abc",
+ };
+
+ // Simulates merging metadata with fetched hunks
+ const merged = {
+ path: meta.path,
+ isNew: meta.isNew,
+ isDeleted: meta.isDeleted,
+ additions: meta.additions,
+ deletions: meta.deletions,
+ status: meta.status,
+ hunks: [],
+ };
+
+ expect(merged.path).toBe("src/index.ts");
+ expect(merged.isNew).toBe(true);
+ expect(merged.additions).toBe(10);
+ expect(merged.hunks).toEqual([]);
+ });
+});
diff --git a/packages/app/src/hooks/use-file-hunks.ts b/packages/app/src/hooks/use-file-hunks.ts
new file mode 100644
index 0000000000..b60db07745
--- /dev/null
+++ b/packages/app/src/hooks/use-file-hunks.ts
@@ -0,0 +1,99 @@
+import { useQuery } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
+import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
+import type { DiffFileMetadata } from "@/hooks/use-checkout-diff-metadata-query";
+
+function fileHunksQueryKey(
+ serverId: string,
+ cwd: string,
+ mode: "uncommitted" | "base",
+ baseRef: string | undefined,
+ ignoreWhitespace: boolean | undefined,
+ path: string,
+ fingerprint: string,
+) {
+ return [
+ "checkoutFileHunks",
+ serverId,
+ cwd,
+ mode,
+ baseRef ?? "",
+ ignoreWhitespace === true,
+ path,
+ fingerprint,
+ ] as const;
+}
+
+interface UseFileHunksOptions {
+ serverId: string;
+ cwd: string;
+ mode: "uncommitted" | "base";
+ baseRef?: string;
+ ignoreWhitespace?: boolean;
+ file: DiffFileMetadata;
+ enabled?: boolean;
+}
+
+export function useFileHunks({
+ serverId,
+ cwd,
+ mode,
+ baseRef,
+ ignoreWhitespace,
+ file,
+ enabled = true,
+}: UseFileHunksOptions) {
+ const client = useHostRuntimeClient(serverId);
+ const isConnected = useHostRuntimeIsConnected(serverId);
+
+ const queryKey = useMemo(
+ () =>
+ fileHunksQueryKey(
+ serverId,
+ cwd,
+ mode,
+ baseRef,
+ ignoreWhitespace,
+ file.path,
+ file.fingerprint,
+ ),
+ [serverId, cwd, mode, baseRef, ignoreWhitespace, file.path, file.fingerprint],
+ );
+
+ const query = useQuery({
+ queryKey,
+ queryFn: async (): Promise => {
+ if (!client) {
+ throw new Error("Daemon client not available");
+ }
+ const payload = await client.getCheckoutFileHunks(
+ cwd,
+ { mode, baseRef, ignoreWhitespace },
+ file.path,
+ );
+ if (payload.error) {
+ throw new Error(payload.error.message);
+ }
+ return {
+ path: file.path,
+ isNew: file.isNew,
+ isDeleted: file.isDeleted,
+ additions: file.additions,
+ deletions: file.deletions,
+ status: file.status,
+ hunks: payload.hunks,
+ };
+ },
+ enabled: !!client && isConnected && enabled,
+ staleTime: 60_000,
+ });
+
+ return {
+ file: query.data ?? null,
+ isLoading: query.isLoading,
+ isFetching: query.isFetching,
+ isError: query.isError,
+ error: query.error,
+ };
+}
diff --git a/packages/app/src/utils/server-info-capabilities.test.ts b/packages/app/src/utils/server-info-capabilities.test.ts
index 00a9146adf..c6a36102c9 100644
--- a/packages/app/src/utils/server-info-capabilities.test.ts
+++ b/packages/app/src/utils/server-info-capabilities.test.ts
@@ -5,6 +5,7 @@ import {
getServerCapabilities,
getVoiceReadinessState,
resolveVoiceUnavailableMessage,
+ supportsLazyDiffRpcs,
} from "./server-info-capabilities";
function buildServerInfo(capabilities?: ServerCapabilities): DaemonServerInfo {
@@ -117,3 +118,42 @@ describe("server-info-capabilities", () => {
).toBeNull();
});
});
+
+describe("supportsLazyDiffRpcs", () => {
+ it("returns false when serverInfo is null", () => {
+ expect(supportsLazyDiffRpcs({ serverInfo: null })).toBe(false);
+ });
+
+ it("returns false when serverInfo is undefined", () => {
+ expect(supportsLazyDiffRpcs({ serverInfo: undefined })).toBe(false);
+ });
+
+ it("returns false when features is absent", () => {
+ const serverInfo = buildServerInfo();
+ expect(supportsLazyDiffRpcs({ serverInfo })).toBe(false);
+ });
+
+ it("returns false when lazyDiffRpcs is not set", () => {
+ const serverInfo: DaemonServerInfo = {
+ ...buildServerInfo(),
+ features: {},
+ };
+ expect(supportsLazyDiffRpcs({ serverInfo })).toBe(false);
+ });
+
+ it("returns true when lazyDiffRpcs is true", () => {
+ const serverInfo: DaemonServerInfo = {
+ ...buildServerInfo(),
+ features: { lazyDiffRpcs: true },
+ };
+ expect(supportsLazyDiffRpcs({ serverInfo })).toBe(true);
+ });
+
+ it("returns false when lazyDiffRpcs is false", () => {
+ const serverInfo: DaemonServerInfo = {
+ ...buildServerInfo(),
+ features: { lazyDiffRpcs: false },
+ };
+ expect(supportsLazyDiffRpcs({ serverInfo })).toBe(false);
+ });
+});
diff --git a/packages/app/src/utils/server-info-capabilities.ts b/packages/app/src/utils/server-info-capabilities.ts
index fdba665548..2600d5d773 100644
--- a/packages/app/src/utils/server-info-capabilities.ts
+++ b/packages/app/src/utils/server-info-capabilities.ts
@@ -1,6 +1,12 @@
import type { ServerCapabilityState } from "@server/shared/messages";
import type { DaemonServerInfo } from "@/stores/session-store";
+export function supportsLazyDiffRpcs(params: {
+ serverInfo: DaemonServerInfo | null | undefined;
+}): boolean {
+ return params.serverInfo?.features?.lazyDiffRpcs === true;
+}
+
export type VoiceReadinessMode = "dictation" | "voice";
export function getServerCapabilities(params: {
diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts
index 74afe4b5c8..c1fb12e058 100644
--- a/packages/server/src/client/daemon-client.test.ts
+++ b/packages/server/src/client/daemon-client.test.ts
@@ -516,6 +516,70 @@ describe("DaemonClient", () => {
});
});
+ test("subscribes to lazy checkout diff updates via RPC handshake", async () => {
+ const logger = createMockLogger();
+ const mock = createMockTransport();
+
+ const client = new DaemonClient({
+ url: "ws://test",
+ clientId: "clsk_unit_test",
+ logger,
+ reconnect: { enabled: false },
+ transportFactory: () => mock.transport,
+ });
+ clients.push(client);
+
+ const connectPromise = client.connect();
+ mock.triggerOpen();
+ await connectPromise;
+
+ const promise = client.subscribeCheckoutDiffLazy(
+ "/tmp/project",
+ { mode: "uncommitted" },
+ { subscriptionId: "checkout-lazy-sub-1" },
+ );
+
+ expect(mock.sent).toHaveLength(1);
+ const request = JSON.parse(mock.sent[0]) as {
+ type: "session";
+ message: {
+ type: "checkout/subscribe_diff";
+ subscriptionId: string;
+ cwd: string;
+ compare: { mode: "uncommitted" | "base"; baseRef?: string };
+ requestId: string;
+ };
+ };
+ expect(request.message.type).toBe("checkout/subscribe_diff");
+ expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1");
+ expect(request.message.cwd).toBe("/tmp/project");
+ expect(request.message.compare).toEqual({ mode: "uncommitted", ignoreWhitespace: false });
+
+ mock.triggerMessage(
+ JSON.stringify({
+ type: "session",
+ message: {
+ type: "checkout/diff_snapshot",
+ payload: {
+ subscriptionId: "checkout-lazy-sub-1",
+ cwd: "/tmp/project",
+ files: [],
+ error: null,
+ requestId: request.message.requestId,
+ },
+ },
+ }),
+ );
+
+ await expect(promise).resolves.toEqual({
+ subscriptionId: "checkout-lazy-sub-1",
+ cwd: "/tmp/project",
+ files: [],
+ error: null,
+ requestId: request.message.requestId,
+ });
+ });
+
test("getCheckoutDiff uses one-shot subscription protocol", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -589,6 +653,105 @@ describe("DaemonClient", () => {
expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId);
});
+ test("getCheckoutFileHunks requests hunks via RPC", async () => {
+ const logger = createMockLogger();
+ const mock = createMockTransport();
+
+ const client = new DaemonClient({
+ url: "ws://test",
+ clientId: "clsk_unit_test",
+ logger,
+ reconnect: { enabled: false },
+ transportFactory: () => mock.transport,
+ });
+ clients.push(client);
+
+ const connectPromise = client.connect();
+ mock.triggerOpen();
+ await connectPromise;
+
+ const promise = client.getCheckoutFileHunks(
+ "/tmp/project",
+ { mode: "base", baseRef: "main" },
+ "src/app.ts",
+ );
+
+ expect(mock.sent).toHaveLength(1);
+ const request = JSON.parse(mock.sent[0]) as {
+ type: "session";
+ message: {
+ type: "checkout/get_file_hunks";
+ cwd: string;
+ compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
+ path: string;
+ requestId: string;
+ };
+ };
+ expect(request.message.type).toBe("checkout/get_file_hunks");
+ expect(request.message.cwd).toBe("/tmp/project");
+ expect(request.message.compare).toEqual({
+ mode: "base",
+ baseRef: "main",
+ ignoreWhitespace: false,
+ });
+ expect(request.message.path).toBe("src/app.ts");
+
+ mock.triggerMessage(
+ JSON.stringify({
+ type: "session",
+ message: {
+ type: "checkout/file_hunks_response",
+ payload: {
+ requestId: request.message.requestId,
+ path: "src/app.ts",
+ hunks: [],
+ error: null,
+ },
+ },
+ }),
+ );
+
+ await expect(promise).resolves.toEqual({
+ requestId: request.message.requestId,
+ path: "src/app.ts",
+ hunks: [],
+ error: null,
+ });
+ });
+
+ test("unsubscribeCheckoutDiffLazy sends unsubscribe request", async () => {
+ const logger = createMockLogger();
+ const mock = createMockTransport();
+
+ const client = new DaemonClient({
+ url: "ws://test",
+ clientId: "clsk_unit_test",
+ logger,
+ reconnect: { enabled: false },
+ transportFactory: () => mock.transport,
+ });
+ clients.push(client);
+
+ const connectPromise = client.connect();
+ mock.triggerOpen();
+ await connectPromise;
+
+ client.unsubscribeCheckoutDiffLazy("checkout-lazy-sub-1");
+
+ expect(mock.sent).toHaveLength(1);
+ const request = JSON.parse(mock.sent[0]) as {
+ type: "session";
+ message: {
+ type: "checkout/unsubscribe_diff";
+ subscriptionId: string;
+ };
+ };
+ expect(request.message).toEqual({
+ type: "checkout/unsubscribe_diff",
+ subscriptionId: "checkout-lazy-sub-1",
+ });
+ });
+
test("requests branch suggestions via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -886,6 +1049,60 @@ describe("DaemonClient", () => {
expect(request.message.requestId.length).toBeGreaterThan(0);
});
+ test("resubscribes lazy checkout diff streams after reconnect", async () => {
+ const logger = createMockLogger();
+ const mock = createMockTransport();
+
+ const client = new DaemonClient({
+ url: "ws://test",
+ clientId: "clsk_unit_test",
+ logger,
+ reconnect: { enabled: false },
+ transportFactory: () => mock.transport,
+ });
+ clients.push(client);
+
+ const internal = client as unknown as {
+ checkoutDiffLazySubscriptions: Map<
+ string,
+ {
+ cwd: string;
+ compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
+ }
+ >;
+ };
+ internal.checkoutDiffLazySubscriptions.set("checkout-lazy-sub-1", {
+ cwd: "/tmp/project",
+ compare: { mode: "base", baseRef: "main", ignoreWhitespace: false },
+ });
+
+ const connectPromise = client.connect();
+ mock.triggerOpen();
+ await connectPromise;
+
+ expect(mock.sent).toHaveLength(1);
+ const request = JSON.parse(mock.sent[0]) as {
+ type: "session";
+ message: {
+ type: "checkout/subscribe_diff";
+ subscriptionId: string;
+ cwd: string;
+ compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
+ requestId: string;
+ };
+ };
+ expect(request.message.type).toBe("checkout/subscribe_diff");
+ expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1");
+ expect(request.message.cwd).toBe("/tmp/project");
+ expect(request.message.compare).toEqual({
+ mode: "base",
+ baseRef: "main",
+ ignoreWhitespace: false,
+ });
+ expect(typeof request.message.requestId).toBe("string");
+ expect(request.message.requestId.length).toBeGreaterThan(0);
+ });
+
test("fetches agents via RPC with filters, sort, and pagination", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts
index 2d7ba7db40..6681b14c73 100644
--- a/packages/server/src/client/daemon-client.ts
+++ b/packages/server/src/client/daemon-client.ts
@@ -216,6 +216,14 @@ type SubscribeCheckoutDiffPayload = Extract<
{ type: "subscribe_checkout_diff_response" }
>["payload"];
type CheckoutDiffPayload = Omit;
+type CheckoutDiffMetadataSnapshotPayload = Extract<
+ SessionOutboundMessage,
+ { type: "checkout/diff_snapshot" }
+>["payload"];
+type CheckoutFileHunksPayload = Extract<
+ SessionOutboundMessage,
+ { type: "checkout/file_hunks_response" }
+>["payload"];
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
@@ -500,8 +508,13 @@ type SetDaemonConfigResponse = Extract<
SessionOutboundMessage,
{ type: "set_daemon_config_response" }
>;
+type CheckoutDiffSnapshotResponse = Extract<
+ SessionOutboundMessage,
+ { type: "checkout/diff_snapshot" }
+>;
type CorrelatedResponseMessage =
| Extract
+ | CheckoutDiffSnapshotResponse
| GetDaemonConfigResponse
| SetDaemonConfigResponse;
type CorrelatedResponseType = CorrelatedResponseMessage["type"];
@@ -616,6 +629,13 @@ export class DaemonClient {
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>();
+ private checkoutDiffLazySubscriptions = new Map<
+ string,
+ {
+ cwd: string;
+ compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
+ }
+ >();
private terminalDirectorySubscriptions = new Set();
private terminalSlots = new Map();
private slotTerminals = new Map();
@@ -1408,6 +1428,22 @@ export class DaemonClient {
}
}
+ private resubscribeCheckoutDiffLazySubscriptions(): void {
+ if (this.checkoutDiffLazySubscriptions.size === 0) {
+ return;
+ }
+ for (const [subscriptionId, subscription] of this.checkoutDiffLazySubscriptions) {
+ const message = SessionInboundMessageSchema.parse({
+ type: "checkout/subscribe_diff",
+ subscriptionId,
+ cwd: subscription.cwd,
+ compare: subscription.compare,
+ requestId: this.createRequestId(),
+ });
+ this.sendSessionMessage(message);
+ }
+ }
+
private resubscribeTerminalDirectorySubscriptions(): void {
if (this.terminalDirectorySubscriptions.size === 0) {
return;
@@ -2270,6 +2306,105 @@ export class DaemonClient {
});
}
+ async getCheckoutDiffMetadata(
+ cwd: string,
+ compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
+ requestId?: string,
+ ): Promise> {
+ const oneShotSubscriptionId = `oneshot-checkout-diff-lazy:${crypto.randomUUID()}`;
+ try {
+ const payload = await this.subscribeCheckoutDiffLazy(cwd, compare, {
+ subscriptionId: oneShotSubscriptionId,
+ requestId,
+ });
+ return {
+ cwd: payload.cwd,
+ files: payload.files,
+ error: payload.error,
+ requestId: payload.requestId,
+ };
+ } finally {
+ try {
+ this.unsubscribeCheckoutDiffLazy(oneShotSubscriptionId);
+ } catch {
+ // Ignore disconnect races during one-shot cleanup.
+ }
+ }
+ }
+
+ async subscribeCheckoutDiffLazy(
+ cwd: string,
+ compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
+ options?: { subscriptionId?: string; requestId?: string },
+ ): Promise {
+ const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
+ const normalizedCompare = this.normalizeCheckoutDiffCompare(compare);
+ const previousSubscription = this.checkoutDiffLazySubscriptions.get(subscriptionId) ?? null;
+ this.checkoutDiffLazySubscriptions.set(subscriptionId, {
+ cwd,
+ compare: normalizedCompare,
+ });
+
+ const resolvedRequestId = this.createRequestId(options?.requestId);
+ const message = SessionInboundMessageSchema.parse({
+ type: "checkout/subscribe_diff",
+ subscriptionId,
+ cwd,
+ compare: normalizedCompare,
+ requestId: resolvedRequestId,
+ });
+
+ try {
+ return await this.sendCorrelatedRequest({
+ requestId: resolvedRequestId,
+ message,
+ responseType: "checkout/diff_snapshot",
+ timeout: 60000,
+ options: { skipQueue: true },
+ selectPayload: (payload) => {
+ if (payload.subscriptionId !== subscriptionId) {
+ return null;
+ }
+ return payload;
+ },
+ });
+ } catch (error) {
+ if (previousSubscription) {
+ this.checkoutDiffLazySubscriptions.set(subscriptionId, previousSubscription);
+ } else {
+ this.checkoutDiffLazySubscriptions.delete(subscriptionId);
+ }
+ throw error;
+ }
+ }
+
+ unsubscribeCheckoutDiffLazy(subscriptionId: string): void {
+ this.checkoutDiffLazySubscriptions.delete(subscriptionId);
+ this.sendSessionMessage({
+ type: "checkout/unsubscribe_diff",
+ subscriptionId,
+ });
+ }
+
+ async getCheckoutFileHunks(
+ cwd: string,
+ compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
+ path: string,
+ requestId?: string,
+ ): Promise {
+ return this.sendCorrelatedSessionRequest({
+ requestId,
+ message: {
+ type: "checkout/get_file_hunks",
+ cwd,
+ compare: this.normalizeCheckoutDiffCompare(compare),
+ path,
+ },
+ responseType: "checkout/file_hunks_response",
+ timeout: 60000,
+ });
+ }
+
async checkoutCommit(
cwd: string,
input: { message?: string; addAll?: boolean },
@@ -3723,6 +3858,7 @@ export class DaemonClient {
this.reconnectAttempt = 0;
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
this.resubscribeCheckoutDiffSubscriptions();
+ this.resubscribeCheckoutDiffLazySubscriptions();
this.resubscribeTerminalDirectorySubscriptions();
this.flushPendingSendQueue();
this.resolveConnect();
diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts
index 356f8cf15c..f8ca46815c 100644
--- a/packages/server/src/server/checkout-diff-manager.test.ts
+++ b/packages/server/src/server/checkout-diff-manager.test.ts
@@ -81,6 +81,53 @@ vi.mock("./checkout-git-utils.js", () => ({
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
+function createStructuredFile(options?: {
+ path?: string;
+ addContent?: string;
+ tokenStyle?: string;
+}): {
+ path: string;
+ isNew: boolean;
+ isDeleted: boolean;
+ additions: number;
+ deletions: number;
+ hunks: Array<{
+ oldStart: number;
+ oldCount: number;
+ newStart: number;
+ newCount: number;
+ lines: Array<{ type: "header" | "context" | "add"; content: string; tokens?: unknown[] }>;
+ }>;
+ status: "ok";
+} {
+ const addContent = options?.addContent ?? "const value = 2;";
+ return {
+ path: options?.path ?? "src/example.ts",
+ isNew: false,
+ isDeleted: false,
+ additions: 1,
+ deletions: 0,
+ hunks: [
+ {
+ oldStart: 1,
+ oldCount: 1,
+ newStart: 1,
+ newCount: 2,
+ lines: [
+ { type: "header", content: "@@ -1,1 +1,2 @@" },
+ { type: "context", content: "const value = 1;" },
+ {
+ type: "add",
+ content: addContent,
+ tokens: [{ text: "const", style: options?.tokenStyle ?? "keyword" }],
+ },
+ ],
+ },
+ ],
+ status: "ok",
+ };
+}
+
describe("CheckoutDiffManager Linux watchers", () => {
const originalPlatform = process.platform;
@@ -134,4 +181,102 @@ describe("CheckoutDiffManager Linux watchers", () => {
subscription.unsubscribe();
manager.dispose();
});
+
+ test("subscribeLazy returns metadata snapshots and caches file hunks", async () => {
+ getCheckoutDiffMock.mockResolvedValueOnce({
+ diff: "",
+ structured: [createStructuredFile()],
+ });
+
+ const logger = {
+ child: () => logger,
+ warn: vi.fn(),
+ };
+ const manager = new CheckoutDiffManager({
+ logger: logger as any,
+ paseoHome: "/tmp/paseo-test",
+ });
+
+ const listener = vi.fn();
+ const subscription = await manager.subscribeLazy(
+ {
+ cwd: path.join("/tmp/repo", "packages", "server"),
+ compare: { mode: "uncommitted" },
+ },
+ listener,
+ );
+
+ expect(subscription.initial.error).toBeNull();
+ expect(subscription.initial.files).toEqual([
+ expect.objectContaining({
+ path: "src/example.ts",
+ additions: 1,
+ deletions: 0,
+ fingerprint: expect.any(String),
+ }),
+ ]);
+ expect((subscription.initial.files[0] as any).hunks).toBeUndefined();
+ expect(
+ manager.getFileHunks(
+ path.join("/tmp/repo", "packages", "server"),
+ { mode: "uncommitted" },
+ "src/example.ts",
+ ),
+ ).toEqual(createStructuredFile().hunks);
+
+ subscription.unsubscribe();
+ manager.dispose();
+ });
+
+ test("lazy metadata fingerprint ignores token-only changes but emits on hunk changes", async () => {
+ getCheckoutDiffMock.mockResolvedValueOnce({
+ diff: "",
+ structured: [createStructuredFile({ tokenStyle: "keyword" })],
+ });
+ getCheckoutDiffMock.mockResolvedValueOnce({
+ diff: "",
+ structured: [createStructuredFile({ tokenStyle: "variableName" })],
+ });
+ getCheckoutDiffMock.mockResolvedValueOnce({
+ diff: "",
+ structured: [createStructuredFile({ addContent: "const value = 3;" })],
+ });
+
+ const logger = {
+ child: () => logger,
+ warn: vi.fn(),
+ };
+ const manager = new CheckoutDiffManager({
+ logger: logger as any,
+ paseoHome: "/tmp/paseo-test",
+ });
+
+ const listener = vi.fn();
+ await manager.subscribeLazy(
+ {
+ cwd: path.join("/tmp/repo", "packages", "server"),
+ compare: { mode: "uncommitted" },
+ },
+ listener,
+ );
+
+ const target = Array.from((manager as any).targets.values())[0];
+ await (manager as any).refreshTarget(target);
+ expect(listener).not.toHaveBeenCalled();
+
+ await (manager as any).refreshTarget(target);
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ files: [
+ expect.objectContaining({
+ path: "src/example.ts",
+ fingerprint: expect.any(String),
+ }),
+ ],
+ }),
+ );
+
+ manager.dispose();
+ });
});
diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts
index 0274fff221..d47775ff78 100644
--- a/packages/server/src/server/checkout-diff-manager.ts
+++ b/packages/server/src/server/checkout-diff-manager.ts
@@ -1,8 +1,10 @@
+import { createHash } from "node:crypto";
import { watch, type FSWatcher } from "node:fs";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type pino from "pino";
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
+import type { DiffFileMetadata } from "./checkout/rpc-schemas.js";
import { getCheckoutDiff } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import { runGitCommand } from "../utils/run-git-command.js";
@@ -18,6 +20,16 @@ export type CheckoutDiffSnapshotPayload = Omit<
"subscriptionId"
>;
+type ParsedDiffFile = CheckoutDiffSnapshotPayload["files"][number];
+
+export type { DiffFileMetadata };
+
+export type CheckoutLazyDiffSnapshotPayload = {
+ cwd: string;
+ files: DiffFileMetadata[];
+ error: CheckoutDiffSnapshotPayload["error"];
+};
+
export type CheckoutDiffMetrics = {
checkoutDiffTargetCount: number;
checkoutDiffSubscriptionCount: number;
@@ -31,13 +43,17 @@ type CheckoutDiffWatchTarget = {
diffCwd: string;
compare: CheckoutDiffCompareInput;
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
+ lazyListeners: Set<(snapshot: CheckoutLazyDiffSnapshotPayload) => void>;
watchers: FSWatcher[];
fallbackRefreshInterval: NodeJS.Timeout | null;
debounceTimer: NodeJS.Timeout | null;
refreshPromise: Promise | null;
refreshQueued: boolean;
latestPayload: CheckoutDiffSnapshotPayload | null;
+ latestLazyPayload: CheckoutLazyDiffSnapshotPayload | null;
latestFingerprint: string | null;
+ latestFilesByPath: Map;
+ latestMetadataFingerprints: Map;
watchedPaths: Set;
repoWatchPath: string | null;
linuxTreeRefreshPromise: Promise | null;
@@ -72,6 +88,7 @@ export class CheckoutDiffManager {
diffCwd: target.diffCwd,
}));
target.latestPayload = initial;
+ target.latestFilesByPath = new Map(initial.files.map((file) => [file.path, file]));
target.latestFingerprint = JSON.stringify(initial);
return {
initial,
@@ -81,6 +98,36 @@ export class CheckoutDiffManager {
};
}
+ async subscribeLazy(
+ params: {
+ cwd: string;
+ compare: CheckoutDiffCompareInput;
+ },
+ listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void,
+ ): Promise<{ initial: CheckoutLazyDiffSnapshotPayload; unsubscribe: () => void }> {
+ const cwd = params.cwd;
+ const compare = this.normalizeCompare(params.compare);
+ const target = await this.ensureTarget(cwd, compare);
+ target.lazyListeners.add(listener);
+
+ const baseSnapshot =
+ target.latestPayload ??
+ (await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, {
+ diffCwd: target.diffCwd,
+ }));
+ target.latestPayload = baseSnapshot;
+ target.latestFilesByPath = new Map(baseSnapshot.files.map((file) => [file.path, file]));
+ const initial = target.latestLazyPayload ?? this.buildLazySnapshot(baseSnapshot);
+ target.latestLazyPayload = initial;
+ target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(initial.files);
+ return {
+ initial,
+ unsubscribe: () => {
+ this.removeLazyListener(target.key, listener);
+ },
+ };
+ }
+
scheduleRefreshForCwd(cwd: string): void {
const resolvedCwd = expandTilde(cwd);
for (const target of this.targets.values()) {
@@ -98,6 +145,7 @@ export class CheckoutDiffManager {
for (const target of this.targets.values()) {
checkoutDiffSubscriptionCount += target.listeners.size;
+ checkoutDiffSubscriptionCount += target.lazyListeners.size;
checkoutDiffWatcherCount += target.watchers.length;
if (target.fallbackRefreshInterval) {
checkoutDiffFallbackRefreshTargetCount += 1;
@@ -154,6 +202,9 @@ export class CheckoutDiffManager {
target.watchers = [];
target.watchedPaths.clear();
target.listeners.clear();
+ target.lazyListeners.clear();
+ target.latestFilesByPath.clear();
+ target.latestMetadataFingerprints.clear();
}
private removeListener(
@@ -165,7 +216,23 @@ export class CheckoutDiffManager {
return;
}
target.listeners.delete(listener);
- if (target.listeners.size > 0) {
+ if (target.listeners.size > 0 || target.lazyListeners.size > 0) {
+ return;
+ }
+ this.closeTarget(target);
+ this.targets.delete(targetKey);
+ }
+
+ private removeLazyListener(
+ targetKey: string,
+ listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void,
+ ): void {
+ const target = this.targets.get(targetKey);
+ if (!target) {
+ return;
+ }
+ target.lazyListeners.delete(listener);
+ if (target.listeners.size > 0 || target.lazyListeners.size > 0) {
return;
}
this.closeTarget(target);
@@ -188,6 +255,15 @@ export class CheckoutDiffManager {
}
}
+ getFileHunks(
+ cwd: string,
+ compare: CheckoutDiffCompareInput,
+ path: string,
+ ): ParsedDiffFile["hunks"] | null {
+ const target = this.targets.get(this.buildTargetKey(cwd, this.normalizeCompare(compare)));
+ return target?.latestFilesByPath.get(path)?.hunks ?? null;
+ }
+
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
@@ -198,6 +274,81 @@ export class CheckoutDiffManager {
}, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS);
}
+ private buildLazySnapshot(
+ snapshot: CheckoutDiffSnapshotPayload,
+ ): CheckoutLazyDiffSnapshotPayload {
+ return {
+ cwd: snapshot.cwd,
+ files: this.deriveMetadata(snapshot.files),
+ error: snapshot.error,
+ };
+ }
+
+ private deriveMetadata(files: ParsedDiffFile[]): DiffFileMetadata[] {
+ return files.map((file) => ({
+ path: file.path,
+ isNew: file.isNew,
+ isDeleted: file.isDeleted,
+ additions: file.additions,
+ deletions: file.deletions,
+ ...(file.status ? { status: file.status } : {}),
+ fingerprint: this.buildFileFingerprint(file),
+ }));
+ }
+
+ private buildFileFingerprint(file: ParsedDiffFile): string {
+ const serialized = JSON.stringify({
+ hunks: file.hunks.map((hunk) => ({
+ oldStart: hunk.oldStart,
+ oldCount: hunk.oldCount,
+ newStart: hunk.newStart,
+ newCount: hunk.newCount,
+ lines: hunk.lines.map((line) => ({
+ type: line.type,
+ content: line.content,
+ })),
+ })),
+ });
+ return createHash("sha256").update(serialized).digest("hex");
+ }
+
+ private buildMetadataFingerprintMap(files: DiffFileMetadata[]): Map {
+ return new Map(
+ files.map((file) => [
+ file.path,
+ createHash("sha256").update(JSON.stringify(file)).digest("hex"),
+ ]),
+ );
+ }
+
+ private metadataSnapshotChanged(
+ target: CheckoutDiffWatchTarget,
+ snapshot: CheckoutLazyDiffSnapshotPayload,
+ ): boolean {
+ if (!target.latestLazyPayload) {
+ return true;
+ }
+ if (Boolean(target.latestLazyPayload.error) !== Boolean(snapshot.error)) {
+ return true;
+ }
+ if (
+ target.latestLazyPayload.error?.code !== snapshot.error?.code ||
+ target.latestLazyPayload.error?.message !== snapshot.error?.message
+ ) {
+ return true;
+ }
+ const nextFingerprints = this.buildMetadataFingerprintMap(snapshot.files);
+ if (nextFingerprints.size !== target.latestMetadataFingerprints.size) {
+ return true;
+ }
+ for (const [path, fingerprint] of nextFingerprints) {
+ if (target.latestMetadataFingerprints.get(path) !== fingerprint) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private async computeCheckoutDiffSnapshot(
cwd: string,
compare: CheckoutDiffCompareInput,
@@ -247,6 +398,7 @@ export class CheckoutDiffManager {
diffCwd: target.diffCwd,
});
target.latestPayload = snapshot;
+ target.latestFilesByPath = new Map(snapshot.files.map((file) => [file.path, file]));
const fingerprint = JSON.stringify(snapshot);
if (fingerprint !== target.latestFingerprint) {
target.latestFingerprint = fingerprint;
@@ -254,6 +406,17 @@ export class CheckoutDiffManager {
listener(snapshot);
}
}
+
+ const lazySnapshot = this.buildLazySnapshot(snapshot);
+ if (this.metadataSnapshotChanged(target, lazySnapshot)) {
+ target.latestLazyPayload = lazySnapshot;
+ target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(lazySnapshot.files);
+ for (const listener of target.lazyListeners) {
+ listener(lazySnapshot);
+ }
+ } else {
+ target.latestLazyPayload = lazySnapshot;
+ }
} while (target.refreshQueued);
})();
@@ -281,13 +444,17 @@ export class CheckoutDiffManager {
diffCwd: watchRoot ?? cwd,
compare,
listeners: new Set(),
+ lazyListeners: new Set(),
watchers: [],
fallbackRefreshInterval: null,
debounceTimer: null,
refreshPromise: null,
refreshQueued: false,
latestPayload: null,
+ latestLazyPayload: null,
latestFingerprint: null,
+ latestFilesByPath: new Map(),
+ latestMetadataFingerprints: new Map(),
watchedPaths: new Set(),
repoWatchPath: null,
linuxTreeRefreshPromise: null,
diff --git a/packages/server/src/server/checkout/rpc-schemas.test.ts b/packages/server/src/server/checkout/rpc-schemas.test.ts
new file mode 100644
index 0000000000..ecd1e3a908
--- /dev/null
+++ b/packages/server/src/server/checkout/rpc-schemas.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, test } from "vitest";
+import {
+ ServerInfoStatusPayloadSchema,
+ SessionInboundMessageSchema,
+ SessionOutboundMessageSchema,
+} from "../../shared/messages.js";
+import { DiffFileMetadataSchema } from "./rpc-schemas.js";
+
+describe("checkout lazy RPC schemas", () => {
+ test("parses checkout/subscribe_diff inbound messages", () => {
+ const parsed = SessionInboundMessageSchema.parse({
+ type: "checkout/subscribe_diff",
+ requestId: "req-1",
+ subscriptionId: "sub-1",
+ cwd: "/tmp/repo",
+ compare: { mode: "uncommitted" },
+ });
+
+ expect(parsed.type).toBe("checkout/subscribe_diff");
+ });
+
+ test("parses checkout/unsubscribe_diff inbound messages", () => {
+ const parsed = SessionInboundMessageSchema.parse({
+ type: "checkout/unsubscribe_diff",
+ subscriptionId: "sub-1",
+ });
+
+ expect(parsed.type).toBe("checkout/unsubscribe_diff");
+ });
+
+ test("parses checkout/get_file_hunks inbound messages", () => {
+ const parsed = SessionInboundMessageSchema.parse({
+ type: "checkout/get_file_hunks",
+ requestId: "req-2",
+ cwd: "/tmp/repo",
+ path: "src/index.ts",
+ compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
+ });
+
+ expect(parsed.type).toBe("checkout/get_file_hunks");
+ });
+
+ test("parses checkout/diff_snapshot outbound messages", () => {
+ const parsed = SessionOutboundMessageSchema.parse({
+ type: "checkout/diff_snapshot",
+ payload: {
+ subscriptionId: "sub-1",
+ requestId: "req-1",
+ cwd: "/tmp/repo",
+ files: [
+ {
+ path: "src/index.ts",
+ isNew: false,
+ isDeleted: false,
+ additions: 3,
+ deletions: 1,
+ status: "ok",
+ fingerprint: "fp-1",
+ },
+ ],
+ error: null,
+ },
+ });
+
+ expect(parsed.type).toBe("checkout/diff_snapshot");
+ });
+
+ test("parses checkout/file_hunks_response outbound messages", () => {
+ const parsed = SessionOutboundMessageSchema.parse({
+ type: "checkout/file_hunks_response",
+ payload: {
+ requestId: "req-3",
+ path: "src/index.ts",
+ hunks: [
+ {
+ oldStart: 1,
+ oldCount: 1,
+ newStart: 1,
+ newCount: 2,
+ lines: [
+ { type: "header", content: "@@ -1,1 +1,2 @@" },
+ { type: "context", content: "const a = 1;" },
+ { type: "add", content: "const b = 2;" },
+ ],
+ },
+ ],
+ error: null,
+ },
+ });
+
+ expect(parsed.type).toBe("checkout/file_hunks_response");
+ });
+
+ test("requires fingerprint on diff metadata", () => {
+ const result = DiffFileMetadataSchema.safeParse({
+ path: "src/index.ts",
+ isNew: false,
+ isDeleted: false,
+ additions: 3,
+ deletions: 1,
+ status: "ok",
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ test("accepts lazyDiffRpcs in server info features", () => {
+ const parsed = ServerInfoStatusPayloadSchema.parse({
+ status: "server_info",
+ serverId: "server-1",
+ hostname: null,
+ version: null,
+ capabilities: {},
+ features: {
+ providersSnapshot: true,
+ lazyDiffRpcs: true,
+ },
+ });
+
+ expect(parsed.features?.lazyDiffRpcs).toBe(true);
+ });
+});
diff --git a/packages/server/src/server/checkout/rpc-schemas.ts b/packages/server/src/server/checkout/rpc-schemas.ts
new file mode 100644
index 0000000000..df6bbbcea0
--- /dev/null
+++ b/packages/server/src/server/checkout/rpc-schemas.ts
@@ -0,0 +1,60 @@
+import { z } from "zod";
+import {
+ CheckoutDiffCompareSchema,
+ CheckoutErrorSchema,
+ DiffHunkSchema,
+} from "../../shared/messages.js";
+
+export const DiffFileMetadataSchema = z.object({
+ path: z.string(),
+ isNew: z.boolean(),
+ isDeleted: z.boolean(),
+ additions: z.number(),
+ deletions: z.number(),
+ status: z.enum(["ok", "too_large", "binary"]).optional(),
+ fingerprint: z.string(),
+});
+
+export type DiffFileMetadata = z.infer;
+
+export const CheckoutSubscribeDiffRequestSchema = z.object({
+ type: z.literal("checkout/subscribe_diff"),
+ requestId: z.string(),
+ subscriptionId: z.string(),
+ cwd: z.string(),
+ compare: z.lazy(() => CheckoutDiffCompareSchema),
+});
+
+export const CheckoutUnsubscribeDiffRequestSchema = z.object({
+ type: z.literal("checkout/unsubscribe_diff"),
+ subscriptionId: z.string(),
+});
+
+export const CheckoutGetFileHunksRequestSchema = z.object({
+ type: z.literal("checkout/get_file_hunks"),
+ requestId: z.string(),
+ cwd: z.string(),
+ path: z.string(),
+ compare: z.lazy(() => CheckoutDiffCompareSchema),
+});
+
+export const CheckoutDiffSnapshotSchema = z.object({
+ type: z.literal("checkout/diff_snapshot"),
+ payload: z.object({
+ subscriptionId: z.string(),
+ requestId: z.string().optional(),
+ cwd: z.string(),
+ files: z.array(DiffFileMetadataSchema),
+ error: z.lazy(() => CheckoutErrorSchema).nullable(),
+ }),
+});
+
+export const CheckoutFileHunksResponseSchema = z.object({
+ type: z.literal("checkout/file_hunks_response"),
+ payload: z.object({
+ requestId: z.string(),
+ path: z.string(),
+ hunks: z.array(z.lazy(() => DiffHunkSchema)),
+ error: z.lazy(() => CheckoutErrorSchema).nullable(),
+ }),
+});
diff --git a/packages/server/src/server/session.checkout-diff.test.ts b/packages/server/src/server/session.checkout-diff.test.ts
new file mode 100644
index 0000000000..45b128df04
--- /dev/null
+++ b/packages/server/src/server/session.checkout-diff.test.ts
@@ -0,0 +1,353 @@
+import { homedir } from "node:os";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const { getCheckoutDiffFileMock } = vi.hoisted(() => ({
+ getCheckoutDiffFileMock: vi.fn(),
+}));
+
+vi.mock("../utils/checkout-git.js", async () => {
+ const actual = await vi.importActual(
+ "../utils/checkout-git.js",
+ );
+ return {
+ ...actual,
+ getCheckoutDiffFile: getCheckoutDiffFileMock,
+ };
+});
+
+import { Session } from "./session.js";
+
+function createSessionForCheckoutDiffTests(options?: {
+ checkoutDiffManager?: {
+ subscribe: ReturnType;
+ subscribeLazy: ReturnType;
+ getFileHunks: ReturnType;
+ scheduleRefreshForCwd: ReturnType;
+ getMetrics: ReturnType;
+ dispose: ReturnType;
+ };
+}) {
+ const emitted: any[] = [];
+ const logger = {
+ child: () => logger,
+ trace: vi.fn(),
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ };
+
+ const checkoutDiffManager =
+ options?.checkoutDiffManager ??
+ ({
+ subscribe: vi.fn(async () => ({
+ initial: { cwd: "/tmp/repo", files: [], error: null },
+ unsubscribe: vi.fn(),
+ })),
+ subscribeLazy: vi.fn(async () => ({
+ initial: { cwd: "/tmp/repo", files: [], error: null },
+ unsubscribe: vi.fn(),
+ })),
+ getFileHunks: vi.fn(() => null),
+ scheduleRefreshForCwd: vi.fn(),
+ getMetrics: vi.fn(() => ({
+ checkoutDiffTargetCount: 0,
+ checkoutDiffSubscriptionCount: 0,
+ checkoutDiffWatcherCount: 0,
+ checkoutDiffFallbackRefreshTargetCount: 0,
+ })),
+ dispose: vi.fn(),
+ } as const);
+
+ const session = new Session({
+ clientId: "test-client",
+ appVersion: null,
+ onMessage: vi.fn(),
+ logger: logger as any,
+ downloadTokenStore: {} as any,
+ pushTokenStore: {} as any,
+ paseoHome: "/tmp/paseo-test",
+ agentManager: {
+ subscribe: () => () => {},
+ listAgents: () => [],
+ getAgent: () => null,
+ archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
+ clearAgentAttention: async () => {},
+ notifyAgentState: () => {},
+ } as any,
+ agentStorage: {
+ list: async () => [],
+ get: async () => null,
+ } as any,
+ projectRegistry: {
+ initialize: async () => {},
+ existsOnDisk: async () => true,
+ list: async () => [],
+ get: async () => null,
+ upsert: async () => {},
+ archive: async () => {},
+ remove: async () => {},
+ } as any,
+ workspaceRegistry: {
+ initialize: async () => {},
+ existsOnDisk: async () => true,
+ list: async () => [],
+ get: async () => null,
+ upsert: async () => {},
+ archive: async () => {},
+ remove: async () => {},
+ } as any,
+ checkoutDiffManager: checkoutDiffManager as any,
+ workspaceGitService: {
+ subscribe: async () => ({
+ initial: {
+ cwd: "/tmp/repo",
+ git: {
+ isGit: false,
+ repoRoot: null,
+ mainRepoRoot: null,
+ currentBranch: null,
+ remoteUrl: null,
+ isPaseoOwnedWorktree: false,
+ isDirty: null,
+ aheadBehind: null,
+ aheadOfOrigin: null,
+ behindOfOrigin: null,
+ diffStat: null,
+ },
+ github: {
+ featuresEnabled: false,
+ pullRequest: null,
+ error: null,
+ refreshedAt: null,
+ },
+ },
+ unsubscribe: vi.fn(),
+ }),
+ peekSnapshot: () => null,
+ getSnapshot: async () => null,
+ refresh: async () => {},
+ dispose: () => {},
+ } as any,
+ mcpBaseUrl: null,
+ stt: null,
+ tts: null,
+ terminalManager: null,
+ }) as any;
+
+ session.emit = (message: any) => emitted.push(message);
+
+ return { session, emitted, checkoutDiffManager };
+}
+
+describe("Session checkout lazy diff handlers", () => {
+ beforeEach(() => {
+ getCheckoutDiffFileMock.mockReset();
+ });
+
+ test("checkout/subscribe_diff emits initial metadata snapshot and replaces existing subscription", async () => {
+ const firstUnsubscribe = vi.fn();
+ const secondUnsubscribe = vi.fn();
+ const subscribeLazy = vi
+ .fn()
+ .mockResolvedValueOnce({
+ initial: {
+ cwd: "/tmp/repo",
+ files: [
+ {
+ path: "src/example.ts",
+ isNew: false,
+ isDeleted: false,
+ additions: 1,
+ deletions: 0,
+ fingerprint: "fp-1",
+ },
+ ],
+ error: null,
+ },
+ unsubscribe: firstUnsubscribe,
+ })
+ .mockResolvedValueOnce({
+ initial: { cwd: "/tmp/repo", files: [], error: null },
+ unsubscribe: secondUnsubscribe,
+ });
+
+ const { session, emitted } = createSessionForCheckoutDiffTests({
+ checkoutDiffManager: {
+ subscribe: vi.fn(),
+ subscribeLazy,
+ getFileHunks: vi.fn(),
+ scheduleRefreshForCwd: vi.fn(),
+ getMetrics: vi.fn(() => ({
+ checkoutDiffTargetCount: 0,
+ checkoutDiffSubscriptionCount: 0,
+ checkoutDiffWatcherCount: 0,
+ checkoutDiffFallbackRefreshTargetCount: 0,
+ })),
+ dispose: vi.fn(),
+ },
+ });
+
+ await session.handleMessage({
+ type: "checkout/subscribe_diff",
+ requestId: "req-1",
+ subscriptionId: "sub-1",
+ cwd: "~/repo",
+ compare: { mode: "uncommitted" },
+ });
+ await session.handleMessage({
+ type: "checkout/subscribe_diff",
+ requestId: "req-2",
+ subscriptionId: "sub-1",
+ cwd: "~/repo",
+ compare: { mode: "uncommitted" },
+ });
+
+ expect(subscribeLazy).toHaveBeenNthCalledWith(
+ 1,
+ { cwd: `${homedir()}/repo`, compare: { mode: "uncommitted" } },
+ expect.any(Function),
+ );
+ expect(firstUnsubscribe).toHaveBeenCalledTimes(1);
+ expect(emitted[0]).toMatchObject({
+ type: "checkout/diff_snapshot",
+ payload: {
+ subscriptionId: "sub-1",
+ requestId: "req-1",
+ cwd: "/tmp/repo",
+ },
+ });
+ });
+
+ test("checkout/get_file_hunks serves cached hunks before falling back to git", async () => {
+ const cachedHunks = [
+ {
+ oldStart: 1,
+ oldCount: 1,
+ newStart: 1,
+ newCount: 2,
+ lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }],
+ },
+ ];
+ const getFileHunks = vi.fn(() => cachedHunks);
+ const { session, emitted } = createSessionForCheckoutDiffTests({
+ checkoutDiffManager: {
+ subscribe: vi.fn(),
+ subscribeLazy: vi.fn(),
+ getFileHunks,
+ scheduleRefreshForCwd: vi.fn(),
+ getMetrics: vi.fn(() => ({
+ checkoutDiffTargetCount: 0,
+ checkoutDiffSubscriptionCount: 0,
+ checkoutDiffWatcherCount: 0,
+ checkoutDiffFallbackRefreshTargetCount: 0,
+ })),
+ dispose: vi.fn(),
+ },
+ });
+
+ await session.handleMessage({
+ type: "checkout/get_file_hunks",
+ requestId: "req-hunks",
+ cwd: "/tmp/repo",
+ path: "src/example.ts",
+ compare: { mode: "uncommitted" },
+ });
+
+ expect(getFileHunks).toHaveBeenCalledWith(
+ "/tmp/repo",
+ { mode: "uncommitted" },
+ "src/example.ts",
+ );
+ expect(getCheckoutDiffFileMock).not.toHaveBeenCalled();
+ expect(emitted[0]).toMatchObject({
+ type: "checkout/file_hunks_response",
+ payload: {
+ requestId: "req-hunks",
+ path: "src/example.ts",
+ hunks: cachedHunks,
+ error: null,
+ },
+ });
+ });
+
+ test("checkout/get_file_hunks falls back to single-file diff and unsubscribe cleans up", async () => {
+ const unsubscribe = vi.fn();
+ getCheckoutDiffFileMock.mockResolvedValueOnce({
+ path: "src/example.ts",
+ isNew: false,
+ isDeleted: false,
+ additions: 1,
+ deletions: 0,
+ hunks: [
+ {
+ oldStart: 1,
+ oldCount: 1,
+ newStart: 1,
+ newCount: 2,
+ lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }],
+ },
+ ],
+ status: "ok",
+ });
+
+ const { session, emitted } = createSessionForCheckoutDiffTests({
+ checkoutDiffManager: {
+ subscribe: vi.fn(),
+ subscribeLazy: vi.fn(async () => ({
+ initial: { cwd: "/tmp/repo", files: [], error: null },
+ unsubscribe,
+ })),
+ getFileHunks: vi.fn(() => null),
+ scheduleRefreshForCwd: vi.fn(),
+ getMetrics: vi.fn(() => ({
+ checkoutDiffTargetCount: 0,
+ checkoutDiffSubscriptionCount: 0,
+ checkoutDiffWatcherCount: 0,
+ checkoutDiffFallbackRefreshTargetCount: 0,
+ })),
+ dispose: vi.fn(),
+ },
+ });
+
+ await session.handleMessage({
+ type: "checkout/subscribe_diff",
+ requestId: "req-sub",
+ subscriptionId: "sub-1",
+ cwd: "/tmp/repo",
+ compare: { mode: "base", baseRef: "main" },
+ });
+ await session.handleMessage({
+ type: "checkout/get_file_hunks",
+ requestId: "req-hunks",
+ cwd: "/tmp/repo",
+ path: "src/example.ts",
+ compare: { mode: "base", baseRef: "main" },
+ });
+ await session.handleMessage({
+ type: "checkout/unsubscribe_diff",
+ subscriptionId: "sub-1",
+ });
+
+ expect(getCheckoutDiffFileMock).toHaveBeenCalledWith(
+ "/tmp/repo",
+ {
+ mode: "base",
+ baseRef: "main",
+ ignoreWhitespace: undefined,
+ includeStructured: true,
+ },
+ "src/example.ts",
+ { paseoHome: "/tmp/paseo-test" },
+ );
+ expect(emitted[1]).toMatchObject({
+ type: "checkout/file_hunks_response",
+ payload: {
+ requestId: "req-hunks",
+ path: "src/example.ts",
+ error: null,
+ },
+ });
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index e7d7d194af..a4304c4d41 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -28,6 +28,9 @@ import {
type CaptureTerminalRequest,
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
+ type CheckoutSubscribeDiffRequest,
+ type CheckoutUnsubscribeDiffRequest,
+ type CheckoutGetFileHunksRequest,
type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId,
@@ -149,6 +152,7 @@ import { type WorktreeConfig } from "../utils/worktree.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import {
getCheckoutDiff,
+ getCheckoutDiffFile,
getCheckoutStatus,
listBranchSuggestions,
commitChanges,
@@ -622,6 +626,7 @@ export class Session {
private inflightRequests = 0;
private peakInflightRequests = 0;
private readonly checkoutDiffSubscriptions = new Map void>();
+ private readonly checkoutDiffLazySubscriptions = new Map void>();
private readonly workspaceGitSubscriptions = new Map void>();
private readonly registerVoiceSpeakHandler?: (
agentId: string,
@@ -1783,6 +1788,18 @@ export class Session {
this.handleUnsubscribeCheckoutDiffRequest(msg);
break;
+ case "checkout/subscribe_diff":
+ await this.handleCheckoutSubscribeDiffRequest(msg);
+ break;
+
+ case "checkout/get_file_hunks":
+ await this.handleCheckoutGetFileHunksRequest(msg);
+ break;
+
+ case "checkout/unsubscribe_diff":
+ this.handleCheckoutUnsubscribeDiffRequest(msg);
+ break;
+
case "checkout_switch_branch_request":
await this.handleCheckoutSwitchBranchRequest(msg);
break;
@@ -4277,6 +4294,89 @@ export class Session {
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
}
+ private async handleCheckoutSubscribeDiffRequest(
+ msg: CheckoutSubscribeDiffRequest,
+ ): Promise {
+ const cwd = expandTilde(msg.cwd);
+ this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.();
+ this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId);
+
+ const subscription = await this.checkoutDiffManager.subscribeLazy(
+ { cwd, compare: msg.compare },
+ (snapshot) => {
+ this.emit({
+ type: "checkout/diff_snapshot",
+ payload: {
+ subscriptionId: msg.subscriptionId,
+ ...snapshot,
+ },
+ });
+ },
+ );
+ this.checkoutDiffLazySubscriptions.set(msg.subscriptionId, subscription.unsubscribe);
+
+ this.emit({
+ type: "checkout/diff_snapshot",
+ payload: {
+ subscriptionId: msg.subscriptionId,
+ ...subscription.initial,
+ requestId: msg.requestId,
+ },
+ });
+ }
+
+ private async handleCheckoutGetFileHunksRequest(msg: CheckoutGetFileHunksRequest): Promise {
+ const cwd = expandTilde(msg.cwd);
+ let hunks = this.checkoutDiffManager.getFileHunks(cwd, msg.compare, msg.path);
+ let error: {
+ code: "UNKNOWN" | "NOT_ALLOWED" | "NOT_GIT_REPO" | "MERGE_CONFLICT";
+ message: string;
+ } | null = null;
+
+ if (hunks === null) {
+ try {
+ const file = await getCheckoutDiffFile(
+ cwd,
+ {
+ mode: msg.compare.mode,
+ baseRef: msg.compare.baseRef,
+ ignoreWhitespace: msg.compare.ignoreWhitespace,
+ includeStructured: true,
+ },
+ msg.path,
+ { paseoHome: this.paseoHome },
+ );
+ if (file) {
+ hunks = file.hunks;
+ } else {
+ hunks = [];
+ error = {
+ code: "UNKNOWN",
+ message: "File no longer present in diff",
+ };
+ }
+ } catch (caughtError) {
+ hunks = [];
+ error = toCheckoutError(caughtError);
+ }
+ }
+
+ this.emit({
+ type: "checkout/file_hunks_response",
+ payload: {
+ requestId: msg.requestId,
+ path: msg.path,
+ hunks,
+ error,
+ },
+ });
+ }
+
+ private handleCheckoutUnsubscribeDiffRequest(msg: CheckoutUnsubscribeDiffRequest): void {
+ this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.();
+ this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId);
+ }
+
private async handleCheckoutSwitchBranchRequest(
msg: Extract,
): Promise {
@@ -7387,6 +7487,11 @@ export class Session {
}
this.checkoutDiffSubscriptions.clear();
+ for (const unsubscribe of this.checkoutDiffLazySubscriptions.values()) {
+ unsubscribe();
+ }
+ this.checkoutDiffLazySubscriptions.clear();
+
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
unsubscribe();
}
diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts
index 411c8dc7e9..2deb186c98 100644
--- a/packages/server/src/server/websocket-server.ts
+++ b/packages/server/src/server/websocket-server.ts
@@ -800,6 +800,7 @@ export class VoiceAssistantWebSocketServer {
features: {
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true,
+ lazyDiffRpcs: true,
},
};
}
diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts
index 63fdd7e6bc..5c6d357790 100644
--- a/packages/server/src/shared/messages.ts
+++ b/packages/server/src/shared/messages.ts
@@ -47,6 +47,13 @@ import {
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "../server/loop/rpc-schemas.js";
+import {
+ CheckoutDiffSnapshotSchema,
+ CheckoutFileHunksResponseSchema,
+ CheckoutGetFileHunksRequestSchema,
+ CheckoutSubscribeDiffRequestSchema,
+ CheckoutUnsubscribeDiffRequestSchema,
+} from "../server/checkout/rpc-schemas.js";
// ---------------------------------------------------------------------------
// Mutable daemon config schemas (shared between server store and client)
// ---------------------------------------------------------------------------
@@ -1013,12 +1020,12 @@ const CheckoutErrorCodeSchema = z.enum([
"UNKNOWN",
]);
-const CheckoutErrorSchema = z.object({
+export const CheckoutErrorSchema = z.object({
code: CheckoutErrorCodeSchema,
message: z.string(),
});
-const CheckoutDiffCompareSchema = z.object({
+export const CheckoutDiffCompareSchema = z.object({
mode: z.enum(["uncommitted", "base"]),
baseRef: z.string().optional(),
ignoreWhitespace: z.boolean().optional(),
@@ -1231,18 +1238,18 @@ export const ArchiveWorkspaceRequestSchema = z.object({
// Highlighted diff token schema
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
-const HighlightTokenSchema = z.object({
+export const HighlightTokenSchema = z.object({
text: z.string(),
style: z.string().nullable(),
});
-const DiffLineSchema = z.object({
+export const DiffLineSchema = z.object({
type: z.enum(["add", "remove", "context", "header"]),
content: z.string(),
tokens: z.array(HighlightTokenSchema).optional(),
});
-const DiffHunkSchema = z.object({
+export const DiffHunkSchema = z.object({
oldStart: z.number(),
oldCount: z.number(),
newStart: z.number(),
@@ -1250,7 +1257,7 @@ const DiffHunkSchema = z.object({
lines: z.array(DiffLineSchema),
});
-const ParsedDiffFileSchema = z.object({
+export const ParsedDiffFileSchema = z.object({
path: z.string(),
isNew: z.boolean(),
isDeleted: z.boolean(),
@@ -1464,6 +1471,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema,
+ CheckoutSubscribeDiffRequestSchema,
+ CheckoutUnsubscribeDiffRequestSchema,
+ CheckoutGetFileHunksRequestSchema,
CheckoutCommitRequestSchema,
CheckoutMergeRequestSchema,
CheckoutMergeFromBaseRequestSchema,
@@ -1686,6 +1696,7 @@ export const ServerInfoStatusPayloadSchema = z
features: z
.object({
providersSnapshot: z.boolean().optional(),
+ lazyDiffRpcs: z.boolean().optional(),
})
.optional(),
})
@@ -2789,6 +2800,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutStatusResponseSchema,
SubscribeCheckoutDiffResponseSchema,
CheckoutDiffUpdateSchema,
+ CheckoutDiffSnapshotSchema,
+ CheckoutFileHunksResponseSchema,
CheckoutCommitResponseSchema,
CheckoutMergeResponseSchema,
CheckoutMergeFromBaseResponseSchema,
@@ -2999,8 +3012,13 @@ export type CheckoutStatusRequest = z.infer;
export type CheckoutStatusResponse = z.infer;
export type SubscribeCheckoutDiffRequest = z.infer;
export type UnsubscribeCheckoutDiffRequest = z.infer;
+export type CheckoutSubscribeDiffRequest = z.infer;
+export type CheckoutUnsubscribeDiffRequest = z.infer;
+export type CheckoutGetFileHunksRequest = z.infer;
export type SubscribeCheckoutDiffResponse = z.infer;
export type CheckoutDiffUpdate = z.infer;
+export type CheckoutDiffSnapshot = z.infer;
+export type CheckoutFileHunksResponse = z.infer;
export type CheckoutCommitRequest = z.infer;
export type CheckoutCommitResponse = z.infer;
export type CheckoutMergeRequest = z.infer;
diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts
index 45cd97f358..9a1c97edbe 100644
--- a/packages/server/src/utils/checkout-git.ts
+++ b/packages/server/src/utils/checkout-git.ts
@@ -1277,9 +1277,10 @@ export async function getCheckoutShortstat(
}
}
-export async function getCheckoutDiff(
+async function getCheckoutDiffInternal(
cwd: string,
compare: CheckoutDiffCompare,
+ options?: { path?: string },
context?: CheckoutContext,
): Promise {
await requireGitRepo(cwd);
@@ -1305,7 +1306,12 @@ export async function getCheckoutDiff(
const ignoreWhitespace = compare.ignoreWhitespace === true;
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
- changes.sort((a, b) => {
+ const targetPath = options?.path;
+ const filteredChanges =
+ typeof targetPath === "string"
+ ? changes.filter((change) => change.path === targetPath)
+ : changes;
+ filteredChanges.sort((a, b) => {
if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1;
});
@@ -1329,8 +1335,8 @@ export async function getCheckoutDiff(
}
};
- const trackedChanges = changes.filter((change) => !change.isUntracked);
- const untrackedChanges = changes.filter((change) => change.isUntracked === true);
+ const trackedChanges = filteredChanges.filter((change) => !change.isUntracked);
+ const untrackedChanges = filteredChanges.filter((change) => change.isUntracked === true);
const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change]));
const trackedNumstatByPath =
@@ -1514,6 +1520,24 @@ export async function getCheckoutDiff(
return { diff: diffText };
}
+export async function getCheckoutDiff(
+ cwd: string,
+ compare: CheckoutDiffCompare,
+ context?: CheckoutContext,
+): Promise {
+ return getCheckoutDiffInternal(cwd, compare, undefined, context);
+}
+
+export async function getCheckoutDiffFile(
+ cwd: string,
+ compare: CheckoutDiffCompare,
+ path: string,
+ context?: CheckoutContext,
+): Promise {
+ const result = await getCheckoutDiffInternal(cwd, compare, { path }, context);
+ return result.structured?.[0] ?? null;
+}
+
export async function commitChanges(
cwd: string,
options: { message: string; addAll?: boolean },