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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-04 10:03:23 +00:00
parent a1bf392298
commit 2b27d5c5da
No known key found for this signature in database
4 changed files with 202 additions and 138 deletions

View File

@ -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<Panel>(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<ReadonlySet<string>>(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<string | null>(null);
const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [conversationPickerOpen, setConversationPickerOpen] = useState(false);
const [botsSidebarCollapsed, setBotsSidebarCollapsed] = useState(false);
const focusPromptAbortRef = useRef<AbortController | null>(null);
const focusPromptBotIdRef = useRef<string | null>(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<string | null>(null);
const [screenUrl, setScreenUrl] = useState<string | null>(null);
const [computerOpen, setComputerOpen] = useState(false);
const [computerViewport, setComputerViewport] = useState<{
height: number;
offsetTop: number;
} | null>(null);
const [computerError, setComputerError] = useState<string | null>(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}
>
<ComputerUpdateProgress
onCompleted={() => {
@ -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}
/>
<main

View File

@ -0,0 +1,67 @@
import { useEffect, useRef, useState } from "react";
import {
BOTS_SIDEBAR_EDGE_DRAG_PX,
readBotsSidebarCollapsed,
writeBotsSidebarCollapsed,
} from "../../lib/bots-sidebar-pref";
export function useBotsSidebarCollapse(userId: string | undefined) {
const [botsSidebarCollapsed, setBotsSidebarCollapsed] = useState(false);
const botsSidebarEdgeDragRef = useRef<{ startX: number; mode: "expand" | "collapse" } | null>(
null,
);
useEffect(() => {
setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId));
}, [userId]);
function setBotsSidebarCollapsedPref(collapsed: boolean) {
setBotsSidebarCollapsed(collapsed);
writeBotsSidebarCollapsed(userId, collapsed);
}
function onBotsSidebarEdgePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
event.currentTarget.setPointerCapture(event.pointerId);
botsSidebarEdgeDragRef.current = {
startX: event.clientX,
mode: botsSidebarCollapsed ? "expand" : "collapse",
};
}
function onBotsSidebarEdgePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
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<HTMLButtonElement>) {
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,
};
}

View File

@ -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;
}

View File

@ -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<HTMLDivElement>) {
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<HTMLDivElement>) {
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,
};
}