fix: stabilize git tab during edit_files (#24648)

- feat(agent/agentgit): shorten fallback poll to 5s
- fix(site/AgentsPage): keep git tab visible after reverting to clean
- feat(site/AgentsPage): show last-checked time in git tab

> 🤖
This commit is contained in:
Cian Johnston
2026-04-23 14:02:47 +01:00
committed by GitHub
parent 397c9fb76a
commit ca14aa37c4
10 changed files with 955 additions and 54 deletions
@@ -85,6 +85,8 @@ const buildGitWatcher = (): ComponentProps<
typeof AgentChatPageView
>["gitWatcher"] => ({
repositories: new Map(),
everDirty: new Set(),
lastCheckedAt: undefined,
refresh: fn().mockReturnValue(true),
});
@@ -127,6 +127,8 @@ interface AgentChatPageViewProps {
debugLoggingEnabled: boolean;
gitWatcher: {
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
everDirty: ReadonlySet<string>;
lastCheckedAt: Date | undefined;
refresh: () => boolean;
};
@@ -343,6 +345,8 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
prNumber && agentId ? { prNumber, chatId: agentId } : undefined
}
repositories={gitWatcher.repositories}
everDirty={gitWatcher.everDirty}
lastCheckedAt={gitWatcher.lastCheckedAt}
onRefresh={handleRefresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
@@ -339,3 +339,94 @@ export const LargeDiff: Story = {
]),
},
};
/**
* Regression: when a repo was dirty during this session and then went
* clean (empty unified_diff), the tab must remain visible. Before the
* ever-dirty fix, the tab vanished the moment the diff became empty,
* which is what users saw as "diff disappears between edit_files".
*/
export const EverDirtyRepoGoneClean: Story = {
args: {
repositories: new Map([
["/home/coder/coder", makeRepo({ unified_diff: "" })],
]),
everDirty: new Set(["/home/coder/coder"]),
},
play: async ({ canvasElement }) => {
// The repo tab is still present (identified by the 'Working'
// prefix used by GitPanel's tab-strip button) even though the
// current diff is empty, because it was dirty earlier in the
// session.
const tabs = Array.from(canvasElement.querySelectorAll("button")).filter(
(b) => (b.textContent ?? "").startsWith("Working"),
);
expect(tabs).toHaveLength(1);
// The content pane shows the diff viewer's empty-diff state.
expect(canvasElement.textContent ?? "").toContain("No file changes");
},
};
/**
* Baseline: a repo reported clean from the start (never dirty in
* this session) has no tab. Ensures the ever-dirty fix did not
* regress the "nothing to show" case.
*/
export const CleanRepoFromStart: Story = {
args: {
repositories: new Map([
["/home/coder/coder", makeRepo({ unified_diff: "" })],
]),
everDirty: new Set(),
},
play: async ({ canvasElement }) => {
// No local repo tab should appear in the tab strip. The
// 'Working' prefix is GitPanel's tab-strip label contract.
const tabs = Array.from(canvasElement.querySelectorAll("button")).filter(
(b) => (b.textContent ?? "").startsWith("Working"),
);
expect(tabs).toHaveLength(0);
},
};
/**
* Renders the relative-time label once a scan has been observed.
*/
export const ShowsLastCheckedLabel: Story = {
args: {
repositories: new Map([["/home/coder/coder", makeRepo()]]),
// Fixed past date keeps the rendered "ago" text deterministic
// for pixel snapshots. dayjs formats "X months ago" or "X
// years ago" at this scale, and those buckets do not flip
// between story collection and story render.
lastCheckedAt: new Date("2024-01-01T00:00:00Z"),
},
play: async ({ canvasElement }) => {
const label = canvasElement.querySelector(
'[data-testid="git-last-checked"]',
);
expect(label).not.toBeNull();
// dayjs' relativeTime renders sub-45s as 'a few seconds ago'
// and longer spans as '<n> <unit> ago'. Accept either shape
// so the story is not coupled to dayjs' specific bucketing.
expect(label?.textContent ?? "").toMatch(/^checked .+ ago$/);
},
};
/**
* With no scan observed yet, the label renders nothing so the
* toolbar collapses cleanly.
*/
export const NoLastCheckedYet: Story = {
args: {
repositories: new Map([["/home/coder/coder", makeRepo()]]),
lastCheckedAt: undefined,
},
play: async ({ canvasElement }) => {
const label = canvasElement.querySelector(
'[data-testid="git-last-checked"]',
);
expect(label).toBeNull();
},
};
@@ -29,6 +29,7 @@ import {
} from "../DiffViewer/DiffViewer";
import { LocalDiffPanel } from "../DiffViewer/LocalDiffPanel";
import { RemoteDiffPanel } from "../DiffViewer/RemoteDiffPanel";
import { LastCheckedLabel } from "./LastCheckedLabel";
type GitView = { type: "remote" } | { type: "local"; repoRoot: string };
@@ -55,6 +56,18 @@ interface GitPanelProps {
remoteDiffStats?: ChatDiffStatus;
/** Ref to the chat input, forwarded to RemoteDiffPanel. */
chatInputRef?: RefObject<ChatMessageInputRef | null>;
/**
* Repo roots that have been dirty at some point during this session.
* Used to keep a repo's tab visible after its diff goes empty, so the
* tab strip does not visibly flip when the agent edits a file and
* then reverts it.
*/
everDirty?: ReadonlySet<string>;
/**
* Timestamp of the last scan received from the server. Rendered as a
* live-updating "checked Ns ago" label next to the refresh button.
*/
lastCheckedAt?: Date | undefined;
}
function repoTabLabel(repoRoot: string): string {
@@ -70,6 +83,8 @@ export const GitPanel: FC<GitPanelProps> = ({
isExpanded,
remoteDiffStats,
chatInputRef,
everDirty,
lastCheckedAt,
}) => {
const hasRemoteStats =
(remoteDiffStats?.additions ?? 0) > 0 ||
@@ -102,9 +117,19 @@ export const GitPanel: FC<GitPanelProps> = ({
return stats;
})();
const localRepos = Array.from(repoStats.keys()).sort((a, b) =>
a.localeCompare(b),
);
// Union of currently-dirty and ever-dirty repos (still known to
// the watcher) so a clean-revert does not hide the tab.
const localRepos = (() => {
const roots = new Set<string>(repoStats.keys());
if (everDirty) {
for (const root of everDirty) {
if (repositories.has(root)) {
roots.add(root);
}
}
}
return Array.from(roots).sort((a, b) => a.localeCompare(b));
})();
// Default to the first local repo when there are only local
// changes and no remote stats.
@@ -122,7 +147,9 @@ export const GitPanel: FC<GitPanelProps> = ({
setView({ type: "local", repoRoot: localRepos[0] });
}
} else if (view.type === "local") {
if (!repoStats.has(view.repoRoot)) {
// localRepos includes ever-dirty repos with empty diffs, so
// the active tab stays valid until its root leaves the set.
if (!localRepos.includes(view.repoRoot)) {
if (showRemoteTab) {
setView({ type: "remote" });
} else if (localRepos.length > 0) {
@@ -130,7 +157,7 @@ export const GitPanel: FC<GitPanelProps> = ({
}
}
}
}, [view, showRemoteTab, localRepos, repoStats]);
}, [view, showRemoteTab, localRepos]);
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
@@ -231,6 +258,10 @@ export const GitPanel: FC<GitPanelProps> = ({
</ScrollArea>
{/* Controls */}
<div className="flex shrink-0 items-center gap-1 py-1.5">
<LastCheckedLabel
at={lastCheckedAt}
className="mr-1 whitespace-nowrap text-[11px] text-content-secondary"
/>
<div className="flex h-6 items-stretch overflow-hidden rounded-md border border-solid border-border-default">
<button
type="button"
@@ -0,0 +1,32 @@
import type { FC } from "react";
import { cn } from "#/utils/cn";
import { relativeTime } from "#/utils/time";
interface LastCheckedLabelProps {
at: Date | undefined;
className?: string;
}
/**
* Renders "checked <relative time>" next to the refresh button.
* Compiled by React Compiler, so re-renders are driven by a fresh
* `at` reference; the server's 5s scan heartbeat supplies that.
* Returns null before the first scan so the toolbar collapses.
*/
export const LastCheckedLabel: FC<LastCheckedLabelProps> = ({
at,
className,
}) => {
if (!at) {
return null;
}
return (
<span
data-testid="git-last-checked"
className={cn(["whitespace-nowrap", className])}
title={at.toLocaleString()}
>
checked {relativeTime(at)}
</span>
);
};
@@ -664,4 +664,432 @@ describe("useGitWatcher", () => {
});
expect(result.current.repositories).toBe(ref1);
});
it("tracks everDirty: adds repo on first non-empty diff", async () => {
const socket = createMockSocket();
const { result } = renderHook(() =>
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
);
act(() => socket.simulateOpen());
act(() => {
socket.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/repo",
branch: "main",
unified_diff: "some diff",
},
],
});
});
await waitFor(() => {
expect(result.current.everDirty.has("/repo")).toBe(true);
});
});
it("tracks everDirty: retains repo after diff goes empty", async () => {
const socket = createMockSocket();
const { result } = renderHook(() =>
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
);
act(() => socket.simulateOpen());
// First, dirty the repo.
act(() => {
socket.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/repo",
branch: "main",
unified_diff: "diff1",
},
],
});
});
await waitFor(() => {
expect(result.current.everDirty.has("/repo")).toBe(true);
});
// Revert the repo to clean (empty diff). The repo stays in
// repositories, and everDirty retains it so the UI can keep
// the tab visible.
act(() => {
socket.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/repo",
branch: "main",
unified_diff: "",
},
],
});
});
await waitFor(() => {
expect(result.current.repositories.get("/repo")?.unified_diff).toBe("");
});
expect(result.current.everDirty.has("/repo")).toBe(true);
});
it("tracks everDirty: does not add on first empty diff", async () => {
const socket = createMockSocket();
const { result } = renderHook(() =>
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
);
act(() => socket.simulateOpen());
act(() => {
socket.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/repo",
branch: "main",
unified_diff: "",
},
],
});
});
await waitFor(() => {
expect(result.current.repositories.size).toBe(1);
});
expect(result.current.everDirty.size).toBe(0);
});
it("tracks everDirty: removes repo on removed: true", async () => {
const socket = createMockSocket();
const { result } = renderHook(() =>
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
);
act(() => socket.simulateOpen());
act(() => {
socket.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/repo",
branch: "main",
unified_diff: "diff1",
},
],
});
});
await waitFor(() => {
expect(result.current.everDirty.has("/repo")).toBe(true);
});
act(() => {
socket.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/repo",
branch: "",
removed: true,
},
],
});
});
await waitFor(() => {
expect(result.current.repositories.size).toBe(0);
});
expect(result.current.everDirty.size).toBe(0);
});
it("tracks everDirty: clears on chatId change", async () => {
const socket1 = createMockSocket();
const { result, rerender } = renderHook(
({ chatId }: { chatId: string }) =>
useGitWatcher({ chatId, agentStatus: "connected" }),
{ initialProps: { chatId: "chat-A" } },
);
act(() => socket1.simulateOpen());
act(() => {
socket1.simulateMessage({
type: "changes",
scanned_at: "2024-01-02T03:04:05Z",
repositories: [
{
repo_root: "/repo",
branch: "main",
unified_diff: "diff1",
},
],
});
});
await waitFor(() => {
expect(result.current.everDirty.has("/repo")).toBe(true);
});
expect(result.current.lastCheckedAt).toBeInstanceOf(Date);
// Switch to a different chat. The hook tears down and recreates
// the socket; everDirty, repositories, and lastCheckedAt must
// all reset so chat-B starts with a clean slate.
createMockSocket();
rerender({ chatId: "chat-B" });
expect(result.current.repositories.size).toBe(0);
expect(result.current.everDirty.size).toBe(0);
expect(result.current.lastCheckedAt).toBeUndefined();
});
it("tracks lastCheckedAt from scanned_at", async () => {
const socket = createMockSocket();
const { result } = renderHook(() =>
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
);
act(() => socket.simulateOpen());
expect(result.current.lastCheckedAt).toBeUndefined();
act(() => {
socket.simulateMessage({
type: "changes",
scanned_at: "2024-01-02T03:04:05Z",
repositories: [],
});
});
await waitFor(() => {
expect(result.current.lastCheckedAt).toBeInstanceOf(Date);
});
expect(result.current.lastCheckedAt?.toISOString()).toBe(
"2024-01-02T03:04:05.000Z",
);
});
it("heartbeat message (no repositories) advances lastCheckedAt without touching repos", async () => {
const socket = createMockSocket();
const { result } = renderHook(() =>
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
);
act(() => socket.simulateOpen());
// Prime a known-dirty repo via a delta message.
act(() => {
socket.simulateMessage({
type: "changes",
scanned_at: "2024-01-02T03:04:05Z",
repositories: [
{
repo_root: "/repo",
branch: "main",
unified_diff: "diff1",
},
],
});
});
await waitFor(() => {
expect(result.current.repositories.size).toBe(1);
expect(result.current.everDirty.has("/repo")).toBe(true);
});
const repoStateBeforeHeartbeat = result.current.repositories;
const everDirtyBeforeHeartbeat = result.current.everDirty;
// Heartbeat: scanned_at advances, but repositories is absent.
// The server sends this shape when Scan() observed no deltas
// (idle clean-staying-clean or no-change-since-last-emit).
act(() => {
socket.simulateMessage({
type: "changes",
scanned_at: "2024-01-02T03:04:10Z",
});
});
await waitFor(() => {
expect(result.current.lastCheckedAt?.toISOString()).toBe(
"2024-01-02T03:04:10.000Z",
);
});
// Heartbeat must not mutate repo state.
expect(result.current.repositories).toBe(repoStateBeforeHeartbeat);
expect(result.current.everDirty).toBe(everDirtyBeforeHeartbeat);
});
it("heartbeat with explicit empty repositories array is also a no-op for repos", async () => {
const socket = createMockSocket();
const { result } = renderHook(() =>
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
);
act(() => socket.simulateOpen());
act(() => {
socket.simulateMessage({
type: "changes",
scanned_at: "2024-01-02T03:04:05Z",
repositories: [
{
repo_root: "/repo",
branch: "main",
unified_diff: "diff1",
},
],
});
});
await waitFor(() => {
expect(result.current.repositories.size).toBe(1);
});
// Some JSON encoders may preserve an empty array rather than
// omitting the field. Either shape must be treated as a pure
// heartbeat on the client.
act(() => {
socket.simulateMessage({
type: "changes",
scanned_at: "2024-01-02T03:04:15Z",
repositories: [],
});
});
await waitFor(() => {
expect(result.current.lastCheckedAt?.toISOString()).toBe(
"2024-01-02T03:04:15.000Z",
);
});
// Repo entry survives an empty-array heartbeat.
expect(result.current.repositories.has("/repo")).toBe(true);
expect(result.current.everDirty.has("/repo")).toBe(true);
});
it("ignores malformed scanned_at without clearing existing value", async () => {
const socket = createMockSocket();
const { result } = renderHook(() =>
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
);
act(() => socket.simulateOpen());
// Prime a valid timestamp.
act(() => {
socket.simulateMessage({
type: "changes",
scanned_at: "2024-01-02T03:04:05Z",
repositories: [],
});
});
await waitFor(() => {
expect(result.current.lastCheckedAt).toBeDefined();
});
const firstIso = result.current.lastCheckedAt?.toISOString();
// A malformed scanned_at must not wipe the previous value.
act(() => {
socket.simulateMessage({
type: "changes",
scanned_at: "not-a-date",
repositories: [],
});
});
// Compare by ISO string, not reference. A future refactor that
// re-creates the Date object identically should still pass; what
// matters is that the observed timestamp did not change.
expect(result.current.lastCheckedAt?.toISOString()).toBe(firstIso);
});
it("tracks everDirty: preserves across agentStatus flap on the same chat", async () => {
const socket1 = createMockSocket();
const { result, rerender } = renderHook(
({ agentStatus }: { agentStatus: WorkspaceAgentStatus | undefined }) =>
useGitWatcher({ chatId: "chat-stable", agentStatus }),
{ initialProps: { agentStatus: "connected" as WorkspaceAgentStatus } },
);
act(() => socket1.simulateOpen());
act(() => {
socket1.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/repo",
branch: "main",
unified_diff: "diff1",
},
],
});
});
await waitFor(() => {
expect(result.current.everDirty.has("/repo")).toBe(true);
});
// Simulate a transient agentStatus flap. This tears down the
// socket but must not forget that /repo was dirty during this
// chat session.
createMockSocket();
rerender({ agentStatus: "connecting" as WorkspaceAgentStatus });
expect(result.current.everDirty.has("/repo")).toBe(true);
createMockSocket();
rerender({ agentStatus: "connected" as WorkspaceAgentStatus });
expect(result.current.everDirty.has("/repo")).toBe(true);
});
it("preserves lastCheckedAt across agentStatus flap on the same chat", async () => {
const socket1 = createMockSocket();
const { result, rerender } = renderHook(
({ agentStatus }: { agentStatus: WorkspaceAgentStatus | undefined }) =>
useGitWatcher({ chatId: "chat-stable", agentStatus }),
{ initialProps: { agentStatus: "connected" as WorkspaceAgentStatus } },
);
act(() => socket1.simulateOpen());
act(() => {
socket1.simulateMessage({
type: "changes",
scanned_at: "2024-01-02T03:04:05Z",
repositories: [],
});
});
await waitFor(() => {
expect(result.current.lastCheckedAt?.toISOString()).toBe(
"2024-01-02T03:04:05.000Z",
);
});
// Transient flap: the socket is torn down but the stale-data
// timestamp must keep advancing so the UI's "checked Ns ago"
// label does not disappear during reconnection backoff.
createMockSocket();
rerender({ agentStatus: "connecting" as WorkspaceAgentStatus });
expect(result.current.lastCheckedAt?.toISOString()).toBe(
"2024-01-02T03:04:05.000Z",
);
createMockSocket();
rerender({ agentStatus: "connected" as WorkspaceAgentStatus });
expect(result.current.lastCheckedAt?.toISOString()).toBe(
"2024-01-02T03:04:05.000Z",
);
});
});
@@ -29,6 +29,16 @@ interface UseGitWatcherOptions {
interface UseGitWatcherResult {
/** Current repo state, keyed by repo root path. */
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
/**
* Repo roots seen with a non-empty unified_diff during this chat.
* Survives reconnects; evicted on `removed: true`, cleared on
* chatId change. Consumers should intersect with `repositories`.
*/
everDirty: ReadonlySet<string>;
/** ScannedAt from the latest server message. Undefined until the
* first message arrives. Preserved across reconnects so the UI's
* relative-time label keeps advancing during backoff. */
lastCheckedAt: Date | undefined;
/** Whether the WebSocket is currently connected. */
isConnected: boolean;
/** Send a refresh request. Returns true if sent, false if disconnected. */
@@ -42,9 +52,24 @@ export function useGitWatcher({
const [repositories, setRepositories] = useState<
ReadonlyMap<string, WorkspaceAgentRepoChanges>
>(new Map());
const [everDirty, setEverDirty] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [lastCheckedAt, setLastCheckedAt] = useState<Date | undefined>(
undefined,
);
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef<WebSocket | null>(null);
// Chat-scoped state (everDirty, lastCheckedAt) resets on chatId
// change but must survive agentStatus flaps on the same chat.
// https://react.dev/reference/react/useState#storing-information-from-previous-renders
const [lastChatId, setLastChatId] = useState<string | undefined>(chatId);
if (lastChatId !== chatId) {
setLastChatId(chatId);
setEverDirty((prev) => (prev.size === 0 ? prev : new Set()));
setLastCheckedAt(undefined);
}
const sendMessage = (msg: WorkspaceAgentGitClientMessage): boolean => {
const socket = socketRef.current;
@@ -86,31 +111,54 @@ export function useGitWatcher({
return;
}
if (data.type === "changes" && data.repositories) {
setRepositories((prev) => {
let changed = false;
const next = new Map(prev);
for (const repo of data.repositories!) {
if (repo.removed) {
if (next.has(repo.repo_root)) {
next.delete(repo.repo_root);
changed = true;
if (data.type === "changes") {
if (data.scanned_at) {
const parsed = new Date(data.scanned_at);
if (!Number.isNaN(parsed.getTime())) {
setLastCheckedAt(parsed);
}
}
if (data.repositories) {
setRepositories((prev) => {
let changed = false;
const next = new Map(prev);
for (const repo of data.repositories!) {
if (repo.removed) {
if (next.has(repo.repo_root)) {
next.delete(repo.repo_root);
changed = true;
}
} else {
const existing = next.get(repo.repo_root);
if (
!existing ||
existing.branch !== repo.branch ||
existing.remote_origin !== repo.remote_origin ||
existing.unified_diff !== repo.unified_diff
) {
next.set(repo.repo_root, repo);
changed = true;
}
}
} else {
const existing = next.get(repo.repo_root);
if (
!existing ||
existing.branch !== repo.branch ||
existing.remote_origin !== repo.remote_origin ||
existing.unified_diff !== repo.unified_diff
) {
next.set(repo.repo_root, repo);
}
return changed ? next : prev;
});
setEverDirty((prev) => {
let changed = false;
const next = new Set(prev);
for (const repo of data.repositories!) {
if (repo.removed) {
if (next.delete(repo.repo_root)) {
changed = true;
}
} else if (repo.unified_diff && !next.has(repo.repo_root)) {
next.add(repo.repo_root);
changed = true;
}
}
}
return changed ? next : prev;
});
return changed ? next : prev;
});
}
} else if (data.type === "error") {
console.warn("[useGitWatcher] server error:", data.message);
}
@@ -134,8 +182,9 @@ export function useGitWatcher({
});
return () => {
// dispose() suppresses onDisconnect, so reset state
// explicitly.
// Reset connection-scoped state only. `everDirty` and
// `lastCheckedAt` are chat-scoped and persist across
// reconnects, so a slow backoff keeps the label advancing.
dispose();
setIsConnected(false);
setRepositories(new Map());
@@ -143,5 +192,5 @@ export function useGitWatcher({
};
}, [chatId, agentStatus]);
return { repositories, isConnected, refresh };
return { repositories, everDirty, lastCheckedAt, isConnected, refresh };
}