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:
parent
a1bf392298
commit
2b27d5c5da
@ -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
|
||||
|
||||
67
apps/web/src/pages/shell/use-bots-sidebar-collapse.ts
Normal file
67
apps/web/src/pages/shell/use-bots-sidebar-collapse.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
43
apps/web/src/pages/shell/use-computer-viewport.ts
Normal file
43
apps/web/src/pages/shell/use-computer-viewport.ts
Normal 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;
|
||||
}
|
||||
66
apps/web/src/pages/shell/use-mobile-sidebar.ts
Normal file
66
apps/web/src/pages/shell/use-mobile-sidebar.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user