From ecc796d4f682b649e1936eb1b5e15ea41687b023 Mon Sep 17 00:00:00 2001 From: marks Date: Sat, 11 Jul 2026 09:16:28 -0500 Subject: [PATCH 1/5] docs: add mobile navigation plan --- docs/mobile-navigation-plan.md | 376 +++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 docs/mobile-navigation-plan.md diff --git a/docs/mobile-navigation-plan.md b/docs/mobile-navigation-plan.md new file mode 100644 index 00000000..d44f1166 --- /dev/null +++ b/docs/mobile-navigation-plan.md @@ -0,0 +1,376 @@ +# Mobile Navigation Plan + +## Status + +Planning only. This document describes an agreed future implementation; no application code has been changed as part of this planning work. + +## 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 includes a simple page transition: + +- Forward navigation slides the child page in from the right. +- Back navigation slides the child page out to the right and reveals its parent. +- 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. + +## 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. + +## Stretch Goal: iOS Edge-Swipe Back + +After the core feature is complete, add an interactive left-edge swipe-back gesture for the iOS Tauri app. + +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. + +Do not install this custom gesture in mobile Safari; Safari owns its browser navigation gesture. No Android-specific equivalent is planned. + +This stretch goal does not block completion of the core feature. + +## 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 settings navigation or persistent return-to-home behavior +- Changing Agent Mode availability or navigation +- Adding draft persistence +- Adding the iOS edge-swipe gesture before the core feature is complete + +## 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. +- [ ] Desktop and mobile render the same shared menu implementation. +- [ ] 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 + +- [ ] A fresh mobile root load shows the full-screen main menu. +- [ ] The mobile main menu has no sidebar collapse control. +- [ ] New Chat opens a transient full-screen new-chat page without changing the URL. +- [ ] Existing chats open as full-screen detail pages using the existing `conversation_id` URL. +- [ ] Project detail uses the existing `project_id` URL. +- [ ] Project rows still expand and collapse inline in the menu. +- [ ] Parent-page menu and project state are preserved while a child page is visible. + +### Back behavior + +- [ ] Mobile chat, new-chat, and project-detail pages show the agreed back button. +- [ ] Back returns to the correct previous in-app page. +- [ ] Directly loaded chat URLs fall back to the mobile main menu from the in-app back button. +- [ ] Browser back and forward remain synchronized with the visible mobile page. +- [ ] Mobile web, iOS, and Android use the same navigation logic. + +### URL and lifecycle behavior + +- [ ] No new route or query parameter is introduced. +- [ ] Web refresh reloads the current chat or project URL at every viewport width. +- [ ] Refreshing mobile `/` shows the main menu rather than reconstructing transient New Chat. +- [ ] Backgrounding and foregrounding an in-memory native app preserves its current page. +- [ ] A fresh native app process starts at the main menu. + +### Chat lifecycle + +- [ ] A chat unmounts after it leaves the screen. +- [ ] Leaving a generating chat does not call the cancel-response operation. +- [ ] Reopening a generating chat catches up without resubmitting the prompt. +- [ ] Reopening after generation completes shows the completed result. +- [ ] 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. +- [ ] Reduced-motion users receive a suitable non-animated or minimized transition. +- [ ] Covered parent pages are inert and hidden from assistive technology. +- [ ] Focus moves predictably on push and pop. +- [ ] Safe areas and the mobile keyboard do not obscure navigation controls. + +### 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. +- [ ] Run the repository's applicable format, lint, typecheck, test, and build checks. + +The unresolved composer-draft gap and the iOS edge-swipe stretch goal do not block core completion. From 4caf07a2ba9b9594bbe7bf2e43446d881a720d3a Mon Sep 17 00:00:00 2001 From: marks Date: Sat, 11 Jul 2026 13:00:52 -0500 Subject: [PATCH 2/5] feat: add mobile navigation stack --- docs/mobile-navigation-plan.md | 65 ++-- .../components/AuthenticatedHomeContent.tsx | 9 + frontend/src/components/ChatHistoryList.tsx | 29 +- .../src/components/MobileNavigationStack.tsx | 356 ++++++++++++++++++ frontend/src/components/ProjectDetailView.tsx | 71 +++- frontend/src/components/Sidebar.tsx | 100 +++-- frontend/src/components/UnifiedChat.tsx | 138 +++++-- frontend/src/utils/mobileNavigation.test.ts | 135 +++++++ frontend/src/utils/mobileNavigation.ts | 156 ++++++++ frontend/src/utils/responseLifecycle.test.ts | 33 ++ frontend/src/utils/responseLifecycle.ts | 29 ++ 11 files changed, 1027 insertions(+), 94 deletions(-) create mode 100644 frontend/src/components/MobileNavigationStack.tsx create mode 100644 frontend/src/utils/mobileNavigation.test.ts create mode 100644 frontend/src/utils/mobileNavigation.ts create mode 100644 frontend/src/utils/responseLifecycle.test.ts create mode 100644 frontend/src/utils/responseLifecycle.ts diff --git a/docs/mobile-navigation-plan.md b/docs/mobile-navigation-plan.md index d44f1166..d7be4671 100644 --- a/docs/mobile-navigation-plan.md +++ b/docs/mobile-navigation-plan.md @@ -2,7 +2,11 @@ ## Status -Planning only. This document describes an agreed future implementation; no application code has been changed as part of this planning work. +Core implementation is complete on the `mobile-navigation` branch. The navigation/history and +stream-disconnect behavior 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 @@ -268,6 +272,11 @@ An in-memory `draftMessages` mechanism exists in local state, but `UnifiedChat` 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 After the core feature is complete, add an interactive left-edge swipe-back gesture for the iOS Tauri app. @@ -314,8 +323,8 @@ The core feature is complete when every agreed non-stretch behavior is implement - [ ] Desktop app navigation is visually and behaviorally unchanged. - [ ] Desktop-width web navigation is visually and behaviorally unchanged. -- [ ] Desktop and mobile render the same shared menu implementation. -- [ ] A shared menu change appears on both desktop and mobile. +- [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. @@ -323,44 +332,44 @@ The core feature is complete when every agreed non-stretch behavior is implement ### Mobile hierarchy -- [ ] A fresh mobile root load shows the full-screen main menu. -- [ ] The mobile main menu has no sidebar collapse control. -- [ ] New Chat opens a transient full-screen new-chat page without changing the URL. -- [ ] Existing chats open as full-screen detail pages using the existing `conversation_id` URL. -- [ ] Project detail uses the existing `project_id` URL. +- [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. -- [ ] Parent-page menu and project state are preserved while a child page is visible. +- [x] Parent-page menu and project state are preserved while a child page is visible. ### Back behavior -- [ ] Mobile chat, new-chat, and project-detail pages show the agreed back button. -- [ ] Back returns to the correct previous in-app page. -- [ ] Directly loaded chat URLs fall back to the mobile main menu from the in-app back button. -- [ ] Browser back and forward remain synchronized with the visible mobile page. -- [ ] Mobile web, iOS, and Android use the same navigation logic. +- [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 -- [ ] No new route or query parameter is introduced. -- [ ] Web refresh reloads the current chat or project URL at every viewport width. -- [ ] Refreshing mobile `/` shows the main menu rather than reconstructing transient New Chat. -- [ ] Backgrounding and foregrounding an in-memory native app preserves its current page. -- [ ] A fresh native app process starts at the main menu. +- [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 -- [ ] A chat unmounts after it leaves the screen. -- [ ] Leaving a generating chat does not call the cancel-response operation. -- [ ] Reopening a generating chat catches up without resubmitting the prompt. -- [ ] Reopening after generation completes shows the completed result. -- [ ] Switching between chat, New Chat, project detail, and the menu does not create duplicate conversations or messages. +- [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. -- [ ] Reduced-motion users receive a suitable non-animated or minimized transition. -- [ ] Covered parent pages are inert and hidden from assistive technology. -- [ ] Focus moves predictably on push and 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. ### Validation @@ -371,6 +380,6 @@ The core feature is complete when every agreed non-stretch behavior is implement - [ ] 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. -- [ ] Run the repository's applicable format, lint, typecheck, test, and build checks. +- [x] Run the repository's applicable format, lint, typecheck, test, and build checks. The unresolved composer-draft gap and the iOS edge-swipe stretch goal do not block core completion. 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..3c4db1d9 --- /dev/null +++ b/frontend/src/components/MobileNavigationStack.tsx @@ -0,0 +1,356 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + 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 { 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 = 220; +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 NavigationLayer({ + active, + children, + className, + layerRef +}: { + active: boolean; + children: ReactNode; + className?: string; + layerRef?: RefObject; +}) { + 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 updateSnapshot = useCallback((next: MobileNavigationSnapshot) => { + snapshotRef.current = next; + nextInstanceIdRef.current = Math.max(nextInstanceIdRef.current, maxInstanceId(next) + 1); + setSnapshot(next); + }, []); + + 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); + + 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 + ); + }, + [updateSnapshot] + ); + + useEffect(() => { + const handlePopState = () => { + const current = snapshotRef.current; + const restored = + readMobileHistoryState(window.history.state) ?? + createInitialMobileNavigation(currentHomeHref()); + + if (restored.historyIndex < current.historyIndex) { + 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, 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) 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, setSelectedProjectId]); + + const baseActivePage = activeMobilePage(snapshot); + const baseProjectPage = lastProjectPage(snapshot); + const incomingActivePage = incomingSnapshot ? activeMobilePage(incomingSnapshot) : null; + const isTransitioningBackward = isExiting && incomingSnapshot !== null; + const visibleChatPages: Array> = []; + + if ( + isTransitioningBackward && + incomingActivePage && + (incomingActivePage.type === "chat" || incomingActivePage.type === "new-chat") + ) { + visibleChatPages.push(incomingActivePage); + } + if (baseActivePage.type === "chat" || baseActivePage.type === "new-chat") { + visibleChatPages.push(baseActivePage); + } + + const renderChatPage = () => ( + + ); + + 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; + + return ( + + {renderChatPage()} + + ); + })} +
+ ); +} 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 +3413,18 @@ export function UnifiedChat() {
- + {standaloneMobile && onMobileBack ? ( + + ) : ( + + )}
diff --git a/frontend/src/utils/mobileNavigation.test.ts b/frontend/src/utils/mobileNavigation.test.ts new file mode 100644 index 00000000..9f698c41 --- /dev/null +++ b/frontend/src/utils/mobileNavigation.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "bun:test"; +import { + MOBILE_NAVIGATION_HISTORY_KEY, + activeMobilePage, + createInitialMobileNavigation, + createMobileHistoryState, + mobilePageHref, + pageFromHref, + promoteNewChatToConversation, + pushMobilePage, + readMobileHistoryState, + type MobileNavigationPage +} from "./mobileNavigation"; + +describe("mobile navigation URL resolution", () => { + it("uses the main menu for a root URL", () => { + expect(createInitialMobileNavigation("/")).toEqual({ + version: 1, + stack: [{ type: "menu", instanceId: 0 }], + hasInAppParent: false, + historyIndex: 0 + }); + }); + + it("loads a directly addressed conversation on web", () => { + expect(createInitialMobileNavigation("/?conversation_id=conv_123")).toEqual({ + version: 1, + stack: [ + { type: "menu", instanceId: 0 }, + { type: "chat", instanceId: 1, conversationId: "conv_123" } + ], + hasInAppParent: false, + historyIndex: 0 + }); + }); + + it("loads a directly addressed project on web", () => { + expect(pageFromHref("/?project_id=project_123", 7)).toEqual({ + type: "project", + instanceId: 7, + projectId: "project_123" + }); + }); + + it("starts a fresh native launch at the main menu even with a stale home URL", () => { + expect( + createInitialMobileNavigation("/?conversation_id=stale", { nativeFreshLaunch: true }) + ).toEqual({ + version: 1, + stack: [{ type: "menu", instanceId: 0 }], + hasInAppParent: false, + historyIndex: 0 + }); + }); +}); + +describe("mobile navigation history state", () => { + it("round-trips a valid stack while preserving unrelated history state", () => { + const initial = createInitialMobileNavigation("/"); + const pushed = pushMobilePage(initial, { + type: "chat", + instanceId: 2, + conversationId: "conv_2" + }); + const state = createMobileHistoryState(pushed, { routerIndex: 4 }); + + expect(state.routerIndex).toBe(4); + expect(readMobileHistoryState(state)).toEqual(pushed); + }); + + it("rejects malformed or unversioned history state", () => { + expect(readMobileHistoryState(null)).toBeNull(); + expect(readMobileHistoryState({ [MOBILE_NAVIGATION_HISTORY_KEY]: { version: 2 } })).toBeNull(); + expect( + readMobileHistoryState({ + [MOBILE_NAVIGATION_HISTORY_KEY]: { + version: 1, + hasInAppParent: true, + historyIndex: 1, + stack: [{ type: "chat", instanceId: 1, conversationId: "conv_1" }] + } + }) + ).toBeNull(); + }); + + it("preserves parent descriptors while pushing detail pages", () => { + let snapshot = createInitialMobileNavigation("/"); + snapshot = pushMobilePage(snapshot, { + type: "project", + instanceId: 1, + projectId: "project_1" + }); + snapshot = pushMobilePage(snapshot, { + type: "chat", + instanceId: 2, + conversationId: "conv_2" + }); + + expect(snapshot.stack.map((page) => page.type)).toEqual(["menu", "project", "chat"]); + expect(snapshot.historyIndex).toBe(2); + expect(activeMobilePage(snapshot)).toEqual({ + type: "chat", + instanceId: 2, + conversationId: "conv_2" + }); + }); +}); + +describe("transient new chat", () => { + it("keeps New Chat on the root URL", () => { + const page: MobileNavigationPage = { + type: "new-chat", + instanceId: 3, + projectId: null + }; + expect(mobilePageHref(page)).toBe("/"); + }); + + it("promotes a transient new chat without changing its mounted instance", () => { + const initial = createInitialMobileNavigation("/"); + const newChat = pushMobilePage(initial, { + type: "new-chat", + instanceId: 8, + projectId: "project_8" + }); + const conversation = promoteNewChatToConversation(newChat, "conv_8"); + + expect(activeMobilePage(conversation)).toEqual({ + type: "chat", + instanceId: 8, + conversationId: "conv_8" + }); + expect(mobilePageHref(activeMobilePage(conversation))).toBe("/?conversation_id=conv_8"); + }); +}); diff --git a/frontend/src/utils/mobileNavigation.ts b/frontend/src/utils/mobileNavigation.ts new file mode 100644 index 00000000..d1ab7e1c --- /dev/null +++ b/frontend/src/utils/mobileNavigation.ts @@ -0,0 +1,156 @@ +export const MOBILE_NAVIGATION_HISTORY_KEY = "__mapleMobileNavigation"; + +export type MobileNavigationPage = + | { type: "menu"; instanceId: number } + | { type: "new-chat"; instanceId: number; projectId: string | null } + | { type: "chat"; instanceId: number; conversationId: string } + | { type: "project"; instanceId: number; projectId: string }; + +export type MobileNavigationSnapshot = { + version: 1; + stack: MobileNavigationPage[]; + hasInAppParent: boolean; + historyIndex: number; +}; + +function menuPage(): MobileNavigationPage { + return { type: "menu", instanceId: 0 }; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isPage(value: unknown): value is MobileNavigationPage { + if (!value || typeof value !== "object") return false; + + const page = value as Record; + if (typeof page.instanceId !== "number" || !Number.isFinite(page.instanceId)) return false; + + switch (page.type) { + case "menu": + return true; + case "new-chat": + return page.projectId === null || isNonEmptyString(page.projectId); + case "chat": + return isNonEmptyString(page.conversationId); + case "project": + return isNonEmptyString(page.projectId); + default: + return false; + } +} + +export function pageFromHref(href: string, instanceId: number): MobileNavigationPage { + const url = new URL(href, "https://maple.local"); + const conversationId = url.searchParams.get("conversation_id"); + if (conversationId) { + return { type: "chat", instanceId, conversationId }; + } + + const projectId = url.searchParams.get("project_id"); + if (projectId) { + return { type: "project", instanceId, projectId }; + } + + return menuPage(); +} + +export function createInitialMobileNavigation( + href: string, + { nativeFreshLaunch = false }: { nativeFreshLaunch?: boolean } = {} +): MobileNavigationSnapshot { + if (nativeFreshLaunch) { + return { version: 1, stack: [menuPage()], hasInAppParent: false, historyIndex: 0 }; + } + + const page = pageFromHref(href, 1); + return { + version: 1, + stack: page.type === "menu" ? [page] : [menuPage(), page], + hasInAppParent: false, + historyIndex: 0 + }; +} + +export function createMobileHistoryState( + snapshot: MobileNavigationSnapshot, + existingState: unknown = null +): Record { + const base = + existingState && typeof existingState === "object" + ? { ...(existingState as Record) } + : {}; + + return { + ...base, + [MOBILE_NAVIGATION_HISTORY_KEY]: snapshot + }; +} + +export function readMobileHistoryState(state: unknown): MobileNavigationSnapshot | null { + if (!state || typeof state !== "object") return null; + + const snapshot = (state as Record)[MOBILE_NAVIGATION_HISTORY_KEY]; + if (!snapshot || typeof snapshot !== "object") return null; + + const candidate = snapshot as Record; + if ( + candidate.version !== 1 || + typeof candidate.hasInAppParent !== "boolean" || + typeof candidate.historyIndex !== "number" || + !Number.isFinite(candidate.historyIndex) || + !Array.isArray(candidate.stack) || + candidate.stack.length === 0 || + !candidate.stack.every(isPage) || + candidate.stack[0].type !== "menu" + ) { + return null; + } + + return candidate as MobileNavigationSnapshot; +} + +export function pushMobilePage( + snapshot: MobileNavigationSnapshot, + page: Exclude +): MobileNavigationSnapshot { + return { + version: 1, + stack: [...snapshot.stack, page], + hasInAppParent: true, + historyIndex: snapshot.historyIndex + 1 + }; +} + +export function promoteNewChatToConversation( + snapshot: MobileNavigationSnapshot, + conversationId: string +): MobileNavigationSnapshot { + const activePage = snapshot.stack[snapshot.stack.length - 1]; + if (activePage.type !== "new-chat") return snapshot; + + return { + ...snapshot, + stack: [ + ...snapshot.stack.slice(0, -1), + { type: "chat", instanceId: activePage.instanceId, conversationId } + ] + }; +} + +export function mobilePageHref(page: MobileNavigationPage): string { + switch (page.type) { + case "chat": + return `/?conversation_id=${encodeURIComponent(page.conversationId)}`; + case "project": + return `/?project_id=${encodeURIComponent(page.projectId)}`; + case "menu": + case "new-chat": + return "/"; + } +} + +export function activeMobilePage(snapshot: MobileNavigationSnapshot): MobileNavigationPage { + return snapshot.stack[snapshot.stack.length - 1]; +} diff --git a/frontend/src/utils/responseLifecycle.test.ts b/frontend/src/utils/responseLifecycle.test.ts new file mode 100644 index 00000000..fc9b7f7d --- /dev/null +++ b/frontend/src/utils/responseLifecycle.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { ResponseLifecycleFence } from "./responseLifecycle"; + +describe("ResponseLifecycleFence", () => { + it("allows ordinary request failures to use existing recovery", () => { + const fence = new ResponseLifecycleFence(); + fence.beginResponse(); + expect(fence.shouldIgnoreErrors()).toBe(false); + expect(fence.canUpdateState()).toBe(true); + }); + + it("suppresses retry and recovery after a deliberate client abort", () => { + const fence = new ResponseLifecycleFence(); + fence.beginResponse(); + fence.abortResponse(); + expect(fence.shouldIgnoreErrors()).toBe(true); + }); + + it("allows a later request after an in-place user cancellation", () => { + const fence = new ResponseLifecycleFence(); + fence.abortResponse(); + fence.beginResponse(); + expect(fence.shouldIgnoreErrors()).toBe(false); + }); + + it("permanently suppresses retries and state updates after unmount", () => { + const fence = new ResponseLifecycleFence(); + fence.unmount(); + fence.beginResponse(); + expect(fence.shouldIgnoreErrors()).toBe(true); + expect(fence.canUpdateState()).toBe(false); + }); +}); diff --git a/frontend/src/utils/responseLifecycle.ts b/frontend/src/utils/responseLifecycle.ts new file mode 100644 index 00000000..221a307b --- /dev/null +++ b/frontend/src/utils/responseLifecycle.ts @@ -0,0 +1,29 @@ +/** + * Separates a deliberate client disconnect from a request failure. A disconnected response must + * never enter retry/error recovery, because Maple's server continues processing it independently. + */ +export class ResponseLifecycleFence { + private responseAborted = false; + private unmounted = false; + + beginResponse() { + if (!this.unmounted) this.responseAborted = false; + } + + abortResponse() { + this.responseAborted = true; + } + + unmount() { + this.unmounted = true; + this.responseAborted = true; + } + + shouldIgnoreErrors() { + return this.responseAborted || this.unmounted; + } + + canUpdateState() { + return !this.unmounted; + } +} From a3755b6566ec5731d514a9a3617653257dfb15bb Mon Sep 17 00:00:00 2001 From: marks Date: Sat, 11 Jul 2026 19:08:31 -0500 Subject: [PATCH 3/5] feat: extend mobile navigation to settings --- docs/mobile-navigation-plan.md | 33 ++- .../src/components/MobileNavigationStack.tsx | 26 +- .../components/settings/SettingsLayout.tsx | 231 +++++++++++------- frontend/src/index.css | 37 +++ frontend/src/routes/settings.index.tsx | 3 +- frontend/src/utils/settingsNavigation.test.ts | 50 ++++ frontend/src/utils/settingsNavigation.ts | 40 +++ 7 files changed, 314 insertions(+), 106 deletions(-) create mode 100644 frontend/src/utils/settingsNavigation.test.ts create mode 100644 frontend/src/utils/settingsNavigation.ts diff --git a/docs/mobile-navigation-plan.md b/docs/mobile-navigation-plan.md index d7be4671..0e498f00 100644 --- a/docs/mobile-navigation-plan.md +++ b/docs/mobile-navigation-plan.md @@ -181,14 +181,29 @@ This behavior must be covered by regression testing, including leaving during an ## Transitions -The core implementation includes a simple page transition: +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. -- Back navigation slides the child page out to the right and reveals its parent. +- 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: + +- `/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 @@ -310,7 +325,7 @@ This stretch goal does not block completion of the core feature. - Changing the existing responsive breakpoint logic - Adding Android-specific navigation behavior without a demonstrated platform bug - Changing Maple/OpenSecret background-processing behavior -- Changing settings navigation or persistent return-to-home behavior +- Changing persistent return-to-home behavior - Changing Agent Mode availability or navigation - Adding draft persistence - Adding the iOS edge-swipe gesture before the core feature is complete @@ -367,11 +382,21 @@ The core feature is complete when every agreed non-stretch behavior is implement ### 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. +### Compact Settings + +- [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. diff --git a/frontend/src/components/MobileNavigationStack.tsx b/frontend/src/components/MobileNavigationStack.tsx index 3c4db1d9..1d8ece8b 100644 --- a/frontend/src/components/MobileNavigationStack.tsx +++ b/frontend/src/components/MobileNavigationStack.tsx @@ -26,7 +26,7 @@ import { type MobileNavigationSnapshot } from "@/utils/mobileNavigation"; -const PAGE_TRANSITION_MS = 220; +const PAGE_TRANSITION_MS = 320; let nativeMobileNavigationInitialized = false; function currentHomeHref() { @@ -269,6 +269,8 @@ export function MobileNavigationStack() { 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 visibleChatPages: Array> = []; if ( @@ -304,7 +306,13 @@ export function MobileNavigationStack() { return (
- + {renderProjectPage(baseProjectPage)} @@ -339,12 +349,12 @@ export function MobileNavigationStack() { key={`chat-${page.instanceId}`} active={!isIncoming} className={cn( - isLeaving && - "z-20 motion-safe:animate-out motion-safe:slide-out-to-right-full motion-safe:duration-200", + "maple-navigation-page shadow-[-12px_0_28px_rgba(0,0,0,0.12)]", + isLeaving && "maple-navigation-page-pop z-20", isIncoming && "z-10", !isTransitioningBackward && page.instanceId === enteringInstanceId && - "motion-safe:animate-in motion-safe:slide-in-from-right-full motion-safe:duration-200" + "maple-navigation-page-enter" )} > {renderChatPage()} diff --git a/frontend/src/components/settings/SettingsLayout.tsx b/frontend/src/components/settings/SettingsLayout.tsx index e7e0375d..3f5bedb5 100644 --- a/frontend/src/components/settings/SettingsLayout.tsx +++ b/frontend/src/components/settings/SettingsLayout.tsx @@ -8,15 +8,20 @@ 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"; @@ -32,6 +37,11 @@ import { stopAgentRuntimeForUser } from "@/services/agentRuntimeService"; import { useLocalState } from "@/state/useLocalState"; import type { TeamStatus } from "@/types/team"; import { isIOS } from "@/utils/platform"; +import { + getSettingsBackTarget, + isSettingsRootPath, + shouldAnimateSettingsPop +} from "@/utils/settingsNavigation"; import { getTeamSeatMismatch } from "@/utils/teamSeats"; import { cn } from "@/utils/utils"; import packageJson from "../../../package.json"; @@ -52,15 +62,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 +75,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 [isPopping, setIsPopping] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false); const [signOutError, setSignOutError] = useState(null); @@ -151,61 +153,129 @@ 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; + }, []); useEffect(() => { - if (!isCompactViewport || !isDrawerOpen) return; + return () => { + if (popTimerRef.current) clearTimeout(popTimerRef.current); + popResolveRef.current?.(); + }; + }, []); + + useEffect(() => { + if (!isCompactViewport || isSettingsRoot) return; + + return router.history.block({ + enableBeforeUnload: false, + blockerFn: async ({ currentLocation, nextLocation, action }) => { + if (isNavigationLocked) return true; + if ( + !shouldAnimateSettingsPop({ + compact: true, + currentPathname: currentLocation.pathname, + nextPathname: nextLocation.pathname, + action + }) + ) { + return false; + } + + await runPopAnimation(); + return false; + } + }); + }, [isCompactViewport, isNavigationLocked, isSettingsRoot, router.history, runPopAnimation]); + + const showSettingsMenu = useCallback(async () => { + if (isNavigationLocked || isPopping) 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, 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"], @@ -347,44 +417,28 @@ function SettingsLayoutContent() { returnToHome(); }; - const openDrawer = () => { - if (isNavigationLocked || isSigningOut) return; - setIsDrawerOpen(true); - }; - - const closeDrawer = () => { - setIsDrawerOpen(false); - window.requestAnimationFrame(() => menuButtonRef.current?.focus()); - }; - return (