Add manual refresh button to git diff controls (#1216)

* feat(git): add manual refresh button to diff controls

Adds a checkout_refresh RPC that forces a hard re-read of the git and
GitHub snapshot plus the diff, bypassing polling. The diff controls now
show a refresh button (feature-gated) that triggers it and surfaces
errors via a toast — an escape hatch when the polled state goes stale.

* refactor(git): address review — dotted RPC name + withUnistyles

- Rename checkout_refresh RPC to the dotted convention
  (checkout.refresh.request/response) per docs/rpc-namespacing.md.
- Replace the banned useUnistyles() call in DiffRefreshButton with
  withUnistyles wrappers for the icon/spinner color, per docs/unistyles.md.

* refactor(git): move refresh into the checkout actions store

Per the feature audit: the manual refresh was the one checkout action
whose UI logic lived inline in the component instead of alongside its
siblings (commit/pull/push) in useCheckoutGitActionsStore. Move it into
the store so there's one home for "UI triggers a checkout RPC" — it now
reuses runCheckoutAction's pending/success status and query invalidation.
The component just reads status and shows an error toast.

* fix(git): size refresh loader to the icon to stop layout shift

ActivityIndicator size="small" renders ~20px regardless of platform,
wider than the 14px refresh icon, so swapping to it grew the button and
shifted the controls row. Use SyncedLoader at the same iconSize — it
renders into a size×size box, so both states share one footprint.

* fix(git): match file-explorer refresh control (RotateCw + LoadingSpinner)

Mirror the file-explorer pane's refresh button exactly: RotateCw icon,
LoadingSpinner while refreshing, both centered in a fixed icon-sized box
so the spinner can't grow the control or shift the row. Replaces the
ad-hoc RefreshCw + SyncedLoader pairing.
This commit is contained in:
Mohamed Boudra
2026-05-29 14:23:16 +08:00
committed by GitHub
parent 04fb0f9b82
commit 14d176a4e2
8 changed files with 284 additions and 1 deletions
@@ -63,6 +63,7 @@ interface SessionHandlerInternals {
handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutRefreshRequest(params: unknown): Promise<unknown>;
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
@@ -2554,6 +2555,76 @@ describe("session checkout pull and push handling", () => {
});
});
describe("session checkout refresh handling", () => {
test("forces a git, GitHub, and diff refresh on demand", async () => {
const messages: unknown[] = [];
const github = { invalidate: vi.fn() };
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() };
const session = createSessionForTest({
github,
workspaceGitService,
checkoutDiffManager,
messages,
});
await asSessionInternals(session).handleCheckoutRefreshRequest({
type: "checkout.refresh.request",
cwd: "/tmp/request-worktree",
requestId: "request-refresh",
});
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
force: true,
includeGitHub: true,
reason: "manual-refresh",
});
expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree");
expect(messages).toContainEqual({
type: "checkout.refresh.response",
payload: {
cwd: "/tmp/request-worktree",
success: true,
error: null,
requestId: "request-refresh",
},
});
});
test("reports an error when the snapshot refresh fails", async () => {
const messages: unknown[] = [];
const github = { invalidate: vi.fn() };
const workspaceGitService = {
getSnapshot: vi.fn().mockRejectedValue(new Error("not a git repository")),
};
const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() };
const session = createSessionForTest({
github,
workspaceGitService,
checkoutDiffManager,
messages,
});
await asSessionInternals(session).handleCheckoutRefreshRequest({
type: "checkout.refresh.request",
cwd: "/tmp/request-worktree",
requestId: "request-refresh-error",
});
expect(checkoutDiffManager.scheduleRefreshForCwd).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "checkout.refresh.response",
payload: {
cwd: "/tmp/request-worktree",
success: false,
error: { code: "UNKNOWN", message: "not a git repository" },
requestId: "request-refresh-error",
},
});
});
});
describe("session checkout status handling", () => {
test("returns checkout status from the workspace git service snapshot", async () => {
const messages: unknown[] = [];
+37
View File
@@ -2033,6 +2033,8 @@ export class Session {
return this.handleCheckoutPullRequest(msg);
case "checkout_push_request":
return this.handleCheckoutPushRequest(msg);
case "checkout.refresh.request":
return this.handleCheckoutRefreshRequest(msg);
case "checkout_pr_create_request":
return this.handleCheckoutPrCreateRequest(msg);
case "checkout_pr_merge_request":
@@ -5297,6 +5299,41 @@ export class Session {
}
}
private async handleCheckoutRefreshRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.refresh.request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
this.github.invalidate({ cwd });
await this.workspaceGitService.getSnapshot(cwd, {
force: true,
includeGitHub: true,
reason: "manual-refresh",
});
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "checkout.refresh.response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout.refresh.response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutPrCreateRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_create_request" }>,
): Promise<void> {
@@ -1049,6 +1049,8 @@ export class VoiceAssistantWebSocketServer {
"terminal-restore-modes": true,
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
rewind: true,
// COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29.
checkoutRefresh: true,
},
};
}