diff --git a/apps/web/src/components/DesktopThreadSwipeNavigation.tsx b/apps/web/src/components/DesktopThreadSwipeNavigation.tsx new file mode 100644 index 000000000000..00797aee0691 --- /dev/null +++ b/apps/web/src/components/DesktopThreadSwipeNavigation.tsx @@ -0,0 +1,141 @@ +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { isElectron } from "../env"; + +type ThreadSwipeDirection = "previous" | "next"; +type ThreadSwipeGesture = { + direction: ThreadSwipeDirection; + progress: number; +}; + +const SWIPE_THRESHOLD_PX = 120; +const SWIPE_IDLE_MS = 180; + +function canScrollHorizontally(target: EventTarget | null): boolean { + const firstElement = + target instanceof HTMLElement ? target : target instanceof Node ? target.parentElement : null; + + for ( + let element = firstElement; + element && element !== document.body; + element = element.parentElement + ) { + const overflowX = window.getComputedStyle(element).overflowX; + if ( + /^(auto|scroll|overlay)$/.test(overflowX) && + element.scrollWidth > element.clientWidth + 1 + ) { + return true; + } + } + + return false; +} + +export function DesktopThreadSwipeNavigation(input: { + navigate: (direction: ThreadSwipeDirection) => boolean; +}) { + const navigateRef = useRef(input.navigate); + const [gesture, setGesture] = useState(null); + + useEffect(() => { + navigateRef.current = input.navigate; + }, [input.navigate]); + + useEffect(() => { + if (!isElectron) return; + + let accumulatedDeltaX = 0; + let didNavigate = false; + let idleTimer: number | null = null; + + const reset = () => { + accumulatedDeltaX = 0; + didNavigate = false; + idleTimer = null; + setGesture(null); + }; + + const onWheel = (event: WheelEvent) => { + if ( + event.defaultPrevented || + event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL || + event.ctrlKey || + event.metaKey || + event.altKey || + event.shiftKey + ) { + return; + } + + const deltaX = event.deltaX; + if (Math.abs(deltaX) < 2 || Math.abs(deltaX) <= Math.abs(event.deltaY) * 1.15) return; + if (canScrollHorizontally(event.composedPath()[0] ?? event.target)) return; + + event.preventDefault(); + if (idleTimer !== null) window.clearTimeout(idleTimer); + idleTimer = window.setTimeout(reset, SWIPE_IDLE_MS); + + if (accumulatedDeltaX !== 0 && Math.sign(accumulatedDeltaX) !== Math.sign(deltaX)) { + accumulatedDeltaX = 0; + didNavigate = false; + } + accumulatedDeltaX += deltaX; + + const direction = accumulatedDeltaX > 0 ? "next" : "previous"; + setGesture({ + direction, + progress: Math.min(Math.abs(accumulatedDeltaX) / SWIPE_THRESHOLD_PX, 1), + }); + if (didNavigate || Math.abs(accumulatedDeltaX) < SWIPE_THRESHOLD_PX) return; + + didNavigate = true; + navigateRef.current(direction); + }; + + window.addEventListener("wheel", onWheel, { passive: false }); + return () => { + window.removeEventListener("wheel", onWheel); + if (idleTimer !== null) window.clearTimeout(idleTimer); + }; + }, []); + + if (!isElectron || gesture === null) return null; + + const isPrevious = gesture.direction === "previous"; + const edgeOffset = (1 - gesture.progress) * 45; + const arrowScale = 0.78 + gesture.progress * 0.22; + const ArrowIcon = isPrevious ? ArrowLeftIcon : ArrowRightIcon; + return ( + + ); +} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ea910905efe0..00d4759f24f1 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -23,6 +23,7 @@ import { useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { DesktopThreadSwipeNavigation } from "./DesktopThreadSwipeNavigation"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; @@ -3506,6 +3507,21 @@ export default function LegacySidebar() { updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); + const navigateToAdjacentThread = useCallback( + (direction: "previous" | "next") => { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: orderedSidebarThreadKeys, + currentThreadId: routeThreadKey, + direction, + }); + if (!targetThreadKey) return false; + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) return false; + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return true; + }, + [navigateToThread, orderedSidebarThreadKeys, routeThreadKey, sidebarThreadByKey], + ); useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { const shortcutContext = getCurrentSidebarShortcutContext(); @@ -3520,22 +3536,10 @@ export default function LegacySidebar() { }); const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; + if (navigateToAdjacentThread(traversalDirection)) { + event.preventDefault(); + event.stopPropagation(); } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); return; } @@ -3566,10 +3570,9 @@ export default function LegacySidebar() { }, [ getCurrentSidebarShortcutContext, keybindings, + navigateToAdjacentThread, navigateToThread, - orderedSidebarThreadKeys, platform, - routeThreadKey, sidebarThreadByKey, threadJumpThreadKeys, ]); @@ -3720,6 +3723,7 @@ export default function LegacySidebar() { return ( <> + {isElectron && } {prewarmedSidebarThreadRefs.map((threadRef) => ( ))} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0719f873e6a9..118c05865dfc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -126,6 +126,7 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat" import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { DesktopThreadSwipeNavigation } from "./DesktopThreadSwipeNavigation"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animatePinnedLayoutChanges, @@ -3487,6 +3488,21 @@ export default function Sidebar() { ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); + const navigateToAdjacentThread = useCallback( + (direction: "previous" | "next") => { + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: orderedThreadKeys, + currentThreadId: routeThreadKey, + direction, + }); + if (!targetThreadKey) return false; + const targetThread = threadByKey.get(targetThreadKey); + if (!targetThread) return false; + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return true; + }, + [navigateToThread, orderedThreadKeys, routeThreadKey, threadByKey], + ); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.repeat) return; @@ -3509,13 +3525,11 @@ export default function Sidebar() { }; const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { - navigateToThreadKey( - resolveAdjacentThreadId({ - threadIds: orderedThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }), - ); + const didNavigate = navigateToAdjacentThread(traversalDirection); + if (didNavigate) { + event.preventDefault(); + event.stopPropagation(); + } return; } const jumpIndex = threadJumpIndexFromCommand(command ?? ""); @@ -3526,10 +3540,10 @@ export default function Sidebar() { return () => window.removeEventListener("keydown", onWindowKeyDown); }, [ keybindings, + navigateToAdjacentThread, navigateToThread, orderedThreadKeys, routeTerminalOpen, - routeThreadKey, threadByKey, ]); @@ -3598,6 +3612,7 @@ export default function Sidebar() { const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> + {isElectron && }