From 2b27d5c5da69514e864b62f7919f62b5e5c7ab7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 10:03:23 +0000 Subject: [PATCH] refactor(web): extract mobile sidebar, bots sidebar and computer viewport hooks Behaviour-preserving move of layout state out of ShellPage: - useMobileSidebar: the mobile navigation open state, closing it at the desktop breakpoint, and the edge-swipe touch handlers that were inline on the shell root. - useBotsSidebarCollapse: the per-user collapsed preference and the edge-drag pointer handlers that were inline on the sidebar edge button. - useComputerViewport: the on-screen-keyboard viewport tracking for the full-screen computer. Each hook is called where its effect was, so effect order is unchanged. The inline JSX handlers become named functions with identical bodies, still recreated on every render as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/src/pages/Shell.tsx | 164 +++--------------- .../pages/shell/use-bots-sidebar-collapse.ts | 67 +++++++ .../src/pages/shell/use-computer-viewport.ts | 43 +++++ .../web/src/pages/shell/use-mobile-sidebar.ts | 66 +++++++ 4 files changed, 202 insertions(+), 138 deletions(-) create mode 100644 apps/web/src/pages/shell/use-bots-sidebar-collapse.ts create mode 100644 apps/web/src/pages/shell/use-computer-viewport.ts create mode 100644 apps/web/src/pages/shell/use-mobile-sidebar.ts 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, + }; +}