mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
Vendored
+1
-1
@@ -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;
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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">
|
||||
−{deletions}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clickable inline diff stats shown in the top bar when the
|
||||
* diff panel is closed.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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."
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
−{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">
|
||||
−{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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user