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