mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix(site): remove last-checked label from git diff panel (#24675)
Removes `LastCheckedLabel.tsx` component and all related plumbing.
Note: chromatic is failing on main, seems to be pre-existing.
> 🤖
This commit is contained in:
@@ -86,7 +86,6 @@ const buildGitWatcher = (): ComponentProps<
|
||||
>["gitWatcher"] => ({
|
||||
repositories: new Map(),
|
||||
everDirty: new Set(),
|
||||
lastCheckedAt: undefined,
|
||||
refresh: fn().mockReturnValue(true),
|
||||
});
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ interface AgentChatPageViewProps {
|
||||
gitWatcher: {
|
||||
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
|
||||
everDirty: ReadonlySet<string>;
|
||||
lastCheckedAt: Date | undefined;
|
||||
refresh: () => boolean;
|
||||
};
|
||||
|
||||
@@ -346,7 +345,6 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
}
|
||||
repositories={gitWatcher.repositories}
|
||||
everDirty={gitWatcher.everDirty}
|
||||
lastCheckedAt={gitWatcher.lastCheckedAt}
|
||||
onRefresh={handleRefresh}
|
||||
onCommit={handleCommit}
|
||||
isExpanded={visualExpanded}
|
||||
|
||||
@@ -389,44 +389,3 @@ export const CleanRepoFromStart: Story = {
|
||||
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,7 +29,6 @@ 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 };
|
||||
|
||||
@@ -63,11 +62,6 @@ interface GitPanelProps {
|
||||
* 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 {
|
||||
@@ -84,7 +78,6 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
remoteDiffStats,
|
||||
chatInputRef,
|
||||
everDirty,
|
||||
lastCheckedAt,
|
||||
}) => {
|
||||
const hasRemoteStats =
|
||||
(remoteDiffStats?.additions ?? 0) > 0 ||
|
||||
@@ -258,10 +251,6 @@ 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"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -842,46 +842,18 @@ describe("useGitWatcher", () => {
|
||||
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.
|
||||
// the socket; everDirty and repositories 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 () => {
|
||||
it("heartbeat message (no repositories) does not touch repos", async () => {
|
||||
const socket = createMockSocket();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -921,11 +893,6 @@ describe("useGitWatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -968,52 +935,11 @@ describe("useGitWatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
expect(result.current.repositories.has("/repo")).toBe(true);
|
||||
});
|
||||
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);
|
||||
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks everDirty: preserves across agentStatus flap on the same chat", async () => {
|
||||
@@ -1053,43 +979,4 @@ describe("useGitWatcher", () => {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,10 +35,6 @@ interface UseGitWatcherResult {
|
||||
* 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. */
|
||||
@@ -55,20 +51,16 @@ export function useGitWatcher({
|
||||
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.
|
||||
// Chat-scoped state (everDirty) 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 => {
|
||||
@@ -112,12 +104,6 @@ export function useGitWatcher({
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -182,9 +168,8 @@ export function useGitWatcher({
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Reset connection-scoped state only. `everDirty` and
|
||||
// `lastCheckedAt` are chat-scoped and persist across
|
||||
// reconnects, so a slow backoff keeps the label advancing.
|
||||
// Reset connection-scoped state only. `everDirty` is
|
||||
// chat-scoped and persists across reconnects.
|
||||
dispose();
|
||||
setIsConnected(false);
|
||||
setRepositories(new Map());
|
||||
@@ -192,5 +177,5 @@ export function useGitWatcher({
|
||||
};
|
||||
}, [chatId, agentStatus]);
|
||||
|
||||
return { repositories, everDirty, lastCheckedAt, isConnected, refresh };
|
||||
return { repositories, everDirty, isConnected, refresh };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user