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() {