diff --git a/web/src/App.tsx b/web/src/App.tsx index 191ebd8..749a302 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import type { TargetedTouchEvent } from "preact"; import { Check, ChevronDown, @@ -140,6 +141,21 @@ const ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"]; const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider"; const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection"; const STREAM_RESUME_RETRY_DELAYS_MS = [0, 250, 750, 1500]; +const MOBILE_LAYOUT_MEDIA_QUERY = "(max-width: 767px)"; +const MOBILE_SWIPE_ACTIVATION_DISTANCE = 18; +const MOBILE_SWIPE_DIRECTION_DOMINANCE = 1.22; +const MOBILE_SWIPE_VELOCITY_PROJECTION_SECONDS = 0.18; +const MOBILE_SWIPE_COMPLETION_VELOCITY = 620; + +type MobileWorkspaceSwipe = { + touchIdentifier: number; + startX: number; + startY: number; + lastX: number; + lastTimestamp: number; + velocityX: number; + direction: -1 | 1 | null; +}; type ProviderModelPreferences = Record; @@ -160,6 +176,23 @@ const EMPTY_ACTIVE_RUNS: ActiveRunsState = { searches: {}, }; +function isMobileLayout() { + return typeof window !== "undefined" && window.matchMedia(MOBILE_LAYOUT_MEDIA_QUERY).matches; +} + +function getMobileSwipeLatchDistance() { + if (typeof window === "undefined") return 112; + return Math.min(Math.max(window.innerWidth * 0.28, 112), 152); +} + +function findTouch(touches: TouchList, identifier: number) { + for (let index = 0; index < touches.length; index += 1) { + const touch = touches.item(index); + if (touch?.identifier === identifier) return touch; + } + return null; +} + function waitForAppForeground() { if (typeof document === "undefined" || typeof window === "undefined") return Promise.resolve(); if (document.visibilityState !== "hidden") return Promise.resolve(); @@ -949,6 +982,8 @@ export default function App() { const searchRunCountersRef = useRef>(new Map()); const shouldAutoScrollRef = useRef(true); const wasSendingRef = useRef(false); + const suppressNextMobileComposerFocusRef = useRef(false); + const mobileWorkspaceSwipeRef = useRef(null); const pendingReplyScrollRef = useRef(false); const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP); const transcriptTailSpacerSettleFrameRef = useRef(null); @@ -1408,6 +1443,7 @@ export default function App() { wasSendingRef.current = isSendingActiveChat; if (isSendingActiveChat) return; if (wasSending) { + suppressNextMobileComposerFocusRef.current = isMobileLayout(); shouldAutoScrollRef.current = false; return; } @@ -1431,6 +1467,11 @@ export default function App() { if (isActiveSelectionSending) return; const hasWorkspaceSelection = Boolean(selectedItem) || draftKind !== null; if (!hasWorkspaceSelection) return; + if (suppressNextMobileComposerFocusRef.current && isMobileLayout()) { + suppressNextMobileComposerFocusRef.current = false; + return; + } + suppressNextMobileComposerFocusRef.current = false; focusComposer(); }, [draftKind, isActiveSelectionSending, selectedKey]); @@ -1572,6 +1613,120 @@ export default function App() { setIsMobileSidebarOpen(false); }; + const resetMobileWorkspaceSwipe = () => { + mobileWorkspaceSwipeRef.current = null; + }; + + const handleMobileWorkspaceTouchStart = (event: TargetedTouchEvent) => { + if ( + event.touches.length !== 1 || + !isMobileLayout() || + isMobileSidebarOpen || + isQuickQuestionOpen || + isChatSettingsOpen || + renameChatDialog !== null + ) { + return; + } + + const target = event.target; + if ( + target instanceof Element && + target.closest("input, textarea, select, button, a, [role='dialog'], [data-mobile-swipe-ignore]") + ) { + return; + } + + const touch = event.touches.item(0); + if (!touch) return; + mobileWorkspaceSwipeRef.current = { + touchIdentifier: touch.identifier, + startX: touch.clientX, + startY: touch.clientY, + lastX: touch.clientX, + lastTimestamp: event.timeStamp, + velocityX: 0, + direction: null, + }; + }; + + const handleMobileWorkspaceTouchMove = (event: TargetedTouchEvent) => { + const swipe = mobileWorkspaceSwipeRef.current; + if (!swipe) return; + const touch = findTouch(event.touches, swipe.touchIdentifier); + if (!touch) return; + + const deltaX = touch.clientX - swipe.startX; + const deltaY = touch.clientY - swipe.startY; + const horizontalTravel = Math.abs(deltaX); + const verticalTravel = Math.abs(deltaY); + + if (swipe.direction === null) { + if ( + verticalTravel >= MOBILE_SWIPE_ACTIVATION_DISTANCE && + verticalTravel > horizontalTravel * MOBILE_SWIPE_DIRECTION_DOMINANCE + ) { + resetMobileWorkspaceSwipe(); + return; + } + if ( + horizontalTravel < MOBILE_SWIPE_ACTIVATION_DISTANCE || + horizontalTravel < verticalTravel * MOBILE_SWIPE_DIRECTION_DOMINANCE + ) { + return; + } + swipe.direction = deltaX > 0 ? 1 : -1; + } + + const elapsedMs = event.timeStamp - swipe.lastTimestamp; + if (elapsedMs > 0 && touch.clientX !== swipe.lastX) { + swipe.velocityX = ((touch.clientX - swipe.lastX) / elapsedMs) * 1000; + } + swipe.lastX = touch.clientX; + swipe.lastTimestamp = event.timeStamp; + if (event.cancelable) event.preventDefault(); + }; + + const handleMobileWorkspaceTouchEnd = (event: TargetedTouchEvent) => { + const swipe = mobileWorkspaceSwipeRef.current; + if (!swipe) return; + const touch = findTouch(event.changedTouches, swipe.touchIdentifier); + if (!touch) return; + + const direction = swipe.direction; + const deltaX = touch.clientX - swipe.startX; + const elapsedMs = event.timeStamp - swipe.lastTimestamp; + if (elapsedMs > 0 && touch.clientX !== swipe.lastX) { + swipe.velocityX = ((touch.clientX - swipe.lastX) / elapsedMs) * 1000; + } + resetMobileWorkspaceSwipe(); + if (direction === null) return; + + const directionalDistance = deltaX * direction; + const directionalVelocity = swipe.velocityX * direction; + const projectedDistance = + directionalDistance + directionalVelocity * MOBILE_SWIPE_VELOCITY_PROJECTION_SECONDS; + const shouldComplete = + directionalVelocity <= -MOBILE_SWIPE_COMPLETION_VELOCITY + ? false + : directionalVelocity >= MOBILE_SWIPE_COMPLETION_VELOCITY || + directionalDistance >= getMobileSwipeLatchDistance() || + projectedDistance >= getMobileSwipeLatchDistance(); + if (!shouldComplete) return; + + if (direction > 0) { + setIsMobileSidebarOpen(true); + } else { + handleCreateChat(); + } + }; + + const handleMobileWorkspaceTouchCancel = (event: TargetedTouchEvent) => { + const swipe = mobileWorkspaceSwipeRef.current; + if (!swipe || !findTouch(event.changedTouches, swipe.touchIdentifier)) return; + resetMobileWorkspaceSwipe(); + }; + const handleOpenQuickQuestion = () => { setQuickQuestionError(null); setIsQuickQuestionOpen(true); @@ -3022,7 +3177,8 @@ export default function App() { await refreshSearch(sentTarget.id); } } finally { - if (!sentTarget || isCurrentSelection(sentTarget)) { + const shouldSuppressMobileChatFocus = sentTarget?.kind === "chat" && isMobileLayout(); + if ((!sentTarget || isCurrentSelection(sentTarget)) && !shouldSuppressMobileChatFocus) { focusComposer(); } } @@ -3209,7 +3365,13 @@ export default function App() { -
+
-