diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 9665eb2..01a5beb 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -135,11 +135,6 @@ import type { ArtifactTarget } from "../lib/artifact-open"; import { attachmentMaxBytes, uploadAttachment } from "../lib/artifact-upload"; import { authClient } from "../lib/auth"; import { takeInitialBootstrap } from "../lib/bootstrap"; -import { - BOTS_SIDEBAR_EDGE_DRAG_PX, - readBotsSidebarCollapsed, - writeBotsSidebarCollapsed, -} from "../lib/bots-sidebar-pref"; import { requestBrowserNotificationPermission } from "../lib/browser-notifications"; import { loadComputerScreen } from "../lib/computer-screen"; import { desktopBridge } from "../lib/desktop"; @@ -240,8 +235,11 @@ import { threadSnapshotSignal, } from "./shell/thread-state"; import { Transcript } from "./shell/transcript"; +import { useBotsSidebarCollapse } from "./shell/use-bots-sidebar-collapse"; import { useBrowserNotifications } from "./shell/use-browser-notifications"; +import { useComputerViewport } from "./shell/use-computer-viewport"; import { useHeuteActionCount } from "./shell/use-heute-action-count"; +import { useMobileSidebar } from "./shell/use-mobile-sidebar"; import { useShellSounds } from "./shell/use-shell-sounds"; import { useSidebarModel } from "./shell/use-sidebar-model"; import { useSpaceSearch } from "./shell/use-space-search"; @@ -284,8 +282,6 @@ type Panel = /** Bound Settings leave so a hung voice status refresh cannot block dismissal. */ const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000; -const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32; -const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56; /** The screen lingers after the work so its final state and the bot's reply land first. */ const AUTO_SCREEN_CLOSE_DELAY_MS = 4_000; /** How long one pointer announcement counts as the bot working on its screen. */ @@ -375,9 +371,14 @@ export function ShellPage() { useEffect(() => { setCollapsedSidebarSections(readCollapsedSidebarSections(userId)); }, [userId]); - useEffect(() => { - setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId)); - }, [userId]); + const { + botsSidebarCollapsed, + setBotsSidebarCollapsedPref, + onBotsSidebarEdgePointerDown, + onBotsSidebarEdgePointerMove, + onBotsSidebarEdgePointerUp, + onBotsSidebarEdgePointerCancel, + } = useBotsSidebarCollapse(userId); const [panel, setPanel] = useState(null); /** The pane the conversation's focus opened; cleared as soon as the user changes the pane. */ @@ -499,17 +500,18 @@ export function ShellPage() { const [dismissedRunErrorIds, setDismissedRunErrorIds] = useState>(readSeenRunErrorIds); const [menuOpen, setMenuOpen] = useState(false); - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const mobileSidebarSwipeRef = useRef<{ startX: number; startY: number } | null>(null); + const { + mobileSidebarOpen, + setMobileSidebarOpen, + onMobileSidebarTouchStart, + onMobileSidebarTouchEnd, + onMobileSidebarTouchCancel, + } = useMobileSidebar(); const [draggedBotId, setDraggedBotId] = useState(null); const [createMenuOpen, setCreateMenuOpen] = useState(false); const [conversationPickerOpen, setConversationPickerOpen] = useState(false); - const [botsSidebarCollapsed, setBotsSidebarCollapsed] = useState(false); const focusPromptAbortRef = useRef(null); const focusPromptBotIdRef = useRef(null); - const botsSidebarEdgeDragRef = useRef<{ startX: number; mode: "expand" | "collapse" } | null>( - null, - ); const [newSpaceOpen, setNewSpaceOpen] = useState(false); const [newSpaceDialogVariant, setNewSpaceDialogVariant] = useState<"space" | "project">("space"); const openNewSpaceDialog = useCallback((variant: "space" | "project") => { @@ -519,15 +521,6 @@ export function ShellPage() { const [pickerInfoTopic, setPickerInfoTopic] = useState<"group" | "space" | null>(null); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); - useEffect(() => { - const desktop = window.matchMedia("(min-width: 768px)"); - function closeMobileSidebar() { - if (desktop.matches) setMobileSidebarOpen(false); - } - closeMobileSidebar(); - desktop.addEventListener("change", closeMobileSidebar); - return () => desktop.removeEventListener("change", closeMobileSidebar); - }, []); const [activityMode, setActivityMode] = useState(readActivityMode); const toggleActivityMode = useCallback(() => { setActivityMode((on) => { @@ -597,44 +590,11 @@ export function ShellPage() { const [routineError, setRoutineError] = useState(null); const [screenUrl, setScreenUrl] = useState(null); const [computerOpen, setComputerOpen] = useState(false); - const [computerViewport, setComputerViewport] = useState<{ - height: number; - offsetTop: number; - } | null>(null); const [computerError, setComputerError] = useState(null); // Screen-load failures can sit beside a still-valid embed URL; boot and // takeover failures must stay visible even when a URL remains. const [computerErrorFromScreen, setComputerErrorFromScreen] = useState(false); - useEffect(() => { - if (!computerOpen) { - setComputerViewport(null); - return; - } - const viewport = window.visualViewport; - if (!viewport) return; - const sync = () => { - const height = Math.round(viewport.height); - const offsetTop = Math.round(viewport.offsetTop); - // Desktop scrollbars and sub-pixel viewport events must not rewrite the - // overlay height: that feedback loop moves the maintenance menu forever. - const keyboardInset = Math.round(window.innerHeight) - height - offsetTop; - if (keyboardInset < 80) { - setComputerViewport((current) => (current === null ? current : null)); - return; - } - setComputerViewport((current) => { - if (current && current.height === height && current.offsetTop === offsetTop) return current; - return { height, offsetTop }; - }); - }; - sync(); - viewport.addEventListener("resize", sync); - viewport.addEventListener("scroll", sync); - return () => { - viewport.removeEventListener("resize", sync); - viewport.removeEventListener("scroll", sync); - }; - }, [computerOpen]); + const computerViewport = useComputerViewport(computerOpen); useEffect(() => { if (!session.data?.user) return; let cancelled = false; @@ -2580,11 +2540,6 @@ export function ShellPage() { focusPromptBotIdRef.current = null; } - function setBotsSidebarCollapsedPref(collapsed: boolean) { - setBotsSidebarCollapsed(collapsed); - writeBotsSidebarCollapsed(userId, collapsed); - } - function openSettings(section: SettingsSection = "general") { setSettingsSection(section); setSettingsOpen(true); @@ -2871,47 +2826,9 @@ export function ShellPage() { data-testid="shell-root" data-ready={shellReady} className="relative flex h-full min-w-0 overflow-hidden bg-background text-foreground/90 p-0 md:p-3 gap-0 md:gap-3" - onTouchStartCapture={(event) => { - if ( - mobileSidebarOpen || - event.touches.length !== 1 || - window.matchMedia("(min-width: 768px)").matches - ) { - mobileSidebarSwipeRef.current = null; - return; - } - const touch = event.touches[0]; - if (!touch) return; - const rtl = document.documentElement.getAttribute("dir") === "rtl"; - const startsAtEdge = rtl - ? touch.clientX >= window.innerWidth - MOBILE_SIDEBAR_SWIPE_EDGE_PX - : touch.clientX <= MOBILE_SIDEBAR_SWIPE_EDGE_PX; - mobileSidebarSwipeRef.current = startsAtEdge - ? { startX: touch.clientX, startY: touch.clientY } - : null; - }} - onTouchEndCapture={(event) => { - const swipe = mobileSidebarSwipeRef.current; - mobileSidebarSwipeRef.current = null; - const touch = event.changedTouches[0]; - if ( - !swipe || - !touch || - mobileSidebarOpen || - window.matchMedia("(min-width: 768px)").matches - ) { - return; - } - const rtl = document.documentElement.getAttribute("dir") === "rtl"; - const horizontal = rtl ? swipe.startX - touch.clientX : touch.clientX - swipe.startX; - const vertical = Math.abs(touch.clientY - swipe.startY); - if (horizontal >= MOBILE_SIDEBAR_SWIPE_DISTANCE_PX && horizontal > vertical * 1.25) { - setMobileSidebarOpen(true); - } - }} - onTouchCancelCapture={() => { - mobileSidebarSwipeRef.current = null; - }} + onTouchStartCapture={onMobileSidebarTouchStart} + onTouchEndCapture={onMobileSidebarTouchEnd} + onTouchCancelCapture={onMobileSidebarTouchCancel} > { @@ -3829,39 +3746,10 @@ export function ShellPage() { className={`absolute inset-y-0 z-50 hidden w-2 cursor-ew-resize touch-none border-0 bg-transparent p-0 md:block ${ botsSidebarCollapsed ? "start-0" : "start-[308px]" }`} - onPointerDown={(event) => { - event.currentTarget.setPointerCapture(event.pointerId); - botsSidebarEdgeDragRef.current = { - startX: event.clientX, - mode: botsSidebarCollapsed ? "expand" : "collapse", - }; - }} - onPointerMove={(event) => { - const drag = botsSidebarEdgeDragRef.current; - if (!drag) return; - const rtl = - typeof document !== "undefined" && - document.documentElement.getAttribute("dir") === "rtl"; - const delta = rtl ? drag.startX - event.clientX : event.clientX - drag.startX; - if (drag.mode === "expand" && delta >= BOTS_SIDEBAR_EDGE_DRAG_PX) { - botsSidebarEdgeDragRef.current = null; - setBotsSidebarCollapsedPref(false); - } else if (drag.mode === "collapse" && delta <= -BOTS_SIDEBAR_EDGE_DRAG_PX) { - botsSidebarEdgeDragRef.current = null; - setBotsSidebarCollapsedPref(true); - } - }} - onPointerUp={(event) => { - const drag = botsSidebarEdgeDragRef.current; - botsSidebarEdgeDragRef.current = null; - if (!drag) return; - if (Math.abs(event.clientX - drag.startX) < BOTS_SIDEBAR_EDGE_DRAG_PX) { - setBotsSidebarCollapsedPref(!botsSidebarCollapsed); - } - }} - onPointerCancel={() => { - botsSidebarEdgeDragRef.current = null; - }} + onPointerDown={onBotsSidebarEdgePointerDown} + onPointerMove={onBotsSidebarEdgePointerMove} + onPointerUp={onBotsSidebarEdgePointerUp} + onPointerCancel={onBotsSidebarEdgePointerCancel} />
( + null, + ); + + useEffect(() => { + setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId)); + }, [userId]); + + function setBotsSidebarCollapsedPref(collapsed: boolean) { + setBotsSidebarCollapsed(collapsed); + writeBotsSidebarCollapsed(userId, collapsed); + } + + function onBotsSidebarEdgePointerDown(event: React.PointerEvent) { + event.currentTarget.setPointerCapture(event.pointerId); + botsSidebarEdgeDragRef.current = { + startX: event.clientX, + mode: botsSidebarCollapsed ? "expand" : "collapse", + }; + } + + function onBotsSidebarEdgePointerMove(event: React.PointerEvent) { + const drag = botsSidebarEdgeDragRef.current; + if (!drag) return; + const rtl = + typeof document !== "undefined" && document.documentElement.getAttribute("dir") === "rtl"; + const delta = rtl ? drag.startX - event.clientX : event.clientX - drag.startX; + if (drag.mode === "expand" && delta >= BOTS_SIDEBAR_EDGE_DRAG_PX) { + botsSidebarEdgeDragRef.current = null; + setBotsSidebarCollapsedPref(false); + } else if (drag.mode === "collapse" && delta <= -BOTS_SIDEBAR_EDGE_DRAG_PX) { + botsSidebarEdgeDragRef.current = null; + setBotsSidebarCollapsedPref(true); + } + } + + function onBotsSidebarEdgePointerUp(event: React.PointerEvent) { + const drag = botsSidebarEdgeDragRef.current; + botsSidebarEdgeDragRef.current = null; + if (!drag) return; + if (Math.abs(event.clientX - drag.startX) < BOTS_SIDEBAR_EDGE_DRAG_PX) { + setBotsSidebarCollapsedPref(!botsSidebarCollapsed); + } + } + + function onBotsSidebarEdgePointerCancel() { + botsSidebarEdgeDragRef.current = null; + } + + return { + botsSidebarCollapsed, + setBotsSidebarCollapsedPref, + onBotsSidebarEdgePointerDown, + onBotsSidebarEdgePointerMove, + onBotsSidebarEdgePointerUp, + onBotsSidebarEdgePointerCancel, + }; +} diff --git a/apps/web/src/pages/shell/use-computer-viewport.ts b/apps/web/src/pages/shell/use-computer-viewport.ts new file mode 100644 index 0000000..c451792 --- /dev/null +++ b/apps/web/src/pages/shell/use-computer-viewport.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +/** Visible area above an on-screen keyboard while the full-screen computer is open. */ +export function useComputerViewport( + computerOpen: boolean, +): { height: number; offsetTop: number } | null { + const [computerViewport, setComputerViewport] = useState<{ + height: number; + offsetTop: number; + } | null>(null); + useEffect(() => { + if (!computerOpen) { + setComputerViewport(null); + return; + } + const viewport = window.visualViewport; + if (!viewport) return; + const sync = () => { + const height = Math.round(viewport.height); + const offsetTop = Math.round(viewport.offsetTop); + // Desktop scrollbars and sub-pixel viewport events must not rewrite the + // overlay height: that feedback loop moves the maintenance menu forever. + const keyboardInset = Math.round(window.innerHeight) - height - offsetTop; + if (keyboardInset < 80) { + setComputerViewport((current) => (current === null ? current : null)); + return; + } + setComputerViewport((current) => { + if (current && current.height === height && current.offsetTop === offsetTop) return current; + return { height, offsetTop }; + }); + }; + sync(); + viewport.addEventListener("resize", sync); + viewport.addEventListener("scroll", sync); + return () => { + viewport.removeEventListener("resize", sync); + viewport.removeEventListener("scroll", sync); + }; + }, [computerOpen]); + + return computerViewport; +} diff --git a/apps/web/src/pages/shell/use-mobile-sidebar.ts b/apps/web/src/pages/shell/use-mobile-sidebar.ts new file mode 100644 index 0000000..8815752 --- /dev/null +++ b/apps/web/src/pages/shell/use-mobile-sidebar.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from "react"; + +const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32; +const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56; + +export function useMobileSidebar() { + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const mobileSidebarSwipeRef = useRef<{ startX: number; startY: number } | null>(null); + + useEffect(() => { + const desktop = window.matchMedia("(min-width: 768px)"); + function closeMobileSidebar() { + if (desktop.matches) setMobileSidebarOpen(false); + } + closeMobileSidebar(); + desktop.addEventListener("change", closeMobileSidebar); + return () => desktop.removeEventListener("change", closeMobileSidebar); + }, []); + + function onMobileSidebarTouchStart(event: React.TouchEvent) { + if ( + mobileSidebarOpen || + event.touches.length !== 1 || + window.matchMedia("(min-width: 768px)").matches + ) { + mobileSidebarSwipeRef.current = null; + return; + } + const touch = event.touches[0]; + if (!touch) return; + const rtl = document.documentElement.getAttribute("dir") === "rtl"; + const startsAtEdge = rtl + ? touch.clientX >= window.innerWidth - MOBILE_SIDEBAR_SWIPE_EDGE_PX + : touch.clientX <= MOBILE_SIDEBAR_SWIPE_EDGE_PX; + mobileSidebarSwipeRef.current = startsAtEdge + ? { startX: touch.clientX, startY: touch.clientY } + : null; + } + + function onMobileSidebarTouchEnd(event: React.TouchEvent) { + const swipe = mobileSidebarSwipeRef.current; + mobileSidebarSwipeRef.current = null; + const touch = event.changedTouches[0]; + if (!swipe || !touch || mobileSidebarOpen || window.matchMedia("(min-width: 768px)").matches) { + return; + } + const rtl = document.documentElement.getAttribute("dir") === "rtl"; + const horizontal = rtl ? swipe.startX - touch.clientX : touch.clientX - swipe.startX; + const vertical = Math.abs(touch.clientY - swipe.startY); + if (horizontal >= MOBILE_SIDEBAR_SWIPE_DISTANCE_PX && horizontal > vertical * 1.25) { + setMobileSidebarOpen(true); + } + } + + function onMobileSidebarTouchCancel() { + mobileSidebarSwipeRef.current = null; + } + + return { + mobileSidebarOpen, + setMobileSidebarOpen, + onMobileSidebarTouchStart, + onMobileSidebarTouchEnd, + onMobileSidebarTouchCancel, + }; +}