From 37df72881cdcd6c368db3ecee643641d13e6459f Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Mon, 6 Jul 2026 17:40:27 +0000 Subject: [PATCH 01/18] feat(site/AgentsPage): consolidate git panel tab strip into a view switcher dropdown --- .../pages/AgentsPage/AgentChatPageView.tsx | 12 +- .../components/DiffViewer/RemoteDiffPanel.tsx | 47 --- .../components/GitPanel/GitPanel.stories.tsx | 100 ++++- .../components/GitPanel/GitPanel.tsx | 373 ++++++++++++++---- 4 files changed, 394 insertions(+), 138 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 1f46b9f8c13..736d6a83f1c 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -43,7 +43,7 @@ import { ChatSharingPopoverContent } from "./components/ChatSharingPopover"; import { getEffectiveTabId } from "./components/ChatsSidebar/tabs/getEffectiveTabId"; import { SidebarTabView } from "./components/ChatsSidebar/tabs/SidebarTabView"; import { ChatTopBar } from "./components/ChatTopBar"; -import { GitPanel } from "./components/GitPanel/GitPanel"; +import { countGitPanelViews, GitPanel } from "./components/GitPanel/GitPanel"; import { DebugPanel } from "./components/RightPanel/DebugPanel/DebugPanel"; import { DesktopPanel } from "./components/RightPanel/DesktopPanel"; import { PortPreviewPanel } from "./components/RightPanel/PortPreviewPanel"; @@ -500,12 +500,20 @@ export const AgentChatPageView: FC = ({ const hasBuiltInTerminal = Boolean( workspace && workspaceAgent && !defaultTerminalHidden, ); + const gitViewCount = countGitPanelViews( + gitWatcher.repositories, + diffStatusData, + prNumber && agentId ? { prNumber } : undefined, + gitWatcher.everDirty, + ); + const gitTabLabel = gitViewCount > 0 ? `Git (${gitViewCount})` : "Git"; + // Single source of truth for available tabs and their order. The list // of tab IDs used by `getEffectiveTabId` is derived from this so a // new tab can never be added to one without the other going out of // sync. Desktop is ordered before terminals so terminals are rightmost. const builtInSidebarTabConfigs = [ - { id: "git", label: "Git" }, + { id: "git", label: gitTabLabel }, ...(debugLoggingEnabled ? [{ id: "debug", label: "Debug" }] : []), ...(availableDesktopChatId ? [{ id: "desktop", label: "Desktop" }] : []), ...(hasBuiltInTerminal ? [{ id: "terminal", label: "Terminal" }] : []), diff --git a/site/src/pages/AgentsPage/components/DiffViewer/RemoteDiffPanel.tsx b/site/src/pages/AgentsPage/components/DiffViewer/RemoteDiffPanel.tsx index 68335d59c93..37173ad080c 100644 --- a/site/src/pages/AgentsPage/components/DiffViewer/RemoteDiffPanel.tsx +++ b/site/src/pages/AgentsPage/components/DiffViewer/RemoteDiffPanel.tsx @@ -3,10 +3,6 @@ import { CopyIcon, ExternalLinkIcon, GitBranchIcon, - GitMergeIcon, - GitPullRequestClosedIcon, - GitPullRequestDraftIcon, - GitPullRequestIcon, } from "lucide-react"; import { type FC, type RefObject, useEffect, useState } from "react"; import { useQuery } from "react-query"; @@ -19,7 +15,6 @@ import { TooltipTrigger, } from "#/components/Tooltip/Tooltip"; import { useClipboard } from "#/hooks/useClipboard"; -import { cn } from "#/utils/cn"; import { parsePullRequestUrl } from "../../utils/pullRequest"; import type { ChatMessageInputRef } from "../AgentChatInput"; import { CommentableDiffViewer } from "../DiffViewer/CommentableDiffViewer"; @@ -59,45 +54,6 @@ const BranchCopyButton: FC<{ branch: string }> = ({ branch }) => { ); }; -// ------------------------------------------------------------------- -// PR state badge -// ------------------------------------------------------------------- - -const PullRequestStateBadge: FC<{ - state?: string; - draft?: boolean; -}> = ({ state, draft }) => { - let Icon = GitPullRequestIcon; - let label = "Open"; - let colorClasses = "bg-surface-git-added text-git-added-bright"; - - if (state === "merged") { - Icon = GitMergeIcon; - label = "Merged"; - colorClasses = "bg-surface-git-merged text-git-merged-bright"; - } else if (state === "closed") { - Icon = GitPullRequestClosedIcon; - label = "Closed"; - colorClasses = "bg-surface-git-deleted text-git-deleted-bright"; - } else if (draft) { - Icon = GitPullRequestDraftIcon; - label = "Draft"; - colorClasses = "text-content-secondary"; - } - - return ( - - - {label} - - ); -}; - // ------------------------------------------------------------------- // Main component // ------------------------------------------------------------------- @@ -162,8 +118,6 @@ export const RemoteDiffPanel: FC = ({ // --------------------------------------------------------------- const pullRequestUrl = diffStatus?.url; const parsedPr = pullRequestUrl ? parsePullRequestUrl(pullRequestUrl) : null; - const prState = diffStatus?.pull_request_state; - const prDraft = diffStatus?.pull_request_draft; const baseBranch = diffStatus?.base_branch; const headBranch = diffStatus?.head_branch; @@ -199,7 +153,6 @@ export const RemoteDiffPanel: FC = ({ )}
- {diffStatus?.additions || diffStatus?.deletions ? ( { + spyOn(API.experimental, "getChatDiffContents").mockResolvedValue({ + ...defaultDiffContents, + diff: sampleDiff, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const switcher = canvas.getByTestId("git-panel-view-switcher"); + await userEvent.click(switcher); + + // The Radix menu portals to document.body, so query the full + // document instead of the story canvas. + const menu = await waitFor(() => { + const el = document.querySelector("[role='menu']"); + if (!el) throw new Error("menu not found"); + return el as HTMLElement; + }); + await expect(menu).toHaveTextContent("PR #23020"); + await expect(menu).toHaveTextContent("Working"); + await expect(menu).toHaveTextContent("coder"); + await expect(menu).toHaveTextContent("other-project"); }, }; @@ -368,9 +432,10 @@ export const LargeDiff: Story = { /** * Regression: when a repo was dirty during this session and then went - * clean (empty unified_diff), the tab must remain visible. Before the - * ever-dirty fix, the tab vanished the moment the diff became empty, - * which is what users saw as "diff disappears between edit_files". + * clean (empty unified_diff), the switcher must keep the entry visible. + * Before the ever-dirty fix, the entry vanished the moment the diff + * became empty, which is what users saw as "diff disappears between + * edit_files". */ export const EverDirtyRepoGoneClean: Story = { args: { @@ -380,14 +445,15 @@ export const EverDirtyRepoGoneClean: Story = { everDirty: new Set(["/home/coder/coder"]), }, play: async ({ canvasElement }) => { - // The repo tab is still present (identified by the 'Working' - // prefix used by GitPanel's tab-strip button) even though the - // current diff is empty, because it was dirty earlier in the - // session. - const tabs = Array.from(canvasElement.querySelectorAll("button")).filter( - (b) => (b.textContent ?? "").startsWith("Working"), + // The switcher trigger is still rendered (identified by its + // stable test id) even though the current diff is empty, + // because the repo was dirty earlier in the session. It reads + // "Working coder" for the single available view. + const switcher = canvasElement.querySelector( + "[data-testid='git-panel-view-switcher']", ); - expect(tabs).toHaveLength(1); + expect(switcher).not.toBeNull(); + expect(switcher?.textContent ?? "").toContain("Working"); // The content pane shows the diff viewer's empty-diff state. expect(canvasElement.textContent ?? "").toContain("No file changes"); @@ -396,8 +462,8 @@ export const EverDirtyRepoGoneClean: Story = { /** * Baseline: a repo reported clean from the start (never dirty in - * this session) has no tab. Ensures the ever-dirty fix did not - * regress the "nothing to show" case. + * this session) has no switcher entry. Ensures the ever-dirty fix + * did not regress the "nothing to show" case. */ export const CleanRepoFromStart: Story = { args: { @@ -407,11 +473,11 @@ export const CleanRepoFromStart: Story = { everDirty: new Set(), }, play: async ({ canvasElement }) => { - // No local repo tab should appear in the tab strip. The - // 'Working' prefix is GitPanel's tab-strip label contract. - const tabs = Array.from(canvasElement.querySelectorAll("button")).filter( - (b) => (b.textContent ?? "").startsWith("Working"), + // No "Working" state appears when no repo has ever been dirty. + // The switcher falls back to its empty placeholder text. + const switcher = canvasElement.querySelector( + "[data-testid='git-panel-view-switcher']", ); - expect(tabs).toHaveLength(0); + expect(switcher?.textContent ?? "").not.toContain("Working"); }, }; diff --git a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx index f96379e1db1..be3ba5655d7 100644 --- a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx +++ b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx @@ -1,5 +1,6 @@ import { CheckIcon, + ChevronDownIcon, CircleDotIcon, ColumnsIcon, GitBranchIcon, @@ -18,7 +19,17 @@ import type { WorkspaceAgentRepoChanges, } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; -import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; import type { ChatMessageInputRef } from "../AgentChatInput"; import { DiffStatBadge } from "../DiffViewer/DiffStats"; @@ -78,6 +89,48 @@ function repoTabLabel(repoRoot: string): string { return segments[segments.length - 1] ?? repoRoot; } +/** + * Total number of switchable views in the git panel. Used by callers to + * decorate the `Git` sidebar tab label with a count. + */ +export function countGitPanelViews( + repositories: ReadonlyMap, + remoteDiffStats: ChatDiffStatus | undefined, + prTab: { prNumber: number } | undefined, + everDirty: ReadonlySet | undefined, +): number { + const hasRemoteDiff = + (remoteDiffStats?.changed_files ?? 0) > 0 || + (remoteDiffStats?.additions ?? 0) > 0 || + (remoteDiffStats?.deletions ?? 0) > 0; + const showRemote = Boolean(prTab) || hasRemoteDiff; + + const localRoots = new Set(); + for (const [root, repo] of repositories.entries()) { + if (repoHasDiff(repo)) { + localRoots.add(root); + } + } + if (everDirty) { + for (const root of everDirty) { + if (repositories.has(root)) { + localRoots.add(root); + } + } + } + + return (showRemote ? 1 : 0) + localRoots.size; +} + +function repoHasDiff(repo: WorkspaceAgentRepoChanges): boolean { + if (!repo.unified_diff) return false; + for (const line of repo.unified_diff.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) return true; + if (line.startsWith("-") && !line.startsWith("---")) return true; + } + return false; +} + export const GitPanel: FC = ({ prTab, repositories, @@ -191,81 +244,99 @@ export const GitPanel: FC = ({ spinTimerRef.current = setTimeout(() => setSpinning(false), 1000); }; + // A single dropdown item for either the PR view or a Working repo. + // Rendered inside `ViewSwitcher` and used to compute the current + // trigger contents. + type ViewItem = { + id: string; + kind: "remote" | "local"; + repoRoot?: string; + /** Left-pill label on the trigger (e.g. "Open", "Merged", "Working"). */ + stateLabel: string; + /** Right-side label on the trigger (e.g. "PR #4847", "coder"). */ + triggerIdentifier: string; + /** Primary text in the dropdown item (e.g. "PR #4847", "Working"). */ + itemPrimary: string; + /** Secondary text in the dropdown item (e.g. PR title, repo name). */ + itemSecondary?: string; + stateClasses: string; + icon: React.ReactNode; + }; + + const remoteItem: ViewItem | null = showRemoteTab + ? prTab + ? { + kind: "remote", + id: "remote", + stateLabel: prStateLabel(prState, prDraft), + triggerIdentifier: `PR #${prTab.prNumber}`, + itemPrimary: `PR #${prTab.prNumber}`, + itemSecondary: prTitle || undefined, + stateClasses: prStateClasses(prState, prDraft), + icon: ( + + ), + } + : { + kind: "remote", + id: "remote", + stateLabel: "Branch", + triggerIdentifier: "Branch", + itemPrimary: "Branch", + stateClasses: "text-content-secondary", + icon: , + } + : null; + + const localItems: ViewItem[] = localRepos.map((repoRoot) => ({ + kind: "local" as const, + id: `local:${repoRoot}`, + repoRoot, + stateLabel: "Working", + triggerIdentifier: repoTabLabel(repoRoot), + itemPrimary: "Working", + itemSecondary: repoTabLabel(repoRoot), + stateClasses: "text-content-warning bg-surface-orange", + icon: , + })); + + const items: ViewItem[] = [ + ...(remoteItem ? [remoteItem] : []), + ...localItems, + ]; + + const activeItem: ViewItem | undefined = + view.type === "remote" + ? (remoteItem ?? undefined) + : items.find( + (item) => item.kind === "local" && item.repoRoot === view.repoRoot, + ); + + const handleSelectItem = (item: ViewItem) => { + if (item.kind === "remote") { + setView({ type: "remote" }); + } else if (item.repoRoot) { + setView({ type: "local", repoRoot: item.repoRoot }); + } + }; + return (
{/* Toolbar */} -
- {/* Tabs — scrollable when they overflow */} - -
- {showRemoteTab && ( - - )} - {localRepos.map((repoRoot) => { - const isActive = - view.type === "local" && view.repoRoot === repoRoot; - return ( - - ); - })} -
-
+
+
+ +
{/* Controls */} -
+
+ {/* PR title row: shown below the switcher when the chat has a PR + with a known title. Truncated, with the full title in a + hover tooltip. */} + {prTab && prTitle && ( +
+ + + + {prTitle} + + + + {prTitle} + + +
+ )} {/* Content */}
{view.type === "remote" ? ( @@ -352,6 +443,118 @@ export const GitPanel: FC = ({
); }; + +// --------------------------------------------------------------- +// View switcher: dropdown replacing the old tab strip. +// --------------------------------------------------------------- + +interface ViewSwitcherProps { + items: ReadonlyArray<{ + id: string; + kind: "remote" | "local"; + stateLabel: string; + triggerIdentifier: string; + itemPrimary: string; + itemSecondary?: string; + stateClasses: string; + icon: React.ReactNode; + }>; + activeItem?: { + id: string; + stateLabel: string; + triggerIdentifier: string; + stateClasses: string; + icon: React.ReactNode; + }; + onSelect: (item: ViewSwitcherProps["items"][number]) => void; +} + +const ViewSwitcher: FC = ({ + items, + activeItem, + onSelect, +}) => { + // When there is nothing to switch between, still render a + // placeholder so the toolbar keeps a stable height. It reads the + // same as the tab strip did in the empty state. + if (!activeItem) { + return ( +
+ + No changes +
+ ); + } + + const triggerContent = ( + <> + + + {activeItem.icon} + + {activeItem.stateLabel} + + + {activeItem.triggerIdentifier} + + + + ); + + const triggerClasses = + "inline-flex h-6 min-w-0 max-w-full cursor-pointer items-stretch overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary text-xs transition-colors hover:bg-surface-secondary"; + + return ( + + + + + + {items.map((item) => { + const isActive = item.id === activeItem.id; + return ( + onSelect(item)} + className={cn( + "flex w-full items-center gap-2 px-2 py-1.5 text-xs", + isActive && "bg-surface-secondary text-content-primary", + )} + > + + {item.icon} + + + {item.itemPrimary} + + {item.itemSecondary && ( + + {item.itemSecondary} + + )} + + ); + })} + + + ); +}; + // --------------------------------------------------------------- // Remote view (branch/PR diff) // --------------------------------------------------------------- @@ -493,6 +696,32 @@ const RepoHeader: FC<{ ); }; +// --------------------------------------------------------------- +// PR state helpers +// --------------------------------------------------------------- + +/** Human-readable state label for the view-switcher trigger. */ +function prStateLabel(state: string | undefined, draft: boolean | undefined) { + if (state === "merged") return "Merged"; + if (state === "closed") return "Closed"; + if (draft) return "Draft"; + return "Open"; +} + +/** Tailwind classes for the state pill on the view-switcher trigger. */ +function prStateClasses(state: string | undefined, draft: boolean | undefined) { + if (state === "merged") { + return "text-git-merged-bright bg-surface-git-merged"; + } + if (state === "closed") { + return "text-git-deleted-bright bg-surface-git-deleted"; + } + if (draft) { + return "text-content-secondary bg-surface-secondary"; + } + return "text-git-added-bright bg-surface-git-added"; +} + // --------------------------------------------------------------- // PR state icon (compact, for the tab bar) // --------------------------------------------------------------- From 16fb0a709a53689ec74efc683c192c4b85306cd0 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Tue, 7 Jul 2026 06:30:04 +0000 Subject: [PATCH 02/18] fix(site/AgentsPage): remove background pill on Working state in git panel switcher --- site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx index be3ba5655d7..082fc1df55f 100644 --- a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx +++ b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx @@ -300,7 +300,7 @@ export const GitPanel: FC = ({ triggerIdentifier: repoTabLabel(repoRoot), itemPrimary: "Working", itemSecondary: repoTabLabel(repoRoot), - stateClasses: "text-content-warning bg-surface-orange", + stateClasses: "text-content-warning", icon: , })); From ef3b17b3b12e6143c0fc71dfbb7615491c59cb40 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Tue, 7 Jul 2026 06:31:31 +0000 Subject: [PATCH 03/18] fix(site/AgentsPage): drop chevron and dropdown behavior in git switcher when there is only one view --- .../components/GitPanel/GitPanel.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx index 082fc1df55f..0534d8be2c7 100644 --- a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx +++ b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.tsx @@ -486,6 +486,8 @@ const ViewSwitcher: FC = ({ ); } + const isSingleItem = items.length <= 1; + const triggerContent = ( <> = ({ {activeItem.triggerIdentifier} - + {!isSingleItem && ( + + )} ); - const triggerClasses = - "inline-flex h-6 min-w-0 max-w-full cursor-pointer items-stretch overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary text-xs transition-colors hover:bg-surface-secondary"; + const triggerBase = + "inline-flex h-6 min-w-0 max-w-full items-stretch overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary text-xs"; + const triggerInteractive = + "cursor-pointer transition-colors hover:bg-surface-secondary"; + + // With a single item there is nothing to pick, so we render a + // static wrapper (no chevron, no dropdown behavior) that keeps + // the same visual footprint as the interactive trigger. + if (isSingleItem) { + return ( +
+ {triggerContent} +
+ ); + } return (