add mobile workspace gestures

This commit is contained in:
2026-07-23 18:05:57 -07:00
parent 9229896ad7
commit a69c641481

View File

@@ -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<Provider, string | null>;
@@ -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<Map<string, number>>(new Map());
const shouldAutoScrollRef = useRef(true);
const wasSendingRef = useRef(false);
const suppressNextMobileComposerFocusRef = useRef(false);
const mobileWorkspaceSwipeRef = useRef<MobileWorkspaceSwipe | null>(null);
const pendingReplyScrollRef = useRef(false);
const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP);
const transcriptTailSpacerSettleFrameRef = useRef<number | null>(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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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() {
</div>
</aside>
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
<main
className="glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden border-violet-300/18 md:touch-auto md:rounded-2xl md:border"
onTouchStart={handleMobileWorkspaceTouchStart}
onTouchMove={handleMobileWorkspaceTouchMove}
onTouchEnd={handleMobileWorkspaceTouchEnd}
onTouchCancel={handleMobileWorkspaceTouchCancel}
>
<header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
<div className="flex min-w-0 items-center gap-2">
<Button
@@ -3284,7 +3446,10 @@ export default function App() {
<div ref={transcriptEndRef} />
</div>
<footer className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-[linear-gradient(to_top,hsl(235_50%_4%)_0%,hsl(235_50%_4%_/_0.92)_58%,transparent)] p-3 pt-14 md:p-6 md:pt-20">
<footer
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-[linear-gradient(to_top,hsl(235_50%_4%)_0%,hsl(235_50%_4%_/_0.92)_58%,transparent)] p-3 pt-14 md:p-6 md:pt-20"
data-mobile-swipe-ignore
>
<div
className={cn(
"pointer-events-auto mx-auto max-w-4xl rounded-2xl border bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20 transition",