refactor: unify agent sidebar into generic tabbed panel with Git sub-views (#22837)

## Summary

Refactors the right-side panel in the Agents page into a generic tabbed
container with a unified Git panel.

### Changes

**Architecture**
- `SidebarTabView` is now a generic tabbed container with no
git-specific logic, ready for additional tabs
- All Git content lives in a new `GitPanel` component with an internal
Remote/Local segmented control

**Git Panel**
- Remote view: branch/PR diff via `FilesChangedPanel`
- Local view: working tree changes with per-repo headers, commit &
refresh actions
- Split/unified diff toggle restored in the toolbar
- `DiffStatBadge` rendered inside the Remote/Local segmented buttons
(full-height, no rounding, inactive opacity 50%)

**Visual polish**
- Active/inactive/hover states match the sidebar agent selection styles
(`bg-surface-quaternary/25`, `hover:bg-surface-tertiary/50`)
- Inactive tab text uses `text-content-secondary` (not primary)
- Tab button sizing fixed: `min-w-0` + `px-2` to prevent inflated width
- Chat title centered via absolute positioning when panel is fullscreen
- Polished empty states with boxed icons (`GitCompareArrowsIcon` for
Remote, `FileDiffIcon` for Local)
- Unified header styles between Remote and Local sections (both use
`bg-surface-secondary` with consistent icon/text sizing)
- Panel toggle always visible in top bar (not gated on having diff data)

**Cleanup**
- Removed dead code: `DiffStatsInline`, `computeDiffStats` export,
`workingDiffStats` memo, `ChatDiffStatusResponse` import
- Simplified `RepoChangesPanel` to a pure `DiffViewer` wrapper
- Simplified `TopBar` to use a generic `panel` prop instead of
diff-specific props
This commit is contained in:
Kyle Carberry
2026-03-09 10:44:56 -04:00
committed by GitHub
parent a072d542a5
commit 3f939375fa
11 changed files with 536 additions and 573 deletions
+31 -38
View File
@@ -70,6 +70,7 @@ import { AgentDetailTopBar } from "./AgentDetail/TopBar";
import { useMessageWindow } from "./AgentDetail/useMessageWindow";
import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher";
import type { AgentsOutletContext } from "./AgentsPage";
import { GitPanel } from "./GitPanel";
import {
getModelCatalogStatusMessage,
getModelOptionsFromCatalog,
@@ -77,7 +78,7 @@ import {
hasConfiguredModelsInCatalog,
} from "./modelOptions";
import { RightPanel } from "./RightPanel";
import { SidebarTabView } from "./SidebarTabView";
import { type SidebarTab, SidebarTabView } from "./SidebarTabView";
import { useFileAttachments } from "./useFileAttachments";
import { useGitWatcher } from "./useGitWatcher";
@@ -989,7 +990,7 @@ const AgentDetail: FC = () => {
workspace && workspaceAgent && sshConfigQuery.data?.hostname_suffix
? `ssh ${workspaceAgent.name}.${workspace.name}.${workspace.owner_name}.${sshConfigQuery.data.hostname_suffix}`
: undefined;
const shouldShowSidebar = (hasDiffStatus || hasGitRepos) && showSidebarPanel;
const shouldShowSidebar = showSidebarPanel;
const generateKeyMutation = useMutation({
mutationFn: () => API.getApiKey(),
@@ -1060,12 +1061,7 @@ const AgentDetail: FC = () => {
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
diff={{
hasDiffStatus: false,
diffStatus: undefined,
hasGitRepos: false,
gitRepoCount: 0,
gitRepositories: new Map(),
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
@@ -1110,7 +1106,7 @@ const AgentDetail: FC = () => {
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/5" />
</div>
</div>{" "}
</div>
</div>
</div>
@@ -1139,12 +1135,7 @@ const AgentDetail: FC = () => {
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
diff={{
hasDiffStatus: false,
diffStatus: undefined,
hasGitRepos: false,
gitRepoCount: 0,
gitRepositories: new Map(),
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
@@ -1166,7 +1157,7 @@ const AgentDetail: FC = () => {
/>
<div className="flex flex-1 items-center justify-center text-content-secondary">
Chat not found
</div>
</div>{" "}
</div>
);
}
@@ -1190,12 +1181,7 @@ const AgentDetail: FC = () => {
chatTitle={chatTitle}
parentChat={parentChat}
onOpenParentChat={(chatId) => navigate(`/agents/${chatId}`)}
diff={{
hasDiffStatus,
diffStatus: diffStatusQuery.data,
hasGitRepos,
gitRepoCount: gitWatcher.repositories.size,
gitRepositories: gitWatcher.repositories,
panel={{
showSidebarPanel,
onToggleSidebar: () => setShowSidebarPanel((prev) => !prev),
}}
@@ -1289,30 +1275,37 @@ const AgentDetail: FC = () => {
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
>
<SidebarTabView
prTab={
prNumber && agentId ? { prNumber, chatId: agentId } : undefined
tabs={
[
(hasDiffStatus || hasGitRepos) && {
id: "git",
label: "Git",
content: (
<GitPanel
prTab={
prNumber && agentId
? { prNumber, chatId: agentId }
: undefined
}
repositories={gitWatcher.repositories}
onRefresh={gitWatcher.refresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusQuery.data}
chatInputRef={editing.chatInputRef}
/>
),
},
].filter(Boolean) as SidebarTab[]
}
repositories={gitWatcher.repositories}
workspace={
workspace
? {
name: workspace.name,
ownerName: workspace.owner_name,
}
: undefined
}
onRefresh={gitWatcher.refresh}
onCommit={handleCommit}
onClose={() => setShowSidebarPanel(false)}
isExpanded={visualExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
diffStatus={diffStatusQuery.data}
chatInputRef={editing.chatInputRef}
/>
</RightPanel>
</RightPanel>{" "}
</div>
);
};
@@ -1,25 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ChatDiffStatusResponse } from "api/api";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { AgentDetailTopBar } from "./TopBar";
const mockDiffStatus: ChatDiffStatusResponse = {
chat_id: "chat-1",
changes_requested: false,
additions: 42,
deletions: 7,
changed_files: 5,
};
const defaultProps = {
chatTitle: "Build authentication feature",
onOpenParentChat: () => {},
diff: {
hasDiffStatus: false,
diffStatus: undefined,
hasGitRepos: false,
gitRepoCount: 0,
gitRepositories: new Map(),
panel: {
showSidebarPanel: false,
onToggleSidebar: () => {},
},
@@ -51,28 +37,9 @@ type Story = StoryObj<typeof AgentDetailTopBar>;
export const Default: Story = {};
export const WithDiffStats: Story = {
export const WithPanelOpen: Story = {
args: {
diff: {
hasDiffStatus: true,
diffStatus: mockDiffStatus,
hasGitRepos: false,
gitRepoCount: 0,
gitRepositories: new Map(),
showSidebarPanel: false,
onToggleSidebar: () => {},
},
},
};
export const WithDiffPanelOpen: Story = {
args: {
diff: {
hasDiffStatus: true,
diffStatus: mockDiffStatus,
hasGitRepos: false,
gitRepoCount: 0,
gitRepositories: new Map(),
panel: {
showSidebarPanel: true,
onToggleSidebar: () => {},
},
@@ -1,4 +1,3 @@
import type { ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
@@ -26,14 +25,8 @@ import {
import type { FC } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { DiffStatsInline } from "../DiffStats";
interface SidebarPanelState {
hasDiffStatus: boolean;
diffStatus: ChatDiffStatusResponse | undefined;
hasGitRepos: boolean;
gitRepoCount: number;
gitRepositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
showSidebarPanel: boolean;
onToggleSidebar: () => void;
}
@@ -47,11 +40,11 @@ interface WorkspaceActions {
sshCommand: string | undefined;
}
interface AgentDetailTopBarProps {
type AgentDetailTopBarProps = {
chatTitle?: string;
parentChat?: TypesGen.Chat;
onOpenParentChat: (chatId: string) => void;
diff: SidebarPanelState;
panel: SidebarPanelState;
workspace: WorkspaceActions;
onArchiveAgent: () => void;
onUnarchiveAgent: () => void;
@@ -60,13 +53,13 @@ interface AgentDetailTopBarProps {
isArchived?: boolean;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
}
};
export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
chatTitle,
parentChat,
onOpenParentChat,
diff,
panel,
workspace,
onArchiveAgent,
onUnarchiveAgent,
@@ -122,16 +115,6 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
<span className="truncate text-sm text-content-primary">
{chatTitle}
</span>
{diff.hasDiffStatus &&
diff.diffStatus &&
!diff.showSidebarPanel && (
<span className="ml-3">
<DiffStatsInline
status={diff.diffStatus}
onClick={diff.onToggleSidebar}
/>
</span>
)}
{isArchived && (
<span className="shrink-0 rounded bg-surface-tertiary px-1.5 py-0.5 text-xs text-content-secondary">
Archived
@@ -231,22 +214,20 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
)}
</DropdownMenuContent>
</DropdownMenu>
{(diff.hasDiffStatus || diff.hasGitRepos) && (
<Button
variant="subtle"
size="icon"
onClick={diff.onToggleSidebar}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
aria-label="Toggle files changed"
>
{diff.showSidebarPanel ? (
<PanelRightCloseIcon className="h-4 w-4" />
) : (
<PanelRightOpenIcon className="h-4 w-4" />
)}
</Button>
)}
</div>{" "}
<Button
variant="subtle"
size="icon"
onClick={panel.onToggleSidebar}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
aria-label="Toggle panel"
>
{panel.showSidebarPanel ? (
<PanelRightCloseIcon className="h-4 w-4" />
) : (
<PanelRightOpenIcon className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
};
+1 -60
View File
@@ -1,35 +1,4 @@
import type { ChatDiffStatusResponse } from "api/api";
import type { FC } from "react";
import { cn } from "utils/cn";
interface DiffStatsProps {
additions: number;
deletions: number;
className?: string;
}
/**
* Renders +N / N counters for diff additions and deletions.
* Always renders both counters so that zero-line changes (e.g.
* binary files like images) still display "+0 0".
*/
const DiffStatNumbers: FC<DiffStatsProps> = ({
additions,
deletions,
className,
}) => {
return (
<span
className={cn(
"inline-flex items-center gap-0.5 font-mono text-xs font-medium tabular-nums",
className,
)}
>
<span className="text-green-700 dark:text-green-500">+{additions}</span>
<span className="text-red-700 dark:text-red-400">&minus;{deletions}</span>
</span>
);
};
/**
* Pill-styled diff stats badge with coloured backgrounds,
@@ -43,7 +12,7 @@ export const DiffStatBadge: FC<{ additions: number; deletions: number }> = ({
return null;
}
return (
<span className="inline-flex h-full items-center self-stretch overflow-hidden rounded-[calc(theme(borderRadius.md)-1px)] font-mono text-xs font-medium">
<span className="inline-flex h-full items-center self-stretch overflow-hidden font-mono text-xs font-medium">
{additions > 0 && (
<span className="flex h-full items-center bg-green-100 dark:bg-green-950 px-1.5 text-green-700 dark:text-green-500">
+{additions}
@@ -57,31 +26,3 @@ export const DiffStatBadge: FC<{ additions: number; deletions: number }> = ({
</span>
);
};
/**
* Clickable inline diff stats shown in the top bar when the
* diff panel is closed.
*/
export const DiffStatsInline: FC<{
status: ChatDiffStatusResponse;
onClick: () => void;
}> = ({ status, onClick }) => {
const additions = status.additions ?? 0;
const deletions = status.deletions ?? 0;
const hasChangedFiles = (status.changed_files ?? 0) > 0;
if (!hasChangedFiles && additions === 0 && deletions === 0) {
return null;
}
return (
<button
type="button"
onClick={onClick}
aria-label="View diff statistics"
className="inline-flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0 leading-none transition-opacity hover:opacity-80 outline-none"
>
<DiffStatNumbers additions={additions} deletions={deletions} />
</button>
);
};
+8 -2
View File
@@ -73,7 +73,7 @@ const STICKY_HEADER_CSS = [
].join(" ");
export type DiffStyle = "unified" | "split";
export const DIFF_STYLE_KEY = "agents.diff-view-style";
const DIFF_STYLE_KEY = "agents.diff-view-style";
export function loadDiffStyle(): DiffStyle {
if (typeof window === "undefined") {
@@ -86,6 +86,10 @@ export function loadDiffStyle(): DiffStyle {
return "unified";
}
export function saveDiffStyle(style: DiffStyle): void {
localStorage.setItem(DIFF_STYLE_KEY, style);
}
/** Width of the file tree sidebar in pixels. */
const FILE_TREE_WIDTH = 300;
@@ -609,7 +613,9 @@ export const DiffViewer: FC<DiffViewerProps> = ({
className="flex h-full min-w-0 flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center gap-1 px-3 py-2">{headerLeft}</div>
<div className="flex items-center gap-1 bg-surface-secondary px-3 py-2">
{headerLeft}
</div>
{/* Diff contents */}
{sortedFiles.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
@@ -796,7 +796,8 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
className="flex h-full min-w-0 flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center gap-3 px-3 py-2">
<div className="flex items-center gap-3 bg-surface-secondary px-3 py-2">
{" "}
{pullRequestUrl && parsedPr ? (
<a
href={pullRequestUrl}
+370
View File
@@ -0,0 +1,370 @@
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import {
CheckIcon,
ColumnsIcon,
FileDiffIcon,
GitBranchIcon,
GitCompareArrowsIcon,
RefreshCwIcon,
RowsIcon,
} from "lucide-react";
import {
type FC,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
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";
type GitView = "remote" | "local";
interface DiffStats {
additions: number;
deletions: number;
}
interface GitPanelProps {
/** PR tab data. Omitted if no PR is associated. */
prTab?: {
prNumber: number;
chatId: string;
};
/** Repository data from git watcher. */
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
/** Callback to send a refresh to the git watcher. */
onRefresh: () => void;
/** Called when the user clicks the Commit button in any repo tab. */
onCommit: (repoRoot: string) => void;
/** Whether the panel is in expanded/fullscreen mode. */
isExpanded?: boolean;
/** 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. */
chatInputRef?: RefObject<ChatMessageInputRef | null>;
}
function repoTabLabel(repoRoot: string): string {
const segments = repoRoot.split("/").filter(Boolean);
return segments[segments.length - 1] ?? repoRoot;
}
export const GitPanel: FC<GitPanelProps> = ({
prTab,
repositories,
onRefresh,
onCommit,
isExpanded,
remoteDiffStats,
localDiffStats,
chatInputRef,
}) => {
const [view, setView] = useState<GitView>("remote");
// Diff style is managed here for the local view only.
// FilesChangedPanel manages its own diff style internally.
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
const hasRemoteStats =
!!remoteDiffStats &&
(remoteDiffStats.additions > 0 || remoteDiffStats.deletions > 0);
const hasLocalStats =
!!localDiffStats &&
(localDiffStats.additions > 0 || localDiffStats.deletions > 0);
const handleDiffStyleChange = useCallback((style: DiffStyle) => {
saveDiffStyle(style);
setDiffStyle(style);
}, []);
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 border border-solid border-border-default text-xs">
<button
type="button"
onClick={() => setView("remote")}
className={cn(
"flex cursor-pointer items-center gap-3 border-none font-medium transition-colors",
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
className={cn(
"flex h-full items-center self-stretch transition-opacity",
view !== "remote" && "opacity-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",
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 h-full items-center self-stretch transition-opacity",
view !== "local" && "opacity-50",
)}
>
<DiffStatBadge
additions={localDiffStats.additions}
deletions={localDiffStats.deletions}
/>
</span>
)}
</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">
<button
type="button"
onClick={() => handleDiffStyleChange("unified")}
aria-label="Unified diff"
className={cn(
"flex cursor-pointer items-center border-none px-1.5 transition-colors",
diffStyle === "unified"
? "bg-surface-quaternary/25 text-content-primary"
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
)}
>
<RowsIcon className="size-3.5" />
</button>
<button
type="button"
onClick={() => handleDiffStyleChange("split")}
aria-label="Split diff"
className={cn(
"flex cursor-pointer items-center border-0 border-l border-solid border-border-default px-1.5 transition-colors",
diffStyle === "split"
? "bg-surface-quaternary/25 text-content-primary"
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
)}
>
<ColumnsIcon className="size-3.5" />
</button>
</div>
)}
</div>
{/* Content */}
<div className="min-h-0 flex-1">
{view === "remote" ? (
<RemoteContent
prTab={prTab}
isExpanded={isExpanded}
chatInputRef={chatInputRef}
/>
) : (
<LocalContent
repositories={repositories}
onRefresh={onRefresh}
onCommit={onCommit}
isExpanded={isExpanded}
diffStyle={diffStyle}
/>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------
// Remote view (branch/PR diff)
// ---------------------------------------------------------------
const RemoteContent: FC<{
prTab?: { prNumber: number; chatId: string };
isExpanded?: boolean;
chatInputRef?: RefObject<ChatMessageInputRef | null>;
}> = ({ prTab, isExpanded, chatInputRef }) => {
if (!prTab) {
return (
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
<div className="mb-4 flex size-10 items-center justify-center rounded-lg border border-solid border-border-default bg-surface-secondary">
<GitCompareArrowsIcon className="size-5 text-content-secondary" />
</div>
<p className="text-sm font-medium text-content-primary">
No pushed changes yet
</p>
<p className="mt-1 max-w-52 text-xs text-content-secondary">
Once commits are pushed, the branch diff will appear here.
</p>
</div>
);
}
return (
<FilesChangedPanel
chatId={prTab.chatId}
isExpanded={isExpanded}
chatInputRef={chatInputRef}
/>
);
};
// ---------------------------------------------------------------
// Local view (working tree changes)
// ---------------------------------------------------------------
const LocalContent: FC<{
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
onRefresh: () => void;
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>
);
}
return (
<ScrollArea className="h-full">
<div className="flex flex-col">
{repoEntries.map(([repoRoot, repo], index) => {
const showSeparator = index > 0;
return (
<section
key={repoRoot}
className={cn(
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>
);
})}
</div>
</ScrollArea>
);
};
// ---------------------------------------------------------------
// Repo header for local view
// ---------------------------------------------------------------
const RepoHeader: FC<{
repoRoot: string;
repo: WorkspaceAgentRepoChanges;
onRefresh: () => void;
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]);
return (
<div className="flex 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">
{repo.branch?.trim() || repoTabLabel(repoRoot)}
</span>
{repo.branch?.trim() && (
<span className="truncate text-xs text-content-secondary">
{repoTabLabel(repoRoot)}
</span>
)}
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<Button
size="sm"
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"
>
<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>
</div>
</div>
);
};
@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import { fn } from "storybook/test";
import { RepoChangesPanel } from "./RepoChangesPanel";
const sampleDiff = `--- a/src/main.ts
@@ -35,8 +34,6 @@ const meta: Meta<typeof RepoChangesPanel> = {
component: RepoChangesPanel,
args: {
repo: baseRepo,
onRefresh: fn(),
onCommit: fn(),
diffStyle: "unified",
},
};
@@ -60,42 +57,3 @@ export const SplitDiffStyle: Story = {
diffStyle: "split",
},
};
export const LongBranchName: Story = {
args: {
repo: {
...baseRepo,
branch:
"feature/TICKET-12345-implement-very-long-branch-name-for-testing-truncation-behavior",
},
},
decorators: [
(Story) => (
<div style={{ width: 400 }}>
<Story />
</div>
),
],
};
export const DeepRepoPath: Story = {
args: {
repo: {
...baseRepo,
repo_root: "/home/coder/workspaces/my-org/services/project",
},
},
};
export const EmptyBranchName: Story = {
args: {
repo: {
...baseRepo,
branch: "",
},
},
};
export const ManyFiles: Story = {};
export const UntrackedFiles: Story = {};
+1 -82
View File
@@ -1,56 +1,19 @@
import { parsePatchFiles } from "@pierre/diffs";
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
CheckIcon,
FolderIcon,
GitBranchIcon,
RefreshCwIcon,
} from "lucide-react";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "utils/cn";
import { type FC, useMemo } from "react";
import { type DiffStyle, DiffViewer } from "./DiffViewer";
interface RepoChangesPanelProps {
repo: WorkspaceAgentRepoChanges;
onRefresh: () => void;
onCommit: () => void;
isExpanded?: boolean;
diffStyle: DiffStyle;
}
function repoParentPath(repoRoot: string): string {
const lastSlash = repoRoot.lastIndexOf("/");
if (lastSlash === -1) {
return "";
}
return repoRoot.slice(0, lastSlash + 1);
}
export const RepoChangesPanel: FC<RepoChangesPanelProps> = ({
repo,
onRefresh,
onCommit,
isExpanded,
diffStyle,
}) => {
const [spinning, setSpinning] = useState(false);
const spinTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(spinTimerRef.current), []);
const handleRefresh = useCallback(() => {
onRefresh();
setSpinning(true);
clearTimeout(spinTimerRef.current);
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
}, [onRefresh]);
const parsedFiles = useMemo(() => {
const diff = repo.unified_diff;
if (!diff) {
@@ -64,52 +27,8 @@ export const RepoChangesPanel: FC<RepoChangesPanelProps> = ({
}
}, [repo.unified_diff]);
const hasChanges = parsedFiles.length > 0;
const parentPath = repoParentPath(repo.repo_root);
return (
<DiffViewer
headerLeft={
<div className="flex w-full min-w-0 items-center gap-1.5">
{parentPath && (
<div className="flex h-7 min-w-0 items-center gap-1 rounded-md border border-solid border-border-default px-1.5 text-xs text-content-secondary">
<FolderIcon className="h-3 w-3 shrink-0" />
<span className="truncate">{parentPath}</span>
</div>
)}
{repo.branch?.trim() && (
<div className="flex h-7 min-w-0 items-center gap-1 rounded-md border border-solid border-border-default px-1.5 text-xs text-content-secondary">
<GitBranchIcon className="h-3 w-3 shrink-0" />
<span className="truncate">{repo.branch}</span>
</div>
)}
<div className="ml-auto flex shrink-0 items-center gap-1">
<Button
size="sm"
onClick={onCommit}
disabled={!hasChanges}
className="h-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"
>
<CheckIcon className="h-3 w-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(
"h-3.5 w-3.5",
spinning && "motion-safe:animate-spin-once",
)}
/>
</Button>
</div>
</div>
}
parsedFiles={parsedFiles}
isExpanded={isExpanded}
emptyMessage="No file changes."
@@ -1,36 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import { fn } from "storybook/test";
import type { SidebarTab } from "./SidebarTabView";
import { SidebarTabView } from "./SidebarTabView";
const sampleDiff = `--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,5 @@
+import { init } from "./init";
+
const main = () => {
console.log("hello");
};
`;
const makePanelContent = (label: string) => (
<div className="flex h-full items-center justify-center p-6 text-sm text-content-secondary">
Content for {label}
</div>
);
const makeRepo = (
name: string,
overrides?: Partial<WorkspaceAgentRepoChanges>,
): WorkspaceAgentRepoChanges => ({
repo_root: `/home/coder/${name}`,
branch: "main",
remote_origin: `https://github.com/coder/${name}.git`,
unified_diff: sampleDiff,
...overrides,
});
const makeBadge = (additions: number, deletions: number) => (
<span className="inline-flex h-full items-center self-stretch overflow-hidden font-mono text-xs font-medium">
{additions > 0 && (
<span className="flex h-full items-center bg-green-100 px-1.5 text-green-700 dark:bg-green-950 dark:text-green-500">
+{additions}
</span>
)}
{deletions > 0 && (
<span className="flex h-full items-center bg-red-100 px-1.5 text-red-700 dark:bg-red-950 dark:text-red-400">
&minus;{deletions}
</span>
)}
</span>
);
const gitTab: SidebarTab = {
id: "git",
label: "Git",
badge: makeBadge(42, 7),
content: makePanelContent("Git"),
};
const meta: Meta<typeof SidebarTabView> = {
title: "pages/AgentsPage/SidebarTabView",
component: SidebarTabView,
args: {
workspace: { name: "my-workspace", ownerName: "admin" },
onRefresh: fn(),
onCommit: fn(),
tabs: [gitTab],
isExpanded: false,
onToggleExpanded: fn(),
},
@@ -45,92 +50,47 @@ const meta: Meta<typeof SidebarTabView> = {
export default meta;
type Story = StoryObj<typeof SidebarTabView>;
export const PROnly: Story = {
export const GitWithBadge: Story = {};
export const GitNoBadge: Story = {
args: {
prTab: { prNumber: 42, chatId: "chat-1" },
repositories: new Map(),
tabs: [{ ...gitTab, badge: undefined }],
},
};
export const SingleRepo: Story = {
export const MultipleTabs: Story = {
args: {
prTab: undefined,
repositories: new Map([["/home/coder/project", makeRepo("project")]]),
},
};
export const PRAndRepos: Story = {
args: {
prTab: { prNumber: 123, chatId: "chat-2" },
repositories: new Map([
["/home/coder/frontend", makeRepo("frontend")],
[
"/home/coder/backend",
makeRepo("backend", {
branch: "feat/api",
}),
],
]),
},
};
export const ManyRepos: Story = {
args: {
prTab: undefined,
repositories: new Map(
["alpha", "bravo", "charlie", "delta", "echo"].map((name) => [
`/home/coder/${name}`,
makeRepo(name),
]),
),
tabs: [
gitTab,
{ id: "preview", label: "Preview", content: makePanelContent("Preview") },
],
},
};
export const EmptyState: Story = {
args: {
prTab: undefined,
repositories: new Map(),
tabs: [],
},
};
export const SplitDiffMode: Story = {
export const ExpandedWithTitle: Story = {
args: {
prTab: undefined,
repositories: new Map([["/home/coder/project", makeRepo("project")]]),
},
};
export const ExpandedWithDiffToggle: Story = {
args: {
prTab: { prNumber: 42, chatId: "chat-1" },
repositories: new Map([["/home/coder/project", makeRepo("project")]]),
tabs: [gitTab],
isExpanded: true,
chatTitle: "Fix authentication bug",
},
decorators: [
(Story) => (
<div style={{ height: 600, width: 900 }}>
<div style={{ height: 500, width: 900 }}>
<Story />
</div>
),
],
};
export const WithDiffStats: Story = {
args: {
prTab: { prNumber: 99, chatId: "chat-3" },
repositories: new Map([
["/home/coder/frontend", makeRepo("frontend")],
["/home/coder/backend", makeRepo("backend", { branch: "feat/api" })],
]),
diffStatus: { additions: 150, deletions: 42 },
},
};
export const NarrowPanel: Story = {
args: {
prTab: { prNumber: 42, chatId: "chat-1" },
repositories: new Map([["/home/coder/project", makeRepo("project")]]),
tabs: [gitTab],
},
decorators: [
(Story) => (
+60 -193
View File
@@ -1,50 +1,39 @@
import { parsePatchFiles } from "@pierre/diffs";
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
ChevronLeftIcon,
ChevronRightIcon,
Columns2Icon,
MaximizeIcon,
MinimizeIcon,
PanelLeftIcon,
Rows3Icon,
XIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import {
type FC,
type RefObject,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "utils/cn";
import type { ChatMessageInputRef } from "./AgentChatInput";
import { DiffStatBadge } from "./DiffStats";
import { DIFF_STYLE_KEY, type DiffStyle, loadDiffStyle } from "./DiffViewer";
import { FilesChangedPanel } from "./FilesChangedPanel";
import { RepoChangesPanel } from "./RepoChangesPanel";
/** A single tab definition for the sidebar panel. */
export interface SidebarTab {
id: string;
/** Label shown in the tab button. */
label: string;
/** Optional icon shown before the label. */
icon?: ReactNode;
/** Optional badge shown after the label (e.g. diff stats). */
badge?: ReactNode;
/** The content to render when this tab is active. */
content: ReactNode;
}
interface SidebarTabViewProps {
/** PR tab data. Omitted if no PR is associated. */
prTab?: {
prNumber: number;
chatId: string;
};
/** Repository tabs from git watcher. */
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
/** Workspace info for the header. */
workspace?: {
name: string;
ownerName: string;
};
/** Callback to send a refresh to the git watcher. */
onRefresh: () => void;
/** Called when the user clicks the Commit button in any repo tab. */
onCommit: (repoRoot: string) => void;
/** The tabs to display. */
tabs: SidebarTab[];
/** Whether the panel is in expanded/fullscreen mode. */
isExpanded: boolean;
/** Callback to toggle expanded state. */
@@ -55,12 +44,8 @@ interface SidebarTabViewProps {
onToggleSidebarCollapsed?: () => void;
/** Shown in center when expanded. */
chatTitle?: string;
/** PR diff stats for the PR tab. */
diffStatus?: { additions?: number; deletions?: number };
/** Callback to close the panel (used on mobile). */
onClose?: () => void;
/** Ref to the chat input, forwarded to FilesChangedPanel. */
chatInputRef?: RefObject<ChatMessageInputRef | null>;
}
/** How far (px) each chevron click scrolls the tab strip. */
@@ -119,98 +104,34 @@ function useTabScroll() {
return { ref, canScrollLeft, canScrollRight, scrollLeft, scrollRight };
}
function repoTabLabel(repoRoot: string): string {
const segments = repoRoot.split("/").filter(Boolean);
return segments[segments.length - 1] ?? repoRoot;
}
function computeDiffStats(unifiedDiff: string | undefined): {
additions: number;
deletions: number;
} {
if (!unifiedDiff) return { additions: 0, deletions: 0 };
try {
const patches = parsePatchFiles(unifiedDiff);
let additions = 0;
let deletions = 0;
for (const patch of patches) {
for (const file of patch.files) {
for (const hunk of file.hunks) {
additions += hunk.additionLines;
deletions += hunk.deletionLines;
}
}
}
return { additions, deletions };
} catch {
return { additions: 0, deletions: 0 };
}
}
export const SidebarTabView: FC<SidebarTabViewProps> = ({
prTab,
repositories,
onRefresh,
onCommit,
tabs,
isExpanded,
onToggleExpanded,
isSidebarCollapsed,
onToggleSidebarCollapsed,
chatTitle,
diffStatus,
onClose,
chatInputRef,
}) => {
const tabIdPrefix = useId();
const repoEntries = Array.from(repositories.entries()).sort(([a], [b]) =>
a.localeCompare(b),
const [activeTabId, setActiveTabId] = useState<string | null>(
tabs.length > 0 ? tabs[0].id : null,
);
const hasPR = Boolean(prTab);
const hasRepos = repoEntries.length > 0;
// Derive the effective tab. Fall back to the first tab if
// the stored activeTabId no longer matches any tab in the list.
const effectiveTabId =
activeTabId !== null && tabs.some((t) => t.id === activeTabId)
? activeTabId
: tabs.length > 0
? tabs[0].id
: null;
// Default active tab: PR if present, otherwise first repo.
const defaultTab = hasPR
? "pr"
: repoEntries.length > 0
? repoEntries[0][0]
: null;
const [activeTab, setActiveTab] = useState<string | null>(defaultTab);
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
const handleSetDiffStyle = useCallback((style: DiffStyle) => {
setDiffStyle(style);
localStorage.setItem(DIFF_STYLE_KEY, style);
}, []);
// Derive the effective tab inline to avoid a one-frame flash when
// activeTab is stale or null but a valid default exists.
const effectiveTab =
activeTab !== null &&
(activeTab === "pr" ? hasPR : repositories.has(activeTab))
? activeTab
: defaultTab;
// Compute diff stats for all repo tabs and cache them.
const repoDiffStats = useMemo(() => {
const statsMap = new Map<
string,
{ additions: number; deletions: number }
>();
for (const [repoRoot, repo] of repoEntries) {
statsMap.set(repoRoot, computeDiffStats(repo.unified_diff));
}
return statsMap;
}, [repoEntries]);
const prDiffAdditions = diffStatus?.additions ?? 0;
const prDiffDeletions = diffStatus?.deletions ?? 0;
const hasPrDiffStats = prDiffAdditions > 0 || prDiffDeletions > 0;
const activeTab = tabs.find((t) => t.id === effectiveTabId) ?? null;
const tabScroll = useTabScroll();
if (!hasPR && !hasRepos) {
if (tabs.length === 0) {
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-surface-primary">
{/* Tab bar always visible for the expand button. */}
@@ -247,7 +168,7 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
</Button>
</div>
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
No changes to display.
No panels available.
</div>
</div>
);
@@ -258,7 +179,7 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
{/* Tab bar */}
<div
role="tablist"
className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1"
className="relative flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1"
>
{/* Sidebar toggle only when expanded and sidebar is collapsed */}
{isExpanded && isSidebarCollapsed && onToggleSidebarCollapsed && (
@@ -288,49 +209,36 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
ref={tabScroll.ref}
className="flex w-full items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{hasPR && prTab && (
<Button
id={`${tabIdPrefix}-tab-pr`}
role="tab"
aria-selected={effectiveTab === "pr"}
onClick={() => setActiveTab("pr")}
variant="outline"
size="lg"
className={cn(
"shrink-0 h-6 px-3 gap-3 py-0 bg-surface-primary",
effectiveTab === "pr" && "bg-surface-tertiary",
hasPrDiffStats && "pr-0",
)}
>
#{prTab.prNumber}
<DiffStatBadge
additions={prDiffAdditions}
deletions={prDiffDeletions}
/>
</Button>
)}
{repoEntries.map(([repoRoot]) => {
const stats = repoDiffStats.get(repoRoot);
const additions = stats?.additions ?? 0;
const deletions = stats?.deletions ?? 0;
const hasStats = additions > 0 || deletions > 0;
{tabs.map((tab) => {
const isActive = effectiveTabId === tab.id;
return (
<Button
key={repoRoot}
id={`${tabIdPrefix}-tab-${repoRoot}`}
key={tab.id}
id={`${tabIdPrefix}-tab-${tab.id}`}
role="tab"
aria-selected={effectiveTab === repoRoot}
onClick={() => setActiveTab(repoRoot)}
aria-selected={isActive}
onClick={() => setActiveTabId(tab.id)}
variant="outline"
size="lg"
className={cn(
"shrink-0 h-6 px-3 gap-3 py-0 bg-surface-primary",
effectiveTab === repoRoot && "bg-surface-tertiary",
hasStats && "pr-0",
"shrink-0 h-6 min-w-0 gap-3 px-2 py-0 bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
isActive &&
"bg-surface-quaternary/25 hover:bg-surface-quaternary/50",
tab.badge && "pr-0",
)}
>
{repoTabLabel(repoRoot)}
<DiffStatBadge additions={additions} deletions={deletions} />
{tab.icon}
{tab.label}
{tab.badge && (
<span
className={cn(
"flex h-full items-center self-stretch transition-opacity",
!isActive && "opacity-50",
)}
>
{tab.badge}
</span>
)}
</Button>
);
})}
@@ -347,40 +255,13 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
)}
</div>
{/* Center: chat title when expanded */}
<div className="min-w-0 shrink-0 text-center">
{isExpanded && chatTitle && (
<span className="truncate text-sm text-content-primary">
{isExpanded && chatTitle && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="truncate px-24 text-sm text-content-primary">
{chatTitle}
</span>
)}
</div>
{/* Diff style toggle */}
<div className="flex shrink-0 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>
)}
{/* Right side: close (mobile) / expand (desktop) */}
{onClose && (
<Button
@@ -407,25 +288,11 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
<div
role="tabpanel"
aria-labelledby={
effectiveTab ? `${tabIdPrefix}-tab-${effectiveTab}` : undefined
effectiveTabId ? `${tabIdPrefix}-tab-${effectiveTabId}` : undefined
}
className="min-h-0 flex-1"
>
{effectiveTab === "pr" && prTab ? (
<FilesChangedPanel
chatId={prTab.chatId}
isExpanded={isExpanded}
chatInputRef={chatInputRef}
/>
) : effectiveTab && repositories.has(effectiveTab) ? (
<RepoChangesPanel
repo={repositories.get(effectiveTab)!}
onRefresh={onRefresh}
onCommit={() => onCommit(effectiveTab)}
isExpanded={isExpanded}
diffStyle={diffStyle}
/>
) : null}
{activeTab?.content}
</div>
</div>
);