diff --git a/site/src/pages/AgentsPage/components/DiffViewer/RemoteDiffPanel.tsx b/site/src/pages/AgentsPage/components/DiffViewer/RemoteDiffPanel.tsx index b506840e87900..d8f821810167e 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"; @@ -58,45 +53,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 // ------------------------------------------------------------------- @@ -152,8 +108,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; @@ -189,7 +143,6 @@ export const RemoteDiffPanel: FC = ({ )}
- {diffStatus?.additions || diffStatus?.deletions ? ( { const canvas = within(canvasElement); - // The branch row exposes a button that copies the PR head - // branch name. The aria-label embeds the branch so a single - // query is enough to assert both presence and target. + // The aria-label embeds the head branch, so a single query + // asserts both presence and target. await expect( canvas.getByLabelText("Copy branch name: feat/add-mcp-config"), ).toBeVisible(); + + const switcher = canvas.getByTestId("git-panel-view-switcher"); + await expect(switcher).toHaveTextContent("Open"); + await expect(switcher).toHaveTextContent("PR #23020"); + + const title = canvas.getByTestId("git-panel-pr-title"); + await expect(title).toHaveTextContent( + "feat(agents): add MCP server configuration to agents", + ); + }, +}; + +/** + * Opens the dropdown, asserts the PR + working repos appear, then + * clicks a working entry to verify the view swap. + */ +export const ViewSwitcherOpen: Story = { + args: { + prTab: { prNumber: 23020, chatId: "test-chat" }, + remoteDiffStats: makePrStatus({ + pull_request_title: "feat: multi-repo workspace support", + head_branch: "feat/multi-repo", + }), + 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.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"); + + // Selecting a menu item swaps the active view and the trigger + // identifier reflects the new selection. + const otherProjectItem = within(menu).getByText("other-project"); + await userEvent.click(otherProjectItem); + await waitFor(() => { + expect(canvas.getByTestId("git-panel-view-switcher")).toHaveTextContent( + "other-project", + ); + }); + await expect( + canvas.getByTestId("git-panel-view-switcher"), + ).toHaveTextContent("Working"); }, }; @@ -172,6 +242,11 @@ export const DraftPullRequest: Story = { diff: sampleDiff, }); }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const switcher = canvas.getByTestId("git-panel-view-switcher"); + await expect(switcher).toHaveTextContent("Draft"); + }, }; /** Merged PR. */ @@ -194,6 +269,11 @@ export const MergedPullRequest: Story = { diff: sampleDiff, }); }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const switcher = canvas.getByTestId("git-panel-view-switcher"); + await expect(switcher).toHaveTextContent("Merged"); + }, }; /** Closed PR. */ @@ -216,6 +296,11 @@ export const ClosedPullRequest: Story = { diff: sampleDiff, }); }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const switcher = canvas.getByTestId("git-panel-view-switcher"); + await expect(switcher).toHaveTextContent("Closed"); + }, }; /** Branch pushed but no PR opened yet. */ @@ -367,10 +452,8 @@ 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". + * Regression: a repo that was dirty earlier in the session must + * keep its switcher entry even after its unified_diff empties. */ export const EverDirtyRepoGoneClean: Story = { args: { @@ -380,24 +463,24 @@ 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"), + // Before the ever-dirty fix, the entry vanished the moment the + // diff emptied and users saw the diff "disappear between + // edit_files". The entry must persist here. + 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. + // The content pane falls through to the diff viewer's empty state. expect(canvasElement.textContent ?? "").toContain("No file changes"); }, }; /** * 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 +490,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"), + const switcher = canvasElement.querySelector( + "[data-testid='git-panel-view-switcher']", ); - expect(tabs).toHaveLength(0); + expect(switcher).not.toBeNull(); + expect(switcher?.textContent ?? "").toContain("No changes"); + 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 f96379e1db18e..4fc4c3fcefb87 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"; @@ -54,7 +65,7 @@ interface GitPanelProps { repositories: ReadonlyMap; /** Callback to send a refresh to the git watcher. Returns false when disconnected. */ onRefresh: () => boolean; - /** Called when the user clicks the Commit button in any repo tab. */ + /** Called when the user clicks the Commit button for a working repo. */ onCommit: (repoRoot: string) => void; /** Whether the panel is in expanded/fullscreen mode. */ isExpanded?: boolean; @@ -66,18 +77,36 @@ interface GitPanelProps { chatInputRef?: RefObject; /** * Repo roots that have been dirty at some point during this session. - * Used to keep a repo's tab visible after its diff goes empty, so the - * tab strip does not visibly flip when the agent edits a file and - * then reverts it. + * Used to keep a repo's entry visible after its diff goes empty, so + * the view switcher does not visibly flip when the agent edits a + * file and then reverts it. */ everDirty?: ReadonlySet; } -function repoTabLabel(repoRoot: string): string { +function repoLabel(repoRoot: string): string { const segments = repoRoot.split("/").filter(Boolean); return segments[segments.length - 1] ?? repoRoot; } +interface ViewItemBase { + id: 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; +} + +type ViewItem = + | (ViewItemBase & { kind: "remote" }) + | (ViewItemBase & { kind: "local"; repoRoot: string }); + export const GitPanel: FC = ({ prTab, repositories, @@ -102,7 +131,8 @@ export const GitPanel: FC = ({ const prState = remoteDiffStats?.pull_request_state; const prDraft = remoteDiffStats?.pull_request_draft; - // Compute per-repo diff stats from unified diffs. + // Compute per-repo diff stats from unified diffs. The React + // Compiler memoizes these derivations. const repoStats = (() => { const stats = new Map(); for (const [root, repo] of repositories.entries()) { @@ -124,7 +154,7 @@ export const GitPanel: FC = ({ })(); // Union of currently-dirty and ever-dirty repos (still known to - // the watcher) so a clean-revert does not hide the tab. + // the watcher) so a clean-revert does not hide the entry. const localRepos = (() => { const roots = new Set(repoStats.keys()); if (everDirty) { @@ -137,8 +167,8 @@ export const GitPanel: FC = ({ return Array.from(roots).sort((a, b) => a.localeCompare(b)); })(); - // Default to the first local repo when there are only local - // changes and no remote stats. + // Default to the first local repo when nothing has been pushed + // upstream yet, so the panel opens on the diff the user just made. const [view, setView] = useState(() => { if (!showRemoteTab && localRepos.length > 0) { return { type: "local", repoRoot: localRepos[0] }; @@ -146,7 +176,7 @@ export const GitPanel: FC = ({ return { type: "remote" }; }); - // If the active tab gets hidden, switch to the first available. + // If the active view gets hidden, switch to the first available. useEffect(() => { if (view.type === "remote" && !showRemoteTab) { if (localRepos.length > 0) { @@ -154,7 +184,7 @@ export const GitPanel: FC = ({ } } else if (view.type === "local") { // localRepos includes ever-dirty repos with empty diffs, so - // the active tab stays valid until its root leaves the set. + // the active view stays valid until its root leaves the set. if (!localRepos.includes(view.repoRoot)) { if (showRemoteTab) { setView({ type: "remote" }); @@ -191,81 +221,121 @@ export const GitPanel: FC = ({ spinTimerRef.current = setTimeout(() => setSpinning(false), 1000); }; + // Reconcile a stale `view` inline so a repo removal never renders + // as "No changes" for a frame before the effect above updates. + // When nothing else is available, the remote view falls through; + // RemoteContent handles its own empty/loading state. + const effectiveView: GitView = + view.type === "remote" + ? showRemoteTab + ? view + : localRepos.length > 0 + ? { type: "local", repoRoot: localRepos[0] } + : view + : localRepos.includes(view.repoRoot) + ? view + : showRemoteTab + ? { type: "remote" } + : localRepos.length > 0 + ? { type: "local", repoRoot: localRepos[0] } + : { type: "remote" }; + + const showPrTitleRow = effectiveView.type === "remote" && prTab && prTitle; + + const [isPrTitleTruncated, setIsPrTitleTruncated] = useState(false); + // Ref callback so the observer attaches whenever the title span + // mounts: first render, switching back to the PR view, or a title + // change (via `key` on the span). ResizeObserver fires once on + // observe, which doubles as the initial truncation check. + const observePrTitle = (el: HTMLSpanElement | null) => { + if (!el) { + return; + } + const observer = new ResizeObserver(() => { + setIsPrTitleTruncated(el.scrollWidth > el.clientWidth); + }); + observer.observe(el); + return () => observer.disconnect(); + }; + + const remoteHeadBranch = remoteDiffStats?.head_branch; + 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: remoteHeadBranch || "Branch", + itemPrimary: "Branch", + itemSecondary: remoteHeadBranch || undefined, + stateClasses: "text-content-secondary", + icon: , + } + : null; + + const localItems: ViewItem[] = localRepos.map((repoRoot) => ({ + kind: "local" as const, + id: `local:${repoRoot}`, + repoRoot, + stateLabel: "Working", + triggerIdentifier: repoLabel(repoRoot), + itemPrimary: "Working", + itemSecondary: repoLabel(repoRoot), + stateClasses: "text-content-warning", + icon: , + })); + + const items: ViewItem[] = [ + ...(remoteItem ? [remoteItem] : []), + ...localItems, + ]; + + const activeItem: ViewItem | undefined = + effectiveView.type === "remote" + ? (remoteItem ?? undefined) + : items.find( + (item) => + item.kind === "local" && item.repoRoot === effectiveView.repoRoot, + ); + + const handleSelectItem = (item: ViewItem) => { + if (item.kind === "remote") { + setView({ type: "remote" }); + } else { + 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: truncates with a hover tooltip only when the + visible text is cut off. */} + {showPrTitleRow && ( +
+ + + + {prTitle} + + + + {prTitle} + + +
+ )} {/* Content */}
- {view.type === "remote" ? ( + {effectiveView.type === "remote" ? ( = ({ /> ) : ( = ({
); }; + +// --------------------------------------------------------------- +// Git view switcher: dropdown for the active PR/Branch/Working view. +// --------------------------------------------------------------- + +interface GitViewSwitcherProps { + items: ReadonlyArray; + activeItem?: ViewItem; + /** + * Whether a remote (PR or Branch) item exists in `items`. Controls + * whether local entries are visually nested (indented) under it. + */ + hasRemoteItem: boolean; + onSelect: (item: ViewItem) => void; +} + +const GitViewSwitcher: FC = ({ + items, + activeItem, + hasRemoteItem, + onSelect, +}) => { + if (!activeItem) { + return ( +
+ + No changes +
+ ); + } + + const isSingleItem = items.length <= 1; + + const triggerContent = ( + <> + + + {activeItem.icon} + + {activeItem.stateLabel} + + + {activeItem.triggerIdentifier} + {!isSingleItem && ( + + )} + + + ); + + if (isSingleItem) { + return ( +
+ {triggerContent} +
+ ); + } + + return ( + + + + + + {items.map((item) => { + const isActive = item.id === activeItem.id; + return ( + onSelect(item)} + className={cn( + "flex items-center gap-2 rounded-sm px-2 py-1.5 text-xs", + // Nest local entries under the remote/PR entry + // when one exists. Without a parent above them, + // nesting reads as an orphan indent. + item.kind === "local" && hasRemoteItem + ? "ml-4 mt-0.5" + : "w-full", + isActive && "bg-surface-secondary text-content-primary", + )} + > + + {item.icon} + + + {item.itemPrimary} + + {item.itemSecondary && ( + + {item.itemSecondary} + + )} + + ); + })} + + + ); +}; + // --------------------------------------------------------------- // Remote view (branch/PR diff) // --------------------------------------------------------------- @@ -470,7 +689,7 @@ const RepoHeader: FC<{
- {repo.branch?.trim() || repoTabLabel(repoRoot)} + {repo.branch?.trim() || repoLabel(repoRoot)} {repoRoot}
@@ -494,7 +713,33 @@ const RepoHeader: FC<{ }; // --------------------------------------------------------------- -// PR state icon (compact, for the tab bar) +// 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"; + } + if (state === "closed") { + return "text-git-deleted-bright"; + } + if (draft) { + return "text-content-secondary"; + } + return "text-git-added-bright"; +} + +// --------------------------------------------------------------- +// PR state icon (compact, for the view switcher) // --------------------------------------------------------------- export const PrStateIcon: FC<{ @@ -502,24 +747,15 @@ export const PrStateIcon: FC<{ draft?: boolean; className?: string; }> = ({ state, draft, className }) => { + const colorClass = prStateClasses(state, draft); if (state === "merged") { - return ; + return ; } if (state === "closed") { - return ( - - ); + return ; } if (draft) { - return ( - - ); + return ; } - return ( - - ); + return ; };