diff --git a/docs/mobile-navigation-plan.md b/docs/mobile-navigation-plan.md new file mode 100644 index 00000000..34ff3d02 --- /dev/null +++ b/docs/mobile-navigation-plan.md @@ -0,0 +1,440 @@ +# Mobile Navigation Plan + +## Status + +Core implementation and the iOS edge-swipe stretch goal are complete on the `mobile-navigation` +branch. Navigation/history, stream-disconnect, and gesture decisions have focused automated +coverage, and the repository's format, lint, typecheck, test, and production-build checks pass. The +unchecked acceptance items below require interactive browser or physical iOS/Android validation and +remain the final release-validation pass. + +## Objective + +Give compact/mobile layouts a traditional page hierarchy while leaving the existing desktop-width experience unchanged: + +- The existing menu becomes a full-screen mobile main menu. +- Opening a chat or project pushes a detail page over its parent page. +- Detail pages have a top-left back button. +- A chat is unmounted after it leaves the screen. +- Desktop app windows and desktop-width web browsers retain the existing sidebar-and-content layout. + +The implementation should be as small as practical, avoid duplicate menu implementations, and preserve the existing URL scheme. + +## Definitions + +This plan uses the existing responsive layout rules: + +- **Compact/mobile layout:** the current viewport-width and short-landscape checks used by `useIsMobile()` and `useIsLandscapeMobile()`. +- **Desktop layout:** the Tauri desktop app and web browsers at desktop width. + +The breakpoints and compact-layout detection logic are not changing. Larger tablets that currently receive the desktop layout will continue to receive it. + +## Agreed Product Behavior + +### Desktop + +- Preserve the current sidebar-and-chat layout. +- Preserve the current sidebar open/close behavior. +- Preserve the current chat and project headers. +- Treat a desktop-width web browser the same as the desktop app. + +### Mobile main menu + +- The menu is a full-screen root page, not a partial-width drawer. +- It uses the same menu content and behavior as the desktop sidebar. +- Menu items do not receive mobile-specific behavior changes. +- Projects continue to expand and collapse inline. +- The existing **View Project** action continues to open project detail. +- New Chat, Search, projects, pinned chats, recents, selection actions, pull-to-refresh, and account controls retain their current behavior. +- Existing platform and feature-flag visibility rules, including the desktop-only Agent Mode entry, remain unchanged. +- The Maple wordmark remains in the header. +- The desktop sidebar collapse control is not shown because the mobile main menu is the root page. + +### Mobile chat and new-chat pages + +- Opening a chat pushes a full-screen chat detail page. +- Opening New Chat pushes a transient new-chat page. +- The mobile menu/hamburger control becomes a top-left back arrow. +- Existing conversation headers retain the wordmark, conversation title, and New Chat action. +- The empty new-chat page has a back arrow but does not show a redundant New Chat action. +- Portrait and short-landscape mobile headers follow the same navigation rules. + +### Mobile project detail + +- Project detail is a pushed page. +- Its mobile menu/hamburger control becomes a back arrow. +- Opening a chat from project detail pushes the chat over project detail. +- Back from that chat returns to project detail; back from project detail returns to the main menu. + +## Shared Menu Architecture + +There must be one menu implementation. + +The current `Sidebar` combines two responsibilities: + +1. Sidebar-specific layout, sizing, visibility, outside-click, and collapse behavior. +2. The actual menu UI and behavior. + +Refactor this boundary without redesigning the menu: + +- Extract the inner menu into a shared `MainMenu` component. +- Keep `Sidebar` as a thin desktop wrapper around `MainMenu`. +- Render the same `MainMenu` as the full-screen mobile root page. +- Continue using the existing `ChatHistoryList` for projects, pinned chats, recents, selection, and list actions. + +Changes made later to shared menu content must appear in both the desktop sidebar and the mobile main menu automatically. + +## Mobile Navigation Stack + +Use a small mobile navigation stack rather than introducing a second route hierarchy. + +- Keep parent pages mounted while a child page is visible. +- Hide and make covered parent pages non-interactive and inaccessible to assistive technology. +- Preserve main-menu scroll position, expanded projects, search state, and selection state while a child page is open. +- Preserve project-detail state while a chat opened from that project is visible. +- Unmount a chat after its back transition completes. +- If one chat is replaced by New Chat or another chat, unmount the chat that left the screen. + +Keeping the parent page mounted is intentional and matches a native navigation stack. It does not keep a popped chat loaded. + +The current root shell already keeps `AuthenticatedHomeContent` mounted and inert behind dedicated settings routes. Reuse that established mounted-surface pattern where practical rather than creating a competing persistence mechanism. Mobile navigation changes must not break the existing return-from-settings behavior managed by `PersistentHomeNavigationProvider`. + +## URL and History Rules + +Do not add a new route or query parameter for this feature. + +Continue using the current URLs: + +- `/` remains the root URL. +- `/?conversation_id=` remains a chat detail URL. +- `/?project_id=` remains a project detail URL. + +### Root URL + +- On compact/mobile layouts, a fresh load of `/` shows the mobile main menu. +- On desktop layouts, `/` keeps its existing new-chat behavior with the sidebar visible. + +### Existing chats and projects + +- Selecting an existing chat continues to push its `conversation_id` into browser history. +- Opening project detail continues to use `project_id`. +- Web reloads always load the current URL, regardless of viewport width. +- A web reload on a chat URL reloads that chat. +- A web reload on a project URL reloads that project. + +### New Chat + +New Chat has no durable URL until a conversation exists: + +- Push transient in-memory browser history state without changing `/`. +- Do not reconstruct the transient new-chat screen from that history state during a full document reload. +- On the first successful send, continue replacing the current URL with the newly created `conversation_id`. +- Back before the first send returns to the prior in-app page. +- Reloading `/` on mobile returns to the main menu. + +The exact internal `history.state` shape is an implementation detail and should be centralized rather than spread across components. + +## Back Navigation + +All mobile surfaces use one shared back-navigation flow. Do not design separate flows for mobile web, iOS, or Android. + +- The top-left back arrow returns to the previous in-app page. +- A chat opened from the main menu returns to the main menu. +- A chat opened from project detail returns to project detail. +- A new chat opened from an existing chat may return to that previous chat; the previous chat is reloaded when remounted. +- A chat loaded directly from a URL with no in-app parent returns to the mobile main menu instead of sending the user out of Maple. +- Browser back/forward navigation and the in-app back button must resolve through the same centralized navigation state. +- Do not plan Android-specific native navigation handling. Verify the shared browser-history behavior on Android and address only demonstrated platform bugs. + +## App Lifecycle + +### Web + +- The URL is authoritative at every viewport width. +- Refreshing or reopening the current web URL loads the page represented by that URL. + +### iOS and Android apps + +- If the app process remains in memory, preserve the current page through backgrounding and foregrounding. +- If the app process launches fresh, start on the mobile main menu. +- Do not persist the active navigation page across native process restarts. +- Existing non-navigation deep-link handling remains outside this feature's scope. + +## Chat Unmounting and Catch-Up + +Maple/OpenSecret already continues processing a submitted chat after the client disconnects or the app exits. This is existing system behavior, not new backend work. + +When a chat leaves the mobile navigation stack: + +- Complete its exit transition. +- Disconnect its local streaming reader without invoking the user-facing cancel-response operation. +- Clear its component-local UI state by unmounting it. + +When that chat is opened again: + +- Mount a fresh chat component. +- Load the stored conversation and items using the existing conversation-loading flow. +- Use the existing polling/catch-up behavior to reach the current processing state or completed result. +- Do not resubmit the user's prompt. + +This behavior must be covered by regression testing, including leaving during an active response and reopening before and after completion. + +## Transitions + +The core implementation uses a paired page transition modeled on the standard iOS navigation +controller push/pop motion: + +- Forward navigation slides the child page in from the right while shifting its parent partially + off the left edge. +- Back navigation slides the child page out to the right while returning its parent from the left. +- The popped page unmounts after its exit transition completes. +- Respect `prefers-reduced-motion` by removing or minimizing nonessential animation. +- Keep transition state centralized in the mobile navigation shell. + +## Compact Settings Navigation + +Compact Settings follows the same root/detail hierarchy and paired motion: + +- Opening Settings from the compact main menu pushes the entire Settings surface over the mounted + main menu using the same paired parent/child motion. +- Back from the Settings menu pops the entire Settings surface and reveals the preserved main menu. +- `/settings` is the full-screen Settings menu rather than a drawer over Account settings. +- Selecting a category pushes its existing detail route over the mounted Settings menu. +- The detail header uses a top-left back arrow that returns to the Settings menu. +- Browser history back to the Settings menu uses the same paired pop animation. +- A directly loaded Settings detail URL falls back to `/settings` from the in-app back button. +- Existing nested category routes, navigation locks, sign-out behavior, and persistent return to the + prior home surface remain unchanged. +- Desktop-width Settings keeps its existing two-column navigation and detail layout. + +## Implementation Sequence + +### Phase 1: Shared menu boundary + +1. Extract the existing inner menu UI into `MainMenu`. +2. Keep menu data, actions, dialogs, search, selection, and list behavior unchanged. +3. Convert `Sidebar` into a thin desktop layout/collapse wrapper. +4. Confirm the desktop sidebar is visually and behaviorally unchanged before adding mobile navigation. + +### Phase 2: Mobile navigation state + +1. Extend the current authenticated-home shell with a compact-layout navigation stack that always provides the mobile main-menu parent page. +2. Centralize interpretation of the existing URL plus transient in-memory history state. +3. Represent main menu, new chat, existing chat, and project detail without adding routes or query parameters. +4. Keep parent pages mounted and mark covered pages inert/hidden appropriately. +5. Preserve the existing persistent-home URL capture and return flow used by settings routes. + +### Phase 3: Page headers and back flow + +1. Replace the mobile chat hamburger with the shared back button. +2. Add the back button to the empty new-chat header. +3. Replace the mobile project-detail hamburger with the same back button. +4. Implement the direct-URL fallback to the main menu. +5. Synchronize header back, browser history back/forward, and transient new-chat history. + +### Phase 4: Unmount lifecycle + +1. Unmount popped chat pages after their exit transition. +2. Disconnect local streaming work without canceling server-side processing. +3. Confirm reopening uses the existing load-and-poll flow. +4. Prevent duplicate prompt submission or duplicate conversation creation during back/forward transitions. + +### Phase 5: Motion and accessibility + +1. Add the forward and backward slide transitions. +2. Add reduced-motion behavior. +3. Move focus to the pushed page when navigation completes. +4. Restore sensible focus when returning to the parent. +5. Ensure covered pages cannot receive pointer, keyboard, or assistive-technology interaction. + +### Phase 6: Platform lifecycle and regression validation + +1. Ensure web reloads honor the current URL. +2. Ensure native resume preserves the in-memory page. +3. Ensure a fresh native launch starts at the main menu. +4. Validate the existing compact breakpoint and short-landscape behavior. +5. Complete desktop and menu-behavior regression testing. + +## Likely Files Involved + +This is a planning estimate, not a requirement to modify every file listed. + +- `frontend/src/components/Sidebar.tsx` +- `frontend/src/components/ChatHistoryList.tsx` +- `frontend/src/components/UnifiedChat.tsx` +- `frontend/src/components/ProjectDetailView.tsx` +- `frontend/src/components/AuthenticatedHomeContent.tsx` +- `frontend/src/contexts/PersistentHomeNavigationContext.ts` +- `frontend/src/routes/__root.tsx` only if the existing mounted-home wrapper needs a small integration change +- `frontend/src/routes/index.tsx` only if authenticated-root coordination requires it +- `frontend/src/utils/utils.ts` only if a shared navigation helper belongs there; breakpoint behavior must not change +- A small new shared menu and/or mobile navigation-shell component +- Focused tests for navigation-state resolution and history behavior + +No backend, database, or OpenSecret API change is expected. + +## Gap Analysis + +### Unsent composer state + +Behavior is intentionally undecided when a chat or transient new-chat page is unmounted with unsent content. + +Relevant transient state includes: + +- Typed but unsent text +- Selected images +- Selected documents +- Composer-specific UI state + +An in-memory `draftMessages` mechanism exists in local state, but `UnifiedChat` does not currently use it. Do not silently connect, remove, or redesign that mechanism as part of this navigation work. + +Before implementation is finalized, record the actual resulting behavior and decide whether preserving unsent text should become a separate follow-up. Draft persistence is not currently part of the feature's definition of done. + +The implemented core behavior is that unsent composer text and attachments are discarded when a +chat or transient New Chat page is popped and unmounted. Preserving them should be considered as a +separate follow-up; this implementation does not connect or change the existing unused +`draftMessages` state. + +## Stretch Goal: iOS Edge-Swipe Back + +Implemented as an interactive left-edge swipe-back gesture for the iOS Tauri app. The same shared +gesture tracker is used for chat/project navigation, Settings detail-to-menu navigation, and +Settings menu-to-home navigation. + +Wry 0.55.1 does not expose built-in back/forward navigation gestures on iOS, so this requires an app-level gesture rather than a configuration switch. + +Expected behavior: + +- Begin only from the left screen edge. +- Track horizontal finger movement while rejecting primarily vertical gestures. +- Move the current page with the finger and reveal the previous mounted page underneath. +- Complete based on distance and/or velocity. +- Snap back cleanly when canceled. +- Use the same centralized back action as the header button. +- Unmount the popped chat after the completed gesture. +- Avoid intercepting controls or horizontally scrollable content away from the left-edge activation area. + +Implementation details: + +- The gesture activates only within the leftmost 28 pixels. +- It locks after 8 pixels of primarily rightward movement and yields to primarily vertical movement. +- It completes at 35% of the screen width or with sufficient rightward release velocity; otherwise, + it animates back to the current page. +- It reuses the existing history/back destinations and skips a second non-interactive pop animation + after the finger-driven transition completes. +- A previous chat is mounted only when needed to reveal it during an interactive gesture. Canceling + the gesture unmounts that preview; completing it leaves the popped chat unmounted. +- Navigation locks can opt a surface out of gesture capture, and other controls can use + `data-swipe-back-ignore` if a future left-edge interaction needs priority. + +Do not install this custom gesture in mobile Safari; Safari owns its browser navigation gesture. No Android-specific equivalent is planned. + +Physical-device verification remains required because the gesture is intentionally disabled outside +the iOS Tauri runtime. + +## Non-Goals + +- Redesigning or duplicating the menu +- Changing how menu items behave +- Changing project expand/collapse behavior +- Adding a new chat or project URL scheme +- Adding `new_chat=true` or a similar query parameter +- Replacing the current conversation/project query parameters with path routes +- Changing desktop app or desktop-width web navigation +- Changing the existing responsive breakpoint logic +- Adding Android-specific navigation behavior without a demonstrated platform bug +- Changing Maple/OpenSecret background-processing behavior +- Changing persistent return-to-home behavior +- Changing Agent Mode availability or navigation +- Adding draft persistence + +## Definition of Done + +The core feature is complete when every agreed non-stretch behavior is implemented and verified. No additional product scope is implied by this checklist. + +### Shared menu and desktop preservation + +- [ ] Desktop app navigation is visually and behaviorally unchanged. +- [ ] Desktop-width web navigation is visually and behaviorally unchanged. +- [x] Desktop and mobile render the same shared menu implementation. +- [x] A shared menu change appears on both desktop and mobile. +- [ ] Existing menu item behavior remains unchanged. +- [ ] Existing platform and feature-flag visibility rules remain unchanged. +- [ ] Projects, pinned chats, recents, search, selection, pull-to-refresh, and account controls still work. +- [ ] Leaving for settings and returning home still restores the correct home URL and surface. + +### Mobile hierarchy + +- [x] A fresh mobile root load shows the full-screen main menu. +- [x] The mobile main menu has no sidebar collapse control. +- [x] New Chat opens a transient full-screen new-chat page without changing the URL. +- [x] Existing chats open as full-screen detail pages using the existing `conversation_id` URL. +- [x] Project detail uses the existing `project_id` URL. +- [ ] Project rows still expand and collapse inline in the menu. +- [x] Parent-page menu and project state are preserved while a child page is visible. + +### Back behavior + +- [x] Mobile chat, new-chat, and project-detail pages show the agreed back button. +- [x] Back returns to the correct previous in-app page. +- [x] Directly loaded chat URLs fall back to the mobile main menu from the in-app back button. +- [x] Browser back and forward remain synchronized with the visible mobile page. +- [x] Mobile web, iOS, and Android use the same navigation logic. + +### URL and lifecycle behavior + +- [x] No new route or query parameter is introduced. +- [x] Web refresh reloads the current chat or project URL at every viewport width. +- [x] Refreshing mobile `/` shows the main menu rather than reconstructing transient New Chat. +- [x] Backgrounding and foregrounding an in-memory native app preserves its current page. +- [x] A fresh native app process starts at the main menu. + +### Chat lifecycle + +- [x] A chat unmounts after it leaves the screen. +- [x] Leaving a generating chat does not call the cancel-response operation. +- [x] Reopening a generating chat catches up without resubmitting the prompt. +- [x] Reopening after generation completes shows the completed result. +- [x] Switching between chat, New Chat, project detail, and the menu does not create duplicate conversations or messages. + +### Motion and accessibility + +- [ ] Forward and back slide transitions work in portrait and short landscape. +- [x] Parent menu/project surfaces shift left on push and return on pop. +- [x] Reduced-motion users receive a suitable non-animated or minimized transition. +- [x] Covered parent pages are inert and hidden from assistive technology. +- [x] Focus moves predictably on push and pop. +- [ ] Safe areas and the mobile keyboard do not obscure navigation controls. + +### iOS edge-swipe back + +- [x] The gesture is limited to the iOS Tauri app and begins only at the left edge. +- [x] Horizontal movement tracks the finger while primarily vertical movement is rejected. +- [x] Distance and velocity determine completion, and canceled gestures snap back. +- [x] Chat, project, Settings detail, and Settings-root flows use their existing back destinations. +- [x] A completed gesture does not replay the non-interactive pop animation. +- [x] Popped chats unmount after completion, and canceled chat previews unmount after snap-back. +- [ ] Verify tracking, completion, cancellation, and vertical-scroll rejection on a physical iPhone. + +### Compact Settings + +- [x] Opening and closing Settings uses the same paired push/pop treatment as chat navigation. +- [x] `/settings` renders the full-screen Settings menu on compact layouts. +- [x] Settings categories push existing detail routes over the mounted menu. +- [x] Settings detail back returns to the menu through shared browser history when available. +- [x] Directly loaded Settings detail routes fall back to the Settings menu. +- [x] Settings navigation locks continue to block unsafe navigation. +- [x] Desktop-width Settings retains its existing two-column layout. + +### Validation + +- [ ] Validate just below and at the existing desktop breakpoint. +- [ ] Validate representative portrait and short-landscape phone viewports. +- [ ] Validate mobile web refresh and browser back/forward. +- [ ] Validate iOS suspend/resume and fresh launch. +- [ ] Validate Android suspend/resume and fresh launch. +- [ ] Validate navigation away from and back to an actively generating chat. +- [x] Run the repository's applicable format, lint, typecheck, test, and build checks. + +The unresolved composer-draft gap does not block completion. Physical iOS gesture validation remains +part of the final release-validation pass. diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index 3614b4a9..c7280b68 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -7,6 +7,7 @@ import { CreditUsage } from "@/components/CreditUsage"; import { useCompactSettingsLayout } from "@/components/settings/useCompactSettingsLayout"; import { useLocalState } from "@/state/useLocalState"; import type { TeamStatus } from "@/types/team"; +import { SETTINGS_HOME_PARENT_STATE_KEY } from "@/utils/settingsNavigation"; import { getTeamSeatMismatch } from "@/utils/teamSeats"; export function AccountMenu() { @@ -45,6 +46,11 @@ export function AccountMenu() {
({ ...previous, [SETTINGS_HOME_PARENT_STATE_KEY]: true }) + : undefined + } aria-label={attentionLabel ? `Open settings, ${attentionLabel}` : "Open settings"} title="Settings" className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[hsl(var(--sidebar-chrome))] text-[hsl(var(--on-sidebar-chrome))] shadow-none ring-0 transition-colors hover:bg-[hsl(var(--sidebar-chrome-hover))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" diff --git a/frontend/src/components/AuthenticatedHomeContent.tsx b/frontend/src/components/AuthenticatedHomeContent.tsx index 35ef5d77..2a2aa955 100644 --- a/frontend/src/components/AuthenticatedHomeContent.tsx +++ b/frontend/src/components/AuthenticatedHomeContent.tsx @@ -10,7 +10,9 @@ import { import { useLocation, useRouter } from "@tanstack/react-router"; import { ProjectDetailView } from "@/components/ProjectDetailView"; import { UnifiedChat } from "@/components/UnifiedChat"; +import { MobileNavigationStack } from "@/components/MobileNavigationStack"; import { PersistentHomeNavigationContext } from "@/contexts/PersistentHomeNavigationContext"; +import { useIsLandscapeMobile, useIsMobile } from "@/utils/utils"; const TRANSIENT_HOME_SEARCH_PARAMS = ["team_setup", "credits_success", "api_settings"]; @@ -118,6 +120,9 @@ export function AuthenticatedHomeContent({ }: { homeLocationHref: string | null; }) { + const isMobile = useIsMobile(); + const isLandscapeMobile = useIsLandscapeMobile(); + const isCompactLayout = isMobile || isLandscapeMobile; const [selection, setSelection] = useState(readHomeSelection); const syncFromHomeLocation = useCallback(() => { @@ -152,6 +157,10 @@ export function AuthenticatedHomeContent({ }; }, [syncFromHomeLocation]); + if (isCompactLayout) { + return ; + } + if (selection.projectId && !selection.hasConversationId) { return ; } diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx index 45079384..15af0e3b 100644 --- a/frontend/src/components/ChatHistoryList.tsx +++ b/frontend/src/components/ChatHistoryList.tsx @@ -69,6 +69,9 @@ interface ChatHistoryListProps { selectedIds: Set; onSelectionChange: (ids: Set) => void; containerRef?: React.RefObject; + onOpenConversation?: (conversation: Conversation) => void; + onOpenProject?: (projectId: string) => void; + onOpenNewChat?: (projectId: string | null) => void; } export function ChatHistoryList({ @@ -79,7 +82,10 @@ export function ChatHistoryList({ onExitSelectionMode, selectedIds, onSelectionChange, - containerRef + containerRef, + onOpenConversation, + onOpenProject, + onOpenNewChat }: ChatHistoryListProps) { const opensecret = useOpenSecret(); const router = useRouter(); @@ -848,6 +854,11 @@ export function ChatHistoryList({ setSelectedProjectId(projectId); setExpandedProjectId(projectId); + if (onOpenProject) { + onOpenProject(projectId); + return; + } + if (window.location.pathname !== "/") { await router.navigate({ to: "/" }); } @@ -860,13 +871,18 @@ export function ChatHistoryList({ window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId } })); window.dispatchEvent(new Event("projectselected")); }, - [router, setSelectedProjectId] + [onOpenProject, router, setSelectedProjectId] ); const handleNewChatInProject = useCallback( async (projectId: string) => { setSelectedProjectId(projectId); + if (onOpenNewChat) { + onOpenNewChat(projectId); + return; + } + if (window.location.pathname !== "/") { await router.navigate({ to: "/" }); } @@ -878,7 +894,7 @@ export function ChatHistoryList({ window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId } })); setTimeout(() => document.getElementById("message")?.focus(), 0); }, - [router, setSelectedProjectId] + [onOpenNewChat, router, setSelectedProjectId] ); const handleCreateProject = useCallback( @@ -994,6 +1010,11 @@ export function ChatHistoryList({ async (conversation: Conversation) => { setSelectedProjectId(conversation.project_id ?? null); + if (onOpenConversation) { + onOpenConversation(conversation); + return; + } + if (window.location.pathname !== "/") { await router.navigate({ to: "/" }); } @@ -1009,7 +1030,7 @@ export function ChatHistoryList({ }) ); }, - [router, setSelectedProjectId] + [onOpenConversation, router, setSelectedProjectId] ); // Listen for conversation created event to refresh the list diff --git a/frontend/src/components/MobileNavigationStack.tsx b/frontend/src/components/MobileNavigationStack.tsx new file mode 100644 index 00000000..bee953ff --- /dev/null +++ b/frontend/src/components/MobileNavigationStack.tsx @@ -0,0 +1,470 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, + type RefObject +} from "react"; +import { flushSync } from "react-dom"; +import { MainMenu } from "@/components/Sidebar"; +import { ProjectDetailView } from "@/components/ProjectDetailView"; +import { UnifiedChat } from "@/components/UnifiedChat"; +import { useIOSSwipeBack } from "@/components/useIOSSwipeBack"; +import { useLocalState } from "@/state/useLocalState"; +import { cn } from "@/utils/utils"; +import { isTauriMobile } from "@/utils/platform"; +import { + activeMobilePage, + createInitialMobileNavigation, + createMobileHistoryState, + mobilePageHref, + promoteNewChatToConversation, + pushMobilePage, + readMobileHistoryState, + type MobileNavigationPage, + type MobileNavigationSnapshot +} from "@/utils/mobileNavigation"; + +const PAGE_TRANSITION_MS = 320; +let nativeMobileNavigationInitialized = false; + +function currentHomeHref() { + return `${window.location.pathname}${window.location.search}${window.location.hash}`; +} + +function consumeNativeFreshLaunch() { + if (!isTauriMobile() || nativeMobileNavigationInitialized) return false; + nativeMobileNavigationInitialized = true; + return true; +} + +function maxInstanceId(snapshot: MobileNavigationSnapshot) { + return Math.max(...snapshot.stack.map((page) => page.instanceId)); +} + +function lastProjectPage(snapshot: MobileNavigationSnapshot) { + for (let index = snapshot.stack.length - 1; index >= 0; index -= 1) { + const page = snapshot.stack[index]; + if (page.type === "project") return page; + } + return null; +} + +function parentSnapshotForSwipe(snapshot: MobileNavigationSnapshot) { + if (snapshot.stack.length <= 1) return null; + + return { + ...snapshot, + stack: snapshot.stack.slice(0, -1), + historyIndex: Math.max(0, snapshot.historyIndex - 1) + }; +} + +function NavigationLayer({ + active, + children, + className, + layerRef, + style +}: { + active: boolean; + children: ReactNode; + className?: string; + layerRef?: RefObject; + style?: CSSProperties; +}) { + const fallbackRef = useRef(null); + const ref = layerRef ?? fallbackRef; + + useEffect(() => { + const element = ref.current; + if (!element) return; + + if (active) { + element.removeAttribute("inert"); + requestAnimationFrame(() => element.focus({ preventScroll: true })); + } else { + element.setAttribute("inert", ""); + } + }, [active, ref]); + + return ( +
+ {children} +
+ ); +} + +export function MobileNavigationStack() { + const { setSelectedProjectId } = useLocalState(); + const nativeFreshLaunchRef = useRef(consumeNativeFreshLaunch()); + const [snapshot, setSnapshot] = useState(() => + createInitialMobileNavigation(currentHomeHref(), { + nativeFreshLaunch: nativeFreshLaunchRef.current + }) + ); + const snapshotRef = useRef(snapshot); + const nextInstanceIdRef = useRef(maxInstanceId(snapshot) + 1); + const [incomingSnapshot, setIncomingSnapshot] = useState(null); + const [isExiting, setIsExiting] = useState(false); + const [enteringInstanceId, setEnteringInstanceId] = useState( + activeMobilePage(snapshot).type === "menu" ? null : activeMobilePage(snapshot).instanceId + ); + const transitionTimerRef = useRef | null>(null); + const skipNextBackwardAnimationRef = useRef(false); + + const updateSnapshot = useCallback((next: MobileNavigationSnapshot) => { + snapshotRef.current = next; + nextInstanceIdRef.current = Math.max(nextInstanceIdRef.current, maxInstanceId(next) + 1); + setSnapshot(next); + }, []); + + const getSwipeParentSnapshot = useCallback(() => { + if (activeMobilePage(snapshotRef.current).type === "menu") return null; + return parentSnapshotForSwipe(snapshotRef.current); + }, []); + + const commitSwipeBack = useCallback( + (_parentSnapshot: MobileNavigationSnapshot, resetSwipe: () => void) => { + const current = snapshotRef.current; + if (current.hasInAppParent) { + skipNextBackwardAnimationRef.current = true; + window.history.back(); + return; + } + + const menu = createInitialMobileNavigation("/"); + window.history.replaceState(createMobileHistoryState(menu, window.history.state), "", "/"); + window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId: null } })); + flushSync(() => { + setSelectedProjectId(null); + updateSnapshot(menu); + setIncomingSnapshot(null); + setIsExiting(false); + setEnteringInstanceId(null); + }); + resetSwipe(); + }, + [setSelectedProjectId, updateSnapshot] + ); + + const { + active: isSwipeBackActive, + currentStyle: swipeCurrentStyle, + parentStyle: swipeParentStyle, + platformEnabled: isIOSSwipeBackEnabled, + pointerHandlers: swipeBackPointerHandlers, + reset: resetSwipeBack, + visual: swipeVisual + } = useIOSSwipeBack({ + blocked: isExiting, + getContext: getSwipeParentSnapshot, + onComplete: commitSwipeBack + }); + + useLayoutEffect(() => { + const href = nativeFreshLaunchRef.current ? "/" : currentHomeHref(); + window.history.replaceState( + createMobileHistoryState(snapshotRef.current, window.history.state), + "", + href + ); + }, []); + + useEffect(() => { + if (!nativeFreshLaunchRef.current) return; + + // Let the persistent-home provider record the normalized native launch URL after its event + // listeners have mounted, so a settings round-trip cannot restore the stale pre-launch URL. + const timeout = setTimeout(() => { + window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId: null } })); + }, 0); + + return () => clearTimeout(timeout); + }, []); + + const completeBackwardNavigation = useCallback( + (next: MobileNavigationSnapshot) => { + if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current); + resetSwipeBack(); + + setIncomingSnapshot(next); + setIsExiting(true); + + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + transitionTimerRef.current = setTimeout( + () => { + updateSnapshot(next); + setIncomingSnapshot(null); + setIsExiting(false); + setEnteringInstanceId(null); + transitionTimerRef.current = null; + }, + reducedMotion ? 0 : PAGE_TRANSITION_MS + ); + }, + [resetSwipeBack, updateSnapshot] + ); + + useEffect(() => { + const handlePopState = () => { + const current = snapshotRef.current; + const restored = + readMobileHistoryState(window.history.state) ?? + createInitialMobileNavigation(currentHomeHref()); + + if (restored.historyIndex < current.historyIndex) { + if (skipNextBackwardAnimationRef.current) { + skipNextBackwardAnimationRef.current = false; + updateSnapshot(restored); + setIncomingSnapshot(null); + setIsExiting(false); + setEnteringInstanceId(null); + resetSwipeBack(); + return; + } + + completeBackwardNavigation(restored); + return; + } + + updateSnapshot(restored); + const activePage = activeMobilePage(restored); + setEnteringInstanceId(activePage.type === "menu" ? null : activePage.instanceId); + }; + + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, [completeBackwardNavigation, resetSwipeBack, updateSnapshot]); + + useEffect(() => { + return () => { + if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current); + }; + }, []); + + const pushPage = useCallback( + (page: Exclude) => { + const next = pushMobilePage(snapshotRef.current, page); + window.history.pushState(createMobileHistoryState(next), "", mobilePageHref(page)); + updateSnapshot(next); + setEnteringInstanceId(page.instanceId); + }, + [updateSnapshot] + ); + + const openConversation = useCallback( + (conversationId: string) => { + const page: MobileNavigationPage = { + type: "chat", + instanceId: nextInstanceIdRef.current++, + conversationId + }; + pushPage(page); + window.dispatchEvent(new CustomEvent("conversationselected", { detail: { conversationId } })); + }, + [pushPage] + ); + + const openProject = useCallback( + (projectId: string) => { + const page: MobileNavigationPage = { + type: "project", + instanceId: nextInstanceIdRef.current++, + projectId + }; + pushPage(page); + window.dispatchEvent(new Event("projectselected")); + }, + [pushPage] + ); + + const openNewChat = useCallback( + (projectId: string | null) => { + flushSync(() => setSelectedProjectId(projectId)); + const page: MobileNavigationPage = { + type: "new-chat", + instanceId: nextInstanceIdRef.current++, + projectId + }; + pushPage(page); + window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId } })); + }, + [pushPage, setSelectedProjectId] + ); + + const handleConversationCreated = useCallback((conversationId: string) => { + const next = promoteNewChatToConversation(snapshotRef.current, conversationId); + if (next === snapshotRef.current) return; + + snapshotRef.current = next; + setSnapshot(next); + window.history.replaceState( + createMobileHistoryState(next, window.history.state), + "", + mobilePageHref(activeMobilePage(next)) + ); + }, []); + + const goBack = useCallback(() => { + if (isExiting || isSwipeBackActive) return; + + const current = snapshotRef.current; + if (current.hasInAppParent) { + window.history.back(); + return; + } + + const menu = createInitialMobileNavigation("/"); + flushSync(() => setSelectedProjectId(null)); + window.history.replaceState(createMobileHistoryState(menu, window.history.state), "", "/"); + window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId: null } })); + completeBackwardNavigation(menu); + }, [completeBackwardNavigation, isExiting, isSwipeBackActive, setSelectedProjectId]); + + const baseActivePage = activeMobilePage(snapshot); + const baseProjectPage = lastProjectPage(snapshot); + const incomingActivePage = incomingSnapshot ? activeMobilePage(incomingSnapshot) : null; + const isTransitioningBackward = isExiting && incomingSnapshot !== null; + const targetActivePage = incomingActivePage ?? baseActivePage; + const isMenuCovered = targetActivePage.type !== "menu"; + const swipeParentPage = swipeVisual ? activeMobilePage(swipeVisual.context) : null; + const visibleChatPages: Array> = []; + + if (swipeParentPage?.type === "chat" || swipeParentPage?.type === "new-chat") { + visibleChatPages.push(swipeParentPage); + } + if ( + isTransitioningBackward && + incomingActivePage && + (incomingActivePage.type === "chat" || incomingActivePage.type === "new-chat") + ) { + visibleChatPages.push(incomingActivePage); + } + if (baseActivePage.type === "chat" || baseActivePage.type === "new-chat") { + if (!visibleChatPages.some((page) => page.instanceId === baseActivePage.instanceId)) { + visibleChatPages.push(baseActivePage); + } + } + + const renderChatPage = (page: Extract) => ( + + ); + + const renderProjectPage = (page: Extract) => ( + + ); + + return ( +
+ + + + + {baseProjectPage ? ( + + {renderProjectPage(baseProjectPage)} + + ) : null} + + {visibleChatPages.map((page) => { + const isLeaving = isTransitioningBackward && page.instanceId === baseActivePage.instanceId; + const isIncoming = + isTransitioningBackward && page.instanceId === incomingActivePage?.instanceId; + const isSwipeParent = swipeParentPage?.instanceId === page.instanceId; + const isSwipeCurrent = !!swipeVisual && baseActivePage.instanceId === page.instanceId; + + return ( + + {renderChatPage(page)} + + ); + })} +
+ ); +} diff --git a/frontend/src/components/ProjectDetailView.tsx b/frontend/src/components/ProjectDetailView.tsx index c5e4ca7b..fe31e7f8 100644 --- a/frontend/src/components/ProjectDetailView.tsx +++ b/frontend/src/components/ProjectDetailView.tsx @@ -10,7 +10,8 @@ import { Pin, PinOff, SquarePen, - Trash2 + Trash2, + ArrowLeft } from "lucide-react"; import { useOpenSecret, type Conversation } from "@opensecret/react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -53,6 +54,11 @@ const MAX_SELECTION = 20; interface ProjectDetailViewProps { projectId: string; + standaloneMobile?: boolean; + onMobileBack?: () => void; + onMobileOpenConversation?: (conversationId: string) => void; + onMobileOpenNewChat?: (projectId: string) => void; + onMobileProjectDeleted?: () => void; } function getConversationTitle(conversation: Conversation) { @@ -141,7 +147,14 @@ function ProjectInstructionsDialog({ ); } -export function ProjectDetailView({ projectId }: ProjectDetailViewProps) { +export function ProjectDetailView({ + projectId, + standaloneMobile = false, + onMobileBack, + onMobileOpenConversation, + onMobileOpenNewChat, + onMobileProjectDeleted +}: ProjectDetailViewProps) { const os = useOpenSecret(); const userId = os.auth.user?.user.id; const queryClient = useQueryClient(); @@ -302,17 +315,27 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) { const handleStartNewChat = useCallback(() => { setSelectedProjectId(projectId); + if (onMobileOpenNewChat) { + onMobileOpenNewChat(projectId); + return; + } + const params = new URLSearchParams(window.location.search); params.delete("project_id"); params.delete("conversation_id"); window.history.replaceState(null, "", params.toString() ? `/?${params.toString()}` : "/"); window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId } })); setTimeout(() => document.getElementById("message")?.focus(), 0); - }, [projectId, setSelectedProjectId]); + }, [onMobileOpenNewChat, projectId, setSelectedProjectId]); const handleSelectConversation = useCallback( (conversation: Conversation) => { setSelectedProjectId(conversation.project_id ?? null); + if (onMobileOpenConversation) { + onMobileOpenConversation(conversation.id); + return; + } + const params = new URLSearchParams(window.location.search); params.delete("project_id"); params.set("conversation_id", conversation.id); @@ -323,7 +346,7 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) { }) ); }, - [setSelectedProjectId] + [onMobileOpenConversation, setSelectedProjectId] ); const handleSaveProjectInstructions = useCallback( @@ -349,7 +372,8 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) { window.history.replaceState({}, "", "/"); window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId: null } })); window.dispatchEvent(new Event("projectselected")); - }, [invalidateConversationData, os, projectId, setSelectedProjectId]); + onMobileProjectDeleted?.(); + }, [invalidateConversationData, onMobileProjectDeleted, os, projectId, setSelectedProjectId]); const handleRenameConversation = useCallback( async (conversationId: string, newTitle: string) => { @@ -458,13 +482,23 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) { if (isProjectPending && !project) { return (
- -
+ {!standaloneMobile ? : null} +
+ {standaloneMobile && onMobileBack ? ( + + ) : null}

Loading project...

@@ -473,15 +507,26 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) { return (
- + {!standaloneMobile ? : null}
- {!isSidebarOpen ? ( + {standaloneMobile && onMobileBack ? ( +
+ +
+ ) : !isSidebarOpen ? (
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b132b186..57eb5e3d 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -38,19 +38,37 @@ import { FEATURE_FLAGS, flagsClient } from "@/services/flags"; import { UpgradePromptDialog } from "@/components/UpgradePromptDialog"; import { hasApiAccess } from "@/billing/billingAccess"; -export function Sidebar({ - chatId, - isOpen, - mode = "chat", - navigationContent, - onToggle -}: { +export type SidebarProps = { chatId?: string; isOpen: boolean; mode?: "chat" | "agent"; navigationContent?: ReactNode; onToggle: () => void; -}) { +}; + +export type MainMenuProps = { + chatId?: string; + isOpen?: boolean; + mode?: "chat" | "agent"; + navigationContent?: ReactNode; + onToggle?: () => void; + presentation?: "page" | "sidebar"; + onOpenConversation?: (conversationId: string) => void; + onOpenProject?: (projectId: string) => void; + onOpenNewChat?: (projectId: string | null) => void; +}; + +export function MainMenu({ + chatId, + isOpen = true, + mode = "chat", + navigationContent, + onToggle = () => {}, + presentation = "page", + onOpenConversation, + onOpenProject, + onOpenNewChat +}: MainMenuProps) { const router = useRouter(); const location = useLocation(); const os = useOpenSecret(); @@ -96,6 +114,14 @@ export function Sidebar({ }, [selectedIds.size]); async function addChat() { + if (onOpenNewChat) { + flushSync(() => { + setSelectedProjectId(null); + }); + onOpenNewChat(null); + return; + } + // If sidebar is open on compact layout, close it if (isOpen && isCompactLayout) { onToggle(); @@ -218,7 +244,7 @@ export function Sidebar({ // Only applies on mobile - desktop users use the toggle button const handleClickOutside = useCallback( (event: MouseEvent | TouchEvent) => { - if (isOpen && isCompactLayout) { + if (presentation === "sidebar" && isOpen && isCompactLayout) { // Check if the click was inside a dropdown or dialog const target = event.target as HTMLElement; const isInDropdown = target.closest('[role="menu"]'); @@ -230,7 +256,7 @@ export function Sidebar({ } } }, - [isOpen, onToggle, isCompactLayout] + [isOpen, onToggle, isCompactLayout, presentation] ); useClickOutside(sidebarRef, handleClickOutside); @@ -248,7 +274,7 @@ export function Sidebar({ // but preserves search state between navigations useEffect(() => { // Only subscribe if we're on compact layout and sidebar is open - if (!isCompactLayout || !isOpen) return; + if (presentation !== "sidebar" || !isCompactLayout || !isOpen) return; const unsubscribe = router.subscribe("onResolved", () => { // Use a microtask to avoid state updates during render @@ -266,22 +292,27 @@ export function Sidebar({ return () => { unsubscribe(); }; - }, [router, isOpen, onToggle, isCompactLayout]); + }, [router, isOpen, onToggle, isCompactLayout, presentation]); return (
{/* Header section */} @@ -290,14 +321,16 @@ export function Sidebar({
- + {presentation === "sidebar" ? ( + + ) : null}
+ ) : ( + + )} 0 && (isLandscapeMobile && !isSidebarOpen ? (
- + {standaloneMobile && onMobileBack ? ( + + ) : ( + + )}
@@ -3328,7 +3419,18 @@ export function UnifiedChat() {
- + {standaloneMobile && onMobileBack ? ( + + ) : ( + + )}
diff --git a/frontend/src/components/settings/SettingsLayout.tsx b/frontend/src/components/settings/SettingsLayout.tsx index e7e0375d..fbd3a013 100644 --- a/frontend/src/components/settings/SettingsLayout.tsx +++ b/frontend/src/components/settings/SettingsLayout.tsx @@ -8,19 +8,25 @@ import { Database, KeyRound, LogOut, - Menu, MessageSquareText, Settings, ShieldCheck, UserRound, - UsersRound, - X + UsersRound } from "lucide-react"; -import { useEffect, useRef, useState, type ComponentType } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ComponentType +} from "react"; import { getBillingService } from "@/billing/billingService"; import { MapleWordmark } from "@/components/MapleWordmark"; import { SettingsNavigationLockProvider } from "@/components/settings/SettingsNavigationLockProvider"; import { useCompactSettingsLayout } from "@/components/settings/useCompactSettingsLayout"; +import { useIOSSwipeBack } from "@/components/useIOSSwipeBack"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { usePersistentHomeNavigation } from "@/contexts/PersistentHomeNavigationContext"; @@ -32,6 +38,15 @@ import { stopAgentRuntimeForUser } from "@/services/agentRuntimeService"; import { useLocalState } from "@/state/useLocalState"; import type { TeamStatus } from "@/types/team"; import { isIOS } from "@/utils/platform"; +import { + getSettingsBackTarget, + hasSettingsHomeParent, + isSettingsPath, + isSettingsRootPath, + SETTINGS_SHELL_POP_EVENT, + SETTINGS_SHELL_SWIPE_BACK_EVENT, + shouldAnimateSettingsPop +} from "@/utils/settingsNavigation"; import { getTeamSeatMismatch } from "@/utils/teamSeats"; import { cn } from "@/utils/utils"; import packageJson from "../../../package.json"; @@ -52,15 +67,7 @@ type SettingsNavItem = { badgeTone?: "warning" | "danger"; }; -function SettingsNavLink({ - item, - onSelect, - replace -}: { - item: SettingsNavItem; - onSelect?: () => void; - replace?: boolean; -}) { +function SettingsNavLink({ item }: { item: SettingsNavItem }) { const Icon = item.icon; const isNavigationLocked = useSettingsNavigationLockState(); const attentionLabel = @@ -73,7 +80,6 @@ function SettingsNavLink({ return ( isCompactViewport && isSettingsRoot); - const previousPathnameRef = useRef(location.pathname); - const drawerRef = useRef(null); - const drawerCloseButtonRef = useRef(null); - const menuButtonRef = useRef(null); + const menuRef = useRef(null); + const menuBackButtonRef = useRef(null); + const detailBackButtonRef = useRef(null); const mainRef = useRef(null); + const rootHistoryIndexRef = useRef(null); + const popAnimationRef = useRef | null>(null); + const popTimerRef = useRef | null>(null); + const popResolveRef = useRef<(() => void) | null>(null); + const settingsShellPopRef = useRef | null>(null); + const settingsShellPopTimerRef = useRef | null>(null); + const settingsShellPopResolveRef = useRef<(() => void) | null>(null); + const skipNextDetailPopAnimationRef = useRef(false); + const skipNextSettingsShellPopAnimationRef = useRef(false); + const [isPopping, setIsPopping] = useState(false); + const [isClosingSettings, setIsClosingSettings] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false); const [signOutError, setSignOutError] = useState(null); @@ -151,61 +164,277 @@ function SettingsLayoutContent() { } }, [location.href, os.auth.loading, os.auth.user, router]); - useEffect(() => { - const pathChanged = previousPathnameRef.current !== location.pathname; - previousPathnameRef.current = location.pathname; - + useLayoutEffect(() => { if (!isCompactViewport) { - setIsDrawerOpen(false); + rootHistoryIndexRef.current = null; + setIsPopping(false); return; } if (isSettingsRoot) { - setIsDrawerOpen(true); - } else if (pathChanged) { - setIsDrawerOpen(false); + const historyIndex = window.history.state?.__TSR_index; + rootHistoryIndexRef.current = + typeof historyIndex === "number" && Number.isFinite(historyIndex) ? historyIndex : null; + setIsPopping(false); } - }, [isCompactViewport, isSettingsRoot, location.pathname]); + }, [isCompactViewport, isSettingsRoot]); useEffect(() => { if (!isAuthReady) return; - const drawer = drawerRef.current; + const menu = menuRef.current; const main = mainRef.current; - if (drawer) { - drawer.inert = isCompactViewport && !isDrawerOpen; + if (menu) { + menu.inert = isCompactViewport && !isSettingsRoot; } if (main) { - main.inert = isCompactViewport && isDrawerOpen; + main.inert = isCompactViewport && (isSettingsRoot || isPopping); } return () => { - if (drawer) drawer.inert = false; + if (menu) menu.inert = false; if (main) main.inert = false; }; - }, [isAuthReady, isCompactViewport, isDrawerOpen]); + }, [isAuthReady, isCompactViewport, isPopping, isSettingsRoot]); useEffect(() => { - if (!isAuthReady || !isCompactViewport || !isDrawerOpen) return; + if (!isAuthReady || !isCompactViewport) return; - const frame = window.requestAnimationFrame(() => drawerCloseButtonRef.current?.focus()); + const frame = window.requestAnimationFrame(() => { + if (isSettingsRoot) { + menuBackButtonRef.current?.focus({ preventScroll: true }); + } else if (!isPopping) { + detailBackButtonRef.current?.focus({ preventScroll: true }); + } + }); return () => window.cancelAnimationFrame(frame); - }, [isAuthReady, isCompactViewport, isDrawerOpen]); + }, [isAuthReady, isCompactViewport, isPopping, isSettingsRoot]); + + const runPopAnimation = useCallback(() => { + if (popAnimationRef.current) return popAnimationRef.current; + + setIsPopping(true); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const animation = new Promise((resolve) => { + popResolveRef.current = resolve; + popTimerRef.current = setTimeout(resolve, reducedMotion ? 0 : 320); + }).finally(() => { + popAnimationRef.current = null; + popTimerRef.current = null; + popResolveRef.current = null; + }); + popAnimationRef.current = animation; + return animation; + }, []); + + const runSettingsShellPop = useCallback(() => { + if (settingsShellPopRef.current) return settingsShellPopRef.current; + + setIsClosingSettings(true); + window.dispatchEvent(new Event(SETTINGS_SHELL_POP_EVENT)); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const animation = new Promise((resolve) => { + settingsShellPopResolveRef.current = resolve; + settingsShellPopTimerRef.current = setTimeout(resolve, reducedMotion ? 0 : 320); + }).finally(() => { + settingsShellPopRef.current = null; + settingsShellPopTimerRef.current = null; + settingsShellPopResolveRef.current = null; + }); + settingsShellPopRef.current = animation; + return animation; + }, []); + + const getSettingsDetailSwipeContext = useCallback(() => { + if ( + !isCompactViewport || + isSettingsRoot || + isNavigationLocked || + isPopping || + isClosingSettings + ) { + return null; + } + + return location.pathname; + }, [ + isClosingSettings, + isCompactViewport, + isNavigationLocked, + isPopping, + isSettingsRoot, + location.pathname + ]); + + const commitSettingsDetailSwipe = useCallback( + (_pathname: string, resetSwipe: () => void) => { + const target = getSettingsBackTarget( + window.history.state?.__TSR_index, + rootHistoryIndexRef.current + ); + if (target.type === "history") { + skipNextDetailPopAnimationRef.current = true; + router.history.go(target.delta); + return; + } + + void router + .navigate({ to: "/settings", replace: true, ignoreBlocker: true }) + .finally(resetSwipe); + }, + [router] + ); + + const { + active: isSettingsDetailSwipeActive, + currentStyle: settingsDetailSwipeStyle, + parentStyle: settingsMenuSwipeStyle, + platformEnabled: isIOSSwipeBackEnabled, + pointerHandlers: settingsSwipePointerHandlers, + reset: resetSettingsDetailSwipe + } = useIOSSwipeBack({ + enabled: isCompactViewport && !isSettingsRoot, + getContext: getSettingsDetailSwipeContext, + onComplete: commitSettingsDetailSwipe + }); + + useLayoutEffect(() => { + if (isSettingsRoot) resetSettingsDetailSwipe(); + }, [isSettingsRoot, resetSettingsDetailSwipe]); + + const closeSettings = useCallback( + async (interactive = false) => { + if (isNavigationLocked || isSigningOut || isClosingSettings) return; + + if (isCompactViewport) { + if (hasSettingsHomeParent(window.history.state)) { + if (interactive) skipNextSettingsShellPopAnimationRef.current = true; + router.history.back(); + return; + } + + if (!interactive) await runSettingsShellPop(); + } + + returnToHome(); + }, + [ + isClosingSettings, + isCompactViewport, + isNavigationLocked, + isSigningOut, + returnToHome, + router.history, + runSettingsShellPop + ] + ); useEffect(() => { - if (!isCompactViewport || !isDrawerOpen) return; + const handleInteractiveSettingsClose = (event: Event) => { + if (isNavigationLocked || isSigningOut || isClosingSettings) { + event.preventDefault(); + return; + } + + void closeSettings(true); + }; + + window.addEventListener(SETTINGS_SHELL_SWIPE_BACK_EVENT, handleInteractiveSettingsClose); + return () => + window.removeEventListener(SETTINGS_SHELL_SWIPE_BACK_EVENT, handleInteractiveSettingsClose); + }, [closeSettings, isClosingSettings, isNavigationLocked, isSigningOut]); + + useEffect(() => { + return () => { + if (popTimerRef.current) clearTimeout(popTimerRef.current); + popResolveRef.current?.(); + if (settingsShellPopTimerRef.current) clearTimeout(settingsShellPopTimerRef.current); + settingsShellPopResolveRef.current?.(); + }; + }, []); + + useEffect(() => { + if (!isCompactViewport) return; + + return router.history.block({ + enableBeforeUnload: false, + blockerFn: async ({ currentLocation, nextLocation, action }) => { + const shouldPopDetail = shouldAnimateSettingsPop({ + compact: true, + currentPathname: currentLocation.pathname, + nextPathname: nextLocation.pathname, + action + }); + + if (skipNextDetailPopAnimationRef.current && shouldPopDetail) { + skipNextDetailPopAnimationRef.current = false; + if (isNavigationLocked) { + resetSettingsDetailSwipe(); + return true; + } + return false; + } + + if (isNavigationLocked) return true; + + const isBackwardAction = action === "BACK" || action === "GO"; + if ( + isSettingsRootPath(currentLocation.pathname) && + !isSettingsPath(nextLocation.pathname) && + isBackwardAction + ) { + if (skipNextSettingsShellPopAnimationRef.current) { + skipNextSettingsShellPopAnimationRef.current = false; + return false; + } + await runSettingsShellPop(); + return false; + } + + if (!shouldPopDetail) return false; + + await runPopAnimation(); + return false; + } + }); + }, [ + isCompactViewport, + isNavigationLocked, + resetSettingsDetailSwipe, + router.history, + runPopAnimation, + runSettingsShellPop + ]); + + const showSettingsMenu = useCallback(async () => { + if (isNavigationLocked || isPopping || isSettingsDetailSwipeActive) return; + + const target = getSettingsBackTarget( + window.history.state?.__TSR_index, + rootHistoryIndexRef.current + ); + if (target.type === "history") { + router.history.go(target.delta); + return; + } + + await runPopAnimation(); + await router.navigate({ to: "/settings", replace: true, ignoreBlocker: true }); + }, [isNavigationLocked, isPopping, isSettingsDetailSwipeActive, router, runPopAnimation]); + + useEffect(() => { + if (!isCompactViewport || isSettingsRoot) return; const dismissOnEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); - setIsDrawerOpen(false); - window.requestAnimationFrame(() => menuButtonRef.current?.focus()); + void showSettingsMenu(); }; window.addEventListener("keydown", dismissOnEscape); return () => window.removeEventListener("keydown", dismissOnEscape); - }, [isCompactViewport, isDrawerOpen]); + }, [isCompactViewport, isSettingsRoot, showSettingsMenu]); const { data: currentBillingStatus } = useQuery({ queryKey: ["billingStatus"], @@ -342,69 +571,46 @@ function SettingsLayoutContent() { } }; - const closeSettings = () => { - if (isNavigationLocked || isSigningOut) return; - returnToHome(); - }; - - const openDrawer = () => { - if (isNavigationLocked || isSigningOut) return; - setIsDrawerOpen(true); - }; - - const closeDrawer = () => { - setIsDrawerOpen(false); - window.requestAnimationFrame(() => menuButtonRef.current?.focus()); - }; - return (