From 3f939375fa9009e3833686c1ffb4c7e047a51d88 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 9 Mar 2026 07:44:56 -0700 Subject: [PATCH] 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 --- site/src/pages/AgentsPage/AgentDetail.tsx | 69 ++-- .../AgentsPage/AgentDetail/TopBar.stories.tsx | 39 +- .../pages/AgentsPage/AgentDetail/TopBar.tsx | 55 +-- site/src/pages/AgentsPage/DiffStats.tsx | 61 +-- site/src/pages/AgentsPage/DiffViewer.tsx | 10 +- .../pages/AgentsPage/FilesChangedPanel.tsx | 3 +- site/src/pages/AgentsPage/GitPanel.tsx | 370 ++++++++++++++++++ .../AgentsPage/RepoChangesPanel.stories.tsx | 42 -- .../src/pages/AgentsPage/RepoChangesPanel.tsx | 83 +--- .../AgentsPage/SidebarTabView.stories.tsx | 124 ++---- site/src/pages/AgentsPage/SidebarTabView.tsx | 253 +++--------- 11 files changed, 536 insertions(+), 573 deletions(-) create mode 100644 site/src/pages/AgentsPage/GitPanel.tsx diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 80dead8633..3fba4d5063 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -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 = () => {
{titleElement} {}, }} @@ -1110,7 +1106,7 @@ const AgentDetail: FC = () => { -
+ {" "} @@ -1139,12 +1135,7 @@ const AgentDetail: FC = () => {
{titleElement} {}, }} @@ -1166,7 +1157,7 @@ const AgentDetail: FC = () => { />
Chat not found -
+
{" "} ); } @@ -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} > + ), + }, + ].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} /> - + {" "} ); }; diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx index 45f7c33d17..bad14392c2 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx @@ -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; 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: () => {}, }, diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx index c64bc97674..dcb63cd3c9 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx @@ -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; 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 = ({ chatTitle, parentChat, onOpenParentChat, - diff, + panel, workspace, onArchiveAgent, onUnarchiveAgent, @@ -122,16 +115,6 @@ export const AgentDetailTopBar: FC = ({ {chatTitle} - {diff.hasDiffStatus && - diff.diffStatus && - !diff.showSidebarPanel && ( - - - - )} {isArchived && ( Archived @@ -231,22 +214,20 @@ export const AgentDetailTopBar: FC = ({ )} - {(diff.hasDiffStatus || diff.hasGitRepos) && ( - - )} - {" "} + + ); }; diff --git a/site/src/pages/AgentsPage/DiffStats.tsx b/site/src/pages/AgentsPage/DiffStats.tsx index c48b9e39c3..876fc6f70d 100644 --- a/site/src/pages/AgentsPage/DiffStats.tsx +++ b/site/src/pages/AgentsPage/DiffStats.tsx @@ -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 = ({ - additions, - deletions, - className, -}) => { - return ( - - +{additions} - −{deletions} - - ); -}; /** * Pill-styled diff stats badge with coloured backgrounds, @@ -43,7 +12,7 @@ export const DiffStatBadge: FC<{ additions: number; deletions: number }> = ({ return null; } return ( - + {additions > 0 && ( +{additions} @@ -57,31 +26,3 @@ export const DiffStatBadge: FC<{ additions: number; deletions: number }> = ({ ); }; - -/** - * 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 ( - - ); -}; diff --git a/site/src/pages/AgentsPage/DiffViewer.tsx b/site/src/pages/AgentsPage/DiffViewer.tsx index 9d33a61e5d..90cdc741ed 100644 --- a/site/src/pages/AgentsPage/DiffViewer.tsx +++ b/site/src/pages/AgentsPage/DiffViewer.tsx @@ -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 = ({ className="flex h-full min-w-0 flex-col overflow-hidden" > {/* Header */} -
{headerLeft}
+
+ {headerLeft} +
{/* Diff contents */} {sortedFiles.length === 0 ? (
diff --git a/site/src/pages/AgentsPage/FilesChangedPanel.tsx b/site/src/pages/AgentsPage/FilesChangedPanel.tsx index ea3f5ef551..d18dbb31c1 100644 --- a/site/src/pages/AgentsPage/FilesChangedPanel.tsx +++ b/site/src/pages/AgentsPage/FilesChangedPanel.tsx @@ -796,7 +796,8 @@ export const FilesChangedPanel: FC = ({ className="flex h-full min-w-0 flex-col overflow-hidden" > {/* Header */} -
+
+ {" "} {pullRequestUrl && parsedPr ? ( ; + /** 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; +} + +function repoTabLabel(repoRoot: string): string { + const segments = repoRoot.split("/").filter(Boolean); + return segments[segments.length - 1] ?? repoRoot; +} + +export const GitPanel: FC = ({ + prTab, + repositories, + onRefresh, + onCommit, + isExpanded, + remoteDiffStats, + localDiffStats, + chatInputRef, +}) => { + const [view, setView] = useState("remote"); + + // Diff style is managed here for the local view only. + // FilesChangedPanel manages its own diff style internally. + const [diffStyle, setDiffStyle] = useState(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 ( +
+ {/* Toolbar */} +
+ {/* Remote / Local segmented control */} +
+ + +
+
+ {/* Split / Unified toggle — only shown for local view since + FilesChangedPanel has its own toggle built in. */} + {view === "local" && ( +
+ + +
+ )} +
+ + {/* Content */} +
+ {view === "remote" ? ( + + ) : ( + + )} +
+
+ ); +}; + +// --------------------------------------------------------------- +// Remote view (branch/PR diff) +// --------------------------------------------------------------- + +const RemoteContent: FC<{ + prTab?: { prNumber: number; chatId: string }; + isExpanded?: boolean; + chatInputRef?: RefObject; +}> = ({ prTab, isExpanded, chatInputRef }) => { + if (!prTab) { + return ( +
+
+ +
+

+ No pushed changes yet +

+

+ Once commits are pushed, the branch diff will appear here. +

+
+ ); + } + + return ( + + ); +}; + +// --------------------------------------------------------------- +// Local view (working tree changes) +// --------------------------------------------------------------- + +const LocalContent: FC<{ + repositories: ReadonlyMap; + 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 ( +
+
+ +
+

+ No uncommitted changes +

+

+ Local file modifications will appear here as you edit. +

+
+ ); + } + + return ( + +
+ {repoEntries.map(([repoRoot, repo], index) => { + const showSeparator = index > 0; + + return ( +
+ onCommit(repoRoot)} + /> + +
+ ); + })} +
+
+ ); +}; + +// --------------------------------------------------------------- +// 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>(undefined); + useEffect(() => () => clearTimeout(spinTimerRef.current), []); + const handleRefresh = useCallback(() => { + onRefresh(); + setSpinning(true); + clearTimeout(spinTimerRef.current); + spinTimerRef.current = setTimeout(() => setSpinning(false), 1000); + }, [onRefresh]); + + return ( +
+ {/* Repo identity */} +
+ + + {repo.branch?.trim() || repoTabLabel(repoRoot)} + + {repo.branch?.trim() && ( + + {repoTabLabel(repoRoot)} + + )} +
+ + {/* Actions */} +
+ + +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/RepoChangesPanel.stories.tsx b/site/src/pages/AgentsPage/RepoChangesPanel.stories.tsx index 52c06640ac..7a34a2f5a7 100644 --- a/site/src/pages/AgentsPage/RepoChangesPanel.stories.tsx +++ b/site/src/pages/AgentsPage/RepoChangesPanel.stories.tsx @@ -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 = { 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) => ( -
- -
- ), - ], -}; - -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 = {}; diff --git a/site/src/pages/AgentsPage/RepoChangesPanel.tsx b/site/src/pages/AgentsPage/RepoChangesPanel.tsx index 1c746b601d..4366bea8f5 100644 --- a/site/src/pages/AgentsPage/RepoChangesPanel.tsx +++ b/site/src/pages/AgentsPage/RepoChangesPanel.tsx @@ -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 = ({ repo, - onRefresh, - onCommit, isExpanded, diffStyle, }) => { - const [spinning, setSpinning] = useState(false); - const spinTimerRef = useRef>(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 = ({ } }, [repo.unified_diff]); - const hasChanges = parsedFiles.length > 0; - const parentPath = repoParentPath(repo.repo_root); - return ( - {parentPath && ( -
- - {parentPath} -
- )} - {repo.branch?.trim() && ( -
- - {repo.branch} -
- )} -
- - -
-
- } parsedFiles={parsedFiles} isExpanded={isExpanded} emptyMessage="No file changes." diff --git a/site/src/pages/AgentsPage/SidebarTabView.stories.tsx b/site/src/pages/AgentsPage/SidebarTabView.stories.tsx index ede49e9ee3..8a3c0e5707 100644 --- a/site/src/pages/AgentsPage/SidebarTabView.stories.tsx +++ b/site/src/pages/AgentsPage/SidebarTabView.stories.tsx @@ -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) => ( +
+ Content for {label} +
+); -const makeRepo = ( - name: string, - overrides?: Partial, -): 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) => ( + + {additions > 0 && ( + + +{additions} + + )} + {deletions > 0 && ( + + −{deletions} + + )} + +); + +const gitTab: SidebarTab = { + id: "git", + label: "Git", + badge: makeBadge(42, 7), + content: makePanelContent("Git"), +}; const meta: Meta = { 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 = { export default meta; type Story = StoryObj; -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) => ( -
+
), ], }; -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) => ( diff --git a/site/src/pages/AgentsPage/SidebarTabView.tsx b/site/src/pages/AgentsPage/SidebarTabView.tsx index 73b064625d..b0b24688d6 100644 --- a/site/src/pages/AgentsPage/SidebarTabView.tsx +++ b/site/src/pages/AgentsPage/SidebarTabView.tsx @@ -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; - /** 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; } /** 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 = ({ - 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( + 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(defaultTab); - - const [diffStyle, setDiffStyle] = useState(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 (
{/* Tab bar – always visible for the expand button. */} @@ -247,7 +168,7 @@ export const SidebarTabView: FC = ({
- No changes to display. + No panels available.
); @@ -258,7 +179,7 @@ export const SidebarTabView: FC = ({ {/* Tab bar */}
{/* Sidebar toggle – only when expanded and sidebar is collapsed */} {isExpanded && isSidebarCollapsed && onToggleSidebarCollapsed && ( @@ -288,49 +209,36 @@ export const SidebarTabView: FC = ({ ref={tabScroll.ref} className="flex w-full items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" > - {hasPR && prTab && ( - - )} - {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 ( ); })} @@ -347,40 +255,13 @@ export const SidebarTabView: FC = ({ )}
{/* Center: chat title when expanded */} -
- {isExpanded && chatTitle && ( - + {isExpanded && chatTitle && ( +
+ {chatTitle} - )} -
- {/* Diff style toggle */} -
- - -
+
+ )} {/* Right side: close (mobile) / expand (desktop) */} {onClose && (
);