authorgravatar for 10465470+TheIcarusWings@users.noreply.github.comIcarus Wings <10465470+TheIcarusWings@users.noreply.github.com> 2026-06-19 19:20:59+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 11:20:59-07:00
log494350cc0b8b85702d1207cd96912c8325588a63
treef98f0ee33f808a36aa4abe4e01b3e4772d8800ae
parent7dc182337cfddf3de1a3b2c2e1ccae810f60901a
signature Signed by PGP key B5690EEEBB952194

feat(composer): clickable PR pill next to branch selector (#3065)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 88 insertions(+), 33 deletions(-)

apps/web/src/components/BranchToolbarBranchSelector.tsx+49-10
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20} from "react";20} from "react";
2121
22import { useComposerDraftStore, type DraftId } from "../composerDraftStore";22import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
23import { useOpenPrLink } from "../lib/openPullRequestLink";
23import { usePaginatedBranches } from "../state/queries";24import { usePaginatedBranches } from "../state/queries";
24import { useProject, useThread } from "../state/entities";25import { useProject, useThread } from "../state/entities";
25import { useEnvironmentQuery } from "../state/query";26import { useEnvironmentQuery } from "../state/query";
...@@ -37,9 +38,13 @@ import {...@@ -37,9 +38,13 @@ import {
37 resolveEffectiveEnvMode,38 resolveEffectiveEnvMode,
38 shouldIncludeBranchPickerItem,39 shouldIncludeBranchPickerItem,
39} from "./BranchToolbar.logic";40} from "./BranchToolbar.logic";
41import {
42 ChangeRequestStatusIcon,
43 prStatusIndicator,
44 resolveThreadPr,
45} from "./ThreadStatusIndicators";
40import { Button } from "./ui/button";46import { Button } from "./ui/button";
41import { Switch } from "./ui/switch";47import { Switch } from "./ui/switch";
42import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
43import {48import {
44 Combobox,49 Combobox,
45 ComboboxEmpty,50 ComboboxEmpty,
...@@ -51,6 +56,7 @@ import {...@@ -51,6 +56,7 @@ import {
51 ComboboxTrigger,56 ComboboxTrigger,
52} from "./ui/combobox";57} from "./ui/combobox";
53import { stackedThreadToast, toastManager } from "./ui/toast";58import { stackedThreadToast, toastManager } from "./ui/toast";
59import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
5460
55interface BranchToolbarBranchSelectorProps {61interface BranchToolbarBranchSelectorProps {
56 className?: string;62 className?: string;
...@@ -525,6 +531,16 @@ export function BranchToolbarBranchSelector({...@@ -525,6 +531,16 @@ export function BranchToolbarBranchSelector({
525 resolvedActiveBranch,531 resolvedActiveBranch,
526 });532 });
527533
534 // PR pill shown next to the branch selector when the active branch has one.
535 const branchPr = resolveThreadPr(resolvedActiveBranch, branchStatusQuery.data ?? null);
536 const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);
537 // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
538 // state-description tooltip.
539 const branchPrTooltip = branchPr
540 ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state}) in browser`
541 : "";
542 const openPrLink = useOpenPrLink();
543
528 function renderPickerItem(itemValue: string, index: number) {544 function renderPickerItem(itemValue: string, index: number) {
529 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {545 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
530 return (546 return (
...@@ -621,15 +637,38 @@ export function BranchToolbarBranchSelector({...@@ -621,15 +637,38 @@ export function BranchToolbarBranchSelector({
621 open={isBranchMenuOpen}637 open={isBranchMenuOpen}
622 value={resolvedActiveBranch}638 value={resolvedActiveBranch}
623 >639 >
624 <ComboboxTrigger640 <div className={cn("flex min-w-0 items-center gap-1", className)}>
625 render={<Button variant="ghost" size="xs" />}641 {branchPr && branchPrStatus ? (
626 className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)}642 <Tooltip>
627 disabled={isInitialBranchesLoadPending || isBranchActionPending}643 <TooltipTrigger
628 >644 render={
629 <GitBranchIcon className="size-3 shrink-0 opacity-70" />645 <button
630 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>646 type="button"
631 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />647 aria-label={branchPrTooltip}
632 </ComboboxTrigger>648 onClick={(event) => openPrLink(event, branchPrStatus.url)}
649 className={cn(
650 "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",
651 branchPrStatus.colorClass,
652 )}
653 />
654 }
655 >
656 <ChangeRequestStatusIcon className="size-3" />
657 <span>#{branchPr.number}</span>
658 </TooltipTrigger>
659 <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup>
660 </Tooltip>
661 ) : null}
662 <ComboboxTrigger
663 render={<Button variant="ghost" size="xs" />}
664 className="min-w-0 text-muted-foreground/70 hover:text-foreground/80"
665 disabled={isInitialBranchesLoadPending || isBranchActionPending}
666 >
667 <GitBranchIcon className="size-3 shrink-0 opacity-70" />
668 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>
669 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
670 </ComboboxTrigger>
671 </div>
633 <ComboboxPopup align="end" side="top" className="flex w-80 flex-col">672 <ComboboxPopup align="end" side="top" className="flex w-80 flex-col">
634 <div className="shrink-0 px-3 pt-2.5">673 <div className="shrink-0 px-3 pt-2.5">
635 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">674 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
apps/web/src/components/Sidebar.tsx+2-23
...@@ -69,6 +69,7 @@ import {...@@ -69,6 +69,7 @@ import {
69} from "@t3tools/contracts/settings";69} from "@t3tools/contracts/settings";
70import { isElectron } from "../env";70import { isElectron } from "../env";
71import { APP_STAGE_LABEL, APP_VERSION } from "../branding";71import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
72import { useOpenPrLink } from "../lib/openPullRequestLink";
72import { isTerminalFocused } from "../lib/terminalFocus";73import { isTerminalFocused } from "../lib/terminalFocus";
73import { isMacPlatform } from "../lib/utils";74import { isMacPlatform } from "../lib/utils";
74import {75import {
...@@ -1156,29 +1157,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1156,29 +1157,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1156 );1157 );
1157 },1158 },
1158 });1159 });
1159 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {1160 const openPrLink = useOpenPrLink();
1160 event.preventDefault();
1161 event.stopPropagation();
1162
1163 const api = readLocalApi();
1164 if (!api) {
1165 toastManager.add({
1166 type: "error",
1167 title: "Link opening is unavailable.",
1168 });
1169 return;
1170 }
1171
1172 void api.shell.openExternal(prUrl).catch((error) => {
1173 toastManager.add(
1174 stackedThreadToast({
1175 type: "error",
1176 title: "Unable to open pull request link",
1177 description: error instanceof Error ? error.message : "An error occurred.",
1178 }),
1179 );
1180 });
1181 }, []);
1182 const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs);1161 const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs);
1183 const sidebarThreadByKey = useMemo(1162 const sidebarThreadByKey = useMemo(
1184 () =>1163 () =>
apps/web/src/lib/openPullRequestLink.ts created+37
...@@ -0,0 +1,37 @@
1import { type MouseEvent, useCallback } from "react";
2
3import { stackedThreadToast, toastManager } from "../components/ui/toast";
4import { readLocalApi } from "../localApi";
5
6/**
7 * Returns a click handler that opens a pull request URL in the system browser.
8 *
9 * Stops event propagation/default so activating the link does not also trigger
10 * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a
11 * toast when the local API is unavailable or the open fails.
12 */
13export function useOpenPrLink() {
14 return useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => {
15 event.preventDefault();
16 event.stopPropagation();
17
18 const api = readLocalApi();
19 if (!api) {
20 toastManager.add({
21 type: "error",
22 title: "Link opening is unavailable.",
23 });
24 return;
25 }
26
27 void api.shell.openExternal(prUrl).catch((error) => {
28 toastManager.add(
29 stackedThreadToast({
30 type: "error",
31 title: "Unable to open pull request link",
32 description: error instanceof Error ? error.message : "An error occurred.",
33 }),
34 );
35 });
36 }, []);
37}