fix(site/src/pages/AgentsPage): toast when git refresh fails due to disconnection (#23779)

This commit is contained in:
Danielle Maywood
2026-03-30 12:35:23 +01:00
committed by GitHub
parent 28484536b6
commit 4c97b63d79
6 changed files with 68 additions and 12 deletions
@@ -75,7 +75,7 @@ const buildGitWatcher = (): ComponentProps<
typeof AgentChatPageView
>["gitWatcher"] => ({
repositories: new Map(),
refresh: fn(),
refresh: fn().mockReturnValue(true),
});
const agentsRouting = [
@@ -99,7 +99,7 @@ interface AgentChatPageViewProps {
diffStatusData: ChatDiffStatus | undefined;
gitWatcher: {
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
refresh: () => void;
refresh: () => boolean;
};
// Workspace action handlers.
@@ -99,7 +99,7 @@ const meta: Meta<typeof GitPanel> = {
title: "pages/AgentsPage/GitPanel",
component: GitPanel,
args: {
onRefresh: fn(),
onRefresh: fn().mockReturnValue(true),
onCommit: fn(),
repositories: new Map(),
},
@@ -12,6 +12,7 @@ import {
RowsIcon,
} from "lucide-react";
import { type FC, type RefObject, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import type {
ChatDiffStatus,
WorkspaceAgentRepoChanges,
@@ -44,8 +45,8 @@ interface GitPanelProps {
};
/** Repository data from git watcher. */
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
/** Callback to send a refresh to the git watcher. */
onRefresh: () => void;
/** Callback to send a refresh to the git watcher. Returns false when disconnected. */
onRefresh: () => boolean;
/** Called when the user clicks the Commit button in any repo tab. */
onCommit: (repoRoot: string) => void;
/** Whether the panel is in expanded/fullscreen mode. */
@@ -142,7 +143,14 @@ export const GitPanel: FC<GitPanelProps> = ({
const spinTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(spinTimerRef.current), []);
const handleRefresh = () => {
onRefresh();
const sent = onRefresh();
if (!sent) {
toast.error("Unable to refresh git status.", {
id: "git-refresh-disconnected",
description: "Connection lost. Reconnecting\u2026",
});
return;
}
setSpinning(true);
clearTimeout(spinTimerRef.current);
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
@@ -331,14 +331,60 @@ describe("useGitWatcher", () => {
act(() => socket.simulateOpen());
act(() => result.current.refresh());
let sent: boolean | undefined;
act(() => {
sent = result.current.refresh();
});
expect(sent).toBe(true);
expect(socket.send).toHaveBeenCalledTimes(1);
expect(socket.send).toHaveBeenCalledWith(
JSON.stringify({ type: "refresh" }),
);
});
it("refresh returns false when the socket is not connected", () => {
vi.useFakeTimers();
try {
const socket = createMockSocket();
const { result } = renderHook(() =>
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
);
act(() => socket.simulateOpen());
// Close the socket. The close handler sets socketRef to
// null and schedules a reconnect timer, but we don't
// advance timers so the socket stays null.
act(() => socket.simulateClose());
let sent: boolean | undefined;
act(() => {
sent = result.current.refresh();
});
expect(sent).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("refresh returns false before the socket connects", () => {
// Don't connect at all — agentStatus prevents effect from running.
const { result } = renderHook(() =>
useGitWatcher({ chatId: "chat-123", agentStatus: "connecting" }),
);
let sent: boolean | undefined;
act(() => {
sent = result.current.refresh();
});
expect(sent).toBe(false);
});
it("cleans up WebSocket and timers on unmount", () => {
vi.useFakeTimers();
@@ -30,8 +30,8 @@ interface UseGitWatcherResult {
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
/** Whether the WebSocket is currently connected. */
isConnected: boolean;
/** Send a refresh request. */
refresh: () => void;
/** Send a refresh request. Returns true if sent, false if disconnected. */
refresh: () => boolean;
}
const MAX_BACKOFF_MS = 30_000;
@@ -51,15 +51,17 @@ export function useGitWatcher({
// Track whether we've been disposed to avoid reconnecting after unmount.
const disposedRef = useRef(false);
const sendMessage = (msg: WorkspaceAgentGitClientMessage) => {
const sendMessage = (msg: WorkspaceAgentGitClientMessage): boolean => {
const socket = socketRef.current;
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(msg));
return true;
}
return false;
};
const refresh = () => {
sendMessage({ type: "refresh" });
const refresh = (): boolean => {
return sendMessage({ type: "refresh" });
};
useEffect(() => {