From 4c97b63d79a449bc295abe3d47f06a69cdeeaf40 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 30 Mar 2026 12:35:23 +0100 Subject: [PATCH] fix(site/src/pages/AgentsPage): toast when git refresh fails due to disconnection (#23779) --- .../AgentsPage/AgentChatPageView.stories.tsx | 2 +- .../pages/AgentsPage/AgentChatPageView.tsx | 2 +- .../components/GitPanel/GitPanel.stories.tsx | 2 +- .../components/GitPanel/GitPanel.tsx | 14 ++++-- .../AgentsPage/hooks/useGitWatcher.test.ts | 48 ++++++++++++++++++- .../pages/AgentsPage/hooks/useGitWatcher.ts | 12 +++-- 6 files changed, 68 insertions(+), 12 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index ef2db8932e..4739a41a9d 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -75,7 +75,7 @@ const buildGitWatcher = (): ComponentProps< typeof AgentChatPageView >["gitWatcher"] => ({ repositories: new Map(), - refresh: fn(), + refresh: fn().mockReturnValue(true), }); const agentsRouting = [ diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 0be45f1a41..fa22354f01 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -99,7 +99,7 @@ interface AgentChatPageViewProps { diffStatusData: ChatDiffStatus | undefined; gitWatcher: { repositories: ReadonlyMap; - refresh: () => void; + refresh: () => boolean; }; // Workspace action handlers. diff --git a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx index 9d6ed5e647..1e1208a8e6 100644 --- a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx @@ -99,7 +99,7 @@ const meta: Meta = { title: "pages/AgentsPage/GitPanel", component: GitPanel, args: { - onRefresh: fn(), + onRefresh: fn().mockReturnValue(true), onCommit: fn(), repositories: new Map(), }, diff --git a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx index 976e4fa749..8bb4e45118 100644 --- a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx +++ b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx @@ -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; - /** 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 = ({ const spinTimerRef = useRef>(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); diff --git a/site/src/pages/AgentsPage/hooks/useGitWatcher.test.ts b/site/src/pages/AgentsPage/hooks/useGitWatcher.test.ts index 92011e3b5d..119ea08ab2 100644 --- a/site/src/pages/AgentsPage/hooks/useGitWatcher.test.ts +++ b/site/src/pages/AgentsPage/hooks/useGitWatcher.test.ts @@ -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(); diff --git a/site/src/pages/AgentsPage/hooks/useGitWatcher.ts b/site/src/pages/AgentsPage/hooks/useGitWatcher.ts index 56e3c2873b..1615ed2ed7 100644 --- a/site/src/pages/AgentsPage/hooks/useGitWatcher.ts +++ b/site/src/pages/AgentsPage/hooks/useGitWatcher.ts @@ -30,8 +30,8 @@ interface UseGitWatcherResult { repositories: ReadonlyMap; /** 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(() => {