Push-driven checkout diff subscriptions for changes sidebar (#24)

* Implement push-driven checkout diff subscriptions

* Remove legacy checkout diff polling RPC

* Fix checkout diff watcher coverage and fallback refresh
This commit is contained in:
Mohamed Boudra
2026-02-11 13:11:00 +07:00
committed by GitHub
parent 8e5ba622ee
commit a6f60091f2
9 changed files with 1066 additions and 67 deletions

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
compareCheckoutDiffPaths,
orderCheckoutDiffFiles,
} from "./checkout-diff-order";
function createFile(path: string, additions = 0) {
return {
path,
isNew: false,
isDeleted: false,
additions,
deletions: 0,
hunks: [],
};
}
describe("checkout diff ordering", () => {
it("compares paths deterministically", () => {
expect(compareCheckoutDiffPaths("a.ts", "b.ts")).toBeLessThan(0);
expect(compareCheckoutDiffPaths("b.ts", "a.ts")).toBeGreaterThan(0);
expect(compareCheckoutDiffPaths("same.ts", "same.ts")).toBe(0);
});
it("sorts files by path", () => {
const ordered = orderCheckoutDiffFiles([
createFile("zeta.ts"),
createFile("alpha.ts"),
createFile("beta.ts"),
]);
expect(ordered.map((file) => file.path)).toEqual([
"alpha.ts",
"beta.ts",
"zeta.ts",
]);
});
it("preserves relative order for equal paths", () => {
const ordered = orderCheckoutDiffFiles([
createFile("same.ts", 1),
createFile("same.ts", 2),
createFile("same.ts", 3),
]);
expect(ordered.map((file) => file.additions)).toEqual([1, 2, 3]);
});
});

View File

@@ -0,0 +1,21 @@
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
type ParsedDiffFile = SubscribeCheckoutDiffResponse["payload"]["files"][number];
export function compareCheckoutDiffPaths(left: string, right: string): number {
if (left === right) {
return 0;
}
return left < right ? -1 : 1;
}
export function orderCheckoutDiffFiles(
files: ParsedDiffFile[]
): ParsedDiffFile[] {
if (files.length < 2) {
return files;
}
const ordered = [...files];
ordered.sort((a, b) => compareCheckoutDiffPaths(a.path, b.path));
return ordered;
}

View File

@@ -1,9 +1,10 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useId, useMemo } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import { useSessionStore } from "@/stores/session-store";
import { usePanelStore } from "@/stores/panel-store";
import type { CheckoutDiffResponse } from "@server/shared/messages";
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
const CHECKOUT_DIFF_STALE_TIME = 30_000;
@@ -24,11 +25,29 @@ interface UseCheckoutDiffQueryOptions {
enabled?: boolean;
}
export type ParsedDiffFile = CheckoutDiffResponse["payload"]["files"][number];
type CheckoutDiffQueryPayload = Omit<
SubscribeCheckoutDiffResponse["payload"],
"subscriptionId"
>;
export type ParsedDiffFile = CheckoutDiffQueryPayload["files"][number];
export type DiffHunk = ParsedDiffFile["hunks"][number];
export type DiffLine = DiffHunk["lines"][number];
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
function normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
baseRef?: string;
}): { mode: "uncommitted" | "base"; baseRef?: string } {
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef }
: { mode: "base" };
}
export function useCheckoutDiffQuery({
serverId,
cwd,
@@ -49,29 +68,142 @@ export function useCheckoutDiffQuery({
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 }),
[mode, baseRef]
);
const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef;
const queryKey = useMemo(
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
[serverId, cwd, mode, baseRef]
);
const query = useQuery({
queryKey: checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
queryKey,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.getCheckoutDiff(cwd, { mode, baseRef });
const payload = await client.getCheckoutDiff(cwd, {
mode: compareMode,
baseRef: compareBaseRef,
});
return {
...payload,
files: orderCheckoutDiffFiles(payload.files),
};
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: CHECKOUT_DIFF_STALE_TIME,
refetchInterval: 10_000,
});
// Revalidate when sidebar opens with "changes" tab active
useEffect(() => {
if (!isOpen || explorerTab !== "changes" || !cwd) {
if (!client || !isConnected || !cwd || !enabled) {
return;
}
queryClient.invalidateQueries({
queryKey: checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
if (!isOpen || explorerTab !== "changes") {
return;
}
const subscriptionId = [
"checkoutDiff",
hookInstanceId,
serverId,
cwd,
compareMode,
compareBaseRef ?? "",
].join(":");
let cancelled = false;
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
if (message.type !== "checkout_diff_update") {
return;
}
if (message.payload.subscriptionId !== subscriptionId) {
return;
}
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
cwd: message.payload.cwd,
files: orderCheckoutDiffFiles(message.payload.files),
error: message.payload.error,
requestId: `subscription:${subscriptionId}`,
});
});
}, [isOpen, explorerTab, serverId, cwd, mode, baseRef, queryClient]);
const unsubscribeSubscribeResponse = client.on(
"subscribe_checkout_diff_response",
(message) => {
if (message.type !== "subscribe_checkout_diff_response") {
return;
}
if (message.payload.subscriptionId !== subscriptionId) {
return;
}
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
cwd: message.payload.cwd,
files: orderCheckoutDiffFiles(message.payload.files),
error: message.payload.error,
requestId: message.payload.requestId,
});
}
);
void client
.subscribeCheckoutDiff(
cwd,
{
mode: compareMode,
baseRef: compareBaseRef,
},
{ subscriptionId }
)
.then((payload) => {
if (cancelled) {
return;
}
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
cwd: payload.cwd,
files: orderCheckoutDiffFiles(payload.files),
error: payload.error,
requestId: payload.requestId,
});
})
.catch((error) => {
if (cancelled) {
return;
}
console.error("[useCheckoutDiffQuery] subscribeCheckoutDiff failed", {
serverId,
cwd,
error,
});
});
return () => {
cancelled = true;
unsubscribeUpdate();
unsubscribeSubscribeResponse();
try {
client.unsubscribeCheckoutDiff(subscriptionId);
} catch {
// Ignore disconnect race during effect cleanup.
}
};
}, [
client,
isConnected,
cwd,
enabled,
isOpen,
explorerTab,
hookInstanceId,
serverId,
compareMode,
compareBaseRef,
queryKey,
queryClient,
]);
const refresh = useCallback(() => {
return query.refetch();

View File

@@ -151,6 +151,185 @@ describe("DaemonClient", () => {
});
});
test("subscribes to checkout diff updates via RPC handshake", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.subscribeCheckoutDiff(
"/tmp/project",
{ mode: "uncommitted" },
{ subscriptionId: "checkout-sub-1" }
);
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "subscribe_checkout_diff_request";
subscriptionId: string;
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string };
requestId: string;
};
};
expect(request.message.type).toBe("subscribe_checkout_diff_request");
expect(request.message.subscriptionId).toBe("checkout-sub-1");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({ mode: "uncommitted" });
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "subscribe_checkout_diff_response",
payload: {
subscriptionId: "checkout-sub-1",
cwd: "/tmp/project",
files: [],
error: null,
requestId: request.message.requestId,
},
},
})
);
await expect(promise).resolves.toEqual({
subscriptionId: "checkout-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();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.getCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" });
expect(mock.sent).toHaveLength(1);
const subscribeRequest = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "subscribe_checkout_diff_request";
subscriptionId: string;
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string };
requestId: string;
};
};
expect(subscribeRequest.message.type).toBe("subscribe_checkout_diff_request");
expect(subscribeRequest.message.cwd).toBe("/tmp/project");
expect(subscribeRequest.message.compare).toEqual({ mode: "base", baseRef: "main" });
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "subscribe_checkout_diff_response",
payload: {
subscriptionId: subscribeRequest.message.subscriptionId,
cwd: "/tmp/project",
files: [],
error: null,
requestId: subscribeRequest.message.requestId,
},
},
})
);
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
files: [],
error: null,
requestId: subscribeRequest.message.requestId,
});
expect(mock.sent).toHaveLength(2);
const unsubscribeRequest = JSON.parse(mock.sent[1]) as {
type: "session";
message: {
type: "unsubscribe_checkout_diff_request";
subscriptionId: string;
};
};
expect(unsubscribeRequest.message.type).toBe("unsubscribe_checkout_diff_request");
expect(unsubscribeRequest.message.subscriptionId).toBe(
subscribeRequest.message.subscriptionId
);
});
test("resubscribes checkout diff streams after reconnect", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const internal = client as unknown as {
checkoutDiffSubscriptions: Map<
string,
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
>;
};
internal.checkoutDiffSubscriptions.set("checkout-sub-1", {
cwd: "/tmp/project",
compare: { mode: "base", baseRef: "main" },
});
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: "subscribe_checkout_diff_request";
subscriptionId: string;
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string };
requestId: string;
};
};
expect(request.message.type).toBe("subscribe_checkout_diff_request");
expect(request.message.subscriptionId).toBe("checkout-sub-1");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({ mode: "base", baseRef: "main" });
expect(typeof request.message.requestId).toBe("string");
expect(request.message.requestId.length).toBeGreaterThan(0);
});
test("fetches project-grouped agents via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();

View File

@@ -19,7 +19,6 @@ import type {
GitSetupOptions,
HighlightedDiffResponse,
CheckoutStatusResponse,
CheckoutDiffResponse,
CheckoutCommitResponse,
CheckoutMergeResponse,
CheckoutMergeFromBaseResponse,
@@ -182,7 +181,11 @@ export type CreateAgentRequestOptions = {
type GitDiffPayload = GitDiffResponse["payload"];
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
type CheckoutDiffPayload = CheckoutDiffResponse["payload"];
type SubscribeCheckoutDiffPayload = Extract<
SessionOutboundMessage,
{ type: "subscribe_checkout_diff_response" }
>["payload"];
type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
@@ -296,6 +299,10 @@ export class DaemonClient {
string,
{ labels?: Record<string, string>; agentId?: string } | undefined
>();
private checkoutDiffSubscriptions = new Map<
string,
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
>();
private logger: Logger;
private pendingSendQueue: PendingSend[] = [];
private relayClientId: string | null = null;
@@ -401,6 +408,7 @@ export class DaemonClient {
this.reconnectAttempt = 0;
this.updateConnectionState({ status: "connected" });
this.resubscribeAgentUpdates();
this.resubscribeCheckoutDiffSubscriptions();
this.flushPendingSendQueue();
this.resolveConnect();
}),
@@ -951,6 +959,22 @@ export class DaemonClient {
}
}
private resubscribeCheckoutDiffSubscriptions(): void {
if (this.checkoutDiffSubscriptions.size === 0) {
return;
}
for (const [subscriptionId, subscription] of this.checkoutDiffSubscriptions) {
const message = SessionInboundMessageSchema.parse({
type: "subscribe_checkout_diff_request",
subscriptionId,
cwd: subscription.cwd,
compare: subscription.compare,
requestId: this.createRequestId(),
});
this.sendSessionMessage(message);
}
}
// ============================================================================
// Agent Lifecycle
// ============================================================================
@@ -1521,32 +1545,101 @@ export class DaemonClient {
return responsePromise;
}
private normalizeCheckoutDiffCompare(
compare: { mode: "uncommitted" | "base"; baseRef?: string }
): { mode: "uncommitted" | "base"; baseRef?: string } {
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
}
const trimmedBaseRef = compare.baseRef?.trim();
if (!trimmedBaseRef) {
return { mode: "base" };
}
return { mode: "base", baseRef: trimmedBaseRef };
}
async getCheckoutDiff(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string },
requestId?: string
): Promise<CheckoutDiffPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "checkout_diff_request",
const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
try {
const payload = await this.subscribeCheckoutDiff(cwd, compare, {
subscriptionId: oneShotSubscriptionId,
requestId,
});
return {
cwd: payload.cwd,
files: payload.files,
error: payload.error,
requestId: payload.requestId,
};
} finally {
try {
this.unsubscribeCheckoutDiff(oneShotSubscriptionId);
} catch {
// Ignore disconnect races during one-shot cleanup.
}
}
}
async subscribeCheckoutDiff(
cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string },
options?: { subscriptionId?: string; requestId?: string }
): Promise<SubscribeCheckoutDiffPayload> {
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
const normalizedCompare = this.normalizeCheckoutDiffCompare(compare);
const previousSubscription = this.checkoutDiffSubscriptions.get(subscriptionId) ?? null;
this.checkoutDiffSubscriptions.set(subscriptionId, {
cwd,
compare,
compare: normalizedCompare,
});
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "subscribe_checkout_diff_request",
subscriptionId,
cwd,
compare: normalizedCompare,
requestId: resolvedRequestId,
});
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 60000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "checkout_diff_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
try {
return await this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 60000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "subscribe_checkout_diff_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
if (msg.payload.subscriptionId !== subscriptionId) {
return null;
}
return msg.payload;
},
});
} catch (error) {
if (previousSubscription) {
this.checkoutDiffSubscriptions.set(subscriptionId, previousSubscription);
} else {
this.checkoutDiffSubscriptions.delete(subscriptionId);
}
throw error;
}
}
unsubscribeCheckoutDiff(subscriptionId: string): void {
this.checkoutDiffSubscriptions.delete(subscriptionId);
this.sendSessionMessage({
type: "unsubscribe_checkout_diff_request",
subscriptionId,
});
}

View File

@@ -0,0 +1,166 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { SessionOutboundMessage } from "../messages.js";
type CheckoutDiffUpdatePayload = Extract<
SessionOutboundMessage,
{ type: "checkout_diff_update" }
>["payload"];
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-checkout-diff-"));
}
function initGitRepo(cwd: string): void {
execSync("git init -b main", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
}
function commitFile(cwd: string, fileName: string, content: string): void {
const filePath = path.join(cwd, fileName);
writeFileSync(filePath, content);
execSync(`git add "${fileName}"`, { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
}
async function waitForCheckoutDiffUpdate(
ctx: DaemonTestContext,
subscriptionId: string,
predicate: (payload: CheckoutDiffUpdatePayload) => boolean,
timeoutMs = 15000
): Promise<CheckoutDiffUpdatePayload> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(
new Error(
`Timed out waiting for checkout_diff_update (${subscriptionId})`
)
);
}, timeoutMs);
const unsubscribe = ctx.client.on("checkout_diff_update", (message) => {
if (message.type !== "checkout_diff_update") {
return;
}
if (message.payload.subscriptionId !== subscriptionId) {
return;
}
if (!predicate(message.payload)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(message.payload);
});
});
}
describe("daemon E2E checkout diff subscriptions", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
test(
"pushes file-level checkout diff updates with deterministic path order",
async () => {
const cwd = tmpCwd();
try {
initGitRepo(cwd);
commitFile(cwd, "base.txt", "base\n");
const subscriptionId = "checkout-diff-e2e-subscription";
const initial = await ctx.client.subscribeCheckoutDiff(
cwd,
{ mode: "uncommitted" },
{ subscriptionId }
);
expect(initial.error).toBeNull();
expect(initial.files).toEqual([]);
writeFileSync(path.join(cwd, "zeta.txt"), "zeta\n");
writeFileSync(path.join(cwd, "alpha.txt"), "alpha\n");
const update = await waitForCheckoutDiffUpdate(
ctx,
subscriptionId,
(payload) => {
const paths = payload.files.map((file) => file.path);
return paths.includes("alpha.txt") && paths.includes("zeta.txt");
}
);
expect(update.error).toBeNull();
expect(update.files.map((file) => file.path)).toEqual([
"alpha.txt",
"zeta.txt",
]);
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
60000
);
test(
"pushes updates when subscribed from a subdirectory and files change outside it",
async () => {
const cwd = tmpCwd();
try {
initGitRepo(cwd);
commitFile(cwd, "base.txt", "base\n");
const nestedDir = path.join(cwd, "nested", "dir");
mkdirSync(nestedDir, { recursive: true });
const subscriptionId = "checkout-diff-subdir-e2e-subscription";
const initial = await ctx.client.subscribeCheckoutDiff(
nestedDir,
{ mode: "uncommitted" },
{ subscriptionId }
);
expect(initial.error).toBeNull();
expect(initial.files).toEqual([]);
writeFileSync(path.join(cwd, "outside-subdir.txt"), "changed outside\n");
const update = await waitForCheckoutDiffUpdate(
ctx,
subscriptionId,
(payload) => payload.files.some((file) => file.path === "outside-subdir.txt")
);
expect(update.error).toBeNull();
expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true);
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
60000
);
});

View File

@@ -1,4 +1,5 @@
import { v4 as uuidv4 } from "uuid";
import { watch, type FSWatcher } from "node:fs";
import { stat } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
@@ -19,6 +20,8 @@ import {
type UnsubscribeTerminalRequest,
type TerminalInput,
type KillTerminalRequest,
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type ProjectCheckoutLitePayload,
type ProjectPlacementPayload,
} from "./messages.js";
@@ -131,6 +134,8 @@ const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
const RESTART_EXIT_DELAY_MS = 250;
const PROJECT_PLACEMENT_CACHE_TTL_MS = 10_000;
const MAX_AGENTS_PER_PROJECT = 5;
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
/**
* Default model used for auto-generating commit messages and PR descriptions.
@@ -212,6 +217,28 @@ function deriveProjectGroupingName(projectKey: string): string {
type ProcessingPhase = "idle" | "transcribing";
type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"];
type CheckoutDiffSnapshotPayload = Omit<
Extract<SessionOutboundMessage, { type: "checkout_diff_update" }>["payload"],
"subscriptionId"
>;
type CheckoutDiffWatchTarget = {
key: string;
cwd: string;
diffCwd: string;
compare: CheckoutDiffCompareInput;
subscriptions: Set<string>;
watchers: FSWatcher[];
fallbackRefreshInterval: NodeJS.Timeout | null;
debounceTimer: NodeJS.Timeout | null;
refreshPromise: Promise<void> | null;
refreshQueued: boolean;
latestPayload: CheckoutDiffSnapshotPayload | null;
latestFingerprint: string | null;
};
type NormalizedGitOptions = {
baseBranch?: string;
createNewBranch: boolean;
@@ -455,6 +482,8 @@ export class Session {
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
private readonly terminalManager: TerminalManager | null;
private terminalSubscriptions: Map<string, () => void> = new Map();
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>();
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>();
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
private readonly localSpeechModelsDir: string;
private readonly defaultLocalSpeechModelIds: LocalSpeechModelId[];
@@ -1189,8 +1218,12 @@ export class Session {
await this.handleValidateBranchRequest(msg);
break;
case "checkout_diff_request":
await this.handleCheckoutDiffRequest(msg);
case "subscribe_checkout_diff_request":
await this.handleSubscribeCheckoutDiffRequest(msg);
break;
case "unsubscribe_checkout_diff_request":
this.handleUnsubscribeCheckoutDiffRequest(msg);
break;
case "checkout_commit_request":
@@ -3496,14 +3529,127 @@ export class Session {
}
}
private async handleCheckoutDiffRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_diff_request" }>
): Promise<void> {
const { cwd, requestId, compare } = msg;
private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef }
: { mode: "base" };
}
private buildCheckoutDiffTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
return JSON.stringify([
cwd,
compare.mode,
compare.mode === "base" ? (compare.baseRef ?? "") : "",
]);
}
private closeCheckoutDiffWatchTarget(target: CheckoutDiffWatchTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
}
if (target.fallbackRefreshInterval) {
clearInterval(target.fallbackRefreshInterval);
target.fallbackRefreshInterval = null;
}
for (const watcher of target.watchers) {
watcher.close();
}
target.watchers = [];
}
private removeCheckoutDiffSubscription(subscriptionId: string): void {
const subscription = this.checkoutDiffSubscriptions.get(subscriptionId);
if (!subscription) {
return;
}
this.checkoutDiffSubscriptions.delete(subscriptionId);
const target = this.checkoutDiffTargets.get(subscription.targetKey);
if (!target) {
return;
}
target.subscriptions.delete(subscriptionId);
if (target.subscriptions.size === 0) {
this.closeCheckoutDiffWatchTarget(target);
this.checkoutDiffTargets.delete(subscription.targetKey);
}
}
private async resolveCheckoutGitDir(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git rev-parse --absolute-git-dir", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const gitDir = stdout.trim();
return gitDir.length > 0 ? gitDir : null;
} catch {
return null;
}
}
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync(
"git rev-parse --path-format=absolute --show-toplevel",
{
cwd,
env: READ_ONLY_GIT_ENV,
}
);
const root = stdout.trim();
return root.length > 0 ? root : null;
} catch {
return null;
}
}
private scheduleCheckoutDiffTargetRefresh(target: CheckoutDiffWatchTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
}
target.debounceTimer = setTimeout(() => {
target.debounceTimer = null;
void this.refreshCheckoutDiffTarget(target);
}, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS);
}
private emitCheckoutDiffUpdate(
target: CheckoutDiffWatchTarget,
snapshot: CheckoutDiffSnapshotPayload
): void {
if (target.subscriptions.size === 0) {
return;
}
for (const subscriptionId of target.subscriptions) {
this.emit({
type: "checkout_diff_update",
payload: {
subscriptionId,
...snapshot,
},
});
}
}
private checkoutDiffSnapshotFingerprint(snapshot: CheckoutDiffSnapshotPayload): string {
return JSON.stringify(snapshot);
}
private async computeCheckoutDiffSnapshot(
cwd: string,
compare: CheckoutDiffCompareInput,
options?: { diffCwd?: string }
): Promise<CheckoutDiffSnapshotPayload> {
const diffCwd = options?.diffCwd ?? cwd;
try {
const diffResult = await getCheckoutDiff(
cwd,
diffCwd,
{
mode: compare.mode,
baseRef: compare.baseRef,
@@ -3511,25 +3657,202 @@ export class Session {
},
{ paseoHome: this.paseoHome }
);
this.emit({
type: "checkout_diff_response",
payload: {
cwd,
files: diffResult.structured ?? [],
error: null,
requestId,
},
const files = [...(diffResult.structured ?? [])];
files.sort((a, b) => {
if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1;
});
return {
cwd,
files,
error: null,
};
} catch (error) {
this.emit({
type: "checkout_diff_response",
payload: {
cwd,
files: [],
error: this.toCheckoutError(error),
requestId,
},
return {
cwd,
files: [],
error: this.toCheckoutError(error),
};
}
}
private async refreshCheckoutDiffTarget(target: CheckoutDiffWatchTarget): Promise<void> {
if (target.refreshPromise) {
target.refreshQueued = true;
return;
}
target.refreshPromise = (async () => {
do {
target.refreshQueued = false;
const snapshot = await this.computeCheckoutDiffSnapshot(
target.cwd,
target.compare,
{ diffCwd: target.diffCwd }
);
target.latestPayload = snapshot;
const fingerprint = this.checkoutDiffSnapshotFingerprint(snapshot);
if (fingerprint !== target.latestFingerprint) {
target.latestFingerprint = fingerprint;
this.emitCheckoutDiffUpdate(target, snapshot);
}
} while (target.refreshQueued);
})();
try {
await target.refreshPromise;
} finally {
target.refreshPromise = null;
}
}
private async ensureCheckoutDiffWatchTarget(
cwd: string,
compare: CheckoutDiffCompareInput
): Promise<CheckoutDiffWatchTarget> {
const targetKey = this.buildCheckoutDiffTargetKey(cwd, compare);
const existing = this.checkoutDiffTargets.get(targetKey);
if (existing) {
return existing;
}
const watchRoot = await this.resolveCheckoutWatchRoot(cwd);
const target: CheckoutDiffWatchTarget = {
key: targetKey,
cwd,
diffCwd: watchRoot ?? cwd,
compare,
subscriptions: new Set(),
watchers: [],
fallbackRefreshInterval: null,
debounceTimer: null,
refreshPromise: null,
refreshQueued: false,
latestPayload: null,
latestFingerprint: null,
};
const watchPaths = new Set<string>([cwd]);
if (watchRoot) {
watchPaths.add(watchRoot);
}
const gitDir = await this.resolveCheckoutGitDir(cwd);
if (gitDir) {
watchPaths.add(gitDir);
}
let hasWatchRootCoverage = false;
for (const watchPath of watchPaths) {
const createWatcher = (recursive: boolean): FSWatcher =>
watch(
watchPath,
{ recursive },
() => {
this.scheduleCheckoutDiffTargetRefresh(target);
}
);
let watcher: FSWatcher | null = null;
try {
watcher = createWatcher(true);
} catch (error) {
try {
watcher = createWatcher(false);
this.sessionLogger.warn(
{ err: error, watchPath, cwd, compare },
"Checkout diff recursive watch unavailable; using non-recursive fallback"
);
} catch (fallbackError) {
this.sessionLogger.warn(
{ err: fallbackError, watchPath, cwd, compare },
"Failed to start checkout diff watcher"
);
}
}
if (!watcher) {
continue;
}
watcher.on("error", (error) => {
this.sessionLogger.warn(
{ err: error, watchPath, cwd, compare },
"Checkout diff watcher error"
);
});
target.watchers.push(watcher);
if (watchRoot && watchPath === watchRoot) {
hasWatchRootCoverage = true;
}
}
const missingRepoCoverage = Boolean(watchRoot) && !hasWatchRootCoverage;
if (target.watchers.length === 0 || missingRepoCoverage) {
target.fallbackRefreshInterval = setInterval(() => {
this.scheduleCheckoutDiffTargetRefresh(target);
}, CHECKOUT_DIFF_FALLBACK_REFRESH_MS);
this.sessionLogger.warn(
{
cwd,
compare,
intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS,
reason:
target.watchers.length === 0
? "no_watchers"
: "missing_repo_root_coverage",
},
"Checkout diff watchers unavailable; using timed refresh fallback"
);
}
this.checkoutDiffTargets.set(targetKey, target);
return target;
}
private async handleSubscribeCheckoutDiffRequest(
msg: SubscribeCheckoutDiffRequest
): Promise<void> {
const cwd = expandTilde(msg.cwd);
const compare = this.normalizeCheckoutDiffCompare(msg.compare);
this.removeCheckoutDiffSubscription(msg.subscriptionId);
const target = await this.ensureCheckoutDiffWatchTarget(cwd, compare);
target.subscriptions.add(msg.subscriptionId);
this.checkoutDiffSubscriptions.set(msg.subscriptionId, {
targetKey: target.key,
});
const snapshot =
target.latestPayload ??
(await this.computeCheckoutDiffSnapshot(cwd, compare, {
diffCwd: target.diffCwd,
}));
target.latestPayload = snapshot;
target.latestFingerprint = this.checkoutDiffSnapshotFingerprint(snapshot);
this.emit({
type: "subscribe_checkout_diff_response",
payload: {
subscriptionId: msg.subscriptionId,
...snapshot,
requestId: msg.requestId,
},
});
}
private handleUnsubscribeCheckoutDiffRequest(
msg: UnsubscribeCheckoutDiffRequest
): void {
this.removeCheckoutDiffSubscription(msg.subscriptionId);
}
private scheduleCheckoutDiffRefreshForCwd(cwd: string): void {
const resolvedCwd = expandTilde(cwd);
for (const target of this.checkoutDiffTargets.values()) {
if (target.cwd !== resolvedCwd && target.diffCwd !== resolvedCwd) {
continue;
}
this.scheduleCheckoutDiffTargetRefresh(target);
}
}
@@ -3551,6 +3874,7 @@ export class Session {
message,
addAll: msg.addAll ?? true,
});
this.scheduleCheckoutDiffRefreshForCwd(cwd);
this.emit({
type: "checkout_commit_response",
@@ -3624,6 +3948,7 @@ export class Session {
},
{ paseoHome: this.paseoHome }
);
this.scheduleCheckoutDiffRefreshForCwd(cwd);
this.emit({
type: "checkout_merge_response",
@@ -3670,6 +3995,7 @@ export class Session {
requireCleanTarget: msg.requireCleanTarget ?? true,
},
);
this.scheduleCheckoutDiffRefreshForCwd(cwd);
this.emit({
type: "checkout_merge_from_base_response",
@@ -5502,6 +5828,12 @@ export class Session {
unsubscribe();
}
this.terminalSubscriptions.clear();
for (const target of this.checkoutDiffTargets.values()) {
this.closeCheckoutDiffWatchTarget(target);
}
this.checkoutDiffTargets.clear();
this.checkoutDiffSubscriptions.clear();
}
// ============================================================================

View File

@@ -707,13 +707,19 @@ export const CheckoutStatusRequestSchema = z.object({
requestId: z.string(),
});
export const CheckoutDiffRequestSchema = z.object({
type: z.literal("checkout_diff_request"),
export const SubscribeCheckoutDiffRequestSchema = z.object({
type: z.literal("subscribe_checkout_diff_request"),
subscriptionId: z.string(),
cwd: z.string(),
compare: CheckoutDiffCompareSchema,
requestId: z.string(),
});
export const UnsubscribeCheckoutDiffRequestSchema = z.object({
type: z.literal("unsubscribe_checkout_diff_request"),
subscriptionId: z.string(),
});
export const CheckoutCommitRequestSchema = z.object({
type: z.literal("checkout_commit_request"),
cwd: z.string(),
@@ -987,7 +993,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
CheckoutStatusRequestSchema,
CheckoutDiffRequestSchema,
SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema,
CheckoutCommitRequestSchema,
CheckoutMergeRequestSchema,
CheckoutMergeFromBaseRequestSchema,
@@ -1455,16 +1462,25 @@ export const CheckoutStatusResponseSchema = z.object({
]),
});
export const CheckoutDiffResponseSchema = z.object({
type: z.literal("checkout_diff_response"),
payload: z.object({
cwd: z.string(),
files: z.array(ParsedDiffFileSchema),
error: CheckoutErrorSchema.nullable(),
const CheckoutDiffSubscriptionPayloadSchema = z.object({
subscriptionId: z.string(),
cwd: z.string(),
files: z.array(ParsedDiffFileSchema),
error: CheckoutErrorSchema.nullable(),
});
export const SubscribeCheckoutDiffResponseSchema = z.object({
type: z.literal("subscribe_checkout_diff_response"),
payload: CheckoutDiffSubscriptionPayloadSchema.extend({
requestId: z.string(),
}),
});
export const CheckoutDiffUpdateSchema = z.object({
type: z.literal("checkout_diff_update"),
payload: CheckoutDiffSubscriptionPayloadSchema,
});
export const CheckoutCommitResponseSchema = z.object({
type: z.literal("checkout_commit_response"),
payload: z.object({
@@ -1800,7 +1816,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentArchivedMessageSchema,
GitDiffResponseSchema,
CheckoutStatusResponseSchema,
CheckoutDiffResponseSchema,
SubscribeCheckoutDiffResponseSchema,
CheckoutDiffUpdateSchema,
CheckoutCommitResponseSchema,
CheckoutMergeResponseSchema,
CheckoutMergeFromBaseResponseSchema,
@@ -1909,8 +1926,16 @@ export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
export type CheckoutDiffRequest = z.infer<typeof CheckoutDiffRequestSchema>;
export type CheckoutDiffResponse = z.infer<typeof CheckoutDiffResponseSchema>;
export type SubscribeCheckoutDiffRequest = z.infer<
typeof SubscribeCheckoutDiffRequestSchema
>;
export type UnsubscribeCheckoutDiffRequest = z.infer<
typeof UnsubscribeCheckoutDiffRequestSchema
>;
export type SubscribeCheckoutDiffResponse = z.infer<
typeof SubscribeCheckoutDiffResponseSchema
>;
export type CheckoutDiffUpdate = z.infer<typeof CheckoutDiffUpdateSchema>;
export type CheckoutCommitRequest = z.infer<typeof CheckoutCommitRequestSchema>;
export type CheckoutCommitResponse = z.infer<typeof CheckoutCommitResponseSchema>;
export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;

View File

@@ -942,7 +942,10 @@ export async function getCheckoutDiff(
}
const changes = await listCheckoutFileChanges(cwd, refForDiff);
changes.sort((a, b) => a.path.localeCompare(b.path));
changes.sort((a, b) => {
if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1;
});
const structured: ParsedDiffFile[] = [];
let diffText = "";