From f4beda6b876836901f99181335e7ae125a28ae52 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:18:12 -0500 Subject: [PATCH 1/2] fix(web): panels and pop-ups keep clear of the iPhone Duo's side dock The iPhone Duo keeps a strip down the right edge of the screen for its status dock (clock, battery, signal), folded or open, and reports it as env(safe-area-inset-right) for the whole height. The title row and the composer already kept out of it; everything else that touches that edge did not, so the right panel's close button, the file browser's close button and the first controls of several pages sat under the dock. - index.css maps the side insets into --safe-area-left/right, with page-gutters and window-gutters utilities for surfaces that scroll or paint to the window's edge. sideStrips.ts reports the widths in px to script through a ResizeObserver probe (no resize listener, no forced layout). - The right panel's sheet carries its fill into the strip and pads the panel out of it, keeping the panel's own width. - Dialogs, alert dialogs, the command palette, the theme trial bar, the image viewer and toasts stay off the strip; a phone's bottom sheets pad their content out of it. Dialog centering counts the page's edges from the strips. - Menus, popovers, selects, comboboxes, autocompletes, preview cards and anchored toasts add the strips to their collision padding. - Page bodies (Settings, General chats, Fleet, Scheduled, Pull requests, Usage), setup, pairing and reconnect screens, the find bars and the terminal's button row keep off the strip. The conversation stays full width. Two bugs found on the folded screen while testing fold and unfold: - The whole app renders inside the command palette's dialog, and Base UI leaves the backdrop out of a drawer nested in another dialog. The page never dimmed behind the phone's thread list or the right panel's sheet, and a press beside the sheet did not close it. The drawer's backdrop is now forced; the right panel asks for one only in the phone layout, so a narrow desktop window keeps its floating panel. - The phone's thread list stayed open in state while the layout was the desktop one, so unfolding and folding again put it back over the chat. It is put away when the layout stops being the phone's. --- .../ChatRightPanelInlineSidebar.tsx | 6 +- .../components/ChatView.panels.browser.tsx | 139 ++++++++++++++++++ .../src/components/ChatView.shell.browser.tsx | 36 +++++ .../src/components/ChatsDestinationView.tsx | 4 +- apps/web/src/components/RightPanelSheet.tsx | 14 +- .../SavedEnvironmentConnectionOverlay.tsx | 2 +- .../src/components/ThreadTerminalDrawer.tsx | 2 +- .../components/auth/PairingRouteSurface.tsx | 4 +- .../components/chat/ExpandedImageDialog.tsx | 16 +- .../src/components/chat/chatFind/ChatFind.tsx | 5 +- .../file-viewer/FileViewerOverlay.tsx | 6 +- .../components/fleet/FleetComputerView.tsx | 2 +- apps/web/src/components/fleet/FleetView.tsx | 2 +- .../pull-requests/PullRequestsView.tsx | 9 +- .../components/scheduled/ScheduledView.tsx | 2 +- .../components/settings/settingsLayout.tsx | 2 +- .../src/components/setup/AgentSetupScreen.tsx | 4 +- .../components/terminal/TerminalFindBar.tsx | 2 +- .../theme/ui/ThemeTrialBarContent.tsx | 2 +- apps/web/src/components/ui/alert-dialog.tsx | 10 +- apps/web/src/components/ui/autocomplete.tsx | 3 + apps/web/src/components/ui/combobox.tsx | 3 + apps/web/src/components/ui/command.tsx | 4 +- apps/web/src/components/ui/dialog.browser.tsx | 53 ++++++- apps/web/src/components/ui/dialog.tsx | 15 +- apps/web/src/components/ui/drawer.tsx | 39 +++-- apps/web/src/components/ui/menu.tsx | 3 + apps/web/src/components/ui/popover.tsx | 6 +- apps/web/src/components/ui/previewCard.tsx | 3 + apps/web/src/components/ui/select.tsx | 10 +- apps/web/src/components/ui/sidebar.tsx | 8 + apps/web/src/components/ui/toast.tsx | 16 +- apps/web/src/components/usage/UsageView.tsx | 2 +- apps/web/src/index.css | 63 ++++++-- apps/web/src/mainAreaLayout.ts | 17 ++- apps/web/src/rightPanelLayout.ts | 12 +- apps/web/src/sideStrips.browser.tsx | 44 ++++++ apps/web/src/sideStrips.ts | 107 ++++++++++++++ docs/design/design-language.md | 14 ++ 39 files changed, 617 insertions(+), 74 deletions(-) create mode 100644 apps/web/src/sideStrips.browser.tsx create mode 100644 apps/web/src/sideStrips.ts diff --git a/apps/web/src/components/ChatRightPanelInlineSidebar.tsx b/apps/web/src/components/ChatRightPanelInlineSidebar.tsx index 6bb70393e..afbe814d0 100644 --- a/apps/web/src/components/ChatRightPanelInlineSidebar.tsx +++ b/apps/web/src/components/ChatRightPanelInlineSidebar.tsx @@ -106,7 +106,11 @@ export function ChatRightPanelInlineSidebar(props: { collapsible="offcanvas" className={cn( "border-l border-border bg-rail text-foreground", - isElectron && "pe-[var(--app-window-resize-edge-inset)]", + // Out of what the window's right edge is kept for: the desktop + // app's resize grip, or a screen's strip down that side. The strip + // comes out of the panel's own width here (no screen this wide has + // one yet); the sheet in RightPanelSheet adds it instead. + isElectron ? "pe-[var(--app-window-resize-edge-inset)]" : "pr-safe", )} motion="instant" resizable={{ diff --git a/apps/web/src/components/ChatView.panels.browser.tsx b/apps/web/src/components/ChatView.panels.browser.tsx index ffebbf42e..1ad441667 100644 --- a/apps/web/src/components/ChatView.panels.browser.tsx +++ b/apps/web/src/components/ChatView.panels.browser.tsx @@ -12,6 +12,7 @@ import { page, userEvent } from "vite-plus/test/browser"; import { describe, expect, it, vi } from "vite-plus/test"; import { useComposerDraftStore, DraftId } from "../composerDraftStore"; +import { nextFrames } from "../lib/frameProbe.browser-harness"; import { isMacPlatform } from "../lib/utils"; import { RIGHT_PANEL_RAIL_WIDTH } from "../rightPanelLayout"; import { useTerminalStateStore } from "../terminalStateStore"; @@ -365,6 +366,144 @@ describe("ChatView (full app): right panel and header actions", () => { } }); + it("keeps the overlay panel's controls off a strip the screen holds back down its right side", async () => { + // An iPhone Duo, folded: its status dock hangs down the right edge, and + // the sheet's close button used to sit under it. + const strip = 85; + document.documentElement.style.setProperty("--safe-area-right", `${strip}px`); + const mounted = await mountChatView({ + viewport: { ...PHONE_VIEWPORT, width: 466, height: 678 }, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-rail-sheet-strip" as MessageId, + targetText: "rail sheet strip", + }), + }); + + try { + const railToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle panel"]') as HTMLButtonElement | null, + "Unable to find the rail toggle.", + ); + railToggle.click(); + const closeButton = await waitForElement( + () => + document.querySelector('button[aria-label="Close panel"]') as HTMLButtonElement | null, + "The sheet never showed its close button.", + ); + const popup = closeButton.closest('[data-slot="drawer-popup"]') as HTMLElement; + const panel = closeButton.closest("[data-chat-right-panel='true']") as HTMLElement; + + await vi.waitFor( + () => { + // The sheet's fill reaches the screen's edge, under the strip; the + // panel stops at the strip and keeps its own width beside it (the + // rail's, less the sheet's hairline). + expect(popup.getBoundingClientRect().right).toBeCloseTo(window.innerWidth, 0); + expect(panel.getBoundingClientRect().right).toBeCloseTo(window.innerWidth - strip, 0); + expect(panel.getBoundingClientRect().width).toBeCloseTo(RIGHT_PANEL_RAIL_WIDTH - 1, 0); + expect(closeButton.getBoundingClientRect().right).toBeLessThanOrEqual( + window.innerWidth - strip, + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + document.documentElement.style.removeProperty("--safe-area-right"); + await mounted.cleanup(); + } + }); + + it("puts the overlay panel away on a press on the page beside it", async () => { + const mounted = await mountChatView({ + viewport: { ...PHONE_VIEWPORT, width: 466, height: 678 }, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-rail-sheet-press" as MessageId, + targetText: "rail sheet press", + }), + }); + + try { + const railToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle panel"]') as HTMLButtonElement | null, + "Unable to find the rail toggle.", + ); + railToggle.click(); + const launcher = await waitForElement( + () => document.querySelector("[data-right-panel-launcher='true']") as HTMLElement | null, + "The sidebar never opened on a phone width.", + ); + const popup = launcher.closest('[data-slot="drawer-popup"]') as HTMLElement; + await vi.waitFor( + () => { + expect(popup.getBoundingClientRect().right).toBeCloseTo(window.innerWidth, 0); + }, + { timeout: 8_000, interval: 16 }, + ); + + // The slice of the conversation left of the sheet, below the header: + // whatever is on top there takes the press. + const beside = document.elementFromPoint( + popup.getBoundingClientRect().left / 2, + window.innerHeight / 2, + ) as HTMLElement; + beside.click(); + + await vi.waitFor( + () => { + expect(document.querySelector("[data-right-panel-launcher='true']")).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("floats the panel over a narrow window's page, which stays undimmed and in use beside it", async () => { + const mounted = await mountChatView({ + viewport: { ...DEFAULT_VIEWPORT, name: "narrow-sheet", width: 800 }, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-narrow-sheet" as MessageId, + targetText: "narrow sheet", + }), + }); + + try { + const railToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle panel"]') as HTMLButtonElement | null, + "Unable to find the rail toggle.", + ); + railToggle.click(); + const launcher = await waitForElement( + () => document.querySelector("[data-right-panel-launcher='true']") as HTMLElement | null, + "The panel never opened in a narrow window.", + ); + const popup = launcher.closest('[data-slot="drawer-popup"]') as HTMLElement; + await vi.waitFor( + () => { + expect(popup.getBoundingClientRect().right).toBeCloseTo(window.innerWidth, 0); + }, + { timeout: 8_000, interval: 16 }, + ); + + // What is on top of the page beside the panel is the page itself, not + // a layer over it, and a press there leaves the panel open. + const beside = document.elementFromPoint( + popup.getBoundingClientRect().left / 2, + window.innerHeight / 2, + ) as HTMLElement; + expect(beside.closest('[data-testid="app-panel"]')).not.toBeNull(); + beside.click(); + await nextFrames(10); + expect(document.querySelector("[data-right-panel-launcher='true']")).not.toBeNull(); + } finally { + await mounted.cleanup(); + } + }); + it("closes the overlay panel with a finger pushing it right, and not with a mouse drag", async () => { const mounted = await mountChatView({ viewport: PHONE_VIEWPORT, diff --git a/apps/web/src/components/ChatView.shell.browser.tsx b/apps/web/src/components/ChatView.shell.browser.tsx index c69e77d3f..3c1cb8bdb 100644 --- a/apps/web/src/components/ChatView.shell.browser.tsx +++ b/apps/web/src/components/ChatView.shell.browser.tsx @@ -1487,6 +1487,42 @@ describe("ChatView (full app): app shell", () => { } }); + it("finds the list's sheet closed when a phone folds again after unfolding with it open", async () => { + // A folding phone: the folded screen has the phone layout, the open one + // the desktop layout. The sheet used to stay open behind the scenes while + // unfolded and cover the chat on the way back. + const sheet = () => document.querySelector('[data-slot="sidebar"][data-mobile="true"]'); + const mounted = await mountChatView({ + viewport: PHONE_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-phone-fold" as MessageId, + targetText: "phone fold", + }), + }); + + try { + const trigger = await waitForElement( + () => document.querySelector('button[data-sidebar="trigger"]'), + "Unable to find mobile sidebar trigger.", + ); + trigger.click(); + await vi.waitFor(() => expect(sheet()).not.toBeNull(), { timeout: 4_000, interval: 16 }); + + await mounted.setViewport(DEFAULT_VIEWPORT); + await vi.waitFor(() => expect(sheet()).toBeNull(), { timeout: 4_000, interval: 16 }); + + await mounted.setViewport(PHONE_VIEWPORT); + await waitForElement( + () => document.querySelector('button[data-sidebar="trigger"]'), + "The phone layout never came back.", + ); + await nextFrames(10); + expect(sheet()).toBeNull(); + } finally { + await mounted.cleanup(); + } + }); + it("on a phone, lists the open page's own items in the sheet, names its icons, and closes on a pick", async () => { const readingChatId = "thread-phone-chat-reading" as ThreadId; const otherChatId = "thread-phone-chat-other" as ThreadId; diff --git a/apps/web/src/components/ChatsDestinationView.tsx b/apps/web/src/components/ChatsDestinationView.tsx index 41a3af032..a3a5f564a 100644 --- a/apps/web/src/components/ChatsDestinationView.tsx +++ b/apps/web/src/components/ChatsDestinationView.tsx @@ -49,7 +49,7 @@ function StartGeneralChat() {