From b8c9195b3fb285d1a96861da276d1d48e3b9c7ee Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 14 Oct 2026 17:04:40 +0100 Subject: [PATCH] fix(site/src/pages/AgentsPage): make git panel refresh match a page reload (#30678) The git panel's refresh button invalidated the PR diff contents but not the chat, whose `diff_statuses` back the PR tabs, so a page reload could show PR state the button missed. Refresh now also invalidates the chat entity. The button was also disabled until the panel had git context, which left no way to pick up a repo created after the chat touched its path. It is now always enabled; the diff style toggles stay disabled without git context. ### Before (Scenario A) https://github.com/user-attachments/assets/82dc7c48-148f-4661-af73-8400a275b05d ### After (Scenario A) https://github.com/user-attachments/assets/0f24e390-39d3-4b43-9f4c-63d499837e33 ### Before (Scenario B) https://github.com/user-attachments/assets/a779031a-8bb9-466b-9cf3-a56ad02a98d5 ### After (Scenario B) https://github.com/user-attachments/assets/05c081e7-7ad1-4608-8ef7-1bac4940034c
Decision log The git panel's refresh button should show everything a page reload would. Investigation found five ways it didn't, each fixed in its own PR on the current `changes` protocol: 1. `execute` without a workdir didn't record the directory the process ran in (`agent/agentproc`). 2. Refresh only rescanned repos already being watched, so a repo created or recreated after its path was recorded stayed hidden (`agent/agentgit`). A connection with no repos also got no first message. 3. After a git watch reconnect, the new full state was merged into the previous connection's map, so deleted repos stayed visible (`useGitWatcher`). 4. A chat watch reconnect didn't refetch the open chat, so missed `diff_status_change` events were lost (`AgentsPageLayout`). 5. Refresh didn't refetch the chat, whose diff statuses back the PR tabs, and was disabled until git context existed (`GitPanel`, `AgentChatPageView`). Considered and deferred: a snapshot protocol with diffs fetched by hash, which would keep large multi-repo diffs under the 4 MiB proxy read limit, and making scans faster at scale. Neither is needed to fix refresh, and together they made a single ~5900-line change that couldn't be reviewed. The refresh spinner still runs for a fixed time, since tying it to the response needs a request ID in the protocol.
--- _Generated by Coder Agents on behalf of @DanielleMaywood._ --- .../pages/AgentsPage/AgentChatPageView.tsx | 15 +---- .../components/GitPanel/GitPanel.test.tsx | 61 ++++++++++++++++--- .../components/GitPanel/GitPanel.tsx | 54 ++++++++-------- 3 files changed, 85 insertions(+), 45 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 30929d7157..e9fb22e260 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -1,9 +1,7 @@ import { cn } from "cn"; import { ArchiveIcon, TriangleAlertIcon } from "lucide-react"; import { useEffect, useState } from "react"; -import { useQueryClient } from "react-query"; import type { UrlTransform } from "streamdown"; -import { invalidateChatDiffContents } from "#/api/queries/chats"; import type * as TypesGen from "#/api/typesGenerated"; import type { ChatMessagePart } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; @@ -319,7 +317,6 @@ export const AgentChatPageView: React.FC = ({ onMCPAuthComplete, desktopChatId, }) => { - const queryClient = useQueryClient(); const { proxy } = useProxy(); const { entitlements } = useDashboard(); const { permissions, user: currentUser } = useAuthenticated(); @@ -332,16 +329,6 @@ export const AgentChatPageView: React.FC = ({ const canSubmitChatTurn = !isInputDisabled && !isSubmissionPending; - // Wrap the git watcher refresh to also invalidate the cached - // remote/PR diff contents so the panel re-fetches from GitHub. - const handleRefresh = () => { - const sent = gitWatcher.refresh(); - if (sent && agentId) { - void invalidateChatDiffContents(queryClient, agentId); - } - return sent; - }; - const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false); // The turn already running when the page loaded must not anchor the // scroller, even if its prompt is older than the newest messages page and @@ -672,7 +659,7 @@ export const AgentChatPageView: React.FC = ({ workspaceAgent?.status === "connected" && !gitWatcher.hasReceivedChanges } - onRefresh={handleRefresh} + onRefresh={gitWatcher.refresh} isExpanded={visualExpanded} remoteDiffStats={chat.diff_statuses} primaryDiffStatus={chat.diff_status} diff --git a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.test.tsx b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.test.tsx index 18c8d87d9a..390baa757e 100644 --- a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.test.tsx +++ b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.test.tsx @@ -1,15 +1,20 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { QueryClientProvider } from "react-query"; +import { + type QueryClient, + QueryClientProvider, + QueryObserver, +} from "react-query"; import { describe, expect, it, vi } from "vitest"; import { API } from "#/api/api"; +import { chat as chatById, chatDiffContents } from "#/api/queries/chats"; import type { ChatDiffContents, WorkspaceAgentRepoChanges, } from "#/api/typesGenerated"; import { TooltipProvider } from "#/components/Tooltip/Tooltip"; import { ThemeOverride } from "#/contexts/ThemeProvider"; -import { MockChatDiffStatus } from "#/testHelpers/chatEntities"; +import { MockChat, MockChatDiffStatus } from "#/testHelpers/chatEntities"; import { createTestQueryClient } from "#/testHelpers/renderHelpers"; import themes, { DEFAULT_THEME } from "#/theme"; import type { ChatMessageInputRef } from "../AgentChatInput"; @@ -23,8 +28,9 @@ const mockDiffContents: ChatDiffContents = { chat_id: "test-chat", }; -const Wrapper: React.FC = ({ children }) => { - const queryClient = createTestQueryClient(); +const Wrapper: React.FC< + React.PropsWithChildren<{ queryClient: QueryClient }> +> = ({ children, queryClient }) => { return ( @@ -35,8 +41,9 @@ const Wrapper: React.FC = ({ children }) => { }; const renderPanel = (props: Partial>) => { + const queryClient = createTestQueryClient(); const view = render( - + true} @@ -53,7 +60,7 @@ const renderPanel = (props: Partial>) => { next: Partial>, ) => { view.rerender( - + true} @@ -67,7 +74,7 @@ const renderPanel = (props: Partial>) => { , ); }; - return { view, rerenderPanel }; + return { view, rerenderPanel, queryClient }; }; const repoRoot = "/workspace/special-repo"; @@ -109,6 +116,46 @@ function renderGitPanel(input: ChatMessageInputRef) { } describe("GitPanel", () => { + it("allows refreshing before any git context is known", async () => { + const user = userEvent.setup(); + const onRefresh = vi.fn(() => true); + renderPanel({ onRefresh }); + + await user.click(screen.getByRole("button", { name: "Refresh" })); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it.each([ + { name: "the git watch socket is connected", sent: true }, + { name: "the git watch socket is down", sent: false }, + ])( + "refetches the chat and its diff contents on refresh when $name", + async ({ sent }) => { + const user = userEvent.setup(); + const getChat = vi + .spyOn(API.experimental, "getChat") + .mockResolvedValue(MockChat); + const getChatDiffContents = vi + .spyOn(API.experimental, "getChatDiffContents") + .mockResolvedValue(mockDiffContents); + const { queryClient } = renderPanel({ onRefresh: () => sent }); + // Stand in for the chat page and an open diff panel, so the + // invalidated queries are refetched. + new QueryObserver(queryClient, chatById("test-chat")).subscribe(() => {}); + new QueryObserver(queryClient, chatDiffContents("test-chat")).subscribe( + () => {}, + ); + await waitFor(() => expect(getChat).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(getChatDiffContents).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole("button", { name: "Refresh" })); + + await waitFor(() => expect(getChat).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(getChatDiffContents).toHaveBeenCalledTimes(2)); + }, + ); + it("inserts a commit prompt for the active repo when Commit is clicked", async () => { const user = userEvent.setup(); const input = mockComposer(); diff --git a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx index a1b0c6c041..d3a2f83a97 100644 --- a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx +++ b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx @@ -15,6 +15,11 @@ import { RowsIcon, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { useQueryClient } from "react-query"; +import { + invalidateChatDiffContents, + invalidateChatEntity, +} from "#/api/queries/chats"; import type * as TypesGen from "#/api/typesGenerated"; import type { ChatDiffStatus, @@ -129,7 +134,11 @@ type GitPanelProps = { chatId: string; /** Repository data from git watcher. */ repositories: ReadonlyMap; - /** Callback to send a refresh to the git watcher. Returns false when disconnected. */ + /** + * Callback to send a refresh to the git watcher. Returns false when + * disconnected. The panel refetches the chat and its diff contents + * itself. + */ onRefresh: () => boolean; /** Whether the panel is in expanded/fullscreen mode. */ isExpanded: boolean; @@ -325,7 +334,10 @@ export const GitPanel: React.FC = ({ const [spinning, setSpinning] = useState(false); const spinTimerRef = useRef>(undefined); useEffect(() => () => clearTimeout(spinTimerRef.current), []); + const queryClient = useQueryClient(); const handleRefresh = () => { + void invalidateChatEntity(queryClient, chatId); + void invalidateChatDiffContents(queryClient, chatId); const sent = onRefresh(); if (!sent) { toast.error("Unable to refresh git status.", { @@ -488,29 +500,23 @@ export const GitPanel: React.FC = ({ - {/* - * The shared Button applies `disabled:pointer-events-none`, - * which would suppress the native `title` tooltip when the - * control is disabled. Wrap it in a span so the tooltip is - * still reachable on hover in the disabled state. - */} - - - + {/* Refresh stays enabled without git context, since a repo + created after the chat touched its path only appears + once the agent re-resolves the chat's paths. */} + {/* PR title row: truncates with a hover tooltip only when the