fix(mobile): tappable instance/project tab bar while session drawer is open (#459)

## 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 <omer@Omer.dn3uxh3znnmu5eefnjnut0i1af.tlvx.internal.cloudapp.net>
Co-authored-by: Shantur Rathore <i@shantur.com>
This commit is contained in:
Omer Cohen 2026-05-16 11:07:46 -04:00 committed by GitHub
parent 311b60bfb6
commit 681755888f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 58 additions and 0 deletions

View file

@ -194,6 +194,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
unpinRight: unpinRightDrawer,
closeLeft: closeLeftDrawer,
closeRight: closeRightDrawer,
closeFloatingDrawersIfAny,
leftAppBarButtonLabel,
rightAppBarButtonLabel,
leftAppBarButtonIcon,
@ -202,6 +203,45 @@ const InstanceShell2: Component<InstanceShellProps> = (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<InstanceShellProps> = (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<InstanceShellProps> = (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<InstanceShellProps> = (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<InstanceShellProps> = (props) => {
height: floatingHeight(),
},
"& .MuiBackdrop-root": {
pointerEvents: "auto",
backgroundColor: "transparent",
top: floatingTopPx(),
height: floatingHeight(),
},
}}
>

View file

@ -44,6 +44,7 @@ export interface DrawerChromeApi {
unpinRight: () => void
closeLeft: () => void
closeRight: () => void
closeFloatingDrawersIfAny: () => boolean
leftAppBarButtonLabel: Accessor<string>
rightAppBarButtonLabel: Accessor<string>
leftAppBarButtonIcon: Accessor<JSX.Element>
@ -250,6 +251,7 @@ export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeAp
unpinRight,
closeLeft,
closeRight,
closeFloatingDrawersIfAny,
leftAppBarButtonLabel,
rightAppBarButtonLabel,
leftAppBarButtonIcon,