authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-12 17:28:37-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-12 14:28:37-07:00
log2eb099fdc896506a2663bc7fe3117b6062ac345f
treeb5aa5e82716cf980d3476066ae2ceea59b51ecdf
parentb28f9bf0a1bd562623c027c5ed80b5ca50395b28
signature Signed by PGP key B5690EEEBB952194

fix(web): cmd+click sidebar PR numbers open in the browser (#6378)


6 files changed, 71 insertions(+), 10 deletions(-)

apps/web/src/components/LegacySidebar.tsx+9-4
......@@ -174,6 +174,7 @@ import {
174174 getSidebarThreadIdsToPrewarm,
175175 resolveAdjacentThreadId,
176176 isContextMenuPointerDown,
177 isSidebarNestedLinkClick,
177178 isTrailingDoubleClick,
178179 resolveProjectStatusIndicator,
179180 resolveThreadRowClassName,
......@@ -567,7 +568,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
567568 [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef],
568569 );
569570 const handlePrClick = useCallback(
570 (event: React.MouseEvent<HTMLButtonElement>) => {
571 (event: React.MouseEvent<HTMLAnchorElement>) => {
571572 if (!prStatus) return;
572573 const openedInRightPanel = openPrLink(
573574 event,
......@@ -694,14 +695,17 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
694695 <Tooltip>
695696 <TooltipTrigger
696697 render={
697 <button
698 type="button"
698 <a
699 href={prStatus.url}
700 target="_blank"
701 rel="noopener noreferrer"
699702 aria-label={prStatus.tooltip}
700703 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
704 onPointerDown={(event) => event.stopPropagation()}
701705 onClick={handlePrClick}
702706 >
703707 <ChangeRequestStatusIcon className="size-3" />
704 </button>
708 </a>
705709 }
706710 />
707711 <TooltipPopup side="top">
......@@ -1727,6 +1731,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17271731 threadRef: ScopedThreadRef,
17281732 orderedProjectThreadKeys: readonly string[],
17291733 ) => {
1734 if (isSidebarNestedLinkClick(event.target)) return;
17301735 const isMac = isMacPlatform(navigator.platform);
17311736 const isModClick = isMac ? event.metaKey : event.ctrlKey;
17321737 const isShiftClick = event.shiftKey;
apps/web/src/components/Sidebar.logic.test.ts+22
......@@ -12,6 +12,7 @@ import {
1212 getProjectSortTimestamp,
1313 hasUnseenCompletion,
1414 isContextMenuPointerDown,
15 isSidebarNestedLinkClick,
1516 isTrailingDoubleClick,
1617 orderItemsByPreferredIds,
1718 resolveProjectStatusIndicator,
......@@ -411,6 +412,27 @@ describe("isTrailingDoubleClick", () => {
411412 });
412413});
413414
415describe("isSidebarNestedLinkClick", () => {
416 const linkTarget = {
417 closest: (selector: string) => (selector === "a[href]" ? ({} as Element) : null),
418 } as unknown as EventTarget;
419
420 it("ignores row clicks that originated on a nested link", () => {
421 expect(isSidebarNestedLinkClick(linkTarget)).toBe(true);
422 });
423
424 it("walks up from a text node to the enclosing link", () => {
425 expect(isSidebarNestedLinkClick({ parentElement: linkTarget } as unknown as EventTarget)).toBe(
426 true,
427 );
428 });
429
430 it("leaves ordinary row clicks alone", () => {
431 expect(isSidebarNestedLinkClick({ closest: () => null } as unknown as EventTarget)).toBe(false);
432 expect(isSidebarNestedLinkClick(null)).toBe(false);
433 });
434});
435
414436describe("shouldCreateNewThreadInCurrentProject", () => {
415437 it("creates directly on shift+click in a multi-project setup", () => {
416438 expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true);
apps/web/src/components/Sidebar.logic.ts+18
......@@ -274,6 +274,24 @@ export function isTrailingDoubleClick(detail: number): boolean {
274274 return detail > 1;
275275}
276276
277function nodeClosest(node: object | null, selector: string): unknown {
278 if (node === null || !("closest" in node) || typeof node.closest !== "function") return null;
279 return node.closest(selector);
280}
281
282/** Clicks on a nested link keep the link's meaning. The row must not treat them as multi-select. */
283export function isSidebarNestedLinkClick(target: EventTarget | null): boolean {
284 if (target == null || typeof target !== "object") return false;
285 if (nodeClosest(target, "a[href]") !== null) return true;
286 const parent =
287 "parentElement" in target &&
288 target.parentElement !== null &&
289 typeof target.parentElement === "object"
290 ? target.parentElement
291 : null;
292 return nodeClosest(parent, "a[href]") !== null;
293}
294
277295// Shift+click on the new thread button creates directly in the current
278296// project, skipping the command palette's project picker. With a single
279297// project there is nothing to pick, so a plain click already creates
apps/web/src/components/Sidebar.tsx+11-4
......@@ -124,6 +124,7 @@ import {
124124 formatWorkingDurationLabel,
125125 firstValidTimestampMs,
126126 hasUnseenCompletion,
127 isSidebarNestedLinkClick,
127128 isTrailingDoubleClick,
128129 orderItemsByPreferredIds,
129130 planPinnedReorder,
......@@ -1006,7 +1007,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
10061007 if (!showSnoozeButton) setSnoozeMenuOpen(false);
10071008 }, [showSnoozeButton]);
10081009 const handlePrClick = useCallback(
1009 (event: ReactMouseEvent<HTMLElement>) => {
1010 (event: ReactMouseEvent<HTMLAnchorElement>) => {
10101011 if (!pr?.url) return;
10111012 const openedInRightPanel = openPrLink(
10121013 event,
......@@ -1083,10 +1084,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
10831084 </span>
10841085 );
10851086
1087 // A real link so cmd/ctrl+click and middle-click open the host in the
1088 // browser. A plain click still opens T3's pull request view.
10861089 const prBadge =
10871090 prStatus && pr ? (
1088 <button
1089 type="button"
1091 <a
1092 href={pr.url}
1093 target="_blank"
1094 rel="noopener noreferrer"
1095 onPointerDown={(event) => event.stopPropagation()}
10901096 onClick={handlePrClick}
10911097 className={cn(
10921098 // Sidebar chrome follows the interface font; tabular digits keep the
......@@ -1101,7 +1107,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11011107 aria-label={prStatus.tooltip}
11021108 >
11031109 #{pr.number}
1104 </button>
1110 </a>
11051111 ) : null;
11061112 const terminalStatusIcon = terminalStatus ? (
11071113 <span
......@@ -2307,6 +2313,7 @@ export default function Sidebar() {
23072313
23082314 const handleThreadClick = useCallback(
23092315 (event: ReactMouseEvent, threadRef: ScopedThreadRef) => {
2316 if (isSidebarNestedLinkClick(event.target)) return;
23102317 const isMac = isMacPlatform(navigator.platform);
23112318 const isModClick = isMac ? event.metaKey : event.ctrlKey;
23122319 const threadKey = scopedThreadKey(threadRef);
apps/web/src/lib/openPullRequestLink.ts+10-2
......@@ -255,9 +255,17 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) {
255255 const openChangeRequest = useOpenChangeRequestLink(threadRef);
256256 return useCallback(
257257 (event: MouseEvent<HTMLElement>, prUrl: string, targetThreadRef?: ScopedThreadRef) => {
258 event.preventDefault();
259258 event.stopPropagation();
260 if (openChangeRequest(event, prUrl, targetThreadRef)) return true;
259 const openInBrowser = shouldOpenPullRequestExternally(event);
260 const isAnchor =
261 event.currentTarget instanceof HTMLAnchorElement && event.currentTarget.href.length > 0;
262 // A real link already knows how to cmd/ctrl+click. Leave its default
263 // action alone so the browser (or Electron's window-open handler) opens
264 // the host. Buttons have no href, so they still go through openExternal.
265 if (openInBrowser && isAnchor) return false;
266
267 event.preventDefault();
268 if (!openInBrowser && openChangeRequest(event, prUrl, targetThreadRef)) return true;
261269
262270 const api = readLocalApi();
263271 if (!api) {
docs/user/source-control.md+1
......@@ -42,6 +42,7 @@ T3 Code works with the platforms your team already uses:
4242- While working in a thread, open linked reviews in the same compact right-panel tabs without
4343 leaving the conversation
4444- Open the review directly in your browser with one click
45- Command-click (Control-click on Windows and Linux) a pull request number in the sidebar to open it in your browser instead of in T3 Code
4546- Check out a teammate's branch to review code locally
4647
4748**Fix what you wrote, in place**