add mobile workspace gestures
This commit is contained in:
171
web/src/App.tsx
171
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<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",
|
||||
|
||||
Reference in New Issue
Block a user