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 {...@@ -174,6 +174,7 @@ import {
174 getSidebarThreadIdsToPrewarm,174 getSidebarThreadIdsToPrewarm,
175 resolveAdjacentThreadId,175 resolveAdjacentThreadId,
176 isContextMenuPointerDown,176 isContextMenuPointerDown,
177 isSidebarNestedLinkClick,
177 isTrailingDoubleClick,178 isTrailingDoubleClick,
178 resolveProjectStatusIndicator,179 resolveProjectStatusIndicator,
179 resolveThreadRowClassName,180 resolveThreadRowClassName,
...@@ -567,7 +568,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr...@@ -567,7 +568,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
567 [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef],568 [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef],
568 );569 );
569 const handlePrClick = useCallback(570 const handlePrClick = useCallback(
570 (event: React.MouseEvent<HTMLButtonElement>) => {571 (event: React.MouseEvent<HTMLAnchorElement>) => {
571 if (!prStatus) return;572 if (!prStatus) return;
572 const openedInRightPanel = openPrLink(573 const openedInRightPanel = openPrLink(
573 event,574 event,
...@@ -694,14 +695,17 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr...@@ -694,14 +695,17 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
694 <Tooltip>695 <Tooltip>
695 <TooltipTrigger696 <TooltipTrigger
696 render={697 render={
697 <button698 <a
698 type="button"699 href={prStatus.url}
700 target="_blank"
701 rel="noopener noreferrer"
699 aria-label={prStatus.tooltip}702 aria-label={prStatus.tooltip}
700 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}703 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()}
701 onClick={handlePrClick}705 onClick={handlePrClick}
702 >706 >
703 <ChangeRequestStatusIcon className="size-3" />707 <ChangeRequestStatusIcon className="size-3" />
704 </button>708 </a>
705 }709 }
706 />710 />
707 <TooltipPopup side="top">711 <TooltipPopup side="top">
...@@ -1727,6 +1731,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1727,6 +1731,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1727 threadRef: ScopedThreadRef,1731 threadRef: ScopedThreadRef,
1728 orderedProjectThreadKeys: readonly string[],1732 orderedProjectThreadKeys: readonly string[],
1729 ) => {1733 ) => {
1734 if (isSidebarNestedLinkClick(event.target)) return;
1730 const isMac = isMacPlatform(navigator.platform);1735 const isMac = isMacPlatform(navigator.platform);
1731 const isModClick = isMac ? event.metaKey : event.ctrlKey;1736 const isModClick = isMac ? event.metaKey : event.ctrlKey;
1732 const isShiftClick = event.shiftKey;1737 const isShiftClick = event.shiftKey;
apps/web/src/components/Sidebar.logic.test.ts+22
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 getProjectSortTimestamp,12 getProjectSortTimestamp,
13 hasUnseenCompletion,13 hasUnseenCompletion,
14 isContextMenuPointerDown,14 isContextMenuPointerDown,
15 isSidebarNestedLinkClick,
15 isTrailingDoubleClick,16 isTrailingDoubleClick,
16 orderItemsByPreferredIds,17 orderItemsByPreferredIds,
17 resolveProjectStatusIndicator,18 resolveProjectStatusIndicator,
...@@ -411,6 +412,27 @@ describe("isTrailingDoubleClick", () => {...@@ -411,6 +412,27 @@ describe("isTrailingDoubleClick", () => {
411 });412 });
412});413});
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
414describe("shouldCreateNewThreadInCurrentProject", () => {436describe("shouldCreateNewThreadInCurrentProject", () => {
415 it("creates directly on shift+click in a multi-project setup", () => {437 it("creates directly on shift+click in a multi-project setup", () => {
416 expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true);438 expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true);
apps/web/src/components/Sidebar.logic.ts+18
...@@ -274,6 +274,24 @@ export function isTrailingDoubleClick(detail: number): boolean {...@@ -274,6 +274,24 @@ export function isTrailingDoubleClick(detail: number): boolean {
274 return detail > 1;274 return detail > 1;
275}275}
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
277// Shift+click on the new thread button creates directly in the current295// Shift+click on the new thread button creates directly in the current
278// project, skipping the command palette's project picker. With a single296// project, skipping the command palette's project picker. With a single
279// project there is nothing to pick, so a plain click already creates297// 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 {...@@ -124,6 +124,7 @@ import {
124 formatWorkingDurationLabel,124 formatWorkingDurationLabel,
125 firstValidTimestampMs,125 firstValidTimestampMs,
126 hasUnseenCompletion,126 hasUnseenCompletion,
127 isSidebarNestedLinkClick,
127 isTrailingDoubleClick,128 isTrailingDoubleClick,
128 orderItemsByPreferredIds,129 orderItemsByPreferredIds,
129 planPinnedReorder,130 planPinnedReorder,
...@@ -1006,7 +1007,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1006,7 +1007,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1006 if (!showSnoozeButton) setSnoozeMenuOpen(false);1007 if (!showSnoozeButton) setSnoozeMenuOpen(false);
1007 }, [showSnoozeButton]);1008 }, [showSnoozeButton]);
1008 const handlePrClick = useCallback(1009 const handlePrClick = useCallback(
1009 (event: ReactMouseEvent<HTMLElement>) => {1010 (event: ReactMouseEvent<HTMLAnchorElement>) => {
1010 if (!pr?.url) return;1011 if (!pr?.url) return;
1011 const openedInRightPanel = openPrLink(1012 const openedInRightPanel = openPrLink(
1012 event,1013 event,
...@@ -1083,10 +1084,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1083,10 +1084,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1083 </span>1084 </span>
1084 );1085 );
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.
1086 const prBadge =1089 const prBadge =
1087 prStatus && pr ? (1090 prStatus && pr ? (
1088 <button1091 <a
1089 type="button"1092 href={pr.url}
1093 target="_blank"
1094 rel="noopener noreferrer"
1095 onPointerDown={(event) => event.stopPropagation()}
1090 onClick={handlePrClick}1096 onClick={handlePrClick}
1091 className={cn(1097 className={cn(
1092 // Sidebar chrome follows the interface font; tabular digits keep the1098 // Sidebar chrome follows the interface font; tabular digits keep the
...@@ -1101,7 +1107,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1101,7 +1107,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1101 aria-label={prStatus.tooltip}1107 aria-label={prStatus.tooltip}
1102 >1108 >
1103 #{pr.number}1109 #{pr.number}
1104 </button>1110 </a>
1105 ) : null;1111 ) : null;
1106 const terminalStatusIcon = terminalStatus ? (1112 const terminalStatusIcon = terminalStatus ? (
1107 <span1113 <span
...@@ -2307,6 +2313,7 @@ export default function Sidebar() {...@@ -2307,6 +2313,7 @@ export default function Sidebar() {
23072313
2308 const handleThreadClick = useCallback(2314 const handleThreadClick = useCallback(
2309 (event: ReactMouseEvent, threadRef: ScopedThreadRef) => {2315 (event: ReactMouseEvent, threadRef: ScopedThreadRef) => {
2316 if (isSidebarNestedLinkClick(event.target)) return;
2310 const isMac = isMacPlatform(navigator.platform);2317 const isMac = isMacPlatform(navigator.platform);
2311 const isModClick = isMac ? event.metaKey : event.ctrlKey;2318 const isModClick = isMac ? event.metaKey : event.ctrlKey;
2312 const threadKey = scopedThreadKey(threadRef);2319 const threadKey = scopedThreadKey(threadRef);
apps/web/src/lib/openPullRequestLink.ts+10-2
...@@ -255,9 +255,17 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) {...@@ -255,9 +255,17 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) {
255 const openChangeRequest = useOpenChangeRequestLink(threadRef);255 const openChangeRequest = useOpenChangeRequestLink(threadRef);
256 return useCallback(256 return useCallback(
257 (event: MouseEvent<HTMLElement>, prUrl: string, targetThreadRef?: ScopedThreadRef) => {257 (event: MouseEvent<HTMLElement>, prUrl: string, targetThreadRef?: ScopedThreadRef) => {
258 event.preventDefault();
259 event.stopPropagation();258 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
262 const api = readLocalApi();270 const api = readLocalApi();
263 if (!api) {271 if (!api) {
docs/user/source-control.md+1
...@@ -42,6 +42,7 @@ T3 Code works with the platforms your team already uses:...@@ -42,6 +42,7 @@ T3 Code works with the platforms your team already uses:
42- While working in a thread, open linked reviews in the same compact right-panel tabs without42- While working in a thread, open linked reviews in the same compact right-panel tabs without
43 leaving the conversation43 leaving the conversation
44- Open the review directly in your browser with one click44- 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
45- Check out a teammate's branch to review code locally46- Check out a teammate's branch to review code locally
4647
47**Fix what you wrote, in place**48**Fix what you wrote, in place**