diff --git a/apps/web/components/task/changes-panel-file-row.test.tsx b/apps/web/components/task/changes-panel-file-row.test.tsx
index 63666d80a9..6fc288717e 100644
--- a/apps/web/components/task/changes-panel-file-row.test.tsx
+++ b/apps/web/components/task/changes-panel-file-row.test.tsx
@@ -244,6 +244,31 @@ describe("FileRow hover swap (stats <-> actions occupy same cell)", () => {
expect(statsLayer.className.split(/\s+/)).toContain("opacity-0");
expect(actionsLayer.className.split(/\s+/)).toContain("opacity-100");
});
+
+ it("keeps the pending spinner inside a touch-sized target for coarse pointers", () => {
+ responsive.isFinePointer = false;
+ const { container } = render(
+
+
+ ,
+ );
+
+ const spinner = container.querySelector("svg.animate-spin");
+ expect(spinner).not.toBeNull();
+ expect(spinner?.parentElement?.className).toContain("min-h-11");
+ expect(spinner?.parentElement?.className).toContain("min-w-11");
+ });
});
describe("FileRow tree-mode hover stage action", () => {
diff --git a/apps/web/e2e/tests/git/git-changes-panel.spec.ts b/apps/web/e2e/tests/git/git-changes-panel.spec.ts
index 2245965fd7..391494efa6 100644
--- a/apps/web/e2e/tests/git/git-changes-panel.spec.ts
+++ b/apps/web/e2e/tests/git/git-changes-panel.spec.ts
@@ -99,26 +99,56 @@ type WorktreeFileAction = "worktree.stage" | "worktree.unstage";
type PausedWorktreeFileAction = {
action: WorktreeFileAction;
frame: string;
+ requestId: string;
server: WebSocketRoute;
};
-function isWorktreeFileActionFrame(part: string, action: WorktreeFileAction): boolean {
- if (!part.trim()) return false;
+type AwaitedWorktreeFileState = {
+ action: WorktreeFileAction;
+ requestId: string;
+ path: string;
+ staged: boolean;
+ response: { success: boolean; error?: string } | null;
+ fileStateObserved: boolean;
+};
+
+type WorktreeFrame = {
+ id?: unknown;
+ type?: unknown;
+ action?: unknown;
+ payload?: {
+ success?: unknown;
+ error?: unknown;
+ type?: unknown;
+ status?: { files?: Record };
+ };
+};
+
+function parseWorktreeFrame(part: string): WorktreeFrame | null {
+ if (!part.trim()) return null;
try {
- const frame = JSON.parse(part) as { type?: unknown; action?: unknown };
- return frame.type === "request" && frame.action === action;
+ return JSON.parse(part) as WorktreeFrame;
} catch {
- return false;
+ return null;
}
}
+function worktreeFileActionRequestId(part: string, action: WorktreeFileAction): string | null {
+ const frame = parseWorktreeFrame(part);
+ return frame?.type === "request" && frame.action === action && typeof frame.id === "string"
+ ? frame.id
+ : null;
+}
+
function partitionWorktreeFileAction(
message: string | Buffer,
action: WorktreeFileAction,
): { frame: string; passthrough: string | null } | null {
if (typeof message !== "string") return null;
const frames = message.split("\n");
- const actionIndex = frames.findIndex((part) => isWorktreeFileActionFrame(part, action));
+ const actionIndex = frames.findIndex(
+ (part) => worktreeFileActionRequestId(part, action) !== null,
+ );
if (actionIndex === -1) return null;
const passthrough = frames.filter((_, index) => index !== actionIndex).join("\n");
return {
@@ -127,23 +157,63 @@ function partitionWorktreeFileAction(
};
}
+function observeWorktreeResponse(frame: WorktreeFrame, awaited: AwaitedWorktreeFileState) {
+ if (
+ frame.type !== "response" ||
+ frame.action !== awaited.action ||
+ frame.id !== awaited.requestId
+ ) {
+ return;
+ }
+ awaited.response = {
+ success: frame.payload?.success === true,
+ ...(typeof frame.payload?.error === "string" ? { error: frame.payload.error } : {}),
+ };
+}
+
+function observeWorktreeFileState(frame: WorktreeFrame, awaited: AwaitedWorktreeFileState) {
+ if (
+ frame.action === "session.git.event" &&
+ frame.payload?.type === "status_update" &&
+ frame.payload.status?.files?.[awaited.path]?.staged === awaited.staged
+ ) {
+ awaited.fileStateObserved = true;
+ }
+}
+
+function observeWorktreeFileAction(message: string | Buffer, awaited: AwaitedWorktreeFileState) {
+ const parts = (typeof message === "string" ? message : message.toString("utf8")).split("\n");
+ for (const part of parts) {
+ const frame = parseWorktreeFrame(part);
+ if (!frame) continue;
+ observeWorktreeResponse(frame, awaited);
+ observeWorktreeFileState(frame, awaited);
+ }
+}
+
async function pauseNextWorktreeFileAction(page: Page) {
let armedAction: WorktreeFileAction | null = null;
let paused: PausedWorktreeFileAction | null = null;
+ let awaitedState: AwaitedWorktreeFileState | null = null;
- await page.routeWebSocket(/\/ws$/, (socket) => {
+ await page.context().routeWebSocket(/\/ws$/, (socket) => {
const server = socket.connectToServer();
socket.onMessage((message) => {
const partition = armedAction ? partitionWorktreeFileAction(message, armedAction) : null;
if (partition && armedAction) {
- paused = { action: armedAction, frame: partition.frame, server };
+ const requestId = worktreeFileActionRequestId(partition.frame, armedAction);
+ if (!requestId) throw new Error(`Paused ${armedAction} request has no ID`);
+ paused = { action: armedAction, frame: partition.frame, requestId, server };
armedAction = null;
if (partition.passthrough) server.send(partition.passthrough);
return;
}
server.send(message);
});
- server.onMessage((message) => socket.send(message));
+ server.onMessage((message) => {
+ if (awaitedState) observeWorktreeFileAction(message, awaitedState);
+ socket.send(message);
+ });
});
return {
@@ -158,10 +228,33 @@ async function pauseNextWorktreeFileAction(page: Page) {
})
.toBe(action);
},
- release(action: WorktreeFileAction) {
+ async releaseAndWaitForFileState(action: WorktreeFileAction, path: string, staged: boolean) {
if (paused?.action !== action) throw new Error(`No paused ${action} request`);
- paused.server.send(paused.frame);
+ const request = paused;
+ awaitedState = {
+ action,
+ requestId: request.requestId,
+ path,
+ staged,
+ response: null,
+ fileStateObserved: false,
+ };
paused = null;
+ request.server.send(request.frame);
+ await expect
+ .poll(() => awaitedState?.response ?? null, {
+ message: `${action} should return a response`,
+ })
+ .not.toBeNull();
+ if (!awaitedState?.response?.success) {
+ throw new Error(awaitedState?.response?.error ?? `${action} failed`);
+ }
+ await expect
+ .poll(() => awaitedState?.fileStateObserved ?? false, {
+ message: `${action} should publish ${path} with staged=${staged}`,
+ })
+ .toBe(true);
+ awaitedState = null;
},
};
}
@@ -349,7 +442,7 @@ test.describe("Git Changes Panel", () => {
await expect(pendingStageSlot.locator("svg.animate-spin")).toBeVisible();
await expect(siblingRow.locator("svg.animate-spin")).toHaveCount(0);
- pausedAction.release("worktree.stage");
+ await pausedAction.releaseAndWaitForFileState("worktree.stage", "pending-action.txt", true);
await session.expandChangesSection("staged-files-section");
const stagedSection = testPage.getByTestId("staged-files-section");
const stagedRow = stagedSection.getByTestId("file-row-pending-action.txt");
@@ -377,7 +470,7 @@ test.describe("Git Changes Panel", () => {
);
await expect(pendingUnstageSlot.locator("svg.animate-spin")).toBeVisible();
- pausedAction.release("worktree.unstage");
+ await pausedAction.releaseAndWaitForFileState("worktree.unstage", "pending-action.txt", false);
await expect(unstagedSection.getByTestId("file-row-pending-action.txt")).toBeVisible();
await expect(stagedRow).toHaveCount(0);
});
diff --git a/apps/web/hooks/domains/session/use-session-git-pending.test.tsx b/apps/web/hooks/domains/session/use-session-git-pending.test.tsx
new file mode 100644
index 0000000000..74ab2d7b28
--- /dev/null
+++ b/apps/web/hooks/domains/session/use-session-git-pending.test.tsx
@@ -0,0 +1,539 @@
+import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
+import { pendingKey, useSessionGit } from "./use-session-git";
+import { buildPendingFileStateIndex } from "./use-session-git-pending";
+
+const mocks = vi.hoisted(() => ({
+ statuses: [] as Array<{ repository_name: string; status: GitStatusEntry }>,
+ checkoutGenerations: {} as Record,
+ gitOps: {
+ isLoading: false,
+ loadingOperation: null,
+ pull: vi.fn(),
+ push: vi.fn(),
+ rebase: vi.fn(),
+ merge: vi.fn(),
+ abort: vi.fn(),
+ commit: vi.fn(),
+ stage: vi.fn(),
+ unstage: vi.fn(),
+ discard: vi.fn(),
+ revertCommit: vi.fn(),
+ renameBranch: vi.fn(),
+ reset: vi.fn(),
+ createPR: vi.fn(),
+ },
+}));
+
+vi.mock("./use-session-git-status", () => ({
+ useSessionGitStatus: () => undefined,
+ useSessionGitStatusByRepo: () => mocks.statuses,
+ useSessionGitPendingCheckoutGenerations: () => mocks.checkoutGenerations,
+ useSessionGitPendingScope: (sessionId: string | null) =>
+ sessionId ? `${sessionId}:environment` : "",
+}));
+
+vi.mock("./use-session-commits", () => ({
+ useSessionCommits: () => ({ commits: [], loading: false }),
+}));
+
+vi.mock("./use-cumulative-diff", () => ({
+ useCumulativeDiff: () => ({ diff: null }),
+}));
+
+vi.mock("@/hooks/use-git-operations", () => ({
+ useGitOperations: () => mocks.gitOps,
+}));
+
+function status(path: string, staged: boolean, branch = "main"): GitStatusEntry {
+ return {
+ branch,
+ remote_branch: null,
+ modified: [path],
+ added: [],
+ deleted: [],
+ untracked: [],
+ renamed: [],
+ ahead: 0,
+ behind: 0,
+ files: {
+ [path]: {
+ path,
+ status: "modified",
+ staged,
+ },
+ },
+ timestamp: null,
+ };
+}
+
+type OperationResult = {
+ success: boolean;
+ operation: string;
+ output: string;
+ error?: string;
+};
+
+function deferredResult() {
+ let resolve!: (value: OperationResult) => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.statuses = [];
+ mocks.checkoutGenerations = {};
+});
+
+afterEach(cleanup);
+
+describe("useSessionGit pending target-state reconciliation", () => {
+ it.each([
+ { operation: "stage" as const, initialStaged: false, completedStaged: true },
+ { operation: "unstage" as const, initialStaged: true, completedStaged: false },
+ ])(
+ "keeps $operation pending through stale repository refreshes",
+ async ({ operation, initialStaged, completedStaged }) => {
+ const path = `${operation}-pending.txt`;
+ const pending = deferredResult();
+ mocks.gitOps[operation].mockReturnValueOnce(pending.promise);
+ mocks.statuses = [{ repository_name: "", status: status(path, initialStaged) }];
+
+ const hook = renderHook(() => useSessionGit("session-1"));
+
+ act(() => {
+ void hook.result.current[`${operation}File`]([path], "");
+ });
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ mocks.statuses = [{ repository_name: "", status: status(path, initialStaged) }];
+ hook.rerender();
+
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ mocks.statuses = [{ repository_name: "", status: status(path, completedStaged) }];
+ hook.rerender();
+
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ await act(async () => {
+ pending.resolve({ success: true, operation, output: "" });
+ await pending.promise;
+ });
+
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
+ });
+ },
+ );
+
+ it("clears an unstage after the refreshed status removes a clean file", async () => {
+ const path = "clean-after-unstage.txt";
+ const pending = deferredResult();
+ mocks.gitOps.unstage.mockReturnValueOnce(pending.promise);
+ mocks.statuses = [{ repository_name: "", status: status(path, true) }];
+
+ const hook = renderHook(() => useSessionGit("session-1"));
+
+ act(() => {
+ void hook.result.current.unstageFile([path], "");
+ });
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ mocks.statuses = [];
+ hook.rerender();
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ await act(async () => {
+ pending.resolve({ success: true, operation: "unstage", output: "" });
+ await pending.promise;
+ });
+
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
+ });
+ });
+});
+
+describe("useSessionGit failed request cleanup", () => {
+ it("clears pending state when the operation returns a failure without a status change", async () => {
+ const path = "failed-stage.txt";
+ mocks.gitOps.stage.mockResolvedValueOnce({
+ success: false,
+ operation: "stage",
+ output: "",
+ error: "stage failed",
+ });
+ mocks.statuses = [{ repository_name: "", status: status(path, false) }];
+
+ const hook = renderHook(() => useSessionGit("session-1"));
+
+ await act(async () => {
+ await hook.result.current.stageFile([path], "");
+ });
+
+ expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
+ });
+});
+
+describe("useSessionGit partial repository failures", () => {
+ it.each([
+ { operation: "stage" as const, initialStaged: false, completedStaged: true },
+ { operation: "unstage" as const, initialStaged: true, completedStaged: false },
+ ])(
+ "keeps successful repository scopes pending after a partial $operation failure",
+ async ({ operation, initialStaged, completedStaged }) => {
+ const successfulPath = `successful-${operation}.txt`;
+ const failedPath = `failed-${operation}.txt`;
+ mocks.statuses = [
+ { repository_name: "repo-a", status: status(successfulPath, initialStaged) },
+ { repository_name: "repo-b", status: status(failedPath, initialStaged) },
+ ];
+ mocks.gitOps[operation].mockImplementation(
+ async (_paths: string[] | undefined, repositoryName: string | undefined) => ({
+ success: repositoryName === "repo-a",
+ operation,
+ output: "",
+ error: repositoryName === "repo-a" ? undefined : `${operation} failed`,
+ }),
+ );
+
+ const hook = renderHook(() => useSessionGit("session-1"));
+
+ await act(async () => {
+ await hook.result.current[`${operation}File`]([successfulPath, failedPath]);
+ });
+
+ expect(hook.result.current.pendingStageFiles).toEqual(
+ new Set([pendingKey("repo-a", successfulPath)]),
+ );
+
+ mocks.statuses = [
+ { repository_name: "repo-a", status: status(successfulPath, completedStaged) },
+ { repository_name: "repo-b", status: status(failedPath, initialStaged) },
+ ];
+ hook.rerender();
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).not.toContain(
+ pendingKey("repo-a", successfulPath),
+ );
+ });
+ },
+ );
+});
+
+describe("useSessionGit bulk response ownership", () => {
+ it.each([
+ { operation: "stage" as const, initialStaged: false, completedStaged: true },
+ { operation: "unstage" as const, initialStaged: true, completedStaged: false },
+ ])(
+ "does not clear a sibling $operation key when one repository reaches target state before the aggregate response",
+ async ({ operation, initialStaged, completedStaged }) => {
+ const firstPath = `first-${operation}.txt`;
+ const secondPath = `second-${operation}.txt`;
+ const first = deferredResult();
+ const second = deferredResult();
+ mocks.statuses = [
+ { repository_name: "repo-a", status: status(firstPath, initialStaged) },
+ { repository_name: "repo-b", status: status(secondPath, initialStaged) },
+ ];
+ mocks.gitOps[operation].mockImplementation((_paths, repositoryName) =>
+ repositoryName === "repo-a" ? first.promise : second.promise,
+ );
+
+ const hook = renderHook(() => useSessionGit("session-1"));
+
+ let aggregate!: Promise;
+ act(() => {
+ aggregate = hook.result.current[`${operation}File`]([firstPath, secondPath]);
+ });
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toEqual(
+ new Set([pendingKey("repo-a", firstPath), pendingKey("repo-b", secondPath)]),
+ );
+ });
+
+ mocks.statuses = [
+ { repository_name: "repo-a", status: status(firstPath, completedStaged) },
+ { repository_name: "repo-b", status: status(secondPath, initialStaged) },
+ ];
+ hook.rerender();
+
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("repo-a", firstPath));
+ });
+
+ first.resolve({ success: true, operation, output: "" });
+ await waitFor(() => expect(mocks.gitOps[operation]).toHaveBeenCalledTimes(2));
+ second.resolve({ success: true, operation, output: "" });
+ await aggregate;
+
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toEqual(
+ new Set([pendingKey("repo-b", secondPath)]),
+ );
+ });
+
+ mocks.statuses = [
+ { repository_name: "repo-a", status: status(firstPath, completedStaged) },
+ { repository_name: "repo-b", status: status(secondPath, completedStaged) },
+ ];
+ hook.rerender();
+
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toEqual(new Set());
+ });
+ },
+ );
+});
+
+describe("useSessionGit overlapping request ownership", () => {
+ it.each([
+ { operation: "stage" as const, initialStaged: false, completedStaged: true },
+ { operation: "unstage" as const, initialStaged: true, completedStaged: false },
+ ])(
+ "keeps a newer same-direction $operation request pending when the older request fails",
+ async ({ operation, initialStaged, completedStaged }) => {
+ const path = `overlapping-${operation}.txt`;
+ const older = deferredResult();
+ const newer = deferredResult();
+ mocks.statuses = [{ repository_name: "", status: status(path, initialStaged) }];
+ mocks.gitOps[operation].mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
+
+ const hook = renderHook(() => useSessionGit("session-1"));
+
+ act(() => {
+ void hook.result.current[`${operation}File`]([path], "");
+ });
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ act(() => {
+ void hook.result.current[`${operation}File`]([path], "");
+ });
+ await act(async () => {
+ older.resolve({
+ success: false,
+ operation,
+ output: "",
+ error: `older ${operation} failed`,
+ });
+ await older.promise;
+ });
+
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+
+ newer.resolve({ success: true, operation, output: "" });
+ mocks.statuses = [{ repository_name: "", status: status(path, completedStaged) }];
+ hook.rerender();
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
+ });
+ },
+ );
+
+ it.each([
+ { operation: "stage" as const, initialStaged: false, completedStaged: true },
+ { operation: "unstage" as const, initialStaged: true, completedStaged: false },
+ ])(
+ "does not clear a newer same-direction $operation request from an older success status",
+ async ({ operation, initialStaged, completedStaged }) => {
+ const path = `overlapping-success-${operation}.txt`;
+ const older = deferredResult();
+ const newer = deferredResult();
+ mocks.statuses = [{ repository_name: "", status: status(path, initialStaged) }];
+ mocks.gitOps[operation].mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
+
+ const hook = renderHook(() => useSessionGit("session-1"));
+
+ act(() => {
+ void hook.result.current[`${operation}File`]([path], "");
+ });
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ act(() => {
+ void hook.result.current[`${operation}File`]([path], "");
+ });
+
+ await act(async () => {
+ older.resolve({ success: true, operation, output: "" });
+ await older.promise;
+ });
+
+ mocks.statuses = [{ repository_name: "", status: status(path, completedStaged) }];
+ hook.rerender();
+
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ await act(async () => {
+ newer.resolve({ success: true, operation, output: "" });
+ await newer.promise;
+ });
+
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
+ });
+ },
+ );
+});
+
+describe("pending file state indexing", () => {
+ it("tracks staged and unstaged facets for each repository and path", () => {
+ const index = buildPendingFileStateIndex([
+ {
+ path: "shared.ts",
+ repository_name: "repo-a",
+ status: "modified",
+ staged: true,
+ staged_change: { status: "modified" },
+ unstaged_change: { status: "modified" },
+ },
+ {
+ path: "only-staged.ts",
+ repository_name: "repo-b",
+ status: "modified",
+ staged: true,
+ },
+ ]);
+
+ expect(index.get(pendingKey("repo-a", "shared.ts"))).toEqual({
+ hasStaged: true,
+ hasUnstaged: true,
+ });
+ expect(index.get(pendingKey("repo-b", "only-staged.ts"))).toEqual({
+ hasStaged: true,
+ hasUnstaged: false,
+ });
+ });
+});
+
+describe("useSessionGit pending scope ownership", () => {
+ it("resets pending ownership when the active session changes", async () => {
+ const path = "shared-worktree-path.txt";
+ const older = deferredResult();
+ const newer = deferredResult();
+ mocks.statuses = [{ repository_name: "", status: status(path, false) }];
+ mocks.gitOps.stage.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
+
+ const hook = renderHook(({ sessionId }) => useSessionGit(sessionId), {
+ initialProps: { sessionId: "session-1" },
+ });
+
+ act(() => {
+ void hook.result.current.stageFile([path], "");
+ });
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ hook.rerender({ sessionId: "session-2" });
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
+ });
+
+ act(() => {
+ void hook.result.current.stageFile([path], "");
+ });
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ await act(async () => {
+ older.resolve({
+ success: false,
+ operation: "stage",
+ output: "",
+ error: "previous session failed",
+ });
+ await older.promise;
+ });
+
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+
+ newer.resolve({ success: true, operation: "stage", output: "" });
+ });
+
+ it("resets pending ownership when the checked-out branch changes", async () => {
+ const path = "branch-owned-stage.txt";
+ const pending = deferredResult();
+ mocks.statuses = [{ repository_name: "", status: status(path, false, "feature/old") }];
+ mocks.gitOps.stage.mockReturnValueOnce(pending.promise);
+
+ const hook = renderHook(() => useSessionGit("session-1"));
+
+ act(() => {
+ void hook.result.current.stageFile([path], "");
+ });
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
+ });
+
+ mocks.checkoutGenerations = { "": 1 };
+ mocks.statuses = [{ repository_name: "", status: status(path, false, "feature/new") }];
+ hook.rerender();
+
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
+ });
+
+ pending.resolve({ success: true, operation: "stage", output: "" });
+ });
+
+ it("resets only the repository affected by a checkout generation", async () => {
+ const repoAPath = "repo-a-stage.txt";
+ const repoBPath = "repo-b-stage.txt";
+ const repoAPending = deferredResult();
+ const repoBPending = deferredResult();
+ mocks.statuses = [
+ { repository_name: "repo-a", status: status(repoAPath, false) },
+ { repository_name: "repo-b", status: status(repoBPath, false) },
+ ];
+ mocks.gitOps.stage
+ .mockReturnValueOnce(repoAPending.promise)
+ .mockReturnValueOnce(repoBPending.promise);
+
+ const hook = renderHook(() => useSessionGit("session-1"));
+ act(() => {
+ void hook.result.current.stageFile([repoAPath], "repo-a");
+ void hook.result.current.stageFile([repoBPath], "repo-b");
+ });
+ await waitFor(() => {
+ expect(hook.result.current.pendingStageFiles).toEqual(
+ new Set([pendingKey("repo-a", repoAPath), pendingKey("repo-b", repoBPath)]),
+ );
+ });
+
+ mocks.checkoutGenerations = { "repo-a": 1 };
+ mocks.statuses = [
+ { repository_name: "repo-a", status: status(repoAPath, false) },
+ { repository_name: "repo-b", status: status(repoBPath, false) },
+ ];
+ hook.rerender();
+
+ expect(hook.result.current.pendingStageFiles).toEqual(
+ new Set([pendingKey("repo-b", repoBPath)]),
+ );
+
+ repoAPending.resolve({ success: true, operation: "stage", output: "" });
+ repoBPending.resolve({ success: true, operation: "stage", output: "" });
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-session-git-pending.ts b/apps/web/hooks/domains/session/use-session-git-pending.ts
new file mode 100644
index 0000000000..12a7ef6df2
--- /dev/null
+++ b/apps/web/hooks/domains/session/use-session-git-pending.ts
@@ -0,0 +1,226 @@
+import {
+ useEffect,
+ useRef,
+ type Dispatch,
+ type MutableRefObject,
+ type SetStateAction,
+} from "react";
+import type { FileInfo } from "@/lib/state/slices/session-runtime/types";
+import type { useSessionGitStatusByRepo } from "./use-session-git-status";
+import { splitFilesByChangeLayer } from "./git-change-facets";
+
+export type PendingFileOperation = "stage" | "unstage";
+
+export type PendingFileOperationOwner = {
+ operation: PendingFileOperation;
+ requestId: number;
+ scopeIdentity: string;
+ responseSucceeded: boolean;
+ targetStateObservedKeys: Set;
+};
+
+export function pendingKey(repo: string | undefined, path: string): string {
+ return `${repo ?? ""}::${path}`;
+}
+
+export function pendingKeysForFailedRepositories(
+ buckets: Map,
+ perRepoResults: Array<{ repository_name: string; success: boolean }> | undefined,
+): string[] {
+ const allKeys = Array.from(buckets).flatMap(([repositoryName, paths]) =>
+ paths.map((path) => pendingKey(repositoryName, path)),
+ );
+ if (!perRepoResults) return allKeys;
+
+ const failedRepositories = new Set(
+ perRepoResults.filter((entry) => !entry.success).map((entry) => entry.repository_name),
+ );
+ if (failedRepositories.size === 0) return allKeys;
+
+ return Array.from(buckets).flatMap(([repositoryName, paths]) =>
+ failedRepositories.has(repositoryName)
+ ? paths.map((path) => pendingKey(repositoryName, path))
+ : [],
+ );
+}
+
+export function clearPendingFileOperations(
+ keys: string[],
+ owner: PendingFileOperationOwner,
+ pendingFileOperations: MutableRefObject