Skip to content
Open
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
58 changes: 49 additions & 9 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {

import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
import { readEnvironmentApi } from "../environmentApi";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { useVcsStatus } from "../lib/vcsStatusState";
import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState";
import { newCommandId } from "../lib/utils";
Expand All @@ -32,6 +33,11 @@ import {
resolveEffectiveEnvMode,
shouldIncludeBranchPickerItem,
} from "./BranchToolbar.logic";
import {
ChangeRequestStatusIcon,
prStatusIndicator,
resolveThreadPr,
} from "./ThreadStatusIndicators";
import { Button } from "./ui/button";
import {
Combobox,
Expand All @@ -44,6 +50,7 @@ import {
ComboboxTrigger,
} from "./ui/combobox";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

interface BranchToolbarBranchSelectorProps {
className?: string;
Expand Down Expand Up @@ -514,6 +521,16 @@ export function BranchToolbarBranchSelector({
resolvedActiveBranch,
});

// PR pill shown next to the branch selector when the active branch has one.
const branchPr = resolveThreadPr(resolvedActiveBranch, branchStatusQuery.data ?? null);
const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);
// Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
// state-description tooltip.
const branchPrTooltip = branchPr
? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state}) in browser`
: "";
const openPrLink = useOpenPrLink();

function renderPickerItem(itemValue: string, index: number) {
if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
return (
Expand Down Expand Up @@ -610,15 +627,38 @@ export function BranchToolbarBranchSelector({
open={isBranchMenuOpen}
value={resolvedActiveBranch}
>
<ComboboxTrigger
render={<Button variant="ghost" size="xs" />}
className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)}
disabled={isInitialBranchesLoadPending || isBranchActionPending}
>
<GitBranchIcon className="size-3 shrink-0 opacity-70" />
<span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>
<ChevronDownIcon className="size-3 shrink-0 opacity-50" />
</ComboboxTrigger>
<div className={cn("flex min-w-0 items-center gap-1", className)}>
{branchPr && branchPrStatus ? (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={branchPrTooltip}
onClick={(event) => openPrLink(event, branchPrStatus.url)}
className={cn(
"inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[11px] font-medium tabular-nums transition-colors hover:bg-muted/60",
branchPrStatus.colorClass,
)}
/>
}
>
<ChangeRequestStatusIcon className="size-3" />
<span>#{branchPr.number}</span>
</TooltipTrigger>
<TooltipPopup side="top">{branchPrTooltip}</TooltipPopup>
</Tooltip>
) : null}
<ComboboxTrigger
render={<Button variant="ghost" size="xs" />}
className="min-w-0 text-muted-foreground/70 hover:text-foreground/80"
disabled={isInitialBranchesLoadPending || isBranchActionPending}
>
<GitBranchIcon className="size-3 shrink-0 opacity-70" />
<span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>
<ChevronDownIcon className="size-3 shrink-0 opacity-50" />
</ComboboxTrigger>
</div>
<ComboboxPopup align="end" side="top" className="flex w-80 flex-col">
<div className="shrink-0 px-3 pt-2.5">
<div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
Expand Down
25 changes: 2 additions & 23 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
import { usePrimaryEnvironmentId } from "../environments/primary";
import { isElectron } from "../env";
import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { isTerminalFocused } from "../lib/terminalFocus";
import { isMacPlatform, newCommandId } from "../lib/utils";
import {
Expand Down Expand Up @@ -1011,29 +1012,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
);
},
});
const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
event.preventDefault();
event.stopPropagation();

const api = readLocalApi();
if (!api) {
toastManager.add({
type: "error",
title: "Link opening is unavailable.",
});
return;
}

void api.shell.openExternal(prUrl).catch((error) => {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Unable to open pull request link",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
});
}, []);
const openPrLink = useOpenPrLink();
const sidebarThreads = useStore(
useShallow(
useMemo(
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/lib/openPullRequestLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type MouseEvent, useCallback } from "react";

import { stackedThreadToast, toastManager } from "../components/ui/toast";
import { readLocalApi } from "../localApi";

/**
* Returns a click handler that opens a pull request URL in the system browser.
*
* Stops event propagation/default so activating the link does not also trigger
* an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a
* toast when the local API is unavailable or the open fails.
*/
export function useOpenPrLink() {
return useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => {
event.preventDefault();
event.stopPropagation();

const api = readLocalApi();
if (!api) {
toastManager.add({
type: "error",
title: "Link opening is unavailable.",
});
return;
}

void api.shell.openExternal(prUrl).catch((error) => {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Unable to open pull request link",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
});
}, []);
}
Loading