feat: agents git watch frontend (#22570)

Replaces the single-purpose PR diff right panel with a tabbed sidebar
that shows both the existing PR diff and real-time git repository
changes from the workspace agent.

There's an accompanying backend PR
[here](https://github.com/coder/coder/pull/22565).



https://github.com/user-attachments/assets/bbd53f1c-d753-4574-a159-6dad5989e5e3



## Backend surface

One endpoint drives this feature:

- **`WS /api/experimental/workspaceagents/{id}/git/watch`** —
bidirectional WebSocket. The client sends `refresh` messages; the agent
responds with `changes` messages containing per-repo branch and unified
diff. The workspace agent also automatically pushes changes as they
occur in the workspace.
This commit is contained in:
Hugo Dutka
2026-03-06 15:17:14 +01:00
committed by GitHub
parent c0ef3540a5
commit 6665944740
21 changed files with 2652 additions and 911 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ declare module "@storybook/react-vite" {
showOrganizations?: boolean;
organizations?: Organization[];
queries?: { key: QueryKey; data: unknown; isError?: boolean }[];
webSocket?: WebSocketEvent[];
webSocket?: WebSocketEvent[] | Record<string, WebSocketEvent[]>;
user?: User;
permissions?: Partial<Permissions>;
deploymentValues?: DeploymentValues;
+4
View File
@@ -159,6 +159,10 @@ export const watchChats = (): OneWayWebSocket<TypesGen.ServerSentEvent> => {
});
};
export const watchChatGit = (chatId: string): WebSocket => {
return createWebSocket(`/api/experimental/chats/${chatId}/git/watch`);
};
export const watchAgentContainers = (
agentId: string,
): OneWayWebSocket<TypesGen.WorkspaceAgentListContainersResponse> => {
+279 -29
View File
@@ -474,22 +474,24 @@ export const StreamedSubagentTitle: Story = {
},
{ diffUrl: undefined },
),
webSocket: [
{
event: "message",
data: wrapSSE({
type: "message_part",
message_part: {
part: {
type: "tool-call",
tool_call_id: "tool-subagent-stream-1",
tool_name: "spawn_agent",
args_delta: '{"title":"Streamed Child"',
webSocket: {
"/chats/": [
{
event: "message",
data: wrapSSE({
type: "message_part",
message_part: {
part: {
type: "tool-call",
tool_call_id: "tool-subagent-stream-1",
tool_name: "spawn_agent",
args_delta: '{"title":"Streamed Child"',
},
},
},
}),
},
],
}),
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -503,6 +505,252 @@ export const StreamedSubagentTitle: Story = {
},
};
/**
* Sidebar with a PR tab and two git repo tabs. The git watcher receives
* repo data via the mocked WebSocket, and the diff-status query provides
* the PR tab.
*/
export const SidebarWithPRAndRepos: Story = {
parameters: {
queries: buildQueries(
{
chat: {
id: CHAT_ID,
...baseChatFields,
title: "Full sidebar demo",
status: "completed",
},
messages: [],
queued_messages: [],
},
{ diffUrl: "https://github.com/coder/coder/pull/456" },
),
webSocket: {
"/git/watch": [
{
event: "message",
data: JSON.stringify({
type: "changes",
scanned_at: new Date().toISOString(),
repositories: [
{
repo_root: "/home/coder/frontend",
branch: "feat/ui-overhaul",
remote_origin: "https://github.com/coder/frontend.git",
unified_diff: [
"diff --git a/src/index.ts b/src/index.ts",
"index aaa1111..bbb2222 100644",
"--- a/src/index.ts",
"+++ b/src/index.ts",
"@@ -1,4 +1,6 @@",
' import { render } from "react-dom";',
'+import { ThemeProvider } from "./theme";',
"+",
" const root = document.getElementById('root');",
"-render(<App />, root);",
"+render(<ThemeProvider><App /></ThemeProvider>, root);",
"",
"diff --git a/src/old-utils.ts b/src/old-utils.ts",
"deleted file mode 100644",
"index fff6666..0000000",
"--- a/src/old-utils.ts",
"+++ /dev/null",
"@@ -1,3 +0,0 @@",
"-export const deprecatedHelper = () => {};",
"-export const oldFormat = () => {};",
"-export const legacyParse = () => {};",
"",
"diff --git a/src/helpers.ts b/src/utils.ts",
"similarity index 90%",
"rename from src/helpers.ts",
"rename to src/utils.ts",
"index aaa1111..bbb2222 100644",
"--- a/src/helpers.ts",
"+++ b/src/utils.ts",
"",
"diff --git a/src/components/Button.tsx b/src/components/Button.tsx",
"new file mode 100644",
"index 0000000..abc1234",
"--- /dev/null",
"+++ b/src/components/Button.tsx",
"@@ -0,0 +1,8 @@",
'+import { type FC } from "react";',
"+",
"+interface ButtonProps {",
"+ children: React.ReactNode;",
"+}",
"+",
"+export const Button: FC<ButtonProps> = ({ children }) => {",
'+ return <button className="btn">{children}</button>;',
"+};",
].join("\n"),
},
{
repo_root: "/home/coder/backend",
branch: "feat/api-v2",
remote_origin: "https://github.com/coder/backend.git",
unified_diff: [
"diff --git a/cmd/server/main.go b/cmd/server/main.go",
"index ddd4444..eee5555 100644",
"--- a/cmd/server/main.go",
"+++ b/cmd/server/main.go",
"@@ -15,3 +15,7 @@ func main() {",
' srv := &http.Server{Addr: ":8080"}',
"+ srv.ReadTimeout = 30 * time.Second",
"+ srv.WriteTimeout = 30 * time.Second",
"+",
'+ log.Println("starting server on :8080")',
" log.Fatal(srv.ListenAndServe())",
" }",
"",
"diff --git a/internal/old-handler.go b/internal/old-handler.go",
"deleted file mode 100644",
"index ccc3333..0000000",
"--- a/internal/old-handler.go",
"+++ /dev/null",
"@@ -1,5 +0,0 @@",
"-package internal",
"-",
"-func OldHandler() {",
"- // deprecated",
"-}",
"",
"diff --git a/internal/handler.go b/internal/router.go",
"similarity index 85%",
"rename from internal/handler.go",
"rename to internal/router.go",
"index aaa1111..bbb2222 100644",
"--- a/internal/handler.go",
"+++ b/internal/router.go",
"",
"diff --git a/internal/middleware.go b/internal/middleware.go",
"new file mode 100644",
"index 0000000..def5678",
"--- /dev/null",
"+++ b/internal/middleware.go",
"@@ -0,0 +1,12 @@",
"+package internal",
"+",
'+import "net/http"',
"+",
"+// Logger is a middleware that logs incoming requests.",
"+func Logger(next http.Handler) http.Handler {",
"+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {",
'+ log.Printf("%s %s", r.Method, r.URL.Path)',
"+ next.ServeHTTP(w, r)",
"+ })",
"+}",
].join("\n"),
},
{
repo_root: "/home/coder/docs",
branch: "feat/ui-overhaul",
remote_origin: "https://github.com/coder/docs.git",
unified_diff: [
"diff --git a/guides/setup.md b/guides/setup.md",
"index aaa1111..bbb2222 100644",
"--- a/guides/setup.md",
"+++ b/guides/setup.md",
"@@ -5,7 +5,9 @@ ## Installation",
" ",
" ```bash",
"-npm install @coder/sdk",
"+npm install @coder/sdk@latest",
" ```",
" ",
"+> **Note:** Requires Node.js 18 or later.",
"+",
" ## Configuration",
"",
"diff --git a/guides/auth.md b/guides/auth.md",
"index ccc3333..ddd4444 100644",
"--- a/guides/auth.md",
"+++ b/guides/auth.md",
"@@ -12,8 +12,10 @@ ## Token refresh",
" Tokens are valid for 24 hours.",
" ",
"-To refresh a token, call `refreshToken()`.",
"-This will invalidate the old token.",
"+To refresh a token, call `client.refreshToken()`.",
"+This will invalidate the old token and return a",
"+new one with an extended expiry.",
" ",
" ## Revoking access",
].join("\n"),
},
],
} satisfies TypesGen.WorkspaceAgentGitServerMessage),
},
],
},
},
};
/**
* Sidebar with a single git repo tab (no PR). Because there is only one tab
* the tab bar is hidden and the repo panel is rendered directly.
*/
export const SidebarWithSingleRepo: Story = {
parameters: {
queries: buildQueries(
{
chat: {
id: CHAT_ID,
...baseChatFields,
title: "Single repo sidebar",
status: "completed",
},
messages: [],
queued_messages: [],
},
{ diffUrl: undefined },
),
webSocket: {
"/git/watch": [
{
event: "message",
data: JSON.stringify({
type: "changes",
scanned_at: new Date().toISOString(),
repositories: [
{
repo_root: "/home/coder/project",
branch: "main",
remote_origin: "https://github.com/coder/project.git",
unified_diff: [
"diff --git a/src/app.ts b/src/app.ts",
"index aaa1111..bbb2222 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -1,5 +1,7 @@",
' import express from "express";',
'+import cors from "cors";',
" ",
" const app = express();",
"+app.use(cors());",
" ",
' app.get("/", (req, res) => {',
"",
"diff --git a/README.md b/README.md",
"index ccc3333..ddd4444 100644",
"--- a/README.md",
"+++ b/README.md",
"@@ -1,3 +1,5 @@",
" # Project",
" ",
"-A simple app.",
"+A simple app with CORS support.",
"+",
"+## Getting Started",
].join("\n"),
},
],
} satisfies TypesGen.WorkspaceAgentGitServerMessage),
},
],
},
},
};
/**
* Streaming reasoning part via WebSocket — renders collapsed and
* can be expanded on click.
@@ -522,21 +770,23 @@ export const StreamedReasoningCollapsed: Story = {
},
{ diffUrl: undefined },
),
webSocket: [
{
event: "message",
data: wrapSSE({
type: "message_part",
message_part: {
part: {
type: "reasoning",
title: "Plan migration",
text: "Streaming reasoning body",
webSocket: {
"/chats/": [
{
event: "message",
data: wrapSSE({
type: "message_part",
message_part: {
part: {
type: "reasoning",
title: "Plan migration",
text: "Streaming reasoning body",
},
},
},
}),
},
],
}),
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -1,6 +1,7 @@
import { act, renderHook } from "@testing-library/react";
import { createRef } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatMessageInputRef } from "./AgentChatInput";
import {
draftInputStorageKeyPrefix,
useConversationEditingState,
@@ -17,12 +18,16 @@ describe("useConversationEditingState", () => {
const renderEditing = (id: string | undefined = chatID) => {
const onSend = vi.fn().mockResolvedValue(undefined);
const onDeleteQueuedMessage = vi.fn().mockResolvedValue(undefined);
const chatInputRef = createRef<ChatMessageInputRef>();
const inputValueRef: import("react").RefObject<string> = { current: "" };
const hook = renderHook(() =>
useConversationEditingState({
chatID: id,
onSend,
onDeleteQueuedMessage,
chatInputRef,
inputValueRef,
}),
);
+102 -37
View File
@@ -63,9 +63,9 @@ import {
import { buildStreamTools } from "./AgentDetail/streamState";
import { AgentDetailTopBar } from "./AgentDetail/TopBar";
import { useMessageWindow } from "./AgentDetail/useMessageWindow";
import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher";
import type { AgentsOutletContext } from "./AgentsPage";
import { DiffStatBadge } from "./DiffStats";
import { FilesChangedPanel } from "./FilesChangedPanel";
import {
getModelCatalogStatusMessage,
getModelOptionsFromCatalog,
@@ -73,6 +73,8 @@ import {
hasConfiguredModelsInCatalog,
} from "./modelOptions";
import { RightPanel } from "./RightPanel";
import { SidebarTabView } from "./SidebarTabView";
import { useGitWatcher } from "./useGitWatcher";
const noopSetChatErrorReason: AgentsOutletContext["setChatErrorReason"] =
() => {};
@@ -295,13 +297,14 @@ export function useConversationEditingState(deps: {
chatID: string | undefined;
onSend: (message: string, editedMessageID?: number) => Promise<void>;
onDeleteQueuedMessage: (id: number) => Promise<void>;
chatInputRef: React.RefObject<ChatMessageInputRef | null>;
inputValueRef: React.RefObject<string>;
}) {
const { chatID, onSend, onDeleteQueuedMessage } = deps;
const { chatID, onSend, onDeleteQueuedMessage, chatInputRef, inputValueRef } =
deps;
const draftStorageKey = chatID
? `${draftInputStorageKeyPrefix}${chatID}`
: null;
const inputValueRef = useRef("");
const chatInputRef = useRef<ChatMessageInputRef>(null);
const [editorInitialValue, setEditorInitialValue] = useState(() => {
if (typeof window === "undefined" || !draftStorageKey) {
return "";
@@ -328,7 +331,7 @@ export function useConversationEditingState(deps: {
setEditorInitialValue(text);
inputValueRef.current = text;
},
[editingMessageId],
[editingMessageId, inputValueRef],
);
const handleCancelHistoryEdit = useCallback(() => {
@@ -336,7 +339,7 @@ export function useConversationEditingState(deps: {
inputValueRef.current = draftBeforeHistoryEdit ?? "";
setEditingMessageId(null);
setDraftBeforeHistoryEdit(null);
}, [draftBeforeHistoryEdit]);
}, [draftBeforeHistoryEdit, inputValueRef]);
// -- Queue editing state --
const [editingQueuedMessageID, setEditingQueuedMessageID] = useState<
@@ -355,7 +358,7 @@ export function useConversationEditingState(deps: {
setEditorInitialValue(text);
inputValueRef.current = text;
},
[editingQueuedMessageID],
[editingQueuedMessageID, inputValueRef],
);
const handleCancelQueueEdit = useCallback(() => {
@@ -363,7 +366,7 @@ export function useConversationEditingState(deps: {
inputValueRef.current = draftBeforeQueueEdit ?? "";
setEditingQueuedMessageID(null);
setDraftBeforeQueueEdit(null);
}, [draftBeforeQueueEdit]);
}, [draftBeforeQueueEdit, inputValueRef]);
// Wraps the parent onSend to clear local input/editing state
// and handle queue-edit deletion.
@@ -393,11 +396,13 @@ export function useConversationEditingState(deps: {
});
},
[
chatInputRef,
editingMessageId,
editingQueuedMessageID,
onDeleteQueuedMessage,
onSend,
draftStorageKey,
inputValueRef,
],
);
@@ -412,7 +417,7 @@ export function useConversationEditingState(deps: {
}
}
},
[draftStorageKey],
[draftStorageKey, inputValueRef],
);
return {
@@ -436,7 +441,7 @@ const AgentDetail: FC = () => {
const outletContext = useOutletContext<AgentsOutletContext | undefined>();
const queryClient = useQueryClient();
const [selectedModel, setSelectedModel] = useState("");
const [showDiffPanel, setShowDiffPanel] = useState(false);
const [showSidebarPanel, setShowSidebarPanel] = useState(false);
const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false);
// Tracks the live visual expanded state during drag so sibling
// content hides/shows in real-time rather than on pointer-up.
@@ -464,6 +469,8 @@ const AgentDetail: FC = () => {
const onToggleSidebarCollapsed =
outletContext?.onToggleSidebarCollapsed ?? (() => {});
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
const inputValueRef = useRef("");
const chatQuery = useQuery({
...chat(agentId ?? ""),
@@ -498,7 +505,7 @@ const AgentDetail: FC = () => {
if (hasDiffStatus !== prevHasDiffStatus) {
setPrevHasDiffStatus(hasDiffStatus);
if (hasDiffStatus) {
setShowDiffPanel(true);
setShowSidebarPanel(true);
}
}
@@ -563,6 +570,44 @@ const AgentDetail: FC = () => {
clearChatErrorReason,
});
// Git watcher: runs regardless of sidebar visibility.
const gitWatcher = useGitWatcher({
chatId: agentId,
});
// Detect workspace creation so the sidebar can resolve the
// workspace and display agent/git info.
useWorkspaceCreationWatcher({
store,
chatID: agentId,
});
const handleCommit = useCallback((repoRoot: string) => {
const commitPrompt = `Commit and push the working changes in ${repoRoot}. If there are unstaged files, commit them too.`;
const current = inputValueRef.current;
if (current.includes(commitPrompt)) {
return;
}
const prefix = current.trim() ? "\n\n" : "";
chatInputRef.current?.insertText(prefix + commitPrompt);
chatInputRef.current?.focus();
}, []);
// Auto-open sidebar when git watcher receives its first non-empty
// repositories update.
const [prevHasGitRepos, setPrevHasGitRepos] = useState(false);
const hasGitRepos = gitWatcher.repositories.size > 0;
if (hasGitRepos !== prevHasGitRepos) {
setPrevHasGitRepos(hasGitRepos);
if (hasGitRepos) {
setShowSidebarPanel(true);
}
}
// Extract PR number from diff status URL.
const prMatch = diffStatusQuery.data?.url?.match(/\/pull\/(\d+)/)?.[1];
const prNumber = prMatch ? Number(prMatch) : undefined;
useEffect(() => {
setSelectedModel((current) => {
if (current && modelOptions.some((model) => model.id === current)) {
@@ -726,6 +771,8 @@ const AgentDetail: FC = () => {
chatID: agentId,
onSend: handleSend,
onDeleteQueuedMessage: handleDeleteQueuedMessage,
chatInputRef,
inputValueRef,
});
const chatTitle = chatQuery.data?.chat?.title;
@@ -761,7 +808,7 @@ const AgentDetail: FC = () => {
workspace && workspaceAgent && sshConfigQuery.data?.hostname_suffix
? `ssh ${workspaceAgent.name}.${workspace.name}.${workspace.owner_name}.${sshConfigQuery.data.hostname_suffix}`
: undefined;
const shouldShowDiffPanel = hasDiffStatus && showDiffPanel;
const shouldShowSidebar = (hasDiffStatus || hasGitRepos) && showSidebarPanel;
const generateKeyMutation = useMutation({
mutationFn: () => API.getApiKey(),
@@ -834,8 +881,11 @@ const AgentDetail: FC = () => {
diff={{
hasDiffStatus: false,
diffStatus: undefined,
showDiffPanel: false,
onToggleFilesChanged: () => {},
hasGitRepos: false,
gitRepoCount: 0,
gitRepositories: new Map(),
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
@@ -909,8 +959,11 @@ const AgentDetail: FC = () => {
diff={{
hasDiffStatus: false,
diffStatus: undefined,
showDiffPanel: false,
onToggleFilesChanged: () => {},
hasGitRepos: false,
gitRepoCount: 0,
gitRepositories: new Map(),
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
@@ -939,7 +992,7 @@ const AgentDetail: FC = () => {
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1",
shouldShowDiffPanel && !visualExpanded && "flex-col xl:flex-row",
shouldShowSidebar && !visualExpanded && "flex-col xl:flex-row",
)}
>
<div
@@ -956,8 +1009,11 @@ const AgentDetail: FC = () => {
diff={{
hasDiffStatus,
diffStatus: diffStatusQuery.data,
showDiffPanel,
onToggleFilesChanged: () => setShowDiffPanel((prev) => !prev),
hasGitRepos,
gitRepoCount: gitWatcher.repositories.size,
gitRepositories: gitWatcher.repositories,
showSidebarPanel,
onToggleSidebar: () => setShowSidebarPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
@@ -1039,26 +1095,35 @@ const AgentDetail: FC = () => {
</div>
</div>
<RightPanel
isOpen={shouldShowDiffPanel}
isOpen={shouldShowSidebar}
isExpanded={isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => setShowDiffPanel(false)}
chatTitle={chatTitle}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
onClose={() => setShowSidebarPanel(false)}
onVisualExpandedChange={setDragVisualExpanded}
tabContent={{
git: (
<FilesChangedPanel
chatId={agentId}
isExpanded={isRightPanelExpanded}
/>
),
}}
tabMeta={{
git: <DiffStatBadge diffStatus={diffStatusQuery.data} />,
}}
/>
>
<SidebarTabView
prTab={
prNumber && agentId ? { prNumber, chatId: agentId } : undefined
}
repositories={gitWatcher.repositories}
workspace={
workspace
? {
name: workspace.name,
ownerName: workspace.owner_name,
}
: undefined
}
onRefresh={gitWatcher.refresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
diffStatus={diffStatusQuery.data}
/>
</RightPanel>
</div>
);
};
@@ -17,8 +17,11 @@ const defaultProps = {
diff: {
hasDiffStatus: false,
diffStatus: undefined,
showDiffPanel: false,
onToggleFilesChanged: () => {},
hasGitRepos: false,
gitRepoCount: 0,
gitRepositories: new Map(),
showSidebarPanel: false,
onToggleSidebar: () => {},
},
workspace: {
canOpenEditors: true,
@@ -53,8 +56,11 @@ export const WithDiffStats: Story = {
diff: {
hasDiffStatus: true,
diffStatus: mockDiffStatus,
showDiffPanel: false,
onToggleFilesChanged: () => {},
hasGitRepos: false,
gitRepoCount: 0,
gitRepositories: new Map(),
showSidebarPanel: false,
onToggleSidebar: () => {},
},
},
};
@@ -64,8 +70,11 @@ export const WithDiffPanelOpen: Story = {
diff: {
hasDiffStatus: true,
diffStatus: mockDiffStatus,
showDiffPanel: true,
onToggleFilesChanged: () => {},
hasGitRepos: false,
gitRepoCount: 0,
gitRepositories: new Map(),
showSidebarPanel: true,
onToggleSidebar: () => {},
},
},
};
@@ -28,11 +28,14 @@ import { useNavigate } from "react-router";
import { toast } from "sonner";
import { DiffStatsInline } from "../DiffStats";
interface DiffPanelState {
interface SidebarPanelState {
hasDiffStatus: boolean;
diffStatus: ChatDiffStatusResponse | undefined;
showDiffPanel: boolean;
onToggleFilesChanged: () => void;
hasGitRepos: boolean;
gitRepoCount: number;
gitRepositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
showSidebarPanel: boolean;
onToggleSidebar: () => void;
}
interface WorkspaceActions {
@@ -48,7 +51,7 @@ type AgentDetailTopBarProps = {
chatTitle?: string;
parentChat?: TypesGen.Chat;
onOpenParentChat: (chatId: string) => void;
diff: DiffPanelState;
diff: SidebarPanelState;
workspace: WorkspaceActions;
onArchiveAgent: () => void;
onUnarchiveAgent: () => void;
@@ -119,14 +122,16 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
<span className="truncate text-sm text-content-primary">
{chatTitle}
</span>
{diff.hasDiffStatus && diff.diffStatus && !diff.showDiffPanel && (
<span className="ml-3">
<DiffStatsInline
status={diff.diffStatus}
onClick={diff.onToggleFilesChanged}
/>
</span>
)}
{diff.hasDiffStatus &&
diff.diffStatus &&
!diff.showSidebarPanel && (
<span className="ml-3">
<DiffStatsInline
status={diff.diffStatus}
onClick={diff.onToggleSidebar}
/>
</span>
)}
{isArchived && (
<span className="shrink-0 rounded bg-surface-tertiary px-1.5 py-0.5 text-xs text-content-secondary">
Archived
@@ -230,11 +235,11 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
<Button
variant="subtle"
size="icon"
onClick={diff.onToggleFilesChanged}
onClick={diff.onToggleSidebar}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
aria-label="Toggle files changed"
>
{diff.showDiffPanel ? (
{diff.showSidebarPanel ? (
<PanelRightCloseIcon className="h-4 w-4" />
) : (
<PanelRightOpenIcon className="h-4 w-4" />
@@ -0,0 +1,300 @@
import { renderHook, waitFor } from "@testing-library/react";
import type { FC, PropsWithChildren } from "react";
import { act } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamState } from "./types";
import { useWorkspaceCreationWatcher } from "./useWorkspaceCreationWatcher";
type ChatStoreHandle = Parameters<
typeof useWorkspaceCreationWatcher
>[0]["store"];
const createStreamState = (
toolCalls: StreamState["toolCalls"],
toolResults: StreamState["toolResults"] = {},
): StreamState => ({
blocks: [],
toolCalls,
toolResults,
});
type MinimalChatStoreState = Pick<
ReturnType<ChatStoreHandle["getSnapshot"]>,
"streamState"
>;
const createTestStore = (initial: StreamState | null = null) => {
let state: MinimalChatStoreState = { streamState: initial };
const listeners = new Set<() => void>();
const store: Pick<ChatStoreHandle, "getSnapshot" | "subscribe"> = {
getSnapshot: () => state as ReturnType<ChatStoreHandle["getSnapshot"]>,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
return {
store: store as ChatStoreHandle,
setStreamState: (streamState: StreamState | null) => {
state = { streamState };
for (const listener of listeners) {
listener();
}
},
};
};
const createWrapper = (queryClient: QueryClient): FC<PropsWithChildren> => {
return ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe("useWorkspaceCreationWatcher", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
});
it("invalidates chatKey on create_workspace tool result", async () => {
const { store, setStreamState } = createTestStore();
renderHook(
() =>
useWorkspaceCreationWatcher({
store,
chatID: "chat-1",
}),
{ wrapper: createWrapper(queryClient) },
);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
await act(async () => {
setStreamState(
createStreamState(
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
args: { template: "some-template" },
},
},
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
isError: false,
},
},
),
);
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["chats", "chat-1"],
});
});
invalidateSpy.mockRestore();
});
it("does not invalidate chat for non-workspace tools", async () => {
const { store, setStreamState } = createTestStore();
renderHook(
() =>
useWorkspaceCreationWatcher({
store,
chatID: "chat-1",
}),
{ wrapper: createWrapper(queryClient) },
);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
await act(async () => {
setStreamState(
createStreamState(
{
"tool-1": {
id: "tool-1",
name: "read_file",
args: { path: "/workspace/src/main.ts" },
},
},
{
"tool-1": {
id: "tool-1",
name: "read_file",
isError: false,
},
},
),
);
});
await waitFor(() => {
expect(invalidateSpy).not.toHaveBeenCalled();
});
invalidateSpy.mockRestore();
});
it("does not process the same tool call ID twice", async () => {
const { store, setStreamState } = createTestStore();
renderHook(
() =>
useWorkspaceCreationWatcher({
store,
chatID: "chat-1",
}),
{ wrapper: createWrapper(queryClient) },
);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const toolCalls = {
"tool-1": {
id: "tool-1",
name: "create_workspace",
args: { template: "some-template" },
},
};
const toolResults = {
"tool-1": {
id: "tool-1",
name: "create_workspace",
isError: false,
},
};
await act(async () => {
setStreamState(createStreamState(toolCalls, toolResults));
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(1);
});
// Re-emit the same stream state (simulates a re-render).
await act(async () => {
setStreamState(createStreamState(toolCalls, toolResults));
});
// invalidateQueries should still have been called only once.
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(1);
});
invalidateSpy.mockRestore();
});
it("resets processed tool call IDs when chatID changes", async () => {
const { store, setStreamState } = createTestStore();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { rerender } = renderHook(
({ chatID }: { chatID: string }) =>
useWorkspaceCreationWatcher({
store,
chatID,
}),
{
initialProps: { chatID: "chat-1" },
wrapper: createWrapper(queryClient),
},
);
await act(async () => {
setStreamState(
createStreamState(
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
args: { template: "some-template" },
},
},
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
isError: false,
},
},
),
);
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(1);
});
// Switch to a new chat and emit the same tool call ID.
rerender({ chatID: "chat-2" });
await act(async () => {
setStreamState(
createStreamState(
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
args: { template: "some-template" },
},
},
{
"tool-1": {
id: "tool-1",
name: "create_workspace",
isError: false,
},
},
),
);
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledTimes(2);
});
invalidateSpy.mockRestore();
});
it("does nothing when streamState is null", async () => {
const { store } = createTestStore(null);
renderHook(
() =>
useWorkspaceCreationWatcher({
store,
chatID: "chat-1",
}),
{ wrapper: createWrapper(queryClient) },
);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
await waitFor(() => {
expect(invalidateSpy).not.toHaveBeenCalled();
});
invalidateSpy.mockRestore();
});
});
@@ -0,0 +1,71 @@
import { chatKey } from "api/queries/chats";
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "react-query";
import { useChatSelector } from "./ChatContext";
import type { StreamState } from "./types";
type ChatStoreHandle = Parameters<typeof useChatSelector>[0];
const selectStreamState = (state: { streamState: StreamState | null }) =>
state.streamState;
interface UseWorkspaceCreationWatcherOptions {
store: ChatStoreHandle;
chatID: string | undefined;
}
// Triggers chat query invalidation to resolve the workspace/agent.
const WORKSPACE_TOOL_NAMES = new Set(["create_workspace"]);
/**
* Watches stream tool results for create_workspace completions and
* invalidates the chat query so the sidebar can display workspace info.
* The agent now handles all path discovery and scan triggering via
* the PathStore — no frontend refresh needed.
*/
export function useWorkspaceCreationWatcher({
store,
chatID,
}: UseWorkspaceCreationWatcherOptions): void {
const queryClient = useQueryClient();
const streamState = useChatSelector(store, selectStreamState);
const processedToolCallIdsRef = useRef<Set<string>>(new Set());
// Reset processed IDs when chatID changes during render,
// before effects run.
const [previousChatID, setPreviousChatID] = useState(chatID);
if (previousChatID !== chatID) {
setPreviousChatID(chatID);
processedToolCallIdsRef.current = new Set();
}
// Watch stream tool results for create_workspace completions.
useEffect(() => {
if (!streamState || !chatID) {
processedToolCallIdsRef.current.clear();
return;
}
let shouldInvalidateChat = false;
for (const toolResult of Object.values(streamState.toolResults)) {
if (processedToolCallIdsRef.current.has(toolResult.id)) {
continue;
}
if (WORKSPACE_TOOL_NAMES.has(toolResult.name)) {
processedToolCallIdsRef.current.add(toolResult.id);
shouldInvalidateChat = true;
}
}
if (shouldInvalidateChat) {
// Invalidate chatKey to trigger the workspace resolution
// cascade: chat refetch → workspaceId → workspace query →
// agent resolved → git watcher connects.
void queryClient.invalidateQueries({
queryKey: chatKey(chatID),
});
}
}, [chatID, streamState, queryClient]);
}
-25
View File
@@ -31,31 +31,6 @@ const DiffStatNumbers: FC<DiffStatsProps> = ({
);
};
/**
* Pill-styled diff stats badge with coloured backgrounds,
* used inside the Git tab header.
*/
export const DiffStatBadge: FC<{ diffStatus?: ChatDiffStatusResponse }> = ({
diffStatus,
}) => {
const additions = diffStatus?.additions ?? 0;
const deletions = diffStatus?.deletions ?? 0;
const hasChangedFiles = (diffStatus?.changed_files ?? 0) > 0;
if (!hasChangedFiles && additions === 0 && deletions === 0) {
return null;
}
return (
<span className="inline-flex h-full items-center self-stretch overflow-hidden rounded-[calc(theme(borderRadius.md)-1px)] font-mono text-xs font-medium">
<span className="flex h-full items-center bg-green-100 dark:bg-green-950 px-1.5 text-green-700 dark:text-green-500">
+{additions}
</span>
<span className="flex h-full items-center bg-red-100 dark:bg-red-950 px-1.5 text-red-700 dark:text-red-400">
&minus;{deletions}
</span>
</span>
);
};
/**
* Clickable inline diff stats shown in the top bar when the
* diff panel is closed.
+705
View File
@@ -0,0 +1,705 @@
import { useTheme } from "@emotion/react";
import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import {
DIFFS_FONT_STYLE,
getDiffViewerOptions,
} from "components/ai-elements/tool/utils";
import { Button } from "components/Button/Button";
import { FileIcon } from "components/FileIcon/FileIcon";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Skeleton } from "components/Skeleton/Skeleton";
import { ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
import {
type ComponentProps,
type FC,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "utils/cn";
// -------------------------------------------------------------------
// Public interface
// -------------------------------------------------------------------
interface DiffViewerProps {
/** Fragment to display in the top-left of the header bar. */
headerLeft?: ReactNode;
/** Parsed file diffs to render. */
parsedFiles: FileDiffMetadata[];
/** Cache key prefix for parsePatchFiles worker pool LRU cache. */
cacheKeyPrefix?: string;
/** Whether the panel is in expanded mode (affects file tree threshold). */
isExpanded?: boolean;
/** Loading state. */
isLoading?: boolean;
/** Error state. */
error?: unknown;
/** Empty state message. */
emptyMessage?: string;
}
// -------------------------------------------------------------------
// Constants
// -------------------------------------------------------------------
/**
* Minimum container width (px) at which the file tree sidebar
* is shown alongside the diff list.
*/
const FILE_TREE_THRESHOLD = 1000;
/**
* Extra CSS injected via the diff viewer's `unsafeCSS` option to make
* file headers sticky and adjust metadata layout.
*/
const STICKY_HEADER_CSS = [
"[data-diffs-header] {",
" position: sticky; top: 0; z-index: 10;",
" font-size: 13px;",
" border-bottom: 1px solid hsl(var(--border-default));",
" background-color: hsl(var(--surface-quaternary)) !important;",
"}",
"[data-diffs-header] [data-metadata] { flex-direction: row-reverse; }",
"@media (prefers-color-scheme: dark) {",
" [data-diffs-header] { background-color: hsl(var(--surface-secondary)) !important; }",
"}",
].join(" ");
type DiffStyle = "unified" | "split";
const DIFF_STYLE_KEY = "agents.diff-view-style";
function loadDiffStyle(): DiffStyle {
if (typeof window === "undefined") {
return "unified";
}
const stored = localStorage.getItem(DIFF_STYLE_KEY);
if (stored === "split" || stored === "unified") {
return stored;
}
return "unified";
}
/** Width of the file tree sidebar in pixels. */
const FILE_TREE_WIDTH = 300;
// -------------------------------------------------------------------
// Estimated diff height for lazy loading
// -------------------------------------------------------------------
/**
* Estimated height per line in the diff viewer (px). Derived from
* the --diffs-font-size (11px) and --diffs-line-height (1.5)
* values set via DIFFS_FONT_STYLE, plus 1px for the border/gap.
*/
const LINE_HEIGHT_PX = 17.5;
/** Height of the file header row rendered by @pierre/diffs. */
const HEADER_HEIGHT_PX = 36;
/**
* Estimate the rendered pixel height of a file diff so the
* placeholder occupies roughly the same space. This keeps the
* scroll position stable as files are lazily mounted.
*/
function estimateDiffHeight(fileDiff: FileDiffMetadata): number {
return HEADER_HEIGHT_PX + fileDiff.unifiedLineCount * LINE_HEIGHT_PX;
}
// -------------------------------------------------------------------
// File tree data model
// -------------------------------------------------------------------
/** Maps a diff change type to a Tailwind text-color class. */
function changeColor(type?: ChangeTypes): string | undefined {
switch (type) {
case "new":
return "text-green-700 dark:text-green-300";
case "deleted":
return "text-red-700 dark:text-red-300";
case "rename-pure":
case "rename-changed":
return "text-orange-700 dark:text-orange-300";
case "change":
return "text-orange-700 dark:text-orange-300";
default:
return undefined;
}
}
/** Short letter shown after the filename, matching VS Code style. */
function changeLabel(type: ChangeTypes): string {
switch (type) {
case "new":
return "A";
case "deleted":
return "D";
case "rename-pure":
case "rename-changed":
return "R";
case "change":
return "M";
default:
return "";
}
}
interface FileTreeNode {
name: string;
fullPath: string;
type: "file" | "directory";
children: FileTreeNode[];
fileDiff?: FileDiffMetadata;
}
/**
* Builds a nested tree from a flat list of file diffs. Directory
* nodes are created for every intermediate path segment. The
* result is sorted with directories first, then alphabetically.
* Single-child directory chains are collapsed so that e.g.
* `src/pages/AgentsPage` renders as one row.
*/
function buildFileTree(files: FileDiffMetadata[]): FileTreeNode[] {
const root: FileTreeNode[] = [];
for (const file of files) {
const segments = file.name.split("/");
let children = root;
// Walk / create intermediate directory nodes.
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
let dir = children.find((n) => n.type === "directory" && n.name === seg);
if (!dir) {
dir = {
name: seg,
fullPath: segments.slice(0, i + 1).join("/"),
type: "directory",
children: [],
};
children.push(dir);
}
children = dir.children;
}
// Leaf file node.
const fileName = segments[segments.length - 1];
children.push({
name: fileName,
fullPath: file.name,
type: "file",
children: [],
fileDiff: file,
});
}
const sortNodes = (nodes: FileTreeNode[]): FileTreeNode[] => {
for (const node of nodes) {
if (node.children.length > 0) {
node.children = sortNodes(node.children);
}
}
return nodes.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
};
// Collapse single-child directory chains into one node whose
// name uses path separators, e.g. "src/pages/AgentsPage".
const collapse = (nodes: FileTreeNode[]): FileTreeNode[] => {
for (const node of nodes) {
if (node.type === "directory") {
node.children = collapse(node.children);
// If this directory has exactly one child and it is also
// a directory, merge them.
while (
node.children.length === 1 &&
node.children[0].type === "directory"
) {
const child = node.children[0];
node.name = `${node.name}/${child.name}`;
node.fullPath = child.fullPath;
node.children = child.children;
}
}
}
return nodes;
};
return collapse(sortNodes(root));
}
// -------------------------------------------------------------------
// Tree node renderer
// -------------------------------------------------------------------
const FileTreeNodeView: FC<{
node: FileTreeNode;
depth: number;
activeFile: string | null;
onFileClick: (fullPath: string) => void;
}> = ({ node, depth, activeFile, onFileClick }) => {
const [expanded, setExpanded] = useState(true);
if (node.type === "directory") {
return (
<div>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-1.5 rounded-none border-none bg-transparent py-1 text-left text-content-secondary hover:bg-surface-secondary cursor-pointer outline-none"
style={{ paddingLeft: 4 + depth * 8, fontSize: 13 }}
aria-expanded={expanded}
>
<ChevronRightIcon
className={cn(
"size-3 shrink-0 transition-transform",
expanded && "rotate-90",
)}
/>
<span className="truncate">{node.name}</span>
</button>
{expanded &&
node.children.map((child) => (
<FileTreeNodeView
key={child.fullPath}
node={child}
depth={depth + 1}
activeFile={activeFile}
onFileClick={onFileClick}
/>
))}
</div>
);
}
const isActive = activeFile === node.fullPath;
return (
<button
type="button"
onClick={() => onFileClick(node.fullPath)}
className={cn(
"flex w-full items-center gap-1.5 rounded-none border-none bg-transparent py-1 text-left cursor-pointer outline-none border-0 border-r-2 border-solid border-transparent",
"hover:bg-surface-secondary",
isActive && "bg-surface-secondary border-content-link",
)}
style={{ paddingLeft: 4 + depth * 8 + 12, fontSize: 13 }}
title={node.fullPath}
>
<FileIcon fileName={node.name} className="shrink-0" />
<span
className={cn(
"truncate",
changeColor(node.fileDiff?.type) ??
(isActive ? "text-content-primary" : "text-content-secondary"),
)}
>
{node.name}
</span>
{node.fileDiff?.type && (
<span
className={cn(
"ml-auto shrink-0 pr-2 text-xs",
changeColor(node.fileDiff.type),
)}
>
{changeLabel(node.fileDiff.type)}
</span>
)}
</button>
);
};
// -------------------------------------------------------------------
// Lazy file diff wrapper
// -------------------------------------------------------------------
/**
* Wraps a single `<FileDiff>` with an IntersectionObserver so the
* heavy component (Shadow DOM + shiki highlighting) is only mounted
* once the placeholder scrolls into or near the viewport.
*
* Once mounted the component stays mounted — we never unmount a
* FileDiff that the user has already scrolled past, which avoids
* layout shifts and repeated highlighting work.
*/
const LazyFileDiff: FC<{
fileDiff: FileDiffMetadata;
options: ComponentProps<typeof FileDiff>["options"];
}> = ({ fileDiff, options }) => {
const placeholderRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = placeholderRef.current;
if (!el || visible) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
// Pre-load files that are within one viewport-height of
// the visible area so they are ready before the user
// scrolls to them.
{ rootMargin: "100% 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [visible]);
if (!visible) {
return (
<div
ref={placeholderRef}
style={{ height: estimateDiffHeight(fileDiff) }}
className="p-4 space-y-2"
>
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
);
}
return (
<FileDiff fileDiff={fileDiff} options={options} style={DIFFS_FONT_STYLE} />
);
};
// -------------------------------------------------------------------
// Main component
// -------------------------------------------------------------------
export const DiffViewer: FC<DiffViewerProps> = ({
headerLeft,
parsedFiles,
isExpanded,
isLoading,
error,
emptyMessage = "No file changes to display.",
}) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
const handleSetDiffStyle = useCallback((style: DiffStyle) => {
setDiffStyle(style);
localStorage.setItem(DIFF_STYLE_KEY, style);
}, []);
const diffOptions = useMemo(() => {
const base = getDiffViewerOptions(isDark);
return {
...base,
diffStyle,
// Extend the base CSS to make file headers sticky so they
// remain visible while scrolling through long diffs.
unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`,
};
}, [isDark, diffStyle]);
// Memoize the per-file options object so every <FileDiff>
// receives the same reference and avoids re-highlighting
// when the parent re-renders.
const fileOptions = useMemo(
() => ({
...diffOptions,
overflow: "wrap" as const,
enableLineSelection: true,
enableHoverUtility: true,
onLineSelected() {
// TODO: Make this add context to the input so the
// user can type.
},
}),
[diffOptions],
);
const fileTree = useMemo(() => buildFileTree(parsedFiles), [parsedFiles]);
// Sort diff blocks in the same order the file tree displays them
// (directories first, then alphabetical) so the rendering is
// consistent regardless of whether the sidebar is visible.
const sortedFiles = useMemo(() => {
const order = new Map<string, number>();
let idx = 0;
const walk = (nodes: FileTreeNode[]) => {
for (const node of nodes) {
if (node.type === "file") {
order.set(node.fullPath, idx++);
} else {
walk(node.children);
}
}
};
walk(fileTree);
return [...parsedFiles].sort(
(a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0),
);
}, [fileTree, parsedFiles]);
// ---------------------------------------------------------------
// Container width measurement via ResizeObserver so we can decide
// whether to show the file tree sidebar without a prop from the
// parent.
// ---------------------------------------------------------------
const [containerWidth, setContainerWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const containerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
if (!el) {
return;
}
setContainerWidth(el.getBoundingClientRect().width);
const ro = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width);
});
ro.observe(el);
roRef.current = ro;
}, []);
const showTree =
(isExpanded || containerWidth >= FILE_TREE_THRESHOLD) &&
sortedFiles.length > 0;
// ---------------------------------------------------------------
// Refs for each file diff wrapper so we can scroll-to and track
// which file is currently visible.
// ---------------------------------------------------------------
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [activeFile, setActiveFile] = useState<string | null>(null);
// Keep a ref callback that sets up per-file refs.
const setFileRef = useCallback((name: string, el: HTMLDivElement | null) => {
if (el) {
fileRefs.current.set(name, el);
} else {
fileRefs.current.delete(name);
}
}, []);
// Track which file is at the top of the diff scroll area by
// listening to scroll events on the viewport. The active file
// is whichever file wrapper's top edge is closest to (but not
// below) the container's top — i.e. the one whose sticky
// header would be showing.
const diffViewportRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!showTree || sortedFiles.length === 0) {
return;
}
const viewport = diffViewportRef.current;
if (!viewport) {
return;
}
let rafId = 0;
const onScroll = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const containerTop = viewport.getBoundingClientRect().top;
let bestName: string | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const [name, el] of fileRefs.current.entries()) {
const rect = el.getBoundingClientRect();
// The file "owns" the scroll position when its top
// is at or above the container top and its bottom is
// still below it.
if (rect.bottom > containerTop && rect.top <= containerTop + 1) {
const distance = Math.abs(rect.top - containerTop);
if (distance < bestDistance) {
bestDistance = distance;
bestName = name;
}
}
}
// If nothing is at the top (e.g. scrolled to the very top
// with padding), pick the first file whose top is closest
// to the container top.
if (!bestName) {
for (const [name, el] of fileRefs.current.entries()) {
const dist = Math.abs(
el.getBoundingClientRect().top - containerTop,
);
if (dist < bestDistance) {
bestDistance = dist;
bestName = name;
}
}
}
if (bestName) {
setActiveFile(bestName);
}
});
};
// Fire once to set initial state.
onScroll();
viewport.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelAnimationFrame(rafId);
viewport.removeEventListener("scroll", onScroll);
};
}, [showTree, sortedFiles.length]);
const handleFileClick = useCallback((name: string) => {
const el = fileRefs.current.get(name);
if (el) {
el.scrollIntoView({ block: "start" });
setActiveFile(name);
}
}, []);
// ---------------------------------------------------------------
// Loading state
// ---------------------------------------------------------------
if (isLoading) {
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden">
<div className="space-y-4 p-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------
// Error state
// ---------------------------------------------------------------
if (error) {
return (
<div className="p-3">
<ErrorAlert error={error} />
</div>
);
}
// ---------------------------------------------------------------
// Main render
// ---------------------------------------------------------------
return (
<div
ref={containerRef}
className="flex h-full min-w-0 flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center gap-1 px-3 py-2">
{headerLeft}
{/* Diff style toggle */}
<div className="ml-auto flex items-center gap-1">
<Button
variant={diffStyle === "unified" ? "outline" : "subtle"}
size="lg"
onClick={() => handleSetDiffStyle("unified")}
className={cn(
"min-w-0 h-6 px-2 py-0",
diffStyle === "unified" && "bg-surface-secondary",
)}
aria-label="Unified diff view"
>
<Rows3Icon className="!p-0 !size-3.5" />
</Button>
<Button
variant={diffStyle === "split" ? "outline" : "subtle"}
size="lg"
onClick={() => handleSetDiffStyle("split")}
className={cn(
"min-w-0 h-6 px-2 py-0",
diffStyle === "split" && "bg-surface-secondary",
)}
aria-label="Split diff view"
>
<Columns2Icon className="!p-0 !size-3.5" />
</Button>
</div>
</div>
{/* Diff contents */}
{sortedFiles.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
{emptyMessage}
</div>
) : (
<div className="flex min-w-0 flex-1 flex-row overflow-hidden">
{/* File tree sidebar */}
{showTree && (
<ScrollArea
className="shrink-0 border-r border-border"
style={{ width: FILE_TREE_WIDTH }}
scrollBarClassName="w-1"
>
<nav className="flex flex-col py-1">
{fileTree.map((node) => (
<FileTreeNodeView
key={node.fullPath}
node={node}
depth={1}
activeFile={activeFile}
onFileClick={handleFileClick}
/>
))}
</nav>
</ScrollArea>
)}
{/* Diff list */}
<ScrollArea
className={cn(
"min-w-0 flex-1",
showTree &&
"border-0 border-l border-t border-solid border-border-default rounded-tl-md",
)}
scrollBarClassName="w-1.5"
viewportClassName="[&>div]:!block"
ref={(node) => {
const vp = node?.querySelector<HTMLElement>(
"[data-radix-scroll-area-viewport]",
);
diffViewportRef.current = vp ?? null;
}}
>
<div className="min-w-0 text-xs">
{sortedFiles.map((fileDiff) => (
<div
key={fileDiff.name}
ref={(el) => setFileRef(fileDiff.name, el)}
>
<LazyFileDiff fileDiff={fileDiff} options={fileOptions} />
</div>
))}
{/* Spacer so the last file can scroll fully to the top. */}
<div className="h-[calc(100vh-100px)]" />
</div>
</ScrollArea>
</div>
)}
</div>
);
};
+42 -679
View File
@@ -1,84 +1,19 @@
import { useTheme } from "@emotion/react";
import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import { chatDiffContents, chatDiffStatus } from "api/queries/chats";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import {
DIFFS_FONT_STYLE,
getDiffViewerOptions,
} from "components/ai-elements/tool/utils";
import { Button } from "components/Button/Button";
import { FileIcon } from "components/FileIcon/FileIcon";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Skeleton } from "components/Skeleton/Skeleton";
import {
ChevronRightIcon,
Columns2Icon,
ExternalLinkIcon,
GitBranchIcon,
GitPullRequestIcon,
Rows3Icon,
} from "lucide-react";
import {
type ComponentProps,
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type FC, useMemo } from "react";
import { useQuery } from "react-query";
import { cn } from "utils/cn";
import { DiffViewer } from "./DiffViewer";
interface FilesChangedPanelProps {
chatId: string;
isExpanded?: boolean;
}
/**
* Minimum container width (px) at which the file tree sidebar
* is shown alongside the diff list.
*/
const FILE_TREE_THRESHOLD = 1000;
/**
* Extra CSS injected via the diff viewer's `unsafeCSS` option to make
* file headers sticky and adjust metadata layout.
*/
const STICKY_HEADER_CSS = [
"[data-diffs-header] {",
" position: sticky; top: 0; z-index: 10;",
" font-size: 13px;",
" border-bottom: 1px solid hsl(var(--border-default));",
" background-color: hsl(var(--surface-quaternary)) !important;",
"}",
"[data-diffs-header] [data-metadata] { flex-direction: row-reverse; }",
"@media (prefers-color-scheme: dark) {",
" [data-diffs-header] { background-color: hsl(var(--surface-secondary)) !important; }",
"}",
].join(" ");
type DiffStyle = "unified" | "split";
const DIFF_STYLE_KEY = "agents.diff-view-style";
function loadDiffStyle(): DiffStyle {
if (typeof window === "undefined") {
return "unified";
}
const stored = localStorage.getItem(DIFF_STYLE_KEY);
if (stored === "split" || stored === "unified") {
return stored;
}
return "unified";
}
/**
* Width of the file tree sidebar in pixels.
*/
const FILE_TREE_WIDTH = 300;
/**
* Parses a GitHub PR URL into its components.
* Returns null if parsing fails.
@@ -99,254 +34,10 @@ function parsePullRequestUrl(url: string): {
return null;
}
// -------------------------------------------------------------------
// File tree data model
// -------------------------------------------------------------------
/** Maps a diff change type to a Tailwind text-color class. */
function changeColor(type?: ChangeTypes): string | undefined {
switch (type) {
case "new":
return "text-green-700 dark:text-green-300";
case "deleted":
return "text-red-700 dark:text-red-300";
case "rename-pure":
case "rename-changed":
return "text-orange-700 dark:text-orange-300";
case "change":
return "text-orange-700 dark:text-orange-300";
default:
return undefined;
}
}
/** Short letter shown after the filename, matching VS Code style. */
function changeLabel(type: ChangeTypes): string {
switch (type) {
case "new":
return "A";
case "deleted":
return "D";
case "rename-pure":
case "rename-changed":
return "R";
case "change":
return "M";
default:
return "";
}
}
interface FileTreeNode {
name: string;
fullPath: string;
type: "file" | "directory";
children: FileTreeNode[];
fileDiff?: FileDiffMetadata;
}
/**
* Builds a nested tree from a flat list of file diffs. Directory
* nodes are created for every intermediate path segment. The
* result is sorted with directories first, then alphabetically.
* Single-child directory chains are collapsed so that e.g.
* `src/pages/AgentsPage` renders as one row.
*/
function buildFileTree(files: FileDiffMetadata[]): FileTreeNode[] {
const root: FileTreeNode[] = [];
for (const file of files) {
const segments = file.name.split("/");
let children = root;
// Walk / create intermediate directory nodes.
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
let dir = children.find((n) => n.type === "directory" && n.name === seg);
if (!dir) {
dir = {
name: seg,
fullPath: segments.slice(0, i + 1).join("/"),
type: "directory",
children: [],
};
children.push(dir);
}
children = dir.children;
}
// Leaf file node.
const fileName = segments[segments.length - 1];
children.push({
name: fileName,
fullPath: file.name,
type: "file",
children: [],
fileDiff: file,
});
}
const sortNodes = (nodes: FileTreeNode[]): FileTreeNode[] => {
for (const node of nodes) {
if (node.children.length > 0) {
node.children = sortNodes(node.children);
}
}
return nodes.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
};
// Collapse single-child directory chains into one node whose
// name uses path separators, e.g. "src/pages/AgentsPage".
const collapse = (nodes: FileTreeNode[]): FileTreeNode[] => {
for (const node of nodes) {
if (node.type === "directory") {
node.children = collapse(node.children);
// If this directory has exactly one child and it is also
// a directory, merge them.
while (
node.children.length === 1 &&
node.children[0].type === "directory"
) {
const child = node.children[0];
node.name = `${node.name}/${child.name}`;
node.fullPath = child.fullPath;
node.children = child.children;
}
}
}
return nodes;
};
return collapse(sortNodes(root));
}
// -------------------------------------------------------------------
// Tree node renderer
// -------------------------------------------------------------------
const FileTreeNodeView: FC<{
node: FileTreeNode;
depth: number;
activeFile: string | null;
onFileClick: (fullPath: string) => void;
}> = ({ node, depth, activeFile, onFileClick }) => {
const [expanded, setExpanded] = useState(true);
if (node.type === "directory") {
return (
<div>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-1.5 rounded-none border-none bg-transparent py-1 text-left text-content-secondary hover:bg-surface-secondary cursor-pointer outline-none"
style={{ paddingLeft: 4 + depth * 8, fontSize: 13 }}
aria-expanded={expanded}
>
<ChevronRightIcon
className={cn(
"size-3 shrink-0 transition-transform",
expanded && "rotate-90",
)}
/>
<span className="truncate">{node.name}</span>
</button>
{expanded &&
node.children.map((child) => (
<FileTreeNodeView
key={child.fullPath}
node={child}
depth={depth + 1}
activeFile={activeFile}
onFileClick={onFileClick}
/>
))}
</div>
);
}
const isActive = activeFile === node.fullPath;
return (
<button
type="button"
onClick={() => onFileClick(node.fullPath)}
className={cn(
"flex w-full items-center gap-1.5 rounded-none border-none bg-transparent py-1 text-left cursor-pointer outline-none border-0 border-r-2 border-solid border-transparent",
"hover:bg-surface-secondary",
isActive && "bg-surface-secondary border-content-link",
)}
style={{ paddingLeft: 4 + depth * 8 + 12, fontSize: 13 }}
title={node.fullPath}
>
<FileIcon fileName={node.name} className="shrink-0" />
<span
className={cn(
"truncate",
changeColor(node.fileDiff?.type) ??
(isActive ? "text-content-primary" : "text-content-secondary"),
)}
>
{node.name}
</span>
{node.fileDiff?.type && (
<span
className={cn(
"ml-auto shrink-0 pr-2 text-xs",
changeColor(node.fileDiff.type),
)}
>
{changeLabel(node.fileDiff.type)}
</span>
)}
</button>
);
};
export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
chatId,
isExpanded,
}) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
const handleSetDiffStyle = useCallback((style: DiffStyle) => {
setDiffStyle(style);
localStorage.setItem(DIFF_STYLE_KEY, style);
}, []);
const diffOptions = useMemo(() => {
const base = getDiffViewerOptions(isDark);
return {
...base,
diffStyle,
// Extend the base CSS to make file headers sticky so they
// remain visible while scrolling through long diffs.
unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`,
};
}, [isDark, diffStyle]);
// Memoize the per-file options object so every <FileDiff>
// receives the same reference and avoids re-highlighting
// when the parent re-renders.
const fileOptions = useMemo(
() => ({
...diffOptions,
overflow: "wrap" as const,
enableLineSelection: true,
enableHoverUtility: true,
onLineSelected() {
// TODO: Make this add context to the input so the
// user can type.
},
}),
[diffOptions],
);
const diffStatusQuery = useQuery(chatDiffStatus(chatId));
const diffContentsQuery = useQuery({
...chatDiffContents(chatId),
@@ -369,380 +60,52 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
}
}, [diffContentsQuery.data?.diff, chatId]);
const fileTree = useMemo(() => buildFileTree(parsedFiles), [parsedFiles]);
// Sort diff blocks in the same order the file tree displays them
// (directories first, then alphabetical) so the rendering is
// consistent regardless of whether the sidebar is visible.
const sortedFiles = useMemo(() => {
const order = new Map<string, number>();
let idx = 0;
const walk = (nodes: FileTreeNode[]) => {
for (const node of nodes) {
if (node.type === "file") {
order.set(node.fullPath, idx++);
} else {
walk(node.children);
}
}
};
walk(fileTree);
return [...parsedFiles].sort(
(a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0),
);
}, [fileTree, parsedFiles]);
const pullRequestUrl = diffStatusQuery.data?.url;
const parsedPr = pullRequestUrl ? parsePullRequestUrl(pullRequestUrl) : null;
// ---------------------------------------------------------------
// Container width measurement via ResizeObserver so we can decide
// whether to show the file tree sidebar without a prop from the
// parent.
// ---------------------------------------------------------------
const [containerWidth, setContainerWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const containerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
if (!el) {
return;
}
setContainerWidth(el.getBoundingClientRect().width);
const ro = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width);
});
ro.observe(el);
roRef.current = ro;
}, []);
const showTree =
(isExpanded || containerWidth >= FILE_TREE_THRESHOLD) &&
sortedFiles.length > 0;
// ---------------------------------------------------------------
// Refs for each file diff wrapper so we can scroll-to and track
// which file is currently visible.
// ---------------------------------------------------------------
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [activeFile, setActiveFile] = useState<string | null>(null);
// Keep a ref callback that sets up per-file refs.
const setFileRef = useCallback((name: string, el: HTMLDivElement | null) => {
if (el) {
fileRefs.current.set(name, el);
} else {
fileRefs.current.delete(name);
}
}, []);
// Track which file is at the top of the diff scroll area by
// listening to scroll events on the viewport. The active file
// is whichever file wrapper's top edge is closest to (but not
// below) the container's top — i.e. the one whose sticky
// header would be showing.
const diffViewportRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!showTree || sortedFiles.length === 0) {
return;
}
const viewport = diffViewportRef.current;
if (!viewport) {
return;
}
let rafId = 0;
const onScroll = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const containerTop = viewport.getBoundingClientRect().top;
let bestName: string | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const [name, el] of fileRefs.current.entries()) {
const rect = el.getBoundingClientRect();
// The file "owns" the scroll position when its top
// is at or above the container top and its bottom is
// still below it.
if (rect.bottom > containerTop && rect.top <= containerTop + 1) {
const distance = Math.abs(rect.top - containerTop);
if (distance < bestDistance) {
bestDistance = distance;
bestName = name;
}
}
}
// If nothing is at the top (e.g. scrolled to the very top
// with padding), pick the first file whose top is closest
// to the container top.
if (!bestName) {
for (const [name, el] of fileRefs.current.entries()) {
const dist = Math.abs(
el.getBoundingClientRect().top - containerTop,
);
if (dist < bestDistance) {
bestDistance = dist;
bestName = name;
}
}
}
if (bestName) {
setActiveFile(bestName);
}
});
};
// Fire once to set initial state.
onScroll();
viewport.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelAnimationFrame(rafId);
viewport.removeEventListener("scroll", onScroll);
};
}, [showTree, sortedFiles.length]);
const handleFileClick = useCallback((name: string) => {
const el = fileRefs.current.get(name);
if (el) {
el.scrollIntoView({ block: "start" });
setActiveFile(name);
}
}, []);
if (diffContentsQuery.isLoading || diffStatusQuery.isLoading) {
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden">
<div className="space-y-4 p-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
))}
</div>
</div>
);
}
if (diffContentsQuery.isError) {
return (
<div className="p-3">
<ErrorAlert error={diffContentsQuery.error} />
</div>
);
}
return (
<div
ref={containerRef}
className="flex h-full min-w-0 flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center gap-3 px-3 py-2">
{pullRequestUrl && parsedPr ? (
<a
href={pullRequestUrl}
target="_blank"
rel="noreferrer"
className="flex min-w-0 items-center gap-1.5 text-xs text-content-secondary no-underline hover:text-content-primary"
>
<GitPullRequestIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
<span className="text-content-secondary">
{parsedPr.owner}/{parsedPr.repo}
</span>
<span className="text-content-primary">#{parsedPr.number}</span>
</span>
<ExternalLinkIcon className="h-3 w-3 shrink-0 opacity-50" />
</a>
) : pullRequestUrl ? (
<a
href={pullRequestUrl}
target="_blank"
rel="noreferrer"
className="flex min-w-0 items-center gap-1.5 text-xs text-content-secondary no-underline hover:text-content-primary"
>
<GitPullRequestIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{pullRequestUrl}</span>
<ExternalLinkIcon className="h-3 w-3 shrink-0 opacity-50" />
</a>
) : (
<div className="flex items-center gap-1.5 text-xs text-content-secondary">
<GitBranchIcon className="h-3.5 w-3.5" />
<span>Uncommitted changes</span>
</div>
)}
{/* Diff style toggle */}
<div className="ml-auto flex items-center gap-1">
<Button
variant={diffStyle === "unified" ? "outline" : "subtle"}
size="lg"
onClick={() => handleSetDiffStyle("unified")}
className={cn(
"min-w-0 h-6 px-2 py-0",
diffStyle === "unified" && "bg-surface-secondary",
)}
aria-label="Unified diff view"
>
<Rows3Icon className="!p-0 !size-3.5" />
</Button>
<Button
variant={diffStyle === "split" ? "outline" : "subtle"}
size="lg"
onClick={() => handleSetDiffStyle("split")}
className={cn(
"min-w-0 h-6 px-2 py-0",
diffStyle === "split" && "bg-surface-secondary",
)}
aria-label="Split diff view"
>
<Columns2Icon className="!p-0 !size-3.5" />
</Button>
</div>
</div>
{/* Diff contents */}
{sortedFiles.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
No file changes to display.
</div>
) : (
<div className="flex min-w-0 flex-1 flex-row overflow-hidden">
{/* File tree sidebar */}
{showTree && (
<ScrollArea
className="shrink-0 border-r border-border"
style={{ width: FILE_TREE_WIDTH }}
scrollBarClassName="w-1"
>
<nav className="flex flex-col py-1">
{fileTree.map((node) => (
<FileTreeNodeView
key={node.fullPath}
node={node}
depth={1}
activeFile={activeFile}
onFileClick={handleFileClick}
/>
))}
</nav>
</ScrollArea>
)}
{/* Diff list */}
<ScrollArea
className={cn(
"min-w-0 flex-1",
showTree &&
"border-0 border-l border-t border-solid border-border-default rounded-tl-md",
)}
scrollBarClassName="w-1.5"
viewportClassName="[&>div]:!block"
ref={(node) => {
const vp = node?.querySelector<HTMLElement>(
"[data-radix-scroll-area-viewport]",
);
diffViewportRef.current = vp ?? null;
}}
>
<div className="min-w-0 text-xs">
{sortedFiles.map((fileDiff) => (
<div
key={fileDiff.name}
ref={(el) => setFileRef(fileDiff.name, el)}
>
<LazyFileDiff fileDiff={fileDiff} options={fileOptions} />
</div>
))}
{/* Spacer so the last file can scroll fully to the top. */}
<div className="h-[calc(100vh-100px)]" />
</div>
</ScrollArea>
</div>
)}
const headerLeft = pullRequestUrl ? (
parsedPr ? (
<a
href={pullRequestUrl}
target="_blank"
rel="noreferrer"
className="flex min-w-0 items-center gap-1.5 text-xs text-content-secondary no-underline hover:text-content-primary"
>
<GitPullRequestIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
<span className="text-content-secondary">
{parsedPr.owner}/{parsedPr.repo}
</span>
<span className="text-content-primary">#{parsedPr.number}</span>
</span>
<ExternalLinkIcon className="h-3 w-3 shrink-0 opacity-50" />
</a>
) : (
<a
href={pullRequestUrl}
target="_blank"
rel="noreferrer"
className="flex min-w-0 items-center gap-1.5 text-xs text-content-secondary no-underline hover:text-content-primary"
>
<GitPullRequestIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{pullRequestUrl}</span>
<ExternalLinkIcon className="h-3 w-3 shrink-0 opacity-50" />
</a>
)
) : (
<div className="flex items-center gap-1.5 text-xs text-content-secondary">
<GitBranchIcon className="h-3.5 w-3.5" />
<span>Uncommitted changes</span>
</div>
);
};
// -----------------------------------------------------------------------
// Estimated height per line in the diff viewer (px). Derived from
// the --diffs-font-size (11px) and --diffs-line-height (1.5)
// values set via DIFFS_FONT_STYLE, plus 1px for the border/gap.
// -----------------------------------------------------------------------
const LINE_HEIGHT_PX = 17.5;
// Height of the file header row rendered by @pierre/diffs.
const HEADER_HEIGHT_PX = 36;
/**
* Estimate the rendered pixel height of a file diff so the
* placeholder occupies roughly the same space. This keeps the
* scroll position stable as files are lazily mounted.
*/
function estimateDiffHeight(fileDiff: FileDiffMetadata): number {
return HEADER_HEIGHT_PX + fileDiff.unifiedLineCount * LINE_HEIGHT_PX;
}
/**
* Wraps a single `<FileDiff>` with an IntersectionObserver so the
* heavy component (Shadow DOM + shiki highlighting) is only mounted
* once the placeholder scrolls into or near the viewport.
*
* Once mounted the component stays mounted — we never unmount a
* FileDiff that the user has already scrolled past, which avoids
* layout shifts and repeated highlighting work.
*/
const LazyFileDiff: FC<{
fileDiff: FileDiffMetadata;
options: ComponentProps<typeof FileDiff>["options"];
}> = ({ fileDiff, options }) => {
const placeholderRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = placeholderRef.current;
if (!el || visible) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
// Pre-load files that are within one viewport-height of
// the visible area so they are ready before the user
// scrolls to them.
{ rootMargin: "100% 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [visible]);
if (!visible) {
return (
<div
ref={placeholderRef}
style={{ height: estimateDiffHeight(fileDiff) }}
className="p-4 space-y-2"
>
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
);
}
return (
<FileDiff fileDiff={fileDiff} options={options} style={DIFFS_FONT_STYLE} />
<DiffViewer
headerLeft={headerLeft}
parsedFiles={parsedFiles}
isExpanded={isExpanded}
isLoading={diffContentsQuery.isLoading || diffStatusQuery.isLoading}
error={diffContentsQuery.isError ? diffContentsQuery.error : undefined}
/>
);
};
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import { fn } from "storybook/test";
import { RepoChangesPanel } from "./RepoChangesPanel";
const sampleDiff = `--- a/src/main.ts
+++ b/src/main.ts
@@ -1,5 +1,7 @@
import { start } from "./server";
+import { logger } from "./logger";
const port = 3000;
+logger.info("Starting server...");
start(port);
--- a/src/server.ts
+++ b/src/server.ts
@@ -10,3 +10,5 @@
app.listen(port, () => {
console.log("Listening on port " + port);
});
+
+ return app;
}
`;
const baseRepo: WorkspaceAgentRepoChanges = {
repo_root: "/home/coder/project",
branch: "feat/add-logging",
remote_origin: "https://github.com/coder/project.git",
unified_diff: sampleDiff,
};
const meta: Meta<typeof RepoChangesPanel> = {
title: "pages/AgentsPage/RepoChangesPanel",
component: RepoChangesPanel,
args: {
repo: baseRepo,
onRefresh: fn(),
onCommit: fn(),
},
};
export default meta;
type Story = StoryObj<typeof RepoChangesPanel>;
export const WithChanges: Story = {};
export const NoChanges: Story = {
args: {
repo: {
...baseRepo,
unified_diff: undefined,
},
},
};
export const LongRepoName: Story = {
args: {
repo: {
...baseRepo,
repo_root:
"/home/coder/very-long-repository-name-that-should-be-truncated-in-the-header",
},
},
};
export const LongBranchName: Story = {
args: {
repo: {
...baseRepo,
branch:
"feature/TICKET-12345-implement-very-long-branch-name-for-testing-truncation-behavior",
},
},
};
export const EmptyBranchName: Story = {
args: {
repo: {
...baseRepo,
branch: "",
},
},
};
export const ManyFiles: Story = {};
export const UntrackedFiles: Story = {};
@@ -0,0 +1,120 @@
import { parsePatchFiles } from "@pierre/diffs";
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
CheckIcon,
FolderIcon,
GitBranchIcon,
RefreshCwIcon,
} from "lucide-react";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "utils/cn";
import { DiffViewer } from "./DiffViewer";
interface RepoChangesPanelProps {
repo: WorkspaceAgentRepoChanges;
onRefresh: () => void;
onCommit: () => void;
isExpanded?: boolean;
}
function splitRepoPath(repoRoot: string): { parent: string; name: string } {
const lastSlash = repoRoot.lastIndexOf("/");
if (lastSlash === -1) {
return { parent: "", name: repoRoot };
}
return {
parent: repoRoot.slice(0, lastSlash + 1),
name: repoRoot.slice(lastSlash + 1),
};
}
export const RepoChangesPanel: FC<RepoChangesPanelProps> = ({
repo,
onRefresh,
onCommit,
isExpanded,
}) => {
const [spinning, setSpinning] = useState(false);
const spinTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(spinTimerRef.current), []);
const handleRefresh = useCallback(() => {
onRefresh();
setSpinning(true);
clearTimeout(spinTimerRef.current);
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
}, [onRefresh]);
const parsedFiles = useMemo(() => {
const diff = repo.unified_diff;
if (!diff) {
return [];
}
try {
const patches = parsePatchFiles(diff);
return patches.flatMap((p) => p.files);
} catch {
return [];
}
}, [repo.unified_diff]);
const { parent: repoParent, name: repoName } = splitRepoPath(repo.repo_root);
const hasChanges = parsedFiles.length > 0;
return (
<DiffViewer
headerLeft={
<div className="flex w-full min-w-0 items-center gap-1.5">
<FolderIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
<span className="shrink-0 text-xs font-medium text-content-primary">
{repoName}
</span>
<span className="truncate text-xs text-content-secondary">
{repoParent}
</span>
{repo.branch?.trim() && (
<div className="hidden items-center gap-1 rounded-md border border-solid border-border-default px-1.5 py-0.5 text-xs text-content-secondary sm:flex">
<GitBranchIcon className="h-3 w-3 shrink-0" />
<span className="truncate">{repo.branch}</span>
</div>
)}
<div className="ml-auto flex shrink-0 items-center gap-1">
<Button
size="sm"
onClick={onCommit}
disabled={!hasChanges}
className="h-6 gap-1.5 border border-transparent bg-surface-invert-primary px-2 text-xs text-content-invert hover:bg-surface-invert-secondary active:opacity-80"
>
<CheckIcon className="h-3 w-3" />
Commit
</Button>
<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(
"h-3.5 w-3.5",
spinning && "motion-safe:animate-spin-once",
)}
/>
</Button>
</div>
</div>
}
parsedFiles={parsedFiles}
isExpanded={isExpanded}
emptyMessage="No file changes."
/>
);
};
+6 -111
View File
@@ -1,5 +1,3 @@
import { Button } from "components/Button/Button";
import { MaximizeIcon, MinimizeIcon, PanelLeftIcon } from "lucide-react";
import {
type ReactNode,
type PointerEvent as ReactPointerEvent,
@@ -15,8 +13,6 @@ const MIN_WIDTH = 360;
const MAX_WIDTH_RATIO = 0.7;
const DEFAULT_WIDTH = 480;
const TABS = [{ id: "git", label: "Git" }];
const SNAP_THRESHOLD = 80;
function getMaxWidth(): number {
@@ -46,27 +42,20 @@ interface RightPanelProps {
isExpanded: boolean;
onToggleExpanded: () => void;
onClose: () => void;
chatTitle?: string;
isSidebarCollapsed?: boolean;
onToggleSidebarCollapsed?: () => void;
tabContent: Partial<Record<(typeof TABS)[number]["id"], ReactNode>>;
/** Optional extra info per tab (e.g. diff stats). */
tabMeta?: Partial<Record<(typeof TABS)[number]["id"], ReactNode>>;
/** Fires during drag with the live visual expanded state, and
* null when the drag ends so the parent falls back to the
* committed isExpanded prop. */
onVisualExpandedChange?: (visualExpanded: boolean | null) => void;
children: ReactNode;
}
/**
* Encapsulates all drag/resize logic for the right panel:
* refs, pointer handlers, snap state, sidebar collapse
* tracking, and visual state derivation.
* refs, pointer handlers, snap state, and visual state
* derivation.
*/
function useResizableDrag({
isExpanded,
isSidebarCollapsed,
onToggleSidebarCollapsed,
width,
setWidth,
isOpen,
@@ -74,8 +63,6 @@ function useResizableDrag({
onVisualExpandedChange,
}: {
isExpanded: boolean;
isSidebarCollapsed?: boolean;
onToggleSidebarCollapsed?: () => void;
width: number;
setWidth: React.Dispatch<React.SetStateAction<number>>;
isOpen: boolean;
@@ -90,15 +77,11 @@ function useResizableDrag({
const [dragSnap, setDragSnap] = useState<
"normal" | "expanded" | "closed" | null
>(null);
// Whether we collapsed the sidebar during this drag gesture.
// Used to reverse it if the user drags back.
const sidebarCollapsedByDrag = useRef(false);
const handlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
isDragging.current = true;
sidebarCollapsedByDrag.current = false;
setDragSnap(null);
startX.current = e.clientX;
startWidth.current = isExpanded
@@ -120,23 +103,6 @@ function useResizableDrag({
const raw = startWidth.current + delta;
const maxWidth = getMaxWidth();
// Collapse/uncollapse the sidebar live when the pointer
// reaches the left edge of the viewport.
if (e.clientX < SNAP_THRESHOLD && !sidebarCollapsedByDrag.current) {
if (!isSidebarCollapsed && onToggleSidebarCollapsed) {
onToggleSidebarCollapsed();
sidebarCollapsedByDrag.current = true;
}
} else if (
e.clientX >= SNAP_THRESHOLD &&
sidebarCollapsedByDrag.current
) {
if (onToggleSidebarCollapsed) {
onToggleSidebarCollapsed();
sidebarCollapsedByDrag.current = false;
}
}
let nextSnap: "normal" | "expanded" | "closed";
if (raw > maxWidth + SNAP_THRESHOLD) {
nextSnap = "expanded";
@@ -155,13 +121,7 @@ function useResizableDrag({
(nextSnap !== "normal" && nextSnap !== "closed" && isExpanded);
onVisualExpandedChange?.(nextVisualExpanded);
},
[
isSidebarCollapsed,
onToggleSidebarCollapsed,
setWidth,
isExpanded,
onVisualExpandedChange,
],
[setWidth, isExpanded, onVisualExpandedChange],
);
const handlePointerUp = useCallback(
@@ -200,7 +160,6 @@ function useResizableDrag({
handlePointerDown,
handlePointerMove,
handlePointerUp,
sidebarCollapsedByDrag,
};
}
@@ -209,14 +168,9 @@ export const RightPanel = ({
isExpanded,
onToggleExpanded,
onClose,
chatTitle,
isSidebarCollapsed,
onToggleSidebarCollapsed,
tabContent,
tabMeta,
onVisualExpandedChange,
children,
}: RightPanelProps) => {
const [activeTab, setActiveTab] = useState("git");
const [width, setWidth] = useState(loadPersistedWidth);
// Clamp width when the viewport shrinks so the panel
@@ -254,8 +208,6 @@ export const RightPanel = ({
handlePointerUp,
} = useResizableDrag({
isExpanded,
isSidebarCollapsed,
onToggleSidebarCollapsed,
width,
setWidth,
isOpen,
@@ -298,64 +250,7 @@ export const RightPanel = ({
visualExpanded && "-left-1",
)}
/>
<div className="flex min-h-0 flex-1 flex-col">
{/* Tabbed header */}
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1">
{/* Left side: sidebar toggle (expanded + collapsed only) + tabs */}
<div className="flex items-center">
{visualExpanded &&
isSidebarCollapsed &&
onToggleSidebarCollapsed && (
<Button
variant="subtle"
size="icon"
onClick={onToggleSidebarCollapsed}
aria-label="Expand sidebar"
className="mr-1 h-7 w-7 min-w-0 shrink-0"
>
<PanelLeftIcon />
</Button>
)}
{TABS.map((tab) => (
<Button
key={tab.id}
variant={activeTab === tab.id ? "outline" : "subtle"}
size="lg"
onClick={() => setActiveTab(tab.id)}
className={cn(
"min-w-0 h-6 px-3 gap-3 py-0",
activeTab === tab.id && "bg-surface-secondary",
tabMeta?.[tab.id] && "pr-0 items-stretch",
)}
>
{tab.label}
{tabMeta?.[tab.id]}
</Button>
))}
</div>
{/* Center: chat title */}{" "}
<div className="min-w-0 flex-1 text-center">
{visualExpanded && chatTitle && (
<span className="truncate text-sm text-content-primary">
{chatTitle}
</span>
)}
</div>
{/* Right side: expand/contract button */}
<Button
variant="subtle"
size="icon"
onClick={onToggleExpanded}
aria-label={visualExpanded ? "Collapse panel" : "Expand panel"}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
>
{visualExpanded ? <MinimizeIcon /> : <MaximizeIcon />}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-surface-secondary/45">
{tabContent[activeTab]}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</div>
);
};
@@ -0,0 +1,94 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import { fn } from "storybook/test";
import { SidebarTabView } from "./SidebarTabView";
const sampleDiff = `--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,5 @@
+import { init } from "./init";
+
const main = () => {
console.log("hello");
};
`;
const makeRepo = (
name: string,
overrides?: Partial<WorkspaceAgentRepoChanges>,
): WorkspaceAgentRepoChanges => ({
repo_root: `/home/coder/${name}`,
branch: "main",
remote_origin: `https://github.com/coder/${name}.git`,
unified_diff: sampleDiff,
...overrides,
});
const meta: Meta<typeof SidebarTabView> = {
title: "pages/AgentsPage/SidebarTabView",
component: SidebarTabView,
args: {
workspace: { name: "my-workspace", ownerName: "admin" },
onRefresh: fn(),
onCommit: fn(),
isExpanded: false,
onToggleExpanded: fn(),
},
decorators: [
(Story) => (
<div style={{ height: 500, width: 480 }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SidebarTabView>;
export const PROnly: Story = {
args: {
prTab: { prNumber: 42, chatId: "chat-1" },
repositories: new Map(),
},
};
export const SingleRepo: Story = {
args: {
prTab: undefined,
repositories: new Map([["/home/coder/project", makeRepo("project")]]),
},
};
export const PRAndRepos: Story = {
args: {
prTab: { prNumber: 123, chatId: "chat-2" },
repositories: new Map([
["/home/coder/frontend", makeRepo("frontend")],
[
"/home/coder/backend",
makeRepo("backend", {
branch: "feat/api",
}),
],
]),
},
};
export const ManyRepos: Story = {
args: {
prTab: undefined,
repositories: new Map(
["alpha", "bravo", "charlie", "delta", "echo"].map((name) => [
`/home/coder/${name}`,
makeRepo(name),
]),
),
},
};
export const EmptyState: Story = {
args: {
prTab: undefined,
repositories: new Map(),
},
};
@@ -0,0 +1,291 @@
import { parsePatchFiles } from "@pierre/diffs";
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
FolderIcon,
GitPullRequestIcon,
MaximizeIcon,
MinimizeIcon,
PanelLeftIcon,
} from "lucide-react";
import { type FC, useMemo, useState } from "react";
import { cn } from "utils/cn";
import { FilesChangedPanel } from "./FilesChangedPanel";
import { RepoChangesPanel } from "./RepoChangesPanel";
interface SidebarTabViewProps {
/** PR tab data. Omitted if no PR is associated. */
prTab?: {
prNumber: number;
chatId: string;
};
/** Repository tabs from git watcher. */
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
/** Workspace info for the header. */
workspace?: {
name: string;
ownerName: string;
};
/** Callback to send a refresh to the git watcher. */
onRefresh: () => void;
/** Called when the user clicks the Commit button in any repo tab. */
onCommit: (repoRoot: string) => void;
/** Whether the panel is in expanded/fullscreen mode. */
isExpanded: boolean;
/** Callback to toggle expanded state. */
onToggleExpanded: () => void;
/** Whether the left sidebar is collapsed. */
isSidebarCollapsed?: boolean;
/** Callback to toggle left sidebar. */
onToggleSidebarCollapsed?: () => void;
/** Shown in center when expanded. */
chatTitle?: string;
/** PR diff stats for the PR tab. */
diffStatus?: { additions?: number; deletions?: number };
}
function repoTabLabel(repoRoot: string): string {
const segments = repoRoot.split("/").filter(Boolean);
return segments[segments.length - 1] ?? repoRoot;
}
function computeDiffStats(unifiedDiff: string | undefined): {
additions: number;
deletions: number;
} {
if (!unifiedDiff) return { additions: 0, deletions: 0 };
try {
const patches = parsePatchFiles(unifiedDiff);
let additions = 0;
let deletions = 0;
for (const patch of patches) {
for (const file of patch.files) {
for (const hunk of file.hunks) {
additions += hunk.additionLines;
deletions += hunk.deletionLines;
}
}
}
return { additions, deletions };
} catch {
return { additions: 0, deletions: 0 };
}
}
export const SidebarTabView: FC<SidebarTabViewProps> = ({
prTab,
repositories,
onRefresh,
onCommit,
isExpanded,
onToggleExpanded,
isSidebarCollapsed,
onToggleSidebarCollapsed,
chatTitle,
diffStatus,
}) => {
const repoEntries = Array.from(repositories.entries()).sort(([a], [b]) =>
a.localeCompare(b),
);
const hasPR = Boolean(prTab);
const hasRepos = repoEntries.length > 0;
// Default active tab: PR if present, otherwise first repo.
const defaultTab = hasPR
? "pr"
: repoEntries.length > 0
? repoEntries[0][0]
: null;
const [activeTab, setActiveTab] = useState<string | null>(defaultTab);
// Derive the effective tab inline to avoid a one-frame flash when
// activeTab is stale or null but a valid default exists.
const effectiveTab =
activeTab !== null &&
(activeTab === "pr" ? hasPR : repositories.has(activeTab))
? activeTab
: defaultTab;
// Compute diff stats for all repo tabs and cache them.
const repoDiffStats = useMemo(() => {
const statsMap = new Map<
string,
{ additions: number; deletions: number }
>();
for (const [repoRoot, repo] of repoEntries) {
statsMap.set(repoRoot, computeDiffStats(repo.unified_diff));
}
return statsMap;
}, [repoEntries]);
const prDiffAdditions = diffStatus?.additions ?? 0;
const prDiffDeletions = diffStatus?.deletions ?? 0;
if (!hasPR && !hasRepos) {
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden border-0 border-l border-solid bg-surface-primary">
{/* Tab bar – always visible for the expand button. */}
<div
role="tablist"
className="flex shrink-0 items-center gap-1 overflow-x-auto px-1 py-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<div className="min-w-0 flex-1 text-center">
{isExpanded && chatTitle && (
<span className="truncate text-sm text-content-primary">
{chatTitle}
</span>
)}
</div>
<Button
variant="subtle"
size="icon"
onClick={onToggleExpanded}
aria-label={isExpanded ? "Collapse panel" : "Expand panel"}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
>
{isExpanded ? <MinimizeIcon /> : <MaximizeIcon />}
</Button>
</div>
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
No changes to display.
</div>
</div>
);
}
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden border-0 border-l border-solid bg-surface-primary">
{/* Tab bar */}
<div
role="tablist"
className="flex shrink-0 items-center gap-1 overflow-x-auto px-1 py-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{/* Sidebar toggle – only when expanded and sidebar is collapsed */}
{isExpanded && isSidebarCollapsed && onToggleSidebarCollapsed && (
<Button
variant="subtle"
size="icon"
onClick={onToggleSidebarCollapsed}
aria-label="Expand sidebar"
className="mr-1 h-7 w-7 min-w-0 shrink-0"
>
<PanelLeftIcon />
</Button>
)}
{/* Tabs */}
{hasPR && prTab && (
<button
type="button"
id="sidebar-tab-pr"
role="tab"
aria-selected={effectiveTab === "pr"}
onClick={() => setActiveTab("pr")}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded border border-solid border-border-default px-2 py-1 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link",
effectiveTab === "pr"
? "bg-surface-tertiary text-content-primary"
: "bg-transparent text-content-secondary hover:bg-surface-secondary hover:text-content-primary",
)}
>
<GitPullRequestIcon className="h-3.5 w-3.5" />#{prTab.prNumber}
{(prDiffAdditions > 0 || prDiffDeletions > 0) && (
<span className="ml-1 inline-flex items-center gap-1 font-mono text-[10px] tabular-nums">
{prDiffAdditions > 0 && (
<span className="text-green-700 dark:text-green-500">
+{prDiffAdditions}
</span>
)}
{prDiffDeletions > 0 && (
<span className="text-red-700 dark:text-red-400">
&minus;{prDiffDeletions}
</span>
)}
</span>
)}
</button>
)}
{repoEntries.map(([repoRoot]) => {
const stats = repoDiffStats.get(repoRoot);
const additions = stats?.additions ?? 0;
const deletions = stats?.deletions ?? 0;
return (
<button
type="button"
id={`sidebar-tab-${repoRoot}`}
role="tab"
aria-selected={effectiveTab === repoRoot}
key={repoRoot}
onClick={() => setActiveTab(repoRoot)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded border border-solid border-border-default px-2 py-1 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link",
effectiveTab === repoRoot
? "bg-surface-tertiary text-content-primary"
: "bg-transparent text-content-secondary hover:bg-surface-secondary hover:text-content-primary",
)}
>
<FolderIcon className="h-3.5 w-3.5" />
{repoTabLabel(repoRoot)}
{(additions > 0 || deletions > 0) && (
<span className="ml-1 inline-flex items-center gap-1 font-mono text-[10px] tabular-nums">
{additions > 0 && (
<span className="text-green-700 dark:text-green-500">
+{additions}
</span>
)}
{deletions > 0 && (
<span className="text-red-700 dark:text-red-400">
&minus;{deletions}
</span>
)}
</span>
)}
</button>
);
})}
{/* Center: chat title when expanded */}
<div className="min-w-0 flex-1 text-center">
{isExpanded && chatTitle && (
<span className="truncate text-sm text-content-primary">
{chatTitle}
</span>
)}
</div>
{/* Right side: expand/contract button */}
<Button
variant="subtle"
size="icon"
onClick={onToggleExpanded}
aria-label={isExpanded ? "Collapse panel" : "Expand panel"}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
>
{isExpanded ? <MinimizeIcon /> : <MaximizeIcon />}
</Button>
</div>
{/* Tab content */}
<div
role="tabpanel"
aria-labelledby={
effectiveTab ? `sidebar-tab-${effectiveTab}` : undefined
}
className="min-h-0 flex-1"
>
{effectiveTab === "pr" && prTab ? (
<FilesChangedPanel chatId={prTab.chatId} isExpanded={isExpanded} />
) : effectiveTab && repositories.has(effectiveTab) ? (
<RepoChangesPanel
repo={repositories.get(effectiveTab)!}
onRefresh={onRefresh}
onCommit={() => onCommit(effectiveTab)}
isExpanded={isExpanded}
/>
) : null}
</div>
</div>
);
};
@@ -0,0 +1,327 @@
import { renderHook, waitFor } from "@testing-library/react";
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useGitWatcher } from "./useGitWatcher";
vi.mock("api/api", () => ({
watchChatGit: vi.fn(),
}));
import { watchChatGit } from "api/api";
const mockWatchChatGit = vi.mocked(watchChatGit);
class MockWebSocket {
static readonly OPEN = 1;
static readonly CLOSED = 3;
readyState = MockWebSocket.OPEN;
private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
addEventListener(event: string, handler: (...args: unknown[]) => void) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler);
}
removeEventListener(event: string, handler: (...args: unknown[]) => void) {
this.listeners.get(event)?.delete(handler);
}
send = vi.fn();
close = vi.fn(() => {
this.readyState = MockWebSocket.CLOSED;
});
simulateOpen() {
this.readyState = MockWebSocket.OPEN;
for (const handler of this.listeners.get("open") ?? []) {
handler();
}
}
simulateMessage(data: unknown) {
for (const handler of this.listeners.get("message") ?? []) {
handler({ data: JSON.stringify(data) });
}
}
simulateClose() {
this.readyState = MockWebSocket.CLOSED;
for (const handler of this.listeners.get("close") ?? []) {
handler();
}
}
}
function createMockSocket(): MockWebSocket {
const socket = new MockWebSocket();
mockWatchChatGit.mockReturnValue(socket as unknown as WebSocket);
return socket;
}
describe("useGitWatcher", () => {
beforeEach(() => {
mockWatchChatGit.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("connects WebSocket when chatId is provided", () => {
const socket = createMockSocket();
const { result } = renderHook(() => useGitWatcher({ chatId: "chat-123" }));
expect(mockWatchChatGit).toHaveBeenCalledWith("chat-123");
expect(result.current.isConnected).toBe(false);
act(() => socket.simulateOpen());
expect(result.current.isConnected).toBe(true);
});
it("does not connect when chatId is undefined", () => {
const { result } = renderHook(() => useGitWatcher({ chatId: undefined }));
expect(mockWatchChatGit).not.toHaveBeenCalled();
expect(result.current.isConnected).toBe(false);
expect(result.current.repositories.size).toBe(0);
});
it("populates repositories map from incoming changes messages", async () => {
const socket = createMockSocket();
const { result } = renderHook(() => useGitWatcher({ chatId: "chat-123" }));
act(() => socket.simulateOpen());
act(() => {
socket.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/home/user/project-a",
branch: "main",
unified_diff: "diff content a",
},
{
repo_root: "/home/user/project-b",
branch: "feature",
unified_diff: "diff content b",
},
],
});
});
await waitFor(() => {
expect(result.current.repositories.size).toBe(2);
});
const repoA = result.current.repositories.get("/home/user/project-a");
expect(repoA).toEqual({
repo_root: "/home/user/project-a",
branch: "main",
unified_diff: "diff content a",
});
const repoB = result.current.repositories.get("/home/user/project-b");
expect(repoB).toEqual({
repo_root: "/home/user/project-b",
branch: "feature",
unified_diff: "diff content b",
});
});
it("evicts repos with removed: true", async () => {
const socket = createMockSocket();
const { result } = renderHook(() => useGitWatcher({ chatId: "chat-123" }));
act(() => socket.simulateOpen());
// First, populate with two repos.
act(() => {
socket.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/home/user/project-a",
branch: "main",
},
{
repo_root: "/home/user/project-b",
branch: "feature",
},
],
});
});
await waitFor(() => {
expect(result.current.repositories.size).toBe(2);
});
// Remove one of them.
act(() => {
socket.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/home/user/project-a",
branch: "",
removed: true,
},
],
});
});
await waitFor(() => {
expect(result.current.repositories.size).toBe(1);
});
expect(result.current.repositories.has("/home/user/project-a")).toBe(false);
expect(result.current.repositories.has("/home/user/project-b")).toBe(true);
});
it("reconnects with exponential backoff on close", () => {
vi.useFakeTimers();
try {
const socket1 = createMockSocket();
renderHook(() => useGitWatcher({ chatId: "chat-123" }));
expect(mockWatchChatGit).toHaveBeenCalledTimes(1);
act(() => socket1.simulateOpen());
// Close the socket to trigger reconnection (attempt 0 → 1000ms).
const socket2 = createMockSocket();
act(() => socket1.simulateClose());
// Before the timer fires, no reconnection yet.
expect(mockWatchChatGit).toHaveBeenCalledTimes(1);
act(() => vi.advanceTimersByTime(1000));
expect(mockWatchChatGit).toHaveBeenCalledTimes(2);
// Close again (attempt 1 → 2000ms).
const socket3 = createMockSocket();
act(() => socket2.simulateClose());
act(() => vi.advanceTimersByTime(1999));
expect(mockWatchChatGit).toHaveBeenCalledTimes(2);
act(() => vi.advanceTimersByTime(1));
expect(mockWatchChatGit).toHaveBeenCalledTimes(3);
// Close again (attempt 2 → 4000ms).
createMockSocket();
act(() => socket3.simulateClose());
act(() => vi.advanceTimersByTime(3999));
expect(mockWatchChatGit).toHaveBeenCalledTimes(3);
act(() => vi.advanceTimersByTime(1));
expect(mockWatchChatGit).toHaveBeenCalledTimes(4);
} finally {
vi.useRealTimers();
}
});
it("sends a refresh message over the socket", () => {
const socket = createMockSocket();
const { result } = renderHook(() => useGitWatcher({ chatId: "chat-123" }));
act(() => socket.simulateOpen());
act(() => result.current.refresh());
expect(socket.send).toHaveBeenCalledTimes(1);
expect(socket.send).toHaveBeenCalledWith(
JSON.stringify({ type: "refresh" }),
);
});
it("cleans up WebSocket and timers on unmount", () => {
vi.useFakeTimers();
try {
const socket = createMockSocket();
const { unmount } = renderHook(() =>
useGitWatcher({ chatId: "chat-123" }),
);
act(() => socket.simulateOpen());
expect(socket.close).not.toHaveBeenCalled();
unmount();
expect(socket.close).toHaveBeenCalledTimes(1);
// Verify that no reconnection happens after unmount by closing
// the socket and advancing timers.
mockWatchChatGit.mockClear();
act(() => vi.advanceTimersByTime(60_000));
expect(mockWatchChatGit).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("resets repositories when chatId changes", async () => {
const socket1 = createMockSocket();
const { result, rerender } = renderHook(
({ chatId }: { chatId: string | undefined }) => useGitWatcher({ chatId }),
{ initialProps: { chatId: "chat-aaa" as string | undefined } },
);
act(() => socket1.simulateOpen());
act(() => {
socket1.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/home/user/project-a",
branch: "main",
},
],
});
});
await waitFor(() => {
expect(result.current.repositories.size).toBe(1);
});
// The old socket should be closed when we switch chatId.
const socket2 = createMockSocket();
rerender({ chatId: "chat-bbb" });
expect(socket1.close).toHaveBeenCalled();
expect(mockWatchChatGit).toHaveBeenCalledWith("chat-bbb");
// Repositories should be reset immediately after chatId changes.
expect(result.current.repositories.size).toBe(0);
// The new socket should work independently.
act(() => socket2.simulateOpen());
act(() => {
socket2.simulateMessage({
type: "changes",
repositories: [
{
repo_root: "/home/user/project-x",
branch: "develop",
},
],
});
});
await waitFor(() => {
expect(result.current.repositories.size).toBe(1);
});
expect(result.current.repositories.has("/home/user/project-x")).toBe(true);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { watchChatGit } from "api/api";
import type {
WorkspaceAgentGitClientMessage,
WorkspaceAgentGitServerMessage,
WorkspaceAgentRepoChanges,
} from "api/typesGenerated";
import { useCallback, useEffect, useRef, useState } from "react";
interface UseGitWatcherOptions {
chatId: string | undefined;
}
interface UseGitWatcherResult {
/** Current repo state, keyed by repo root path. */
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
/** Whether the WebSocket is currently connected. */
isConnected: boolean;
/** Send a refresh request. */
refresh: () => void;
}
const MAX_BACKOFF_MS = 30_000;
export function useGitWatcher({
chatId,
}: UseGitWatcherOptions): UseGitWatcherResult {
const [repositories, setRepositories] = useState<
ReadonlyMap<string, WorkspaceAgentRepoChanges>
>(new Map());
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef<WebSocket | null>(null);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track whether we've been disposed to avoid reconnecting after unmount.
const disposedRef = useRef(false);
const sendMessage = useCallback((msg: WorkspaceAgentGitClientMessage) => {
const socket = socketRef.current;
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(msg));
}
}, []);
const refresh = useCallback(() => {
sendMessage({ type: "refresh" });
}, [sendMessage]);
useEffect(() => {
if (!chatId) {
return;
}
disposedRef.current = false;
function connect() {
if (disposedRef.current) {
return;
}
const socket = watchChatGit(chatId!);
socketRef.current = socket;
socket.addEventListener("open", () => {
setIsConnected(true);
reconnectAttemptRef.current = 0;
});
socket.addEventListener("message", (event) => {
try {
const data = JSON.parse(
String(event.data),
) as WorkspaceAgentGitServerMessage;
if (data.type === "changes" && data.repositories) {
setRepositories((prev) => {
const next = new Map(prev);
for (const repo of data.repositories!) {
if (repo.removed) {
next.delete(repo.repo_root);
} else {
next.set(repo.repo_root, repo);
}
}
return next;
});
} else if (data.type === "error") {
console.warn("[useGitWatcher] server error:", data.message);
}
} catch {
// Ignore unparsable messages.
}
});
// Note: WebSocket "error" events are always followed by a "close"
// event, so reconnection is handled here.
socket.addEventListener("close", () => {
setIsConnected(false);
socketRef.current = null;
if (disposedRef.current) {
return;
}
// Reconnect with exponential backoff.
const attempt = reconnectAttemptRef.current;
const delay = Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS);
reconnectAttemptRef.current = attempt + 1;
reconnectTimerRef.current = setTimeout(connect, delay);
});
}
connect();
return () => {
disposedRef.current = true;
if (reconnectTimerRef.current !== null) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (socketRef.current) {
socketRef.current.close();
socketRef.current = null;
}
setIsConnected(false);
setRepositories(new Map());
reconnectAttemptRef.current = 0;
};
}, [chatId]);
return { repositories, isConnected, refresh };
}
+44 -8
View File
@@ -74,28 +74,64 @@ export const withDashboardProvider = (
type MessageEvent = Record<"data", string>;
type CallbackFn = (ev?: MessageEvent) => void;
// parameters.webSocket accepts two formats:
//
// Array — events are delivered to every socket (backward-compatible):
// webSocket: [{ event: "message", data: "..." }]
//
// Record keyed by URL substring — events are delivered only to
// sockets whose URL contains the key:
// webSocket: {
// "/api/experimental/chats/": [{ event: "message", data: "..." }],
// "/api/experimental/workspaceagents/": [{ event: "message", data: "..." }],
// }
export const withWebSocket = (Story: FC, { parameters }: StoryContext) => {
const events = parameters.webSocket;
const param = parameters.webSocket;
if (!events) {
if (!param) {
console.warn("You forgot to add `parameters.webSocket` to your story");
return <Story />;
}
const listeners = new Map<string, CallbackFn>();
let callEventsDelay: number;
const isRouted = !Array.isArray(param);
const broadcastEvents = isRouted ? [] : param;
const routedEvents = isRouted ? param : {};
window.WebSocket = class WebSocket {
public readyState = 1;
public binaryType = "blob";
#listeners = new Map<string, CallbackFn>();
#callEventsDelay: number | undefined;
#url: string;
constructor(url?: string) {
this.#url = url ?? "";
}
send() {}
addEventListener(type: string, callback: CallbackFn) {
listeners.set(type, callback);
this.#listeners.set(type, callback);
// Determine which events this socket should receive.
let events = broadcastEvents;
if (isRouted) {
const matchingKey = Object.keys(routedEvents).find((key) =>
this.#url.includes(key),
);
events = matchingKey ? routedEvents[matchingKey] : [];
}
if (events.length === 0) {
return;
}
// Runs when the last event listener is added
clearTimeout(callEventsDelay);
callEventsDelay = window.setTimeout(() => {
clearTimeout(this.#callEventsDelay);
this.#callEventsDelay = window.setTimeout(() => {
for (const entry of events) {
const callback = listeners.get(entry.event);
const callback = this.#listeners.get(entry.event);
if (callback) {
entry.event === "message"
+7
View File
@@ -101,10 +101,17 @@ module.exports = {
"0%": { left: "0%" },
"100%": { left: "100%" },
},
"zip-right": {
"0%": { left: "0%", width: "0%" },
"30%": { left: "0%", width: "40%" },
"100%": { left: "100%", width: "0%" },
},
},
animation: {
loading: "loading 2s ease-in-out infinite alternate",
"caret-scan": "caret-scan 3s ease-in-out infinite",
"spin-once": "spin 1s cubic-bezier(0.4, 0, 0.2, 1)",
"zip-right": "zip-right 1s cubic-bezier(0.4, 0, 0.2, 1)",
},
},
},