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(