From 681755888f62788c319f3f0b1ca7bf00a2022c8e Mon Sep 17 00:00:00 2001 From: Omer Cohen <639682+omercnet@users.noreply.github.com> Date: Sat, 16 May 2026 11:07:46 -0400 Subject: [PATCH] fix(mobile): tappable instance/project tab bar while session drawer is open (#459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes a mobile UX bug where, with the session list (left drawer) open on phone layout, the still-visible instance/project tab bar at the top was non-interactive. Tapping a tab did nothing — users had to close the drawer first and then switch projects/instances. After this change, tapping a tab in that bar switches the instance/project **and** the drawer auto-closes in a single gesture, matching user expectation. ## Root cause On phone (`max-width: 767px`), `InstanceShell` renders the left session sidebar via MUI's `Drawer variant="temporary"`. The drawer paper is offset down to `floatingTopPx()` so the instance tab bar remains visually visible above it. However, MUI's `Modal` Backdrop is `position: fixed; inset: 0` and covers the entire viewport — including the area over the tab bar. The backdrop is styled `backgroundColor: transparent` (so it's invisible) but it still captures pointer events at `z-index: 60`. Taps over the tab bar hit the transparent backdrop → MUI calls `onClose` (closing the drawer) but the tab's click handler never fires. ## Implementation 1. **`packages/ui/src/components/instance/instance-shell2.tsx`** — Constrain the MUI Drawer Backdrop via `sx` overrides on both the left (session list) and right drawers so the backdrop is bound to the drawer paper's vertical range (`top: floatingTopPx(); height: floatingHeight();`) instead of fullscreen. Taps over the tab bar now reach the tab buttons. 2. **`packages/ui/src/styles/panels/tabs.css`** — Lift `.tab-bar-instance` to `position: relative; z-index: 70` so it stacks deterministically above the drawer (z-index 60) across browsers as defense-in-depth. 3. **`packages/ui/src/components/instance/shell/useDrawerChrome.ts` + `instance-shell2.tsx`** — Expose `closeFloatingDrawersIfAny` from `useDrawerChrome` and call it from an `InstanceShell` effect that fires when `props.isActiveInstance` flips `true → false`. This closes any open floating drawer on the instance the user just switched away from, so its previously-open state doesn't bleed back when the user returns to that tab later. Tablet (>=768px) and desktop (>=1280px) layouts use pinned drawers (no temporary modal), so the backdrop constraint and z-index lift are inert there. The fix applies symmetrically to the right drawer on phone. ## Verification - `tsc --noEmit` clean. - `vite build` clean. - Manual mobile-emulation checklist in the task file (`tasks/done/058-mobile-session-list-blocks-tab-switch.md`) and SUMMARY in `evidences/058-mobile-session-list-blocks-tab-switch/`. ## Reviewer manual check (phone viewport <=767px) - Open session list drawer. Tap a different instance tab → it activates AND drawer closes. - Tap "+" while drawer open → folder picker opens, drawer closes. - Tap Settings / Notifications / Remote while drawer open → action fires, drawer closes. - Same checks against the right drawer. - Tap area below tab bar but outside drawer paper → drawer still closes (existing backdrop dismissal preserved). - Resize to tablet and desktop widths → pinned drawers unaffected. - No visual regression in light or dark theme. --------- Co-authored-by: Ubuntu Co-authored-by: Shantur Rathore --- .../components/instance/instance-shell2.tsx | 56 +++++++++++++++++++ .../instance/shell/useDrawerChrome.ts | 2 + 2 files changed, 58 insertions(+) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 42b98c65..c2689fdb 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -194,6 +194,7 @@ const InstanceShell2: Component = (props) => { unpinRight: unpinRightDrawer, closeLeft: closeLeftDrawer, closeRight: closeRightDrawer, + closeFloatingDrawersIfAny, leftAppBarButtonLabel, rightAppBarButtonLabel, leftAppBarButtonIcon, @@ -202,6 +203,45 @@ const InstanceShell2: Component = (props) => { handleRightAppBarButtonClick, } = drawerChrome + // When the user switches away from this instance (e.g., taps a different + // instance/project tab while a floating drawer is open on phone), close any + // open floating drawers so the previous instance's drawer doesn't remain + // visually or interactively open when its tab regains focus later. + let wasActiveInstance = Boolean(props.isActiveInstance) + createEffect(() => { + const isActive = Boolean(props.isActiveInstance) + if (wasActiveInstance && !isActive) { + closeFloatingDrawersIfAny() + } + wasActiveInstance = isActive + }) + + onMount(() => { + if (typeof document === "undefined") return + + const handleFloatingDrawerPointerDown = (event: PointerEvent) => { + if (!props.isActiveInstance) return + + const hasFloatingDrawerOpen = (!leftPinned() && leftOpen()) || (!rightPinned() && rightOpen()) + if (!hasFloatingDrawerOpen) return + + const target = event.target + if (!(target instanceof Node)) return + + const leftContent = leftDrawerContentEl() + const rightContent = rightDrawerContentEl() + const leftPaper = leftContent?.closest(".MuiDrawer-paper") + const rightPaper = rightContent?.closest(".MuiDrawer-paper") + if (leftPaper?.contains(target) || rightPaper?.contains(target)) return + + if (!leftPinned() && leftOpen()) setLeftOpen(false) + if (!rightPinned() && rightOpen()) setRightOpen(false) + } + + document.addEventListener("pointerdown", handleFloatingDrawerPointerDown, true) + onCleanup(() => document.removeEventListener("pointerdown", handleFloatingDrawerPointerDown, true)) + }) + createEffect(() => { const instanceId = props.instance.id loadBackgroundProcesses(instanceId).catch((error) => { @@ -607,7 +647,12 @@ const InstanceShell2: Component = (props) => { ModalProps={modalProps} sx={{ zIndex: 60, + // The tab bar sits outside the floating drawer. Let its controls + // receive the gesture; click-away handling above still closes the + // drawer when the target is not inside the drawer content. + pointerEvents: "none", "& .MuiDrawer-paper": { + pointerEvents: "auto", width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`, boxSizing: "border-box", borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)", @@ -620,8 +665,13 @@ const InstanceShell2: Component = (props) => { height: floatingHeight(), }, + // Keep backdrop dismissal for the area below the tab bar without + // covering the tab bar itself. "& .MuiBackdrop-root": { + pointerEvents: "auto", backgroundColor: "transparent", + top: floatingTopPx(), + height: floatingHeight(), }, }} > @@ -723,7 +773,10 @@ const InstanceShell2: Component = (props) => { ModalProps={modalProps} sx={{ zIndex: 60, + // See the matching override on the left drawer for rationale. + pointerEvents: "none", "& .MuiDrawer-paper": { + pointerEvents: "auto", width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`, boxSizing: "border-box", borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)", @@ -736,7 +789,10 @@ const InstanceShell2: Component = (props) => { height: floatingHeight(), }, "& .MuiBackdrop-root": { + pointerEvents: "auto", backgroundColor: "transparent", + top: floatingTopPx(), + height: floatingHeight(), }, }} > diff --git a/packages/ui/src/components/instance/shell/useDrawerChrome.ts b/packages/ui/src/components/instance/shell/useDrawerChrome.ts index bccc8444..4b123817 100644 --- a/packages/ui/src/components/instance/shell/useDrawerChrome.ts +++ b/packages/ui/src/components/instance/shell/useDrawerChrome.ts @@ -44,6 +44,7 @@ export interface DrawerChromeApi { unpinRight: () => void closeLeft: () => void closeRight: () => void + closeFloatingDrawersIfAny: () => boolean leftAppBarButtonLabel: Accessor rightAppBarButtonLabel: Accessor leftAppBarButtonIcon: Accessor @@ -250,6 +251,7 @@ export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeAp unpinRight, closeLeft, closeRight, + closeFloatingDrawersIfAny, leftAppBarButtonLabel, rightAppBarButtonLabel, leftAppBarButtonIcon,