mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: disable Git controls when Git is not active (#24673)
closes CODAGT-148 In chats with no Git context (no repositories known to the watcher, no PR tab, no remote diff), the refresh button fires an "Unable to refresh git status" toast because the watcher WebSocket never opens. Derive `isGitActive = repositories.size > 0 || showRemoteTab` in `GitPanel` and use it to: - Disable the refresh button, unified-diff toggle, and split-diff toggle with a "Git is not set up for this chat" tooltip. - Show a dedicated empty state explaining how to enable Git, replacing the generic "No pushed changes yet" copy. Chats with at least one repository or a PR tab are unaffected; all controls remain enabled and behave as before. Adds a `GitNotActive` Storybook story with play-function assertions covering the disabled controls and empty-state copy.
This commit is contained in:
@@ -91,6 +91,7 @@ const buildGitWatcher = (): ComponentProps<
|
||||
>["gitWatcher"] => ({
|
||||
repositories: new Map(),
|
||||
everDirty: new Set(),
|
||||
hasReceivedChanges: true,
|
||||
refresh: fn().mockReturnValue(true),
|
||||
});
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ interface AgentChatPageViewProps {
|
||||
gitWatcher: {
|
||||
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
|
||||
everDirty: ReadonlySet<string>;
|
||||
hasReceivedChanges: boolean;
|
||||
|
||||
refresh: () => boolean;
|
||||
};
|
||||
|
||||
@@ -358,6 +360,10 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
}
|
||||
repositories={gitWatcher.repositories}
|
||||
everDirty={gitWatcher.everDirty}
|
||||
isGitStatusLoading={
|
||||
workspaceAgent?.status === "connected" &&
|
||||
!gitWatcher.hasReceivedChanges
|
||||
}
|
||||
onRefresh={handleRefresh}
|
||||
onCommit={handleCommit}
|
||||
isExpanded={visualExpanded}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, spyOn, userEvent } from "storybook/test";
|
||||
import { expect, fn, spyOn, userEvent, within } from "storybook/test";
|
||||
import { API } from "#/api/api";
|
||||
import type {
|
||||
ChatDiffContents,
|
||||
@@ -227,6 +227,10 @@ export const WorkingChangesOnly: Story = {
|
||||
args: {
|
||||
repositories: new Map([["/home/coder/coder", makeRepo()]]),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByLabelText("Refresh")).toBeEnabled();
|
||||
},
|
||||
};
|
||||
|
||||
/** Multiple repos with working changes. */
|
||||
@@ -268,6 +272,35 @@ export const EmptyState: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** No repositories and no remote tab; Git controls should be disabled. */
|
||||
export const GitNotActive: Story = {
|
||||
args: {
|
||||
repositories: new Map(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByLabelText("Refresh")).toBeDisabled();
|
||||
await expect(canvas.getByLabelText("Unified diff")).toBeDisabled();
|
||||
await expect(canvas.getByLabelText("Split diff")).toBeDisabled();
|
||||
await expect(
|
||||
canvas.getByText("Git is not set up for this chat."),
|
||||
).toBeVisible();
|
||||
await expect(canvas.getByText(/Git status will appear/)).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** Git watcher is loading its first repository update. */
|
||||
export const GitStatusLoading: Story = {
|
||||
args: {
|
||||
repositories: new Map(),
|
||||
isGitStatusLoading: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Waiting for Git status")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* PR diff with the inline comment input visible. The play function
|
||||
* waits for the diff to render, then clicks a line number gutter
|
||||
|
||||
@@ -32,6 +32,13 @@ import { RemoteDiffPanel } from "../DiffViewer/RemoteDiffPanel";
|
||||
|
||||
type GitView = { type: "remote" } | { type: "local"; repoRoot: string };
|
||||
|
||||
const GIT_NOT_SETUP_TITLE = "Git is not set up for this chat";
|
||||
const GIT_NOT_SETUP_SENTENCE = "Git is not set up for this chat.";
|
||||
const GIT_NOT_SETUP_BODY =
|
||||
"Git status will appear here once a Git repository is detected in the workspace.";
|
||||
const GIT_STATUS_LOADING_TITLE = "Waiting for Git status";
|
||||
const GIT_STATUS_LOADING_BODY = "Checking the workspace for Git repositories.";
|
||||
|
||||
interface DiffStats {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
@@ -51,6 +58,8 @@ interface GitPanelProps {
|
||||
onCommit: (repoRoot: string) => void;
|
||||
/** Whether the panel is in expanded/fullscreen mode. */
|
||||
isExpanded?: boolean;
|
||||
/** Whether the watcher is loading its initial repository state. */
|
||||
isGitStatusLoading?: boolean;
|
||||
/** Diff status for the remote/branch view (includes PR metadata). */
|
||||
remoteDiffStats?: ChatDiffStatus;
|
||||
/** Ref to the chat input, forwarded to RemoteDiffPanel. */
|
||||
@@ -75,15 +84,19 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
onRefresh,
|
||||
onCommit,
|
||||
isExpanded,
|
||||
isGitStatusLoading = false,
|
||||
remoteDiffStats,
|
||||
chatInputRef,
|
||||
everDirty,
|
||||
}) => {
|
||||
const hasRemoteStats =
|
||||
const hasRemoteDiff =
|
||||
(remoteDiffStats?.changed_files ?? 0) > 0 ||
|
||||
(remoteDiffStats?.additions ?? 0) > 0 ||
|
||||
(remoteDiffStats?.deletions ?? 0) > 0;
|
||||
|
||||
const showRemoteTab = Boolean(prTab) || hasRemoteStats;
|
||||
const showRemoteTab = Boolean(prTab) || hasRemoteDiff;
|
||||
const hasGitContext = repositories.size > 0 || showRemoteTab;
|
||||
const isWaitingForGitStatus = !hasGitContext && isGitStatusLoading;
|
||||
|
||||
const prTitle = remoteDiffStats?.pull_request_title;
|
||||
const prState = remoteDiffStats?.pull_request_state;
|
||||
@@ -147,6 +160,8 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
setView({ type: "remote" });
|
||||
} else if (localRepos.length > 0) {
|
||||
setView({ type: "local", repoRoot: localRepos[0] });
|
||||
} else {
|
||||
setView({ type: "remote" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,8 +271,10 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
type="button"
|
||||
onClick={() => handleDiffStyleChange("unified")}
|
||||
aria-label="Unified diff"
|
||||
disabled={!hasGitContext}
|
||||
title={!hasGitContext ? GIT_NOT_SETUP_TITLE : undefined}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center border-none px-1.5 transition-colors",
|
||||
"flex cursor-pointer items-center border-none px-1.5 transition-colors disabled:cursor-default disabled:opacity-50",
|
||||
diffStyle === "unified"
|
||||
? "bg-surface-quaternary/25 text-content-primary"
|
||||
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
|
||||
@@ -269,8 +286,10 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
type="button"
|
||||
onClick={() => handleDiffStyleChange("split")}
|
||||
aria-label="Split diff"
|
||||
disabled={!hasGitContext}
|
||||
title={!hasGitContext ? GIT_NOT_SETUP_TITLE : undefined}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center border-0 border-l border-solid border-border-default px-1.5 transition-colors",
|
||||
"flex cursor-pointer items-center border-0 border-l border-solid border-border-default px-1.5 transition-colors disabled:cursor-default disabled:opacity-50",
|
||||
diffStyle === "split"
|
||||
? "bg-surface-quaternary/25 text-content-primary"
|
||||
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
|
||||
@@ -279,20 +298,29 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
<ColumnsIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
className="h-6 w-6 text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
spinning && "motion-safe:animate-spin-once",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<span title={!hasGitContext ? GIT_NOT_SETUP_TITLE : undefined}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
disabled={!hasGitContext}
|
||||
className="h-6 w-6 text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
spinning && "motion-safe:animate-spin-once",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
@@ -300,6 +328,8 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
{view.type === "remote" ? (
|
||||
<RemoteContent
|
||||
prTab={prTab}
|
||||
hasGitContext={hasGitContext}
|
||||
isGitStatusLoading={isWaitingForGitStatus}
|
||||
isExpanded={isExpanded}
|
||||
chatInputRef={chatInputRef}
|
||||
diffStyle={diffStyle}
|
||||
@@ -328,22 +358,44 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
|
||||
const RemoteContent: FC<{
|
||||
prTab?: { prNumber: number; chatId: string };
|
||||
hasGitContext: boolean;
|
||||
isGitStatusLoading: boolean;
|
||||
isExpanded?: boolean;
|
||||
chatInputRef?: RefObject<ChatMessageInputRef | null>;
|
||||
diffStyle: DiffStyle;
|
||||
diffStatus?: ChatDiffStatus;
|
||||
}> = ({ prTab, isExpanded, chatInputRef, diffStyle, diffStatus }) => {
|
||||
}> = ({
|
||||
prTab,
|
||||
hasGitContext,
|
||||
isGitStatusLoading,
|
||||
isExpanded,
|
||||
chatInputRef,
|
||||
diffStyle,
|
||||
diffStatus,
|
||||
}) => {
|
||||
if (!prTab) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 flex size-10 items-center justify-center rounded-lg border border-solid border-border-default bg-surface-secondary">
|
||||
<GitCompareArrowsIcon className="size-5 text-content-secondary" />
|
||||
{hasGitContext ? (
|
||||
<GitCompareArrowsIcon className="size-5 text-content-secondary" />
|
||||
) : (
|
||||
<GitBranchIcon className="size-5 text-content-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-content-primary">
|
||||
No pushed changes yet
|
||||
{hasGitContext
|
||||
? "No pushed changes yet"
|
||||
: isGitStatusLoading
|
||||
? GIT_STATUS_LOADING_TITLE
|
||||
: GIT_NOT_SETUP_SENTENCE}
|
||||
</p>
|
||||
<p className="mt-1 max-w-52 text-xs text-content-secondary">
|
||||
Once commits are pushed, the branch diff will appear here.
|
||||
{hasGitContext
|
||||
? "Once commits are pushed, the branch diff will appear here."
|
||||
: isGitStatusLoading
|
||||
? GIT_STATUS_LOADING_BODY
|
||||
: GIT_NOT_SETUP_BODY}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -80,9 +80,11 @@ describe("useGitWatcher", () => {
|
||||
|
||||
expect(mockWatchChatGit).toHaveBeenCalledWith("chat-123");
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.hasReceivedChanges).toBe(false);
|
||||
|
||||
act(() => socket.simulateOpen());
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
expect(result.current.hasReceivedChanges).toBe(false);
|
||||
});
|
||||
|
||||
it("does not connect when chatId is undefined", () => {
|
||||
@@ -92,6 +94,7 @@ describe("useGitWatcher", () => {
|
||||
|
||||
expect(mockWatchChatGit).not.toHaveBeenCalled();
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.hasReceivedChanges).toBe(false);
|
||||
expect(result.current.repositories.size).toBe(0);
|
||||
});
|
||||
|
||||
@@ -104,6 +107,7 @@ describe("useGitWatcher", () => {
|
||||
|
||||
expect(mockWatchChatGit).not.toHaveBeenCalled();
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.hasReceivedChanges).toBe(false);
|
||||
expect(result.current.repositories.size).toBe(0);
|
||||
});
|
||||
|
||||
@@ -116,6 +120,7 @@ describe("useGitWatcher", () => {
|
||||
|
||||
expect(mockWatchChatGit).not.toHaveBeenCalled();
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.hasReceivedChanges).toBe(false);
|
||||
expect(result.current.repositories.size).toBe(0);
|
||||
});
|
||||
|
||||
@@ -211,6 +216,7 @@ describe("useGitWatcher", () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.repositories.size).toBe(2);
|
||||
});
|
||||
expect(result.current.hasReceivedChanges).toBe(true);
|
||||
|
||||
const repoA = result.current.repositories.get("/home/user/project-a");
|
||||
expect(repoA).toEqual({
|
||||
@@ -227,6 +233,24 @@ describe("useGitWatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks empty changes messages as received", async () => {
|
||||
const socket = createMockSocket();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
|
||||
);
|
||||
|
||||
act(() => socket.simulateOpen());
|
||||
act(() => {
|
||||
socket.simulateMessage({ type: "changes", repositories: [] });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.hasReceivedChanges).toBe(true);
|
||||
});
|
||||
expect(result.current.repositories.size).toBe(0);
|
||||
});
|
||||
|
||||
it("evicts repos with removed: true", async () => {
|
||||
const socket = createMockSocket();
|
||||
|
||||
@@ -443,6 +467,7 @@ describe("useGitWatcher", () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.repositories.size).toBe(1);
|
||||
});
|
||||
expect(result.current.hasReceivedChanges).toBe(true);
|
||||
|
||||
// The old socket should be closed when we switch chatId.
|
||||
const socket2 = createMockSocket();
|
||||
@@ -453,6 +478,7 @@ describe("useGitWatcher", () => {
|
||||
|
||||
// Repositories should be reset immediately after chatId changes.
|
||||
expect(result.current.repositories.size).toBe(0);
|
||||
expect(result.current.hasReceivedChanges).toBe(false);
|
||||
|
||||
// The new socket should work independently.
|
||||
act(() => socket2.simulateOpen());
|
||||
@@ -472,6 +498,7 @@ describe("useGitWatcher", () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.repositories.size).toBe(1);
|
||||
});
|
||||
expect(result.current.hasReceivedChanges).toBe(true);
|
||||
expect(result.current.repositories.has("/home/user/project-x")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ interface UseGitWatcherResult {
|
||||
everDirty: ReadonlySet<string>;
|
||||
/** Whether the WebSocket is currently connected. */
|
||||
isConnected: boolean;
|
||||
/** Whether the watcher has received repository state for this chat. */
|
||||
hasReceivedChanges: boolean;
|
||||
/** Send a refresh request. Returns true if sent, false if disconnected. */
|
||||
refresh: () => boolean;
|
||||
}
|
||||
@@ -52,6 +54,7 @@ export function useGitWatcher({
|
||||
() => new Set(),
|
||||
);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [hasReceivedChanges, setHasReceivedChanges] = useState(false);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
// Chat-scoped state (everDirty) resets on chatId change but
|
||||
@@ -104,6 +107,7 @@ export function useGitWatcher({
|
||||
}
|
||||
|
||||
if (data.type === "changes") {
|
||||
setHasReceivedChanges(true);
|
||||
if (data.repositories) {
|
||||
setRepositories((prev) => {
|
||||
let changed = false;
|
||||
@@ -159,6 +163,7 @@ export function useGitWatcher({
|
||||
|
||||
onDisconnect() {
|
||||
setIsConnected(false);
|
||||
setHasReceivedChanges(false);
|
||||
socketRef.current = null;
|
||||
},
|
||||
|
||||
@@ -172,10 +177,11 @@ export function useGitWatcher({
|
||||
// chat-scoped and persists across reconnects.
|
||||
dispose();
|
||||
setIsConnected(false);
|
||||
setHasReceivedChanges(false);
|
||||
setRepositories(new Map());
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [chatId, agentStatus]);
|
||||
|
||||
return { repositories, everDirty, isConnected, refresh };
|
||||
return { repositories, everDirty, isConnected, hasReceivedChanges, refresh };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user