From 5586a9eba54c875526122802488c41f04043c136 Mon Sep 17 00:00:00 2001 From: Vladislav Forsh Date: Tue, 24 Feb 2026 09:34:53 +0300 Subject: [PATCH 1/3] fix: restore edge-swipe tab switching without animations --- src/App.tsx | 174 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 160 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 750410cd8..67c0c0c10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,13 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type TouchEvent as ReactTouchEvent, +} from "react"; import RefreshCw from "lucide-react/dist/esm/icons/refresh-cw"; import "./styles/base.css"; import "./styles/ds-tokens.css"; @@ -175,6 +184,31 @@ const GitHubPanelData = lazy(() => })), ); +type MainTab = "home" | "projects" | "codex" | "git" | "log"; +type EdgeSwipeDirection = -1 | 1; +type EdgeSwipeState = { + edge: "left" | "right"; + startX: number; + startY: number; +}; + +const MAIN_TAB_ORDER: readonly MainTab[] = ["home", "projects", "codex", "git", "log"]; +const EDGE_SWIPE_ZONE_PX = 40; +const EDGE_SWIPE_COMMIT_MIN_PX = 6; +const EDGE_SWIPE_HORIZONTAL_RATIO = 1.2; + +function getAdjacentMainTab(activeTab: MainTab, direction: EdgeSwipeDirection): MainTab | null { + const currentIndex = MAIN_TAB_ORDER.indexOf(activeTab); + if (currentIndex < 0) { + return null; + } + const nextIndex = currentIndex + direction; + if (nextIndex < 0 || nextIndex >= MAIN_TAB_ORDER.length) { + return null; + } + return MAIN_TAB_ORDER[nextIndex] ?? null; +} + function MainApp() { const { appSettings, @@ -213,9 +247,7 @@ function MainApp() { threadListOrganizeMode, setThreadListOrganizeMode, } = useThreadListSortKey(); - const [activeTab, setActiveTab] = useState< - "home" | "projects" | "codex" | "git" | "log" - >("codex"); + const [activeTab, setActiveTab] = useState("codex"); const [mobileThreadRefreshLoading, setMobileThreadRefreshLoading] = useState(false); const tabletTab = activeTab === "projects" || activeTab === "home" ? "codex" : activeTab; @@ -2030,6 +2062,120 @@ function MainApp() { }); useMenuAcceleratorController({ appSettings, onDebug: addDebugEntry }); + const edgeSwipeRef = useRef(null); + const handleMainTabSelect = useCallback( + (tab: MainTab) => { + if (tab === "home") { + resetPullRequestSelection(); + clearDraftState(); + selectHome(); + return; + } + setActiveTab(tab); + }, + [clearDraftState, resetPullRequestSelection, selectHome], + ); + const resolveSwipeTabTarget = useCallback( + (state: EdgeSwipeState, touchX: number, touchY: number): MainTab | null => { + const deltaX = touchX - state.startX; + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(touchY - state.startY); + if (absDeltaX < EDGE_SWIPE_COMMIT_MIN_PX) { + return null; + } + if (absDeltaX < absDeltaY * EDGE_SWIPE_HORIZONTAL_RATIO) { + return null; + } + const direction: EdgeSwipeDirection = state.edge === "left" ? -1 : 1; + if (direction === -1 && deltaX <= 0) { + return null; + } + if (direction === 1 && deltaX >= 0) { + return null; + } + return getAdjacentMainTab(activeTab, direction); + }, + [activeTab], + ); + const handleAppTouchStart = useCallback( + (event: ReactTouchEvent) => { + if (!isPhone) { + edgeSwipeRef.current = null; + return; + } + const touch = event.touches[0]; + if (!touch || event.touches.length !== 1) { + edgeSwipeRef.current = null; + return; + } + const viewportWidth = window.innerWidth; + const edge = + touch.clientX <= EDGE_SWIPE_ZONE_PX + ? "left" + : touch.clientX >= viewportWidth - EDGE_SWIPE_ZONE_PX + ? "right" + : null; + if (!edge) { + edgeSwipeRef.current = null; + return; + } + const direction: EdgeSwipeDirection = edge === "left" ? -1 : 1; + if (!getAdjacentMainTab(activeTab, direction)) { + edgeSwipeRef.current = null; + return; + } + edgeSwipeRef.current = { + edge, + startX: touch.clientX, + startY: touch.clientY, + }; + }, + [activeTab, isPhone], + ); + const handleAppTouchMove = useCallback((event: ReactTouchEvent) => { + const state = edgeSwipeRef.current; + if (!state) { + return; + } + const touch = event.touches[0]; + if (!touch) { + return; + } + const deltaX = touch.clientX - state.startX; + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(touch.clientY - state.startY); + if (absDeltaX < absDeltaY * EDGE_SWIPE_HORIZONTAL_RATIO) { + return; + } + if (state.edge === "left" && deltaX > 0) { + event.preventDefault(); + return; + } + if (state.edge === "right" && deltaX < 0) { + event.preventDefault(); + } + }, []); + const handleAppTouchEnd = useCallback( + (event: ReactTouchEvent) => { + const state = edgeSwipeRef.current; + edgeSwipeRef.current = null; + if (!state) { + return; + } + const touch = event.changedTouches[0]; + if (!touch) { + return; + } + const nextTab = resolveSwipeTabTarget(state, touch.clientX, touch.clientY); + if (nextTab) { + handleMainTabSelect(nextTab); + } + }, + [handleMainTabSelect, resolveSwipeTabTarget], + ); + const handleAppTouchCancel = useCallback(() => { + edgeSwipeRef.current = null; + }, []); const showCompactCodexThreadActions = Boolean(activeWorkspace) && isCompact && @@ -2225,15 +2371,7 @@ function MainApp() { setSelectedDiffPath(null); }, activeTab, - onSelectTab: (tab) => { - if (tab === "home") { - resetPullRequestSelection(); - clearDraftState(); - selectHome(); - return; - } - setActiveTab(tab); - }, + onSelectTab: handleMainTabSelect, tabletNavTab: tabletTab, gitPanelMode, onGitPanelModeChange: handleGitPanelModeChange, @@ -2609,7 +2747,15 @@ function MainApp() { ); return ( -
+
{shouldLoadGitHubPanelData ? ( From 2c9cf24b6f1e7ec9114079f20a97ed7686cdf2e9 Mon Sep 17 00:00:00 2001 From: Vladislav Forsh Date: Tue, 24 Feb 2026 09:41:29 +0300 Subject: [PATCH 2/3] feat: allow tab switches from any horizontal swipe --- src/App.tsx | 34 ++++------------------------------ 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 67c0c0c10..34d69baad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -187,13 +187,11 @@ const GitHubPanelData = lazy(() => type MainTab = "home" | "projects" | "codex" | "git" | "log"; type EdgeSwipeDirection = -1 | 1; type EdgeSwipeState = { - edge: "left" | "right"; startX: number; startY: number; }; const MAIN_TAB_ORDER: readonly MainTab[] = ["home", "projects", "codex", "git", "log"]; -const EDGE_SWIPE_ZONE_PX = 40; const EDGE_SWIPE_COMMIT_MIN_PX = 6; const EDGE_SWIPE_HORIZONTAL_RATIO = 1.2; @@ -2086,11 +2084,8 @@ function MainApp() { if (absDeltaX < absDeltaY * EDGE_SWIPE_HORIZONTAL_RATIO) { return null; } - const direction: EdgeSwipeDirection = state.edge === "left" ? -1 : 1; - if (direction === -1 && deltaX <= 0) { - return null; - } - if (direction === 1 && deltaX >= 0) { + const direction: EdgeSwipeDirection = deltaX > 0 ? -1 : 1; + if (deltaX === 0) { return null; } return getAdjacentMainTab(activeTab, direction); @@ -2108,29 +2103,12 @@ function MainApp() { edgeSwipeRef.current = null; return; } - const viewportWidth = window.innerWidth; - const edge = - touch.clientX <= EDGE_SWIPE_ZONE_PX - ? "left" - : touch.clientX >= viewportWidth - EDGE_SWIPE_ZONE_PX - ? "right" - : null; - if (!edge) { - edgeSwipeRef.current = null; - return; - } - const direction: EdgeSwipeDirection = edge === "left" ? -1 : 1; - if (!getAdjacentMainTab(activeTab, direction)) { - edgeSwipeRef.current = null; - return; - } edgeSwipeRef.current = { - edge, startX: touch.clientX, startY: touch.clientY, }; }, - [activeTab, isPhone], + [isPhone], ); const handleAppTouchMove = useCallback((event: ReactTouchEvent) => { const state = edgeSwipeRef.current; @@ -2147,11 +2125,7 @@ function MainApp() { if (absDeltaX < absDeltaY * EDGE_SWIPE_HORIZONTAL_RATIO) { return; } - if (state.edge === "left" && deltaX > 0) { - event.preventDefault(); - return; - } - if (state.edge === "right" && deltaX < 0) { + if (absDeltaX >= EDGE_SWIPE_COMMIT_MIN_PX) { event.preventDefault(); } }, []); From cee8f62f6600c8eb87f49d1bf782160a3c9d6582 Mon Sep 17 00:00:00 2001 From: Vladislav Forsh Date: Tue, 24 Feb 2026 10:25:26 +0300 Subject: [PATCH 3/3] fix: gate phone tab swipe to edges and non-scrollable starts --- src/App.tsx | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 34d69baad..6b80aeeb3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -194,6 +194,49 @@ type EdgeSwipeState = { const MAIN_TAB_ORDER: readonly MainTab[] = ["home", "projects", "codex", "git", "log"]; const EDGE_SWIPE_COMMIT_MIN_PX = 6; const EDGE_SWIPE_HORIZONTAL_RATIO = 1.2; +const EDGE_SWIPE_START_ZONE_PX = 24; + +function isTouchWithinEdgeSwipeZone(touchX: number, containerRect: DOMRect): boolean { + const distanceFromLeft = touchX - containerRect.left; + const distanceFromRight = containerRect.right - touchX; + return distanceFromLeft <= EDGE_SWIPE_START_ZONE_PX || distanceFromRight <= EDGE_SWIPE_START_ZONE_PX; +} + +function resolveEventTargetElement(target: EventTarget | null): Element | null { + if (target instanceof Element) { + return target; + } + if (target instanceof Node) { + return target.parentElement; + } + return null; +} + +function isHorizontallyScrollableElement(element: HTMLElement): boolean { + const overflowX = window.getComputedStyle(element).overflowX; + if (overflowX !== "auto" && overflowX !== "scroll" && overflowX !== "overlay") { + return false; + } + return element.scrollWidth > element.clientWidth + 1; +} + +function hasHorizontalScrollableAncestor( + target: EventTarget | null, + container: HTMLElement | null, +): boolean { + const targetElement = resolveEventTargetElement(target); + if (!targetElement) { + return false; + } + let current: Element | null = targetElement; + while (current && current !== container) { + if (current instanceof HTMLElement && isHorizontallyScrollableElement(current)) { + return true; + } + current = current.parentElement; + } + return false; +} function getAdjacentMainTab(activeTab: MainTab, direction: EdgeSwipeDirection): MainTab | null { const currentIndex = MAIN_TAB_ORDER.indexOf(activeTab); @@ -2098,17 +2141,30 @@ function MainApp() { edgeSwipeRef.current = null; return; } + const container = appRef.current; + if (!container) { + edgeSwipeRef.current = null; + return; + } const touch = event.touches[0]; if (!touch || event.touches.length !== 1) { edgeSwipeRef.current = null; return; } + if (!isTouchWithinEdgeSwipeZone(touch.clientX, container.getBoundingClientRect())) { + edgeSwipeRef.current = null; + return; + } + if (hasHorizontalScrollableAncestor(event.target, container)) { + edgeSwipeRef.current = null; + return; + } edgeSwipeRef.current = { startX: touch.clientX, startY: touch.clientY, }; }, - [isPhone], + [appRef, isPhone], ); const handleAppTouchMove = useCallback((event: ReactTouchEvent) => { const state = edgeSwipeRef.current;