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 { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
|
import type { TargetedTouchEvent } from "preact";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -140,6 +141,21 @@ const ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"];
|
|||||||
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
||||||
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
||||||
const STREAM_RESUME_RETRY_DELAYS_MS = [0, 250, 750, 1500];
|
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>;
|
type ProviderModelPreferences = Record<Provider, string | null>;
|
||||||
|
|
||||||
@@ -160,6 +176,23 @@ const EMPTY_ACTIVE_RUNS: ActiveRunsState = {
|
|||||||
searches: {},
|
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() {
|
function waitForAppForeground() {
|
||||||
if (typeof document === "undefined" || typeof window === "undefined") return Promise.resolve();
|
if (typeof document === "undefined" || typeof window === "undefined") return Promise.resolve();
|
||||||
if (document.visibilityState !== "hidden") 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 searchRunCountersRef = useRef<Map<string, number>>(new Map());
|
||||||
const shouldAutoScrollRef = useRef(true);
|
const shouldAutoScrollRef = useRef(true);
|
||||||
const wasSendingRef = useRef(false);
|
const wasSendingRef = useRef(false);
|
||||||
|
const suppressNextMobileComposerFocusRef = useRef(false);
|
||||||
|
const mobileWorkspaceSwipeRef = useRef<MobileWorkspaceSwipe | null>(null);
|
||||||
const pendingReplyScrollRef = useRef(false);
|
const pendingReplyScrollRef = useRef(false);
|
||||||
const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP);
|
const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP);
|
||||||
const transcriptTailSpacerSettleFrameRef = useRef<number | null>(null);
|
const transcriptTailSpacerSettleFrameRef = useRef<number | null>(null);
|
||||||
@@ -1408,6 +1443,7 @@ export default function App() {
|
|||||||
wasSendingRef.current = isSendingActiveChat;
|
wasSendingRef.current = isSendingActiveChat;
|
||||||
if (isSendingActiveChat) return;
|
if (isSendingActiveChat) return;
|
||||||
if (wasSending) {
|
if (wasSending) {
|
||||||
|
suppressNextMobileComposerFocusRef.current = isMobileLayout();
|
||||||
shouldAutoScrollRef.current = false;
|
shouldAutoScrollRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1431,6 +1467,11 @@ export default function App() {
|
|||||||
if (isActiveSelectionSending) return;
|
if (isActiveSelectionSending) return;
|
||||||
const hasWorkspaceSelection = Boolean(selectedItem) || draftKind !== null;
|
const hasWorkspaceSelection = Boolean(selectedItem) || draftKind !== null;
|
||||||
if (!hasWorkspaceSelection) return;
|
if (!hasWorkspaceSelection) return;
|
||||||
|
if (suppressNextMobileComposerFocusRef.current && isMobileLayout()) {
|
||||||
|
suppressNextMobileComposerFocusRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suppressNextMobileComposerFocusRef.current = false;
|
||||||
focusComposer();
|
focusComposer();
|
||||||
}, [draftKind, isActiveSelectionSending, selectedKey]);
|
}, [draftKind, isActiveSelectionSending, selectedKey]);
|
||||||
|
|
||||||
@@ -1572,6 +1613,120 @@ export default function App() {
|
|||||||
setIsMobileSidebarOpen(false);
|
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 = () => {
|
const handleOpenQuickQuestion = () => {
|
||||||
setQuickQuestionError(null);
|
setQuickQuestionError(null);
|
||||||
setIsQuickQuestionOpen(true);
|
setIsQuickQuestionOpen(true);
|
||||||
@@ -3022,7 +3177,8 @@ export default function App() {
|
|||||||
await refreshSearch(sentTarget.id);
|
await refreshSearch(sentTarget.id);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!sentTarget || isCurrentSelection(sentTarget)) {
|
const shouldSuppressMobileChatFocus = sentTarget?.kind === "chat" && isMobileLayout();
|
||||||
|
if ((!sentTarget || isCurrentSelection(sentTarget)) && !shouldSuppressMobileChatFocus) {
|
||||||
focusComposer();
|
focusComposer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3209,7 +3365,13 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</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">
|
<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">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -3284,7 +3446,10 @@ export default function App() {
|
|||||||
<div ref={transcriptEndRef} />
|
<div ref={transcriptEndRef} />
|
||||||
</div>
|
</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
|
<div
|
||||||
className={cn(
|
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",
|
"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