Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 61 additions & 32 deletions src/components/layout/sidebar-workspace-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -620,13 +633,13 @@ export function SidebarWorkspaceRow({ workspace, isActive }: Props) {
<Workflow className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : isPrimary ? (
<Laptop className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : showWorkspaceIconAsPr ? (
<PrStatusIcon state={workspace.pr_state} size={3.5} />
) : (
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
);

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) {
Expand All @@ -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. */}
<div className="relative size-5 flex items-center justify-center shrink-0 mr-2">
{workspaceStatus === "working" ? (
<AsciiSpinner />
) : (
<>
{icon}
{showWorkspaceIconAsPr ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handlePrClick}
disabled={!workspace.pr_url}
aria-label={
workspace.pr_number
? `Open PR #${workspace.pr_number} on GitHub — ${prHumanState ?? "Pull request"}`
: `Open pull request on GitHub — ${prHumanState ?? ""}`
}
className={cn(
"inline-flex items-center justify-center rounded transition-opacity",
workspace.pr_url ? "hover:opacity-70" : "cursor-not-allowed opacity-60",
)}
>
{icon}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4} className="text-xs">
{prHumanState ? `${prHumanState} PR` : "Pull request"}
{workspace.pr_number ? ` #${workspace.pr_number}` : ""}
{workspace.pr_url ? " — click to open" : ""}
</TooltipContent>
</Tooltip>
) : (
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 && (
<StatusIndicator
status={workspaceStatus}
Expand Down Expand Up @@ -740,9 +790,12 @@ export function SidebarWorkspaceRow({ workspace, isActive }: Props) {
</span>
)}

{/* 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) && (
<div className={cn(
"flex items-center gap-1 shrink-0",
!hasDiff && "ml-auto",
Expand All @@ -766,30 +819,6 @@ export function SidebarWorkspaceRow({ workspace, isActive }: Props) {
issue={workspace.linked_issue}
/>
)}
{showPrIcon && (
<button
type="button"
onClick={handlePrClick}
disabled={!workspace.pr_url}
aria-label={
workspace.pr_number
? `Open PR #${workspace.pr_number} on GitHub — ${prHumanState ?? "Pull request"}`
: `Open pull request on GitHub — ${prHumanState ?? ""}`
}
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-px transition-opacity",
prToneCls,
workspace.pr_url ? "hover:opacity-80" : "cursor-not-allowed opacity-60",
)}
>
<PrStatusIcon state={workspace.pr_state} size={3} />
{workspace.pr_number && (
<span className="text-[10px] tabular-nums text-muted-foreground/60">
#{workspace.pr_number}
</span>
)}
</button>
)}
</div>
)}
</div>
Expand Down
121 changes: 121 additions & 0 deletions src/components/layout/sidebar-workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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(
<TooltipProvider>
<SidebarWorkspaceRow workspace={ws} isActive={false} />
</TooltipProvider>,
);
// 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(
<TooltipProvider>
<SidebarWorkspaceRow workspace={ws} isActive={false} />
</TooltipProvider>,
);
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(
<TooltipProvider>
<SidebarWorkspaceRow workspace={ws} isActive={false} />
</TooltipProvider>,
);
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<typeof vi.fn>).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(
<TooltipProvider>
<SidebarWorkspaceRow workspace={ws} isActive={false} />
</TooltipProvider>,
);
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(
<TooltipProvider>
<SidebarWorkspaceRow workspace={ws} isActive={false} />
</TooltipProvider>,
);
// 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(
<SidebarWorkspaceRow workspace={ws} isActive={false} />,
);
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(
Expand Down
Loading