refactor(site): consolidate Git panel diff viewers and polish UI (#23080)

## Summary

Refactors the Git panel in the Agents page to consolidate duplicated
diff viewer code and significantly improve the UI.

### Deduplication
- **RemoteDiffPanel** now uses the shared `DiffViewer` component instead
of duplicating file tree, lazy loading, scroll tracking, and layout
(~500 lines removed).
- Renamed `RepoChangesPanel` → `LocalDiffPanel`, `FilesChangedPanel` →
`RemoteDiffPanel` to reflect actual scope.
- Removed `headerLeft`/`headerRight` abstraction from `DiffViewer` —
each consumer owns its own header.
- Replaced hand-rolled `ChatDiffStatusResponse` with auto-generated
`ChatDiffStatus` from `typesGenerated.ts`.

### Tab Redesign
- Per-repo tabs: each local repo gets its own tab (`Working <repo>`)
instead of a single stacked view.
- PR tab shows state icon + PR title; branch-only tab shows branch icon.
- Tabs use `Button variant="outline"` matching the Git/Desktop tab
style.
- Radix `ScrollArea` with thin horizontal scrollbar for tab overflow.
- Diff style toggle and refresh button lifted to shared toolbar, always
visible.

### PR Header
- Compact sub-header: `base_branch ←`, state badge
(`Open`/`Draft`/`Merged`/`Closed`), diff stats, and `View PR` button.
- GitHub-style state-aware icons (green open, gray draft, purple merged,
red closed).
- New API fields synced: `base_branch`, `author_login`, `pr_number`,
`commits`, `approved`, `reviewer_count`.

### Local Changes Header  
- Compact sub-header: branch name, repo root path, diff stats, and
`Commit` button (styled to match `View PR`).
- `CircleDotIcon` (amber) for working changes tabs — universal
"modified" indicator.

### Visual Polish
- All text in sub-headers and buttons at 13px matching chat font size.
- All badges (`DiffStatBadge`, PR state, `View PR`, `Commit`) use
consistent `border-border-default`, `rounded-sm`, `leading-5`.
- No background color on diff viewer header bars.
- Tabs hidden when their view has no content; auto-switch when active
tab disappears.

### Stories
- New `GitPanel.stories.tsx` covering: open PR + working changes, draft
PR, merged PR, closed PR, branch only, working changes only, multiple
repos, empty state.
- Removed old `LocalDiffPanel.stories.tsx` and
`RemoteDiffPanel.stories.tsx`.
This commit is contained in:
Kyle Carberry
2026-03-14 12:21:30 -07:00
committed by GitHub
parent 83e4f9f93e
commit 266c611716
14 changed files with 1274 additions and 1717 deletions
+2 -16
View File
@@ -365,20 +365,6 @@ interface ChatGitChangeResponse extends TypesGen.ChatGitChange {
readonly diffs_link?: string;
}
export type ChatDiffStatusResponse = Readonly<
{
chat_id: string;
url?: string;
pull_request_state?: string;
changes_requested: boolean;
additions: number;
deletions: number;
changed_files: number;
refreshed_at?: string;
stale_at?: string;
} & Record<string, unknown>
>;
function normalizeGetTemplatesOptions(
options: GetTemplatesOptions | GetTemplatesQuery = {},
): Record<string, string> {
@@ -3059,8 +3045,8 @@ class ApiMethods {
getChatDiffStatus = async (
chatId: string,
): Promise<ChatDiffStatusResponse> => {
const response = await this.axios.get<ChatDiffStatusResponse>(
): Promise<TypesGen.ChatDiffStatus> => {
const response = await this.axios.get<TypesGen.ChatDiffStatus>(
`/api/experimental/chats/${chatId}/diff-status`,
);
return response.data;
+3 -2
View File
@@ -1,4 +1,4 @@
import { API, type ChatDiffStatusResponse } from "api/api";
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { QueryClient, UseInfiniteQueryOptions } from "react-query";
@@ -357,7 +357,8 @@ export const chatDiffStatusKey = (chatId: string) =>
export const chatDiffStatus = (chatId: string) => ({
queryKey: chatDiffStatusKey(chatId),
queryFn: (): Promise<ChatDiffStatusResponse> => API.getChatDiffStatus(chatId),
queryFn: (): Promise<TypesGen.ChatDiffStatus> =>
API.getChatDiffStatus(chatId),
});
export const chatDiffContentsKey = (chatId: string) =>
+36 -1
View File
@@ -3,32 +3,67 @@
* @see {@link https://ui.shadcn.com/docs/components/scroll-area}
*/
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { useCallback, useRef } from "react";
import { cn } from "utils/cn";
interface ScrollAreaProps
extends React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.Root> {
scrollBarClassName?: string;
viewportClassName?: string;
/** Which scrollbar(s) to show. Defaults to "vertical". */
orientation?: "vertical" | "horizontal" | "both";
}
export const ScrollArea: React.FC<ScrollAreaProps> = ({
className,
scrollBarClassName,
viewportClassName,
orientation = "vertical",
children,
...props
}) => {
const viewportRef = useRef<HTMLDivElement>(null);
// Translate vertical wheel events into horizontal scroll when the
// scroll area only scrolls horizontally. Without this, the mouse
// wheel does nothing on a horizontal-only container.
const handleWheel = useCallback(
(e: React.WheelEvent<HTMLDivElement>) => {
if (orientation !== "horizontal") return;
const el = viewportRef.current;
if (!el) return;
// Only redirect when the user is scrolling vertically.
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
e.preventDefault();
el.scrollBy({ left: e.deltaY, behavior: "smooth" });
},
[orientation],
);
return (
<ScrollAreaPrimitive.Root
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
onWheel={handleWheel}
className={cn("h-full w-full rounded-[inherit]", viewportClassName)}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar className={cn("z-10", scrollBarClassName)} />
{(orientation === "vertical" || orientation === "both") && (
<ScrollBar
orientation="vertical"
className={cn("z-10", scrollBarClassName)}
/>
)}
{(orientation === "horizontal" || orientation === "both") && (
<ScrollBar
orientation="horizontal"
className={cn("z-10", scrollBarClassName)}
/>
)}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
@@ -1,8 +1,8 @@
import { MockUserOwner } from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { ChatDiffStatus } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { fn } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
@@ -199,11 +199,13 @@ export const WithSidebarPanel: Story = {
diffStatusData: {
chat_id: AGENT_ID,
url: "https://github.com/coder/coder/pull/123",
pull_request_title: "fix: resolve race condition in workspace builds",
pull_request_draft: false,
changes_requested: false,
additions: 42,
deletions: 7,
changed_files: 5,
} satisfies ChatDiffStatusResponse,
} satisfies ChatDiffStatus,
},
};
+3 -19
View File
@@ -1,8 +1,8 @@
import type { ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { ChatDiffStatus } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { ArchiveIcon } from "lucide-react";
import { type FC, type RefObject, useMemo, useState } from "react";
import { type FC, type RefObject, useState } from "react";
import type { UrlTransform } from "streamdown";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
@@ -89,7 +89,7 @@ interface AgentDetailViewProps {
// Sidebar content data.
prNumber: number | undefined;
diffStatusData: ChatDiffStatusResponse | undefined;
diffStatusData: ChatDiffStatus | undefined;
gitWatcher: {
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
refresh: () => void;
@@ -180,21 +180,6 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded;
// Compute local diff stats from git watcher unified diffs.
const localDiffStats = useMemo(() => {
let additions = 0;
let deletions = 0;
for (const repo of gitWatcher.repositories.values()) {
if (!repo.unified_diff) continue;
for (const line of repo.unified_diff.split("\n")) {
if (line.startsWith("+") && !line.startsWith("+++")) {
additions++;
} else if (line.startsWith("-") && !line.startsWith("---")) {
deletions++;
}
}
}
return { additions, deletions, changed_files: 0 };
}, [gitWatcher.repositories]);
const titleElement = (
<title>
@@ -335,7 +320,6 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusData}
localDiffStats={localDiffStats}
chatInputRef={editing.chatInputRef}
/>
),
+3 -3
View File
@@ -12,14 +12,14 @@ export const DiffStatBadge: FC<{ additions: number; deletions: number }> = ({
return null;
}
return (
<span className="inline-flex h-full items-center self-stretch overflow-hidden font-mono text-xs font-medium">
<span className="inline-flex items-center overflow-hidden rounded-sm border border-solid border-border-default font-mono text-[13px] font-medium leading-5">
{additions > 0 && (
<span className="flex h-full items-center bg-surface-git-added px-1.5 text-git-added-bright">
<span className="flex items-center px-1.5 bg-surface-git-added text-git-added-bright">
+{additions}
</span>
)}
{deletions > 0 && (
<span className="flex h-full items-center bg-surface-git-deleted px-1.5 text-git-deleted-bright">
<span className="flex items-center px-1.5 bg-surface-git-deleted text-git-deleted-bright">
&minus;{deletions}
</span>
)}
+117 -12
View File
@@ -1,5 +1,5 @@
import { useTheme } from "@emotion/react";
import type { FileDiffMetadata } from "@pierre/diffs";
import type { DiffLineAnnotation, FileDiffMetadata } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import {
@@ -28,8 +28,6 @@ import { changeColor, changeLabel } from "./diffColors";
// -------------------------------------------------------------------
interface DiffViewerProps {
/** Fragment to display in the top-left of the header bar. */
headerLeft?: ReactNode;
/** Parsed file diffs to render. */
parsedFiles: readonly FileDiffMetadata[];
/** Cache key prefix for parsePatchFiles worker pool LRU cache. */
@@ -44,6 +42,44 @@ interface DiffViewerProps {
emptyMessage?: string;
/** Which diff rendering style to use. */
diffStyle: DiffStyle;
/**
* Called when a line number gutter element is clicked.
* Receives the file name and click metadata.
*/
onLineNumberClick?: (
fileName: string,
props: { lineNumber: number; annotationSide: "additions" | "deletions" },
) => void;
/**
* Called when a range of lines is selected (e.g. shift-click).
* Receives the file name and the selected range (or null on
* deselection).
*/
onLineSelected?: (
fileName: string,
range: {
start: number;
end: number;
side?: "additions" | "deletions";
} | null,
) => void;
/**
* Returns line annotations for the given file. Used to render
* inline widgets such as comment inputs.
*/
getLineAnnotations?: (fileName: string) => DiffLineAnnotation<string>[];
/**
* Renderer for line annotations returned by `getLineAnnotations`.
*/
renderAnnotation?: (annotation: DiffLineAnnotation<string>) => ReactNode;
/**
* When set to a file name, DiffViewer scrolls to that file and
* highlights it in the tree. The parent should reset this to
* null via `onScrollToFileComplete` after the scroll completes.
*/
scrollToFile?: string | null;
/** Called after scrollToFile has been processed. */
onScrollToFileComplete?: () => void;
}
// -------------------------------------------------------------------
@@ -307,7 +343,14 @@ const FileTreeNodeView: FC<{
const LazyFileDiff: FC<{
fileDiff: FileDiffMetadata;
options: ComponentProps<typeof FileDiff>["options"];
}> = ({ fileDiff, options }) => {
lineAnnotations?: DiffLineAnnotation<string>[];
renderAnnotation?: (annotation: DiffLineAnnotation<string>) => ReactNode;
}> = ({
fileDiff,
options,
lineAnnotations,
renderAnnotation: renderAnnotationProp,
}) => {
const placeholderRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
@@ -348,7 +391,13 @@ const LazyFileDiff: FC<{
}
return (
<FileDiff fileDiff={fileDiff} options={options} style={DIFFS_FONT_STYLE} />
<FileDiff
fileDiff={fileDiff}
options={options}
style={DIFFS_FONT_STYLE}
lineAnnotations={lineAnnotations}
renderAnnotation={renderAnnotationProp}
/>
);
};
@@ -357,13 +406,18 @@ const LazyFileDiff: FC<{
// -------------------------------------------------------------------
export const DiffViewer: FC<DiffViewerProps> = ({
headerLeft,
parsedFiles,
isExpanded,
isLoading,
error,
emptyMessage = "No file changes to display.",
diffStyle,
onLineNumberClick,
onLineSelected,
getLineAnnotations,
renderAnnotation,
scrollToFile,
onScrollToFileComplete,
}) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
@@ -396,6 +450,38 @@ export const DiffViewer: FC<DiffViewerProps> = ({
[diffOptions],
);
// When the parent provides per-file callbacks (e.g. line click
// handlers for comment inputs), build options per file. Otherwise
// share a single stable object to avoid unnecessary re-highlights.
const hasPerFileCallbacks = !!(onLineNumberClick || onLineSelected);
const getOptionsForFile = useCallback(
(fileName: string) => ({
...diffOptions,
overflow: "wrap" as const,
enableLineSelection: true,
enableHoverUtility: true,
...(onLineNumberClick && {
onLineNumberClick: (props: {
lineNumber: number;
annotationSide: "additions" | "deletions";
}) => onLineNumberClick(fileName, props),
}),
onLineSelected: onLineSelected
? (
range: {
start: number;
end: number;
side?: "additions" | "deletions";
} | null,
) => onLineSelected(fileName, range)
: () => {
// TODO: Make this add context to the input.
},
}),
[diffOptions, onLineNumberClick, onLineSelected],
);
const fileTree = useMemo(() => buildFileTree(parsedFiles), [parsedFiles]);
// Sort diff blocks in the same order the file tree displays them
@@ -540,6 +626,20 @@ export const DiffViewer: FC<DiffViewerProps> = ({
}
}, []);
// Scroll to a file programmatically when the parent sets
// scrollToFile. This enables external navigation (e.g.
// clicking a file reference chip in the chat input).
useEffect(() => {
if (scrollToFile) {
const el = fileRefs.current.get(scrollToFile);
if (el) {
el.scrollIntoView({ block: "start", behavior: "smooth" });
setActiveFile(scrollToFile);
}
onScrollToFileComplete?.();
}
}, [scrollToFile, onScrollToFileComplete]);
// ---------------------------------------------------------------
// Loading state
// ---------------------------------------------------------------
@@ -579,11 +679,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
ref={containerRef}
className="flex h-full min-w-0 flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center gap-1 bg-surface-secondary px-3 py-2">
{headerLeft}
</div>
{/* Diff contents */}
{/* Diff contents */}{" "}
{sortedFiles.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
{emptyMessage}
@@ -632,7 +728,16 @@ export const DiffViewer: FC<DiffViewerProps> = ({
key={fileDiff.name}
ref={(el) => setFileRef(fileDiff.name, el)}
>
<LazyFileDiff fileDiff={fileDiff} options={fileOptions} />
<LazyFileDiff
fileDiff={fileDiff}
options={
hasPerFileCallbacks
? getOptionsForFile(fileDiff.name)
: fileOptions
}
lineAnnotations={getLineAnnotations?.(fileDiff.name)}
renderAnnotation={renderAnnotation}
/>
</div>
))}
{/* Spacer so the last file can scroll fully to the top. */}
@@ -1,430 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ChatDiffStatusResponse } from "api/api";
import { API } from "api/api";
import type { ChatDiffContents } from "api/typesGenerated";
import { expect, screen, spyOn } from "storybook/test";
import { FilesChangedPanel } from "./FilesChangedPanel";
// ---------------------------------------------------------------------------
// Large-diff generator — produces a realistic unified diff with the
// requested number of additions and deletions spread across multiple
// TypeScript files. Used by the LargeDiff story to reproduce the
// performance characteristics of a +2000 -1000 agent chat.
// ---------------------------------------------------------------------------
/** Generate a block of realistic-looking TypeScript lines. */
function tsLines(prefix: string, count: number, startIdx = 0): string[] {
const templates = [
(i: number) => ` const ${prefix}Val${i} = computeValue(${i}, opts);`,
(i: number) => ` if (${prefix}Val${i} !== undefined) {`,
(i: number) =>
` logger.info("Processing ${prefix} item", { index: ${i} });`,
(i: number) => ` results.push(await transform(${prefix}Val${i}));`,
(_i: number) => " }",
(i: number) => ` // Handle edge case for ${prefix} iteration ${i}`,
(i: number) =>
` const ${prefix}Mapped${i} = items.map((x) => x.${prefix}Field${i});`,
(i: number) =>
` await db.query(\`SELECT * FROM ${prefix}_table WHERE id = \${${i}}\`);`,
(i: number) =>
` export function ${prefix}Handler${i}(req: Request): Response {`,
(i: number) =>
` return new Response(JSON.stringify({ ${prefix}: ${i} }));`,
];
const lines: string[] = [];
for (let i = 0; i < count; i++) {
const tpl = templates[(startIdx + i) % templates.length];
lines.push(tpl(startIdx + i));
}
return lines;
}
/**
* Build a single file section of a unified diff. `contextLines` lines
* of shared context surround each hunk. `additions` new lines and
* `deletions` removed lines are interleaved across multiple hunks.
*/
function buildFileDiff(opts: {
oldPath: string;
newPath: string;
additions: number;
deletions: number;
contextPerHunk?: number;
hunks?: number;
}): string {
const {
oldPath,
newPath,
additions,
deletions,
contextPerHunk = 5,
hunks = Math.max(1, Math.ceil((additions + deletions) / 80)),
} = opts;
const addPerHunk = Math.ceil(additions / hunks);
const delPerHunk = Math.ceil(deletions / hunks);
const prefix = oldPath.replace(/[^a-zA-Z]/g, "").slice(0, 6);
let output = `diff --git a/${oldPath} b/${newPath}\n`;
output += "index 1a2b3c4..5d6e7f8 100644\n";
output += `--- a/${oldPath}\n`;
output += `+++ b/${newPath}\n`;
let oldLine = 1;
let additionsLeft = additions;
let deletionsLeft = deletions;
for (let h = 0; h < hunks; h++) {
const ctxLines = tsLines(prefix, contextPerHunk, oldLine);
const hunkDel = Math.min(delPerHunk, deletionsLeft);
const hunkAdd = Math.min(addPerHunk, additionsLeft);
deletionsLeft -= hunkDel;
additionsLeft -= hunkAdd;
const oldCount = contextPerHunk + hunkDel + contextPerHunk;
const newCount = contextPerHunk + hunkAdd + contextPerHunk;
output += `@@ -${oldLine},${oldCount} +${oldLine},${newCount} @@\n`;
// Leading context.
for (const l of ctxLines) {
output += ` ${l}\n`;
}
// Deletions.
for (const l of tsLines(
`old${prefix}`,
hunkDel,
oldLine + contextPerHunk,
)) {
output += `-${l}\n`;
}
// Additions.
for (const l of tsLines(
`new${prefix}`,
hunkAdd,
oldLine + contextPerHunk,
)) {
output += `+${l}\n`;
}
// Trailing context.
for (const l of tsLines(
prefix,
contextPerHunk,
oldLine + contextPerHunk + hunkDel,
)) {
output += ` ${l}\n`;
}
oldLine += oldCount + 20;
}
return output;
}
/**
* Generate a complete multi-file unified diff targeting roughly
* `totalAdditions` added lines and `totalDeletions` removed lines.
*/
function generateLargeDiff(
totalAdditions: number,
totalDeletions: number,
): string {
const files = [
{
path: "site/src/pages/AgentsPage/AgentDetail.tsx",
addPct: 0.25,
delPct: 0.2,
},
{
path: "site/src/components/ai-elements/tool/utils.ts",
addPct: 0.15,
delPct: 0.15,
},
{
path: "site/src/pages/AgentsPage/FilesChangedPanel.tsx",
addPct: 0.15,
delPct: 0.1,
},
{ path: "site/src/api/queries/chats.ts", addPct: 0.1, delPct: 0.15 },
{
path: "site/src/modules/resources/AgentLogs/AgentLogs.tsx",
addPct: 0.1,
delPct: 0.1,
},
{ path: "coderd/database/queries/chats.sql", addPct: 0.05, delPct: 0.1 },
{
path: "site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx",
addPct: 0.1,
delPct: 0.05,
},
{
path: "site/src/pages/AgentsPage/RightPanel.tsx",
addPct: 0.05,
delPct: 0.1,
},
{ path: "site/src/hooks/useDiffViewer.ts", addPct: 0.05, delPct: 0.05 },
];
const patches: string[] = [];
for (const f of files) {
const add = Math.round(totalAdditions * f.addPct);
const del = Math.round(totalDeletions * f.delPct);
if (add === 0 && del === 0) continue;
patches.push(
buildFileDiff({
oldPath: f.path,
newPath: f.path,
additions: add,
deletions: del,
}),
);
}
return patches.join("");
}
const sampleUnifiedDiff = `diff --git a/site/src/pages/AgentsPage/FilesChangedPanel.tsx b/site/src/pages/AgentsPage/FilesChangedPanel.tsx
index abc1234..def5678 100644
--- a/site/src/pages/AgentsPage/FilesChangedPanel.tsx
+++ b/site/src/pages/AgentsPage/FilesChangedPanel.tsx
@@ -1,10 +1,15 @@
+import { useTheme } from "@emotion/react";
import { parsePatchFiles } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
+import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react"; // deliberately long import to verify horizontal overflow handling in narrow panels
+import {
+ DIFFS_FONT_STYLE,
+ getDiffViewerOptions,
+} from "components/ai-elements/tool/utils";
import { chatDiffContents, chatDiffStatus } from "api/queries/chats";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
-import { Skeleton } from "components/Skeleton/Skeleton";
import { type FC, useMemo } from "react";
import { useQuery } from "react-query";
diff --git a/site/src/components/ai-elements/tool/utils.ts b/site/src/components/ai-elements/tool/utils.ts
index 1234567..abcdef0 100644
--- a/site/src/components/ai-elements/tool/utils.ts
+++ b/site/src/components/ai-elements/tool/utils.ts
@@ -10,6 +10,18 @@ export const diffViewerCSS =
export function getDiffViewerOptions(isDark: boolean) {
return {
diffStyle: "unified" as const,
+ diffIndicators: "bars" as const,
+ overflow: "scroll" as const,
themeType: (isDark ? "dark" : "light") as "dark" | "light",
+ theme: isDark ? "github-dark-high-contrast" : "github-light",
+ unsafeCSS: diffViewerCSS,
};
}
+
+export const DIFFS_FONT_STYLE = {
+ "--diffs-font-size": "11px",
+ "--diffs-line-height": "1.5",
+} as React.CSSProperties;
`;
const defaultDiffStatus: ChatDiffStatusResponse = {
chat_id: "test-chat",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 0,
deletions: 0,
changed_files: 0,
};
const defaultDiffContents: ChatDiffContents = {
chat_id: "test-chat",
};
const meta: Meta<typeof FilesChangedPanel> = {
title: "pages/AgentsPage/FilesChangedPanel",
component: FilesChangedPanel,
args: {
chatId: "test-chat",
},
decorators: [
(Story) => (
<div style={{ height: 600, width: 500 }}>
<Story />
</div>
),
],
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue(defaultDiffStatus);
spyOn(API, "getChatDiffContents").mockResolvedValue(defaultDiffContents);
},
};
export default meta;
type Story = StoryObj<typeof FilesChangedPanel>;
export const EmptyDiff: Story = {
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue({
...defaultDiffStatus,
url: undefined,
});
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: "",
});
},
play: async () => {
await screen.findByText("No file changes to display.");
expect(screen.getByText("No file changes to display.")).toBeInTheDocument();
},
};
export const ParseError: Story = {
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue({
...defaultDiffStatus,
url: "https://github.com/coder/coder/pull/123",
});
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: "not-a-valid-unified-diff",
});
},
play: async () => {
await screen.findByText("No file changes to display.");
expect(screen.getByText("No file changes to display.")).toBeInTheDocument();
},
};
export const WithDiffDark: Story = {
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue({
...defaultDiffStatus,
url: "https://github.com/coder/coder/pull/456",
additions: 14,
deletions: 2,
changed_files: 2,
});
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: sampleUnifiedDiff,
});
},
};
export const WithDiffLight: Story = {
globals: {
theme: "light",
},
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue({
...defaultDiffStatus,
url: "https://github.com/coder/coder/pull/456",
additions: 14,
deletions: 2,
changed_files: 2,
});
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: sampleUnifiedDiff,
});
},
};
export const NoPullRequestDark: Story = {
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue({
...defaultDiffStatus,
url: "https://github.com/coder/coder/pull/456",
additions: 14,
deletions: 2,
changed_files: 2,
});
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: sampleUnifiedDiff,
});
},
};
export const NoPullRequestLight: Story = {
globals: {
theme: "light",
},
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue({
...defaultDiffStatus,
url: "https://github.com/coder/coder/pull/456",
additions: 14,
deletions: 2,
changed_files: 2,
});
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: sampleUnifiedDiff,
});
},
};
/**
* Stress-test story: renders a diff with approximately +2000 -1000
* lines spread across 9 TypeScript files. Use this to reproduce
* and profile the performance issues reported for large agent chats.
*
* Open the browser DevTools Performance tab and record while this
* story loads to measure:
* - Initial render blocking time (shiki tokenization)
* - Total DOM node count
* - Scroll jank / frame drops
*/
export const LargeDiff: Story = {
decorators: [
(Story) => (
<div style={{ height: 800, width: 600 }}>
<Story />
</div>
),
],
beforeEach: () => {
const largeDiff = generateLargeDiff(2000, 1000);
spyOn(API, "getChatDiffStatus").mockResolvedValue({
...defaultDiffStatus,
url: "https://github.com/coder/coder/pull/789",
additions: 2000,
deletions: 1000,
changed_files: 9,
});
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: largeDiff,
});
},
};
/**
* Same as LargeDiff but in light mode for visual comparison.
*/
export const LargeDiffLight: Story = {
globals: {
theme: "light",
},
decorators: [
(Story) => (
<div style={{ height: 800, width: 600 }}>
<Story />
</div>
),
],
beforeEach: () => {
const largeDiff = generateLargeDiff(2000, 1000);
spyOn(API, "getChatDiffStatus").mockResolvedValue({
...defaultDiffStatus,
url: "https://github.com/coder/coder/pull/789",
additions: 2000,
deletions: 1000,
changed_files: 9,
});
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: largeDiff,
});
},
};
@@ -1,993 +0,0 @@
import { useTheme } from "@emotion/react";
import type { DiffLineAnnotation, 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,
CornerDownLeftIcon,
ExternalLinkIcon,
GitBranchIcon,
GitPullRequestIcon,
Rows3Icon,
} from "lucide-react";
import {
type ComponentProps,
type FC,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useQuery } from "react-query";
import { cn } from "utils/cn";
import type { ChatMessageInputRef } from "./AgentChatInput";
import { changeColor, changeLabel } from "./diffColors";
interface FilesChangedPanelProps {
chatId: string;
isExpanded?: boolean;
chatInputRef?: React.RefObject<ChatMessageInputRef | null>;
}
/**
* 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";
/**
* Walk the parsed hunks for a file and collect code lines that fall
* within `startLine..endLine` on the given side. For "additions"
* lines are matched against addition line numbers (using
* `hunk.additionStart`); for "deletions" against deletion line
* numbers (using `hunk.deletionStart`). Context lines that fall
* in range are included as well.
*/
function extractDiffContent(
parsedFiles: readonly FileDiffMetadata[],
fileName: string,
startLine: number,
endLine: number,
side: "additions" | "deletions",
): string {
const file = parsedFiles.find((f) => f.name === fileName);
if (!file) return "";
const lines = side === "additions" ? file.additionLines : file.deletionLines;
const collected: string[] = [];
for (const hunk of file.hunks) {
let addLine = hunk.additionStart;
let delLine = hunk.deletionStart;
for (const block of hunk.hunkContent) {
if (block.type === "context") {
for (let i = 0; i < block.lines; i++) {
const ln = side === "additions" ? addLine : delLine;
if (ln >= startLine && ln <= endLine) {
const idx =
side === "additions"
? block.additionLineIndex + i
: block.deletionLineIndex + i;
if (lines[idx] != null) collected.push(lines[idx]);
}
addLine++;
delLine++;
}
} else {
// ChangeContent block.
if (side === "deletions") {
for (let i = 0; i < block.deletions; i++) {
if (delLine >= startLine && delLine <= endLine) {
const line = lines[block.deletionLineIndex + i];
if (line != null) collected.push(line);
}
delLine++;
}
// Addition lines in a change block still advance
// the addition counter.
addLine += block.additions;
} else {
// side === "additions"
// Deletion lines in a change block still advance
// the deletion counter.
delLine += block.deletions;
for (let i = 0; i < block.additions; i++) {
if (addLine >= startLine && addLine <= endLine) {
const line = lines[block.additionLineIndex + i];
if (line != null) collected.push(line);
}
addLine++;
}
}
}
}
}
return collected.join("\n");
}
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.
*/
function parsePullRequestUrl(url: string): {
owner: string;
repo: string;
number: string;
} | null {
try {
const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
if (match) {
return { owner: match[1], repo: match[2], number: match[3] };
}
} catch {
// Fall through.
}
return null;
}
// -------------------------------------------------------------------
// File tree data model
// -------------------------------------------------------------------
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>
);
};
/**
* Inline input rendered as a diff annotation under the selected
* line(s). Supports multiline via Shift+Enter. Enter submits,
* Escape dismisses.
*/
const InlinePromptInput: FC<{
onSubmit: (text: string) => void;
onCancel: () => void;
}> = ({ onSubmit, onCancel }) => {
const [text, setText] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Focus the textarea on mount. We use a ref callback via rAF
// rather than autoFocus because the component renders inside
// Shadow DOM where autoFocus is unreliable.
useEffect(() => {
requestAnimationFrame(() => {
textareaRef.current?.focus();
});
}, []);
return (
<div className="px-2 py-1.5">
<div className="rounded-lg border border-border-default bg-surface-secondary p-1 shadow-sm has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-content-link/40">
<textarea
ref={textareaRef}
className="w-full resize-none border-none bg-transparent px-2.5 py-1.5 font-sans text-[13px] leading-5 text-content-primary placeholder:text-content-secondary outline-none ring-0 focus:outline-none focus:ring-0"
placeholder="Add a comment to include with this reference..."
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (text.trim()) {
onSubmit(text.trim());
} else {
onCancel();
}
}
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
/>
<div className="flex items-center justify-end px-1.5 pb-1">
<Button
size="sm"
variant="subtle"
className="h-6 gap-1.5 px-2 text-xs text-content-secondary hover:text-content-primary"
disabled={!text.trim()}
onMouseDown={(e) => {
// Prevent blur from firing before click.
e.preventDefault();
}}
onClick={() => {
if (text.trim()) {
onSubmit(text.trim());
}
}}
>
<CornerDownLeftIcon className="size-3" />
Add to chat
</Button>
</div>
</div>
</div>
);
};
export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
chatId,
isExpanded,
chatInputRef,
}) => {
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 [activeCommentBox, setActiveCommentBox] = useState<{
fileName: string;
startLine: number;
endLine: number;
side: "additions" | "deletions";
} | null>(null);
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]);
// Returns per-file diff options that include a line-number click
// handler scoped to the given file name.
const getFileOptions = useCallback(
(fileName: string) => ({
...diffOptions,
overflow: "wrap" as const,
enableLineSelection: true,
enableHoverUtility: true,
onLineNumberClick(props: {
lineNumber: number;
annotationSide: "additions" | "deletions";
}) {
setActiveCommentBox({
fileName,
startLine: props.lineNumber,
endLine: props.lineNumber,
side: props.annotationSide,
});
},
onLineSelected(
range: {
start: number;
end: number;
side?: "additions" | "deletions";
} | null,
) {
if (!range || range.start === range.end) return;
const side = range.side ?? "additions";
setActiveCommentBox({
fileName,
startLine: Math.min(range.start, range.end),
endLine: Math.max(range.start, range.end),
side,
});
},
}),
[diffOptions],
);
const getAnnotationsForFile = useCallback(
(fileName: string): DiffLineAnnotation<string>[] => {
if (activeCommentBox && activeCommentBox.fileName === fileName) {
return [
{
side: activeCommentBox.side,
lineNumber: activeCommentBox.startLine,
metadata: "active-input",
},
];
}
return [];
},
[activeCommentBox],
);
const handleCancelComment = useCallback(() => {
setActiveCommentBox(null);
}, []);
const diffStatusQuery = useQuery(chatDiffStatus(chatId));
const diffContentsQuery = useQuery({
...chatDiffContents(chatId),
enabled: Boolean(diffStatusQuery.data?.url),
});
const parsedFiles = useMemo(() => {
const diff = diffContentsQuery.data?.diff;
if (!diff) {
return [];
}
try {
// The cacheKeyPrefix enables the worker pool's LRU cache
// so highlighted ASTs are reused across re-renders instead
// of being re-computed on every render cycle. We include
// dataUpdatedAt so that when the diff content changes
// (e.g. new commits pushed) the old cached highlight AST
// is not reused with mismatched line indices, which would
// cause DiffHunksRenderer.processDiffResult to throw.
const patches = parsePatchFiles(
diff,
`chat-${chatId}-${diffContentsQuery.dataUpdatedAt}`,
);
return patches.flatMap((p) => p.files);
} catch {
return [];
}
}, [diffContentsQuery.data?.diff, diffContentsQuery.dataUpdatedAt, chatId]);
const handleSubmitComment = useCallback(
(text: string) => {
if (!activeCommentBox) return;
const content = extractDiffContent(
parsedFiles,
activeCommentBox.fileName,
activeCommentBox.startLine,
activeCommentBox.endLine,
activeCommentBox.side,
);
// Single imperative call — chip inserted atomically
// in one Lexical update. No rAF hack needed.
chatInputRef?.current?.addFileReference({
fileName: activeCommentBox.fileName,
startLine: activeCommentBox.startLine,
endLine: activeCommentBox.endLine,
content,
});
if (text.trim()) {
chatInputRef?.current?.insertText(text);
}
setActiveCommentBox(null);
},
[activeCommentBox, chatInputRef, parsedFiles],
);
const renderAnnotation = useCallback(
(annotation: DiffLineAnnotation<string>) => {
if (annotation.metadata === "active-input") {
if (!activeCommentBox) return null;
return (
<InlinePromptInput
onSubmit={handleSubmitComment}
onCancel={handleCancelComment}
/>
);
}
return null;
},
[activeCommentBox, handleSubmitComment, handleCancelComment],
);
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);
}
}, []);
// Listen for chip clicks from the chat input to scroll to the
// corresponding comment annotation in the diff.
useEffect(() => {
const handler = (e: Event) => {
const { fileName } = (e as CustomEvent).detail ?? {};
if (typeof fileName !== "string") return;
const el = fileRefs.current.get(fileName);
if (el) {
el.scrollIntoView({ block: "start", behavior: "smooth" });
setActiveFile(fileName);
}
};
window.addEventListener("file-reference-click", handler);
return () => window.removeEventListener("file-reference-click", handler);
}, []);
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 bg-surface-secondary 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={getFileOptions(fileDiff.name)}
lineAnnotations={getAnnotationsForFile(fileDiff.name)}
renderAnnotation={renderAnnotation}
/>
</div>
))}
{/* Spacer so the last file can scroll fully to the top. */}
<div className="h-[calc(100vh-100px)]" />
</div>
</ScrollArea>
</div>
)}
</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"];
lineAnnotations?: DiffLineAnnotation<string>[];
renderAnnotation?: (annotation: DiffLineAnnotation<string>) => ReactNode;
}> = ({
fileDiff,
options,
lineAnnotations,
renderAnnotation: renderAnnotationProp,
}) => {
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}
lineAnnotations={lineAnnotations}
renderAnnotation={renderAnnotationProp}
/>
);
};
@@ -0,0 +1,367 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import type {
ChatDiffContents,
ChatDiffStatus,
WorkspaceAgentRepoChanges,
} from "api/typesGenerated";
import { expect, fn, spyOn, userEvent } from "storybook/test";
import { GitPanel } from "./GitPanel";
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const sampleDiff = `diff --git a/src/main.ts b/src/main.ts
index abc1234..def5678 100644
--- 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);
diff --git a/src/server.ts b/src/server.ts
index 1111111..2222222 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -10,3 +10,5 @@
app.listen(port, () => {
console.log("Listening on port " + port);
});
+
+ return app;
}
`;
const secondRepoDiff = `diff --git a/README.md b/README.md
index aaa1111..bbb2222 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
# My Project
+
+This project does things.
## Getting Started
-Follow the steps below.
+Follow the steps below to get started.
`;
const makeRepo = (
overrides: Partial<WorkspaceAgentRepoChanges> = {},
): WorkspaceAgentRepoChanges => ({
repo_root: "/home/coder/coder",
branch: "feat/add-logging",
remote_origin: "https://github.com/coder/coder.git",
unified_diff: sampleDiff,
...overrides,
});
const defaultDiffStatus: ChatDiffStatus = {
chat_id: "test-chat",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 0,
deletions: 0,
changed_files: 0,
};
const defaultDiffContents: ChatDiffContents = {
chat_id: "test-chat",
};
/** Reusable PR diff status with head/base branches. */
const makePrStatus = (
overrides: Partial<ChatDiffStatus> = {},
): ChatDiffStatus => ({
...defaultDiffStatus,
url: "https://github.com/coder/coder/pull/23020",
pull_request_title: "feat(agents): add MCP server configuration to agents",
pull_request_state: "open",
pull_request_draft: false,
base_branch: "main",
head_branch: "feat/add-mcp-config",
additions: 4037,
deletions: 7,
changed_files: 12,
...overrides,
});
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof GitPanel> = {
title: "pages/AgentsPage/GitPanel",
component: GitPanel,
args: {
onRefresh: fn(),
onCommit: fn(),
repositories: new Map(),
},
decorators: [
(Story) => (
<div style={{ height: 600, width: 480 }}>
<Story />
</div>
),
],
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue(defaultDiffStatus);
spyOn(API, "getChatDiffContents").mockResolvedValue(defaultDiffContents);
},
};
export default meta;
type Story = StoryObj<typeof GitPanel>;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/** PR is open with a title, head/base branches, and working changes. */
export const PullRequestAndWorkingChanges: Story = {
args: {
prTab: { prNumber: 23020, chatId: "test-chat" },
remoteDiffStats: makePrStatus(),
repositories: new Map([["/home/coder/coder", makeRepo()]]),
},
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue(makePrStatus());
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: sampleDiff,
});
},
};
/** Draft PR with head/base branches. */
export const DraftPullRequest: Story = {
args: {
prTab: { prNumber: 22950, chatId: "test-chat" },
remoteDiffStats: makePrStatus({
url: "https://github.com/coder/coder/pull/22950",
pull_request_title: "fix: resolve race condition in workspace builds",
pull_request_draft: true,
head_branch: "fix/race-condition",
additions: 142,
deletions: 38,
changed_files: 5,
}),
repositories: new Map([
["/home/coder/coder", makeRepo({ branch: "fix/race-condition" })],
]),
},
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue(
makePrStatus({
url: "https://github.com/coder/coder/pull/22950",
pull_request_title: "fix: resolve race condition in workspace builds",
pull_request_draft: true,
head_branch: "fix/race-condition",
additions: 142,
deletions: 38,
changed_files: 5,
}),
);
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: sampleDiff,
});
},
};
/** Merged PR. */
export const MergedPullRequest: Story = {
args: {
prTab: { prNumber: 23000, chatId: "test-chat" },
remoteDiffStats: makePrStatus({
url: "https://github.com/coder/coder/pull/23000",
pull_request_title: "chore: update dependencies to latest",
pull_request_state: "merged",
head_branch: "chore/update-deps",
additions: 89,
deletions: 45,
changed_files: 3,
}),
},
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue(
makePrStatus({
url: "https://github.com/coder/coder/pull/23000",
pull_request_title: "chore: update dependencies to latest",
pull_request_state: "merged",
head_branch: "chore/update-deps",
additions: 89,
deletions: 45,
changed_files: 3,
}),
);
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: sampleDiff,
});
},
};
/** Closed PR. */
export const ClosedPullRequest: Story = {
args: {
prTab: { prNumber: 22800, chatId: "test-chat" },
remoteDiffStats: makePrStatus({
url: "https://github.com/coder/coder/pull/22800",
pull_request_title: "feat: experimental websocket transport",
pull_request_state: "closed",
head_branch: "feat/websocket-transport",
additions: 200,
deletions: 10,
changed_files: 4,
}),
},
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue(
makePrStatus({
url: "https://github.com/coder/coder/pull/22800",
pull_request_title: "feat: experimental websocket transport",
pull_request_state: "closed",
head_branch: "feat/websocket-transport",
additions: 200,
deletions: 10,
changed_files: 4,
}),
);
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: sampleDiff,
});
},
};
/** Branch pushed but no PR opened yet. */
export const BranchOnly: Story = {
args: {
remoteDiffStats: {
...defaultDiffStatus,
additions: 42,
deletions: 7,
changed_files: 3,
},
repositories: new Map([["/home/coder/coder", makeRepo()]]),
},
};
/** Only local working changes, no remote/PR. */
export const WorkingChangesOnly: Story = {
args: {
repositories: new Map([["/home/coder/coder", makeRepo()]]),
},
};
/** Multiple repos with working changes. */
export const MultipleRepos: Story = {
args: {
prTab: { prNumber: 23020, chatId: "test-chat" },
remoteDiffStats: makePrStatus({
pull_request_title: "feat: multi-repo workspace support",
head_branch: "feat/multi-repo",
additions: 500,
deletions: 120,
changed_files: 8,
}),
repositories: new Map([
["/home/coder/coder", makeRepo()],
[
"/home/coder/other-project",
makeRepo({
repo_root: "/home/coder/other-project",
branch: "main",
remote_origin: "https://github.com/coder/other-project.git",
unified_diff: secondRepoDiff,
}),
],
]),
},
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue(
makePrStatus({
pull_request_title: "feat: multi-repo workspace support",
head_branch: "feat/multi-repo",
additions: 500,
deletions: 120,
changed_files: 8,
}),
);
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: sampleDiff,
});
},
};
/** No remote changes, no working changes — empty state. */
export const EmptyState: Story = {
args: {
prTab: { prNumber: 23020, chatId: "test-chat" },
},
};
/**
* PR diff with the inline comment input visible. The play function
* waits for the diff to render, then clicks a line number gutter
* to trigger the annotation input.
*/
export const InlineCommentInput: Story = {
args: {
prTab: { prNumber: 23020, chatId: "test-chat" },
remoteDiffStats: makePrStatus(),
},
decorators: [
(Story) => (
<div style={{ height: 700, width: 600 }}>
<Story />
</div>
),
],
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue(makePrStatus());
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: sampleDiff,
});
},
play: async ({ canvasElement }) => {
// Wait for the diff to load and render inside Shadow DOM.
// The line numbers live inside @pierre/diffs FileDiff web
// components, so we need to wait a bit for them to mount.
await new Promise((resolve) => setTimeout(resolve, 2000));
// Find a line number element inside a Shadow DOM diff viewer.
// The diff renders in shadow roots, so we look for the
// host elements and query inside their shadow DOMs.
const diffHosts = canvasElement.querySelectorAll("[data-diffs]");
for (const host of diffHosts) {
const shadow = host.shadowRoot;
if (!shadow) continue;
// Look for a line number cell — they have data-line-number.
const lineNumber = shadow.querySelector(
"[data-line-number]",
) as HTMLElement | null;
if (lineNumber) {
await userEvent.click(lineNumber);
break;
}
}
// Verify the inline prompt appeared.
const textarea = canvasElement.querySelector("textarea");
if (textarea) {
expect(textarea).toBeInTheDocument();
}
},
};
+237 -178
View File
@@ -1,11 +1,19 @@
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import type {
ChatDiffStatus,
WorkspaceAgentRepoChanges,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import {
CheckIcon,
CircleDotIcon,
ColumnsIcon,
FileDiffIcon,
GitBranchIcon,
GitCompareArrowsIcon,
GitMergeIcon,
GitPullRequestClosedIcon,
GitPullRequestDraftIcon,
GitPullRequestIcon,
RefreshCwIcon,
RowsIcon,
} from "lucide-react";
@@ -22,10 +30,10 @@ import { cn } from "utils/cn";
import type { ChatMessageInputRef } from "./AgentChatInput";
import { DiffStatBadge } from "./DiffStats";
import { type DiffStyle, loadDiffStyle, saveDiffStyle } from "./DiffViewer";
import { FilesChangedPanel } from "./FilesChangedPanel";
import { RepoChangesPanel } from "./RepoChangesPanel";
import { LocalDiffPanel } from "./LocalDiffPanel";
import { RemoteDiffPanel } from "./RemoteDiffPanel";
type GitView = "remote" | "local";
type GitView = { type: "remote" } | { type: "local"; repoRoot: string };
interface DiffStats {
additions: number;
@@ -46,11 +54,9 @@ interface GitPanelProps {
onCommit: (repoRoot: string) => void;
/** Whether the panel is in expanded/fullscreen mode. */
isExpanded?: boolean;
/** Diff stats for the remote/branch view. */
remoteDiffStats?: DiffStats;
/** Diff stats for the local/working tree view. */
localDiffStats?: DiffStats;
/** Ref to the chat input, forwarded to FilesChangedPanel. */
/** Diff status for the remote/branch view (includes PR metadata). */
remoteDiffStats?: ChatDiffStatus;
/** Ref to the chat input, forwarded to RemoteDiffPanel. */
chatInputRef?: RefObject<ChatMessageInputRef | null>;
}
@@ -66,24 +72,70 @@ export const GitPanel: FC<GitPanelProps> = ({
onCommit,
isExpanded,
remoteDiffStats,
localDiffStats,
chatInputRef,
}) => {
const hasRemoteStats =
!!remoteDiffStats &&
(remoteDiffStats.additions > 0 || remoteDiffStats.deletions > 0);
const hasLocalStats =
!!localDiffStats &&
(localDiffStats.additions > 0 || localDiffStats.deletions > 0);
// Default to "local" when there are only local changes and no
// remote stats, so the user sees content immediately.
const [view, setView] = useState<GitView>(
!hasRemoteStats && hasLocalStats ? "local" : "remote",
const showRemoteTab = !!prTab || hasRemoteStats;
const prTitle = remoteDiffStats?.pull_request_title;
const prState = remoteDiffStats?.pull_request_state;
const prDraft = remoteDiffStats?.pull_request_draft;
// Compute per-repo diff stats from unified diffs.
const repoStats = useMemo(() => {
const stats = new Map<string, DiffStats>();
for (const [root, repo] of repositories.entries()) {
if (!repo.unified_diff) continue;
let additions = 0;
let deletions = 0;
for (const line of repo.unified_diff.split("\n")) {
if (line.startsWith("+") && !line.startsWith("+++")) {
additions++;
} else if (line.startsWith("-") && !line.startsWith("---")) {
deletions++;
}
}
if (additions > 0 || deletions > 0) {
stats.set(root, { additions, deletions });
}
}
return stats;
}, [repositories]);
const localRepos = useMemo(
() => Array.from(repoStats.keys()).sort((a, b) => a.localeCompare(b)),
[repoStats],
);
// Diff style is managed here for the local view only.
// FilesChangedPanel manages its own diff style internally.
// Default to the first local repo when there are only local
// changes and no remote stats.
const [view, setView] = useState<GitView>(() => {
if (!showRemoteTab && localRepos.length > 0) {
return { type: "local", repoRoot: localRepos[0] };
}
return { type: "remote" };
});
// If the active tab gets hidden, switch to the first available.
useEffect(() => {
if (view.type === "remote" && !showRemoteTab) {
if (localRepos.length > 0) {
setView({ type: "local", repoRoot: localRepos[0] });
}
} else if (view.type === "local") {
if (!repoStats.has(view.repoRoot)) {
if (showRemoteTab) {
setView({ type: "remote" });
} else if (localRepos.length > 0) {
setView({ type: "local", repoRoot: localRepos[0] });
}
}
}
}, [view, showRemoteTab, localRepos, repoStats]);
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
const handleDiffStyleChange = useCallback((style: DiffStyle) => {
@@ -91,70 +143,90 @@ export const GitPanel: FC<GitPanelProps> = ({
setDiffStyle(style);
}, []);
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]);
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1.5">
{/* Remote / Local segmented control */}
<div className="flex h-6 items-stretch overflow-hidden rounded-md text-xs">
<button
type="button"
onClick={() => setView("remote")}
className={cn(
"flex cursor-pointer items-center gap-3 border-none font-medium transition-colors outline-none focus-visible:outline-none",
view === "remote"
? "bg-surface-quaternary/25 text-content-primary"
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
hasRemoteStats ? "pl-3 pr-0" : "px-3",
)}
>
Remote
{hasRemoteStats && (
<span
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3">
{/* Tabs — scrollable when they overflow */}
<ScrollArea
className="min-w-0 flex-1"
orientation="horizontal"
scrollBarClassName="h-1.5"
>
<div className="flex items-center gap-0.5 py-1.5 text-xs">
{showRemoteTab && (
<Button
variant="outline"
size="lg"
onClick={() => setView({ type: "remote" })}
className={cn(
"flex -my-px items-center self-stretch transition-opacity",
view !== "remote" && "opacity-50",
"shrink-0 h-6 min-w-0 gap-1.5 px-2 py-0 bg-surface-primary",
view.type === "remote" &&
"bg-surface-quaternary/25 text-content-primary hover:bg-surface-quaternary/50",
)}
>
<DiffStatBadge
additions={remoteDiffStats.additions}
deletions={remoteDiffStats.deletions}
/>
</span>
)}
</button>
<button
type="button"
onClick={() => setView("local")}
className={cn(
"flex cursor-pointer items-center gap-3 border-0 border-l border-solid border-border-default font-medium transition-colors outline-none focus-visible:outline-none",
view === "local"
? "bg-surface-quaternary/25 text-content-primary"
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
hasLocalStats ? "pl-3 pr-0" : "px-3",
)}
>
Local
{hasLocalStats && (
<span
className={cn(
"flex -my-px items-center self-stretch transition-opacity",
view !== "local" && "opacity-50",
{prTab ? (
<>
<PrStateIcon
state={prState}
draft={prDraft}
className="!size-4 shrink-0"
/>
<span className="truncate">
{prTitle || `PR #${prTab.prNumber}`}
</span>
</>
) : (
<>
<GitBranchIcon className="!size-3.5 shrink-0" />
<span className="truncate">Branch</span>
</>
)}
>
<DiffStatBadge
additions={localDiffStats.additions}
deletions={localDiffStats.deletions}
/>
</span>
</Button>
)}
</button>
</div>
<div className="flex-1" />
{/* Split / Unified toggle — only shown for local view since
FilesChangedPanel has its own toggle built in. */}
{view === "local" && (
<div className="flex h-6 items-stretch overflow-hidden rounded-md border border-solid border-border-default text-xs">
{localRepos.map((repoRoot) => {
const isActive =
view.type === "local" && view.repoRoot === repoRoot;
return (
<Button
key={repoRoot}
variant="outline"
size="lg"
onClick={() => setView({ type: "local", repoRoot })}
className={cn(
"shrink-0 h-6 min-w-0 gap-1.5 px-2 py-0 bg-surface-primary",
isActive &&
"bg-surface-quaternary/25 text-content-primary hover:bg-surface-quaternary/50",
)}
>
<CircleDotIcon
className={cn(
"!size-3.5 shrink-0",
isActive ? "text-amber-400" : "text-amber-400/60",
)}
/>
<span className="truncate">
Working{" "}
<span className="opacity-50">{repoTabLabel(repoRoot)}</span>
</span>
</Button>
);
})}
</div>
</ScrollArea>
{/* Controls */}
<div className="flex shrink-0 items-center gap-1 py-1.5">
<div className="flex h-6 items-stretch overflow-hidden rounded-md border border-solid border-border-default">
<button
type="button"
onClick={() => handleDiffStyleChange("unified")}
@@ -182,21 +254,38 @@ export const GitPanel: FC<GitPanelProps> = ({
<ColumnsIcon className="size-3.5" />
</button>
</div>
)}
<Button
variant="subtle"
size="icon"
onClick={handleRefresh}
aria-label="Refresh"
className="h-6 w-6 text-content-secondary hover:text-content-primary"
>
<RefreshCwIcon
className={cn(
"size-3.5",
spinning && "motion-safe:animate-spin-once",
)}
/>
</Button>
</div>
</div>
{/* Content */}
<div className="min-h-0 flex-1">
{view === "remote" ? (
{view.type === "remote" ? (
<RemoteContent
prTab={prTab}
isExpanded={isExpanded}
chatInputRef={chatInputRef}
diffStyle={diffStyle}
/>
) : (
<LocalContent
repositories={repositories}
onRefresh={onRefresh}
<LocalRepoContent
repoRoot={view.repoRoot}
repo={repositories.get(view.repoRoot)}
diffStats={
repoStats.get(view.repoRoot) ?? { additions: 0, deletions: 0 }
}
onCommit={onCommit}
isExpanded={isExpanded}
diffStyle={diffStyle}
@@ -215,7 +304,8 @@ const RemoteContent: FC<{
prTab?: { prNumber: number; chatId: string };
isExpanded?: boolean;
chatInputRef?: RefObject<ChatMessageInputRef | null>;
}> = ({ prTab, isExpanded, chatInputRef }) => {
diffStyle: DiffStyle;
}> = ({ prTab, isExpanded, chatInputRef, diffStyle }) => {
if (!prTab) {
return (
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
@@ -233,75 +323,44 @@ const RemoteContent: FC<{
}
return (
<FilesChangedPanel
<RemoteDiffPanel
chatId={prTab.chatId}
isExpanded={isExpanded}
chatInputRef={chatInputRef}
diffStyle={diffStyle}
/>
);
};
// ---------------------------------------------------------------
// Local view (working tree changes)
// Local view (single repo)
// ---------------------------------------------------------------
const LocalContent: FC<{
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
onRefresh: () => void;
const LocalRepoContent: FC<{
repoRoot: string;
repo: WorkspaceAgentRepoChanges | undefined;
diffStats: DiffStats;
onCommit: (repoRoot: string) => void;
isExpanded?: boolean;
diffStyle: DiffStyle;
}> = ({ repositories, onRefresh, onCommit, isExpanded, diffStyle }) => {
const repoEntries = useMemo(
() =>
Array.from(repositories.entries()).sort(([a], [b]) => a.localeCompare(b)),
[repositories],
);
if (repoEntries.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
<div className="mb-4 flex size-10 items-center justify-center rounded-lg border border-solid border-border-default bg-surface-secondary">
<FileDiffIcon className="size-5 text-content-secondary" />
</div>
<p className="text-sm font-medium text-content-primary">
No uncommitted changes
</p>
<p className="mt-1 max-w-52 text-xs text-content-secondary">
Local file modifications will appear here as you edit.
</p>
</div>
);
}> = ({ repoRoot, repo, diffStats, onCommit, isExpanded, diffStyle }) => {
if (!repo) {
return null;
}
return (
<div className="flex h-full flex-col">
{repoEntries.map(([repoRoot, repo], index) => {
const showSeparator = index > 0;
return (
<section
key={repoRoot}
className={cn(
"flex min-h-0 flex-1 flex-col",
showSeparator &&
"border-0 border-t border-solid border-border-default",
)}
>
<RepoHeader
repoRoot={repoRoot}
repo={repo}
onRefresh={onRefresh}
onCommit={() => onCommit(repoRoot)}
/>
<RepoChangesPanel
repo={repo}
isExpanded={isExpanded}
diffStyle={diffStyle}
/>
</section>
);
})}
<RepoHeader
repoRoot={repoRoot}
repo={repo}
diffStats={diffStats}
onCommit={() => onCommit(repoRoot)}
/>
<LocalDiffPanel
repo={repo}
isExpanded={isExpanded}
diffStyle={diffStyle}
/>
</div>
);
};
@@ -313,60 +372,60 @@ const LocalContent: FC<{
const RepoHeader: FC<{
repoRoot: string;
repo: WorkspaceAgentRepoChanges;
onRefresh: () => void;
diffStats: DiffStats;
onCommit: () => void;
}> = ({ repoRoot, repo, onRefresh, onCommit }) => {
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]);
}> = ({ repoRoot, repo, diffStats, onCommit }) => {
return (
<div className="flex shrink-0 items-center gap-2 bg-surface-secondary px-3 py-2">
{/* Repo identity */}
<div className="flex min-w-0 flex-1 items-center gap-2">
<GitBranchIcon className="size-3.5 shrink-0 text-content-secondary" />
<span className="truncate text-sm font-medium text-content-primary">
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-[13px] text-content-secondary">
<GitBranchIcon className="size-3.5 shrink-0" />
<span className="truncate">
{repo.branch?.trim() || repoTabLabel(repoRoot)}
</span>
{repo.branch?.trim() && (
<span className="truncate text-xs text-content-secondary">
{repoTabLabel(repoRoot)}
</span>
)}
<span className="truncate opacity-50">{repoRoot}</span>
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<Button
size="sm"
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<DiffStatBadge
additions={diffStats.additions}
deletions={diffStats.deletions}
/>
<button
type="button"
onClick={onCommit}
disabled={!repo.unified_diff}
className="h-7 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"
className="inline-flex cursor-pointer items-center gap-1 rounded-sm border border-solid border-border-default bg-transparent px-2 text-[13px] font-medium leading-5 text-content-secondary no-underline transition-colors hover:bg-surface-secondary hover:text-content-primary disabled:pointer-events-none disabled:opacity-50"
>
<CheckIcon className="size-3" />
Commit
</Button>
<Button
variant="subtle"
size="icon"
onClick={handleRefresh}
aria-label="Refresh"
className="h-7 w-7 text-content-secondary hover:text-content-primary"
>
<RefreshCwIcon
className={cn(
"size-3.5",
spinning && "motion-safe:animate-spin-once",
)}
/>
</Button>
</button>
</div>
</div>
);
};
// ---------------------------------------------------------------
// PR state icon (compact, for the tab bar)
// ---------------------------------------------------------------
const PrStateIcon: FC<{
state?: string;
draft?: boolean;
className?: string;
}> = ({ state, draft, className }) => {
if (state === "merged") {
return <GitMergeIcon className={cn("text-purple-400", className)} />;
}
if (state === "closed") {
return (
<GitPullRequestClosedIcon className={cn("text-red-400", className)} />
);
}
if (draft) {
return (
<GitPullRequestDraftIcon
className={cn("text-content-secondary", className)}
/>
);
}
return <GitPullRequestIcon className={cn("text-green-400", className)} />;
};
@@ -3,13 +3,13 @@ import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import { type FC, useMemo } from "react";
import { type DiffStyle, DiffViewer } from "./DiffViewer";
interface RepoChangesPanelProps {
interface LocalDiffPanelProps {
repo: WorkspaceAgentRepoChanges;
isExpanded?: boolean;
diffStyle: DiffStyle;
}
export const RepoChangesPanel: FC<RepoChangesPanelProps> = ({
export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
repo,
isExpanded,
diffStyle,
@@ -0,0 +1,500 @@
import type { DiffLineAnnotation, FileDiffMetadata } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import { chatDiffContents, chatDiffStatus } from "api/queries/chats";
import { Button } from "components/Button/Button";
import {
ArrowLeftIcon,
CornerDownLeftIcon,
ExternalLinkIcon,
GitBranchIcon,
GitMergeIcon,
GitPullRequestClosedIcon,
GitPullRequestDraftIcon,
GitPullRequestIcon,
} from "lucide-react";
import {
type FC,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useQuery } from "react-query";
import { cn } from "utils/cn";
import type { ChatMessageInputRef } from "./AgentChatInput";
import { DiffStatBadge } from "./DiffStats";
import type { DiffStyle } from "./DiffViewer";
import { DiffViewer } from "./DiffViewer";
// -------------------------------------------------------------------
// Diff content extraction
// -------------------------------------------------------------------
/**
* Walk the parsed hunks for a file and collect code lines that fall
* within `startLine..endLine` on the given side. For "additions"
* lines are matched against addition line numbers (using
* `hunk.additionStart`); for "deletions" against deletion line
* numbers (using `hunk.deletionStart`). Context lines that fall
* in range are included as well.
*/
function extractDiffContent(
parsedFiles: readonly FileDiffMetadata[],
fileName: string,
startLine: number,
endLine: number,
side: "additions" | "deletions",
): string {
const file = parsedFiles.find((f) => f.name === fileName);
if (!file) return "";
const lines = side === "additions" ? file.additionLines : file.deletionLines;
const collected: string[] = [];
for (const hunk of file.hunks) {
let addLine = hunk.additionStart;
let delLine = hunk.deletionStart;
for (const block of hunk.hunkContent) {
if (block.type === "context") {
for (let i = 0; i < block.lines; i++) {
const ln = side === "additions" ? addLine : delLine;
if (ln >= startLine && ln <= endLine) {
const idx =
side === "additions"
? block.additionLineIndex + i
: block.deletionLineIndex + i;
if (lines[idx] != null) collected.push(lines[idx]);
}
addLine++;
delLine++;
}
} else {
// ChangeContent block.
if (side === "deletions") {
for (let i = 0; i < block.deletions; i++) {
if (delLine >= startLine && delLine <= endLine) {
const line = lines[block.deletionLineIndex + i];
if (line != null) collected.push(line);
}
delLine++;
}
// Addition lines in a change block still advance
// the addition counter.
addLine += block.additions;
} else {
// side === "additions"
// Deletion lines in a change block still advance
// the deletion counter.
delLine += block.deletions;
for (let i = 0; i < block.additions; i++) {
if (addLine >= startLine && addLine <= endLine) {
const line = lines[block.additionLineIndex + i];
if (line != null) collected.push(line);
}
addLine++;
}
}
}
}
}
return collected.join("\n");
}
/**
* Parses a GitHub PR URL into its components.
* Returns null if parsing fails.
*/
// -------------------------------------------------------------------
// PR state badge
// -------------------------------------------------------------------
const PullRequestStateBadge: FC<{
state?: string;
draft?: boolean;
}> = ({ state, draft }) => {
let Icon = GitPullRequestIcon;
let label = "Open";
let colorClasses = "border-border-default bg-green-500/10 text-green-400";
if (state === "merged") {
Icon = GitMergeIcon;
label = "Merged";
colorClasses = "border-border-default bg-purple-500/10 text-purple-400";
} else if (state === "closed") {
Icon = GitPullRequestClosedIcon;
label = "Closed";
colorClasses = "border-border-default bg-red-500/10 text-red-400";
} else if (draft) {
Icon = GitPullRequestDraftIcon;
label = "Draft";
colorClasses =
"border-border-default bg-surface-secondary text-content-secondary";
}
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-sm border border-solid px-2 text-[13px] font-medium leading-5",
colorClasses,
)}
>
<Icon className="size-3" />
{label}
</span>
);
};
function parsePullRequestUrl(url: string): {
owner: string;
repo: string;
number: string;
} | null {
try {
const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
if (match) {
return { owner: match[1], repo: match[2], number: match[3] };
}
} catch {
// Fall through.
}
return null;
}
// -------------------------------------------------------------------
// Inline prompt input
// -------------------------------------------------------------------
/**
* Inline input rendered as a diff annotation under the selected
* line(s). Supports multiline via Shift+Enter. Enter submits,
* Escape dismisses.
*/
const InlinePromptInput: FC<{
onSubmit: (text: string) => void;
onCancel: () => void;
}> = ({ onSubmit, onCancel }) => {
const [text, setText] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Focus the textarea on mount. We use a ref callback via rAF
// rather than autoFocus because the component renders inside
// Shadow DOM where autoFocus is unreliable.
useEffect(() => {
requestAnimationFrame(() => {
textareaRef.current?.focus();
});
}, []);
return (
<div className="px-2 py-1.5">
<div className="rounded-lg border border-border-default bg-surface-secondary p-1 shadow-sm has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-content-link/40">
<textarea
ref={textareaRef}
className="w-full resize-none border-none bg-transparent px-2.5 py-1.5 font-sans text-[13px] leading-5 text-content-primary placeholder:text-content-secondary outline-none ring-0 focus:outline-none focus:ring-0"
placeholder="Add a comment to include with this reference..."
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (text.trim()) {
onSubmit(text.trim());
} else {
onCancel();
}
}
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
/>
<div className="flex items-center justify-end px-1.5 pb-1">
<Button
size="sm"
variant="subtle"
className="h-6 gap-1.5 px-2 text-xs text-content-secondary hover:text-content-primary"
disabled={!text.trim()}
onMouseDown={(e: React.MouseEvent) => {
// Prevent blur from firing before click.
e.preventDefault();
}}
onClick={() => {
if (text.trim()) {
onSubmit(text.trim());
}
}}
>
<CornerDownLeftIcon className="size-3" />
Add to chat
</Button>
</div>
</div>
</div>
);
};
// -------------------------------------------------------------------
// Main component
// -------------------------------------------------------------------
interface RemoteDiffPanelProps {
chatId: string;
isExpanded?: boolean;
chatInputRef?: RefObject<ChatMessageInputRef | null>;
diffStyle: DiffStyle;
}
export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
chatId,
isExpanded,
chatInputRef,
diffStyle,
}) => {
// ---------------------------------------------------------------
// Comment / annotation state
// ---------------------------------------------------------------
const [activeCommentBox, setActiveCommentBox] = useState<{
fileName: string;
startLine: number;
endLine: number;
side: "additions" | "deletions";
} | null>(null);
// ---------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------
const diffStatusQuery = useQuery(chatDiffStatus(chatId));
const diffContentsQuery = useQuery({
...chatDiffContents(chatId),
enabled: Boolean(diffStatusQuery.data?.url),
});
const parsedFiles = useMemo(() => {
const diff = diffContentsQuery.data?.diff;
if (!diff) {
return [];
}
try {
// The cacheKeyPrefix enables the worker pool's LRU cache
// so highlighted ASTs are reused across re-renders instead
// of being re-computed on every render cycle. We include
// dataUpdatedAt so that when the diff content changes
// (e.g. new commits pushed) the old cached highlight AST
// is not reused with mismatched line indices, which would
// cause DiffHunksRenderer.processDiffResult to throw.
const patches = parsePatchFiles(
diff,
`chat-${chatId}-${diffContentsQuery.dataUpdatedAt}`,
);
return patches.flatMap((p) => p.files);
} catch {
return [];
}
}, [diffContentsQuery.data?.diff, diffContentsQuery.dataUpdatedAt, chatId]);
// ---------------------------------------------------------------
// Line interaction callbacks
// ---------------------------------------------------------------
const handleLineNumberClick = useCallback(
(
fileName: string,
props: {
lineNumber: number;
annotationSide: "additions" | "deletions";
},
) => {
setActiveCommentBox({
fileName,
startLine: props.lineNumber,
endLine: props.lineNumber,
side: props.annotationSide,
});
},
[],
);
const handleLineSelected = useCallback(
(
fileName: string,
range: {
start: number;
end: number;
side?: "additions" | "deletions";
} | null,
) => {
if (!range || range.start === range.end) return;
const side = range.side ?? "additions";
setActiveCommentBox({
fileName,
startLine: Math.min(range.start, range.end),
endLine: Math.max(range.start, range.end),
side,
});
},
[],
);
// ---------------------------------------------------------------
// Annotation helpers
// ---------------------------------------------------------------
const getLineAnnotations = useCallback(
(fileName: string): DiffLineAnnotation<string>[] => {
if (activeCommentBox && activeCommentBox.fileName === fileName) {
return [
{
side: activeCommentBox.side,
lineNumber: activeCommentBox.startLine,
metadata: "active-input",
},
];
}
return [];
},
[activeCommentBox],
);
const handleCancelComment = useCallback(() => {
setActiveCommentBox(null);
}, []);
const handleSubmitComment = useCallback(
(text: string) => {
if (!activeCommentBox) return;
const content = extractDiffContent(
parsedFiles,
activeCommentBox.fileName,
activeCommentBox.startLine,
activeCommentBox.endLine,
activeCommentBox.side,
);
// Single imperative call — chip inserted atomically
// in one Lexical update. No rAF hack needed.
chatInputRef?.current?.addFileReference({
fileName: activeCommentBox.fileName,
startLine: activeCommentBox.startLine,
endLine: activeCommentBox.endLine,
content,
});
if (text.trim()) {
chatInputRef?.current?.insertText(text);
}
setActiveCommentBox(null);
},
[activeCommentBox, chatInputRef, parsedFiles],
);
const renderAnnotation = useCallback(
(annotation: DiffLineAnnotation<string>) => {
if (annotation.metadata === "active-input") {
if (!activeCommentBox) return null;
return (
<InlinePromptInput
onSubmit={handleSubmitComment}
onCancel={handleCancelComment}
/>
);
}
return null;
},
[activeCommentBox, handleSubmitComment, handleCancelComment],
);
// ---------------------------------------------------------------
// Scroll-to-file from chat input chip clicks
// ---------------------------------------------------------------
const [scrollTarget, setScrollTarget] = useState<string | null>(null);
useEffect(() => {
const handler = (e: Event) => {
const { fileName } = (e as CustomEvent).detail ?? {};
if (typeof fileName !== "string") return;
setScrollTarget(fileName);
};
window.addEventListener("file-reference-click", handler);
return () => window.removeEventListener("file-reference-click", handler);
}, []);
const handleScrollComplete = useCallback(() => {
setScrollTarget(null);
}, []);
// ---------------------------------------------------------------
// Header content
// ---------------------------------------------------------------
const pullRequestUrl = diffStatusQuery.data?.url;
const parsedPr = pullRequestUrl ? parsePullRequestUrl(pullRequestUrl) : null;
const prState = diffStatusQuery.data?.pull_request_state;
const prDraft = diffStatusQuery.data?.pull_request_draft;
const baseBranch = diffStatusQuery.data?.base_branch;
const headBranch = diffStatusQuery.data?.head_branch;
// ---------------------------------------------------------------
// Render
// ---------------------------------------------------------------
return (
<div className="flex h-full flex-col">
{/* Compact PR sub-header */}
{pullRequestUrl && (
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-[13px] text-content-secondary">
{baseBranch || headBranch ? (
<>
<GitBranchIcon className="size-3.5 shrink-0" />
{baseBranch && <span className="truncate">{baseBranch}</span>}
{headBranch && baseBranch && (
<ArrowLeftIcon className="size-3 shrink-0 opacity-50" />
)}
{headBranch && (
<span className="truncate"> {headBranch}</span>
)}{" "}
</>
) : parsedPr ? (
<span className="truncate">
{parsedPr.owner}/{parsedPr.repo}#{parsedPr.number}
</span>
) : (
<span className="truncate">{pullRequestUrl}</span>
)}
</div>{" "}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<PullRequestStateBadge state={prState} draft={prDraft} />
{diffStatusQuery.data?.additions ||
diffStatusQuery.data?.deletions ? (
<DiffStatBadge
additions={diffStatusQuery.data.additions}
deletions={diffStatusQuery.data.deletions}
/>
) : null}
<a
href={pullRequestUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-sm border border-solid border-border-default px-2 text-[13px] font-medium leading-5 text-content-secondary no-underline transition-colors hover:bg-surface-secondary hover:text-content-primary"
>
View PR
<ExternalLinkIcon className="size-3" />
</a>
</div>
</div>
)}{" "}
<DiffViewer
parsedFiles={parsedFiles}
isExpanded={isExpanded}
diffStyle={diffStyle}
isLoading={diffContentsQuery.isLoading || diffStatusQuery.isLoading}
error={diffContentsQuery.isError ? diffContentsQuery.error : undefined}
onLineNumberClick={handleLineNumberClick}
onLineSelected={handleLineSelected}
getLineAnnotations={getLineAnnotations}
renderAnnotation={renderAnnotation}
scrollToFile={scrollTarget}
onScrollToFileComplete={handleScrollComplete}
/>
</div>
);
};
@@ -1,59 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
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,
diffStyle: "unified",
},
};
export default meta;
type Story = StoryObj<typeof RepoChangesPanel>;
export const WithChanges: Story = {};
export const NoChanges: Story = {
args: {
repo: {
...baseRepo,
unified_diff: undefined,
},
},
};
export const SplitDiffStyle: Story = {
args: {
repo: baseRepo,
diffStyle: "split",
},
};