diff --git a/src/components/layout/sidebar-workspace-row.tsx b/src/components/layout/sidebar-workspace-row.tsx index ffaeb59a..31b3037e 100644 --- a/src/components/layout/sidebar-workspace-row.tsx +++ b/src/components/layout/sidebar-workspace-row.tsx @@ -39,7 +39,7 @@ import { Loader2, } from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { PrStatusIcon, humanizePrState, prStatusToneClass } from "@/components/github/pr-status-icon"; +import { PrStatusIcon, humanizePrState } from "@/components/github/pr-status-icon"; import { activateWorkspace, checkoutDefaultBranchInWorkspace, @@ -586,6 +586,19 @@ export function SidebarWorkspaceRow({ workspace, isActive }: Props) { const isPushOrPullInFlight = useAppStore( (s) => s.workspacePushPullInFlight === workspace.workspace_id, ); + + // When a worktree workspace has a PR, the leading icon doubles as + // the PR-state indicator (open=green, merged=purple, closed=red, + // draft=gray) and becomes a clickable button that opens the PR URL. + // The PR number rides in the tooltip on hover; there's no trailing + // pill, since that would duplicate the same signal. + const isWorktreeRow = + !isPushOrPullInFlight && + !isRemote && + !isPrimary && + workspace.workspace_type !== "open_flow"; + const showWorkspaceIconAsPr = isWorktreeRow && !!workspace.pr_state; + // Phase-4d elapsed-time signal: when an in-flight push/pull // crosses 2 seconds, show a small "12s" pill so the user knows // the operation is still working. Identical math to the overview @@ -620,13 +633,13 @@ export function SidebarWorkspaceRow({ workspace, isActive }: Props) { ) : isPrimary ? ( + ) : showWorkspaceIconAsPr ? ( + ) : ( ); - const showPrIcon = !!workspace.pr_state && workspaceStatus !== "working"; const prHumanState = humanizePrState(workspace.pr_state); - const prToneCls = prStatusToneClass(workspace.pr_state); const handlePrClick = (e: React.MouseEvent) => { e.stopPropagation(); if (workspace.pr_url) { @@ -652,13 +665,50 @@ export function SidebarWorkspaceRow({ workspace, isActive }: Props) { isActive && "bg-muted", )} > - {/* Icon column — size-5 to subordinate to project avatar */} + {/* Icon column — size-5 to subordinate to project avatar. + When a worktree workspace has a PR, the icon turns into a + PR-state-colored button that opens the PR URL on click. */}
{workspaceStatus === "working" ? ( ) : ( <> - {icon} + {showWorkspaceIconAsPr ? ( + + + + + + {prHumanState ? `${prHumanState} PR` : "Pull request"} + {workspace.pr_number ? ` #${workspace.pr_number}` : ""} + {workspace.pr_url ? " — click to open" : ""} + + + ) : ( + icon + )} + {/* StatusIndicator is rendered as a sibling of the + icon/button and positioned absolutely relative to + the parent `relative size-5` div, so the agent-state + dots (working amber/pulsing, review green, permission + red/pulsing) still overlay the top-right corner of + the icon column regardless of whether the icon is + the plain branch icon or the new PR-state icon. */} {workspaceStatus && ( )} - {/* Indicator cluster — muted bell, linked issue, PR. - Only renders when at least one is present. */} - {(workspace.linked_issue || showPrIcon || workspace.notifications_muted) && ( + {/* Indicator cluster — muted bell + linked issue. + The PR signal moved entirely to the leading icon + column (colored icon + tooltip with "#39 — click + to open"), so this trailing slot no longer carries + a PR number. */} + {(workspace.linked_issue || workspace.notifications_muted) && (
)} - {showPrIcon && ( - - )}
)}
diff --git a/src/components/layout/sidebar-workspace.test.tsx b/src/components/layout/sidebar-workspace.test.tsx index b95a228e..51f33aa9 100644 --- a/src/components/layout/sidebar-workspace.test.tsx +++ b/src/components/layout/sidebar-workspace.test.tsx @@ -8,6 +8,11 @@ const setShowNewWorkspaceDialogMock = vi.fn(); let enableAgentChatFlag = false; let enableLazyFlag = false; +const mockOpenUrl = vi.fn().mockResolvedValue(undefined); +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: (...args: unknown[]) => mockOpenUrl(...args), +})); + // Mock Tauri commands vi.mock("@/tauri/commands", () => ({ activateWorkspace: vi.fn().mockResolvedValue(undefined), @@ -330,6 +335,122 @@ describe("SidebarWorkspaceRow", () => { expect(branchIcon).toBeInTheDocument(); }); + // ── PR-state icon replaces the GitBranch icon ── + // + // When a worktree workspace has a pull request, the leading icon turns + // into the PR-state-colored icon so the row carries the open/merged/ + // closed signal at its leading edge. The right cluster keeps just the + // muted "#39" number. + + it("shows GitPullRequest icon (not GitBranch) when the worktree has an open PR", () => { + const ws = makeWorkspace({ + worktree_path: "/home/user/.worktrees/feature", + pr_state: "OPEN", + pr_number: 39, + pr_url: "https://github.com/u/r/pull/39", + }); + const { container } = render( + + + , + ); + // PrStatusIcon for "open" renders the GitPullRequest lucide icon. + expect(container.querySelector("svg.lucide-git-pull-request")).toBeInTheDocument(); + // The plain branch icon is replaced. + expect(container.querySelector("svg.lucide-git-branch")).toBeNull(); + }); + + it("shows GitMerge icon when the worktree's PR is merged", () => { + const ws = makeWorkspace({ + worktree_path: "/home/user/.worktrees/feature", + pr_state: "MERGED", + pr_number: 39, + pr_url: "https://github.com/u/r/pull/39", + }); + const { container } = render( + + + , + ); + expect(container.querySelector("svg.lucide-git-merge")).toBeInTheDocument(); + expect(container.querySelector("svg.lucide-git-branch")).toBeNull(); + }); + + it("shows the closed PR icon when the worktree's PR is closed", () => { + const ws = makeWorkspace({ + worktree_path: "/home/user/.worktrees/feature", + pr_state: "CLOSED", + pr_number: 39, + pr_url: "https://github.com/u/r/pull/39", + }); + const { container } = render( + + + , + ); + expect(container.querySelector("svg.lucide-git-pull-request-closed")).toBeInTheDocument(); + expect(container.querySelector("svg.lucide-git-branch")).toBeNull(); + }); + + it("opens the PR URL when the leading PR icon is clicked (stops propagation, does not activate workspace)", () => { + mockOpenUrl.mockClear(); + (activateWorkspace as ReturnType).mockClear(); + const ws = makeWorkspace({ + worktree_path: "/home/user/.worktrees/feature", + pr_state: "OPEN", + pr_number: 39, + pr_url: "https://github.com/u/r/pull/39", + }); + const { container } = render( + + + , + ); + const btn = container.querySelector("button[aria-label*='Open PR #39']"); + expect(btn).not.toBeNull(); + fireEvent.click(btn!); + expect(mockOpenUrl).toHaveBeenCalledWith("https://github.com/u/r/pull/39"); + // The icon click must NOT activate the workspace — that's reserved + // for the rest of the row. + expect(activateWorkspace).not.toHaveBeenCalled(); + }); + + it("does NOT render the PR number in the trailing cluster (PR signal is fully on the leading icon)", () => { + const ws = makeWorkspace({ + worktree_path: "/home/user/.worktrees/feature", + pr_state: "OPEN", + pr_number: 39, + pr_url: "https://github.com/u/r/pull/39", + }); + const { container } = render( + + + , + ); + // The "#39" duplicate is gone — the leading icon's tooltip already + // carries the PR number, so showing it again in the row's trailing + // slot is redundant noise. + expect(container.textContent).not.toContain("#39"); + // The only PR aria-label belongs to the leading icon button in the + // icon column — the old colored-pill button is gone. + const prButtons = container.querySelectorAll("button[aria-label*='Open PR']"); + expect(prButtons.length).toBe(1); + }); + + it("falls back to GitBranch when the worktree has no PR", () => { + const ws = makeWorkspace({ + worktree_path: "/home/user/.worktrees/feature", + pr_state: null, + pr_number: null, + pr_url: null, + }); + const { container } = render( + , + ); + expect(container.querySelector("svg.lucide-git-branch")).toBeInTheDocument(); + expect(container.querySelector("svg.lucide-git-pull-request")).toBeNull(); + }); + it("hides remove button for primary checkout (Hide-only via right-click)", () => { const ws = makeWorkspace({ worktree_path: null }); const { container } = render(