diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 916e3214ac1..e7436c0a75a 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -174,6 +174,7 @@ import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, + isSidebarNestedLinkClick, isTrailingDoubleClick, resolveProjectStatusIndicator, resolveThreadRowClassName, @@ -567,7 +568,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], ); const handlePrClick = useCallback( - (event: React.MouseEvent) => { + (event: React.MouseEvent) => { if (!prStatus) return; const openedInRightPanel = openPrLink( event, @@ -694,14 +695,17 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr event.stopPropagation()} onClick={handlePrClick} > - + } /> @@ -1727,6 +1731,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec threadRef: ScopedThreadRef, orderedProjectThreadKeys: readonly string[], ) => { + if (isSidebarNestedLinkClick(event.target)) return; const isMac = isMacPlatform(navigator.platform); const isModClick = isMac ? event.metaKey : event.ctrlKey; const isShiftClick = event.shiftKey; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 94e78c0216e..c6e113a4452 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -12,6 +12,7 @@ import { getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, + isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, @@ -411,6 +412,27 @@ describe("isTrailingDoubleClick", () => { }); }); +describe("isSidebarNestedLinkClick", () => { + const linkTarget = { + closest: (selector: string) => (selector === "a[href]" ? ({} as Element) : null), + } as unknown as EventTarget; + + it("ignores row clicks that originated on a nested link", () => { + expect(isSidebarNestedLinkClick(linkTarget)).toBe(true); + }); + + it("walks up from a text node to the enclosing link", () => { + expect(isSidebarNestedLinkClick({ parentElement: linkTarget } as unknown as EventTarget)).toBe( + true, + ); + }); + + it("leaves ordinary row clicks alone", () => { + expect(isSidebarNestedLinkClick({ closest: () => null } as unknown as EventTarget)).toBe(false); + expect(isSidebarNestedLinkClick(null)).toBe(false); + }); +}); + describe("shouldCreateNewThreadInCurrentProject", () => { it("creates directly on shift+click in a multi-project setup", () => { expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9b1db5cb4d9..9cb09219df0 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -274,6 +274,24 @@ export function isTrailingDoubleClick(detail: number): boolean { return detail > 1; } +function nodeClosest(node: object | null, selector: string): unknown { + if (node === null || !("closest" in node) || typeof node.closest !== "function") return null; + return node.closest(selector); +} + +/** Clicks on a nested link keep the link's meaning. The row must not treat them as multi-select. */ +export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { + if (target == null || typeof target !== "object") return false; + if (nodeClosest(target, "a[href]") !== null) return true; + const parent = + "parentElement" in target && + target.parentElement !== null && + typeof target.parentElement === "object" + ? target.parentElement + : null; + return nodeClosest(parent, "a[href]") !== null; +} + // Shift+click on the new thread button creates directly in the current // project, skipping the command palette's project picker. With a single // project there is nothing to pick, so a plain click already creates diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 840e4918bfa..f054b3deedc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -124,6 +124,7 @@ import { formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, + isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, planPinnedReorder, @@ -1006,7 +1007,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { if (!showSnoozeButton) setSnoozeMenuOpen(false); }, [showSnoozeButton]); const handlePrClick = useCallback( - (event: ReactMouseEvent) => { + (event: ReactMouseEvent) => { if (!pr?.url) return; const openedInRightPanel = openPrLink( event, @@ -1083,10 +1084,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ); + // A real link so cmd/ctrl+click and middle-click open the host in the + // browser. A plain click still opens T3's pull request view. const prBadge = prStatus && pr ? ( - + ) : null; const terminalStatusIcon = terminalStatus ? ( { + if (isSidebarNestedLinkClick(event.target)) return; const isMac = isMacPlatform(navigator.platform); const isModClick = isMac ? event.metaKey : event.ctrlKey; const threadKey = scopedThreadKey(threadRef); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index d2263d96123..0b7e6bf0f97 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -255,9 +255,17 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) { const openChangeRequest = useOpenChangeRequestLink(threadRef); return useCallback( (event: MouseEvent, prUrl: string, targetThreadRef?: ScopedThreadRef) => { - event.preventDefault(); event.stopPropagation(); - if (openChangeRequest(event, prUrl, targetThreadRef)) return true; + const openInBrowser = shouldOpenPullRequestExternally(event); + const isAnchor = + event.currentTarget instanceof HTMLAnchorElement && event.currentTarget.href.length > 0; + // A real link already knows how to cmd/ctrl+click. Leave its default + // action alone so the browser (or Electron's window-open handler) opens + // the host. Buttons have no href, so they still go through openExternal. + if (openInBrowser && isAnchor) return false; + + event.preventDefault(); + if (!openInBrowser && openChangeRequest(event, prUrl, targetThreadRef)) return true; const api = readLocalApi(); if (!api) { diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 1bce11ad505..88a10f8daf8 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -42,6 +42,7 @@ T3 Code works with the platforms your team already uses: - While working in a thread, open linked reviews in the same compact right-panel tabs without leaving the conversation - Open the review directly in your browser with one click +- 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 - Check out a teammate's branch to review code locally **Fix what you wrote, in place**