Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/web/src/components/ChatRightPanelInlineSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={{
Expand Down
139 changes: 139 additions & 0 deletions apps/web/src/components/ChatView.panels.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/components/ChatView.shell.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>('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<HTMLButtonElement>('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;
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ChatsDestinationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function StartGeneralChat() {
<div className="flex h-full min-h-0 w-full min-w-0 flex-col" data-testid="chats-view">
<PageTitlebar label="General chats" />
<div
className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 px-6 pb-16 text-center"
className="page-gutters flex min-h-0 flex-1 flex-col items-center justify-center gap-4 pb-16 text-center [--side-gutter:--spacing(6)]"
{...glassPage.content}
>
<MessagesSquareIcon aria-hidden="true" className="size-6 text-muted-foreground" />
Expand Down Expand Up @@ -112,7 +112,7 @@ function GeneralChatsPage() {
</PageTitlebar>
{/* The pane-wide element scrolls so the scrollbar hugs the pane's edge
(like Settings); the reading column centers inside it. */}
<div className="min-h-0 flex-1 overflow-y-auto" {...glassPage.content}>
<div className="page-gutters min-h-0 flex-1 overflow-y-auto" {...glassPage.content}>
<div
className="mx-auto flex min-h-full w-full max-w-2xl flex-col px-6 py-6"
{...glassPage.column}
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/components/RightPanelSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type CSSProperties, type ReactNode } from "react";

import { isElectron } from "../env";
import { useIsMobile } from "../hooks/useMediaQuery";
import { cn } from "../lib/utils";
import {
RIGHT_PANEL_RAIL_SHEET_CLASS_NAME,
Expand Down Expand Up @@ -28,6 +29,11 @@ export function RightPanelSheet(props: {
size?: keyof typeof SHEET_CLASS_NAME_BY_SIZE;
}) {
const size = props.size ?? "default";
// On a phone the page dims behind the sheet and a press on it puts the
// sheet away. In the desktop layout (a narrow window, a folding phone's
// open screen) the sheet floats: the page beside it stays in use, and the
// sheet closes from its own button or the header's.
const phoneLayout = useIsMobile();

return (
<Drawer
Expand All @@ -38,7 +44,7 @@ export function RightPanelSheet(props: {
// That needs a non-modal dialog (the header must stay interactive) with
// pointer dismissal disabled: an outside-press dismissal on a header
// toggle would close the sheet before the toggle's click reopens it.
// The backdrop closes it explicitly instead.
// On a phone the backdrop closes it explicitly instead.
modal={false}
disablePointerDismissal
onOpenChange={(open) => {
Expand All @@ -50,14 +56,18 @@ export function RightPanelSheet(props: {
<DrawerPopup
side="right"
keepMounted
backdrop={phoneLayout}
viewportClassName={RIGHT_PANEL_SHEET_VIEWPORT_CLASS_NAME}
backdropProps={{
className: RIGHT_PANEL_SHEET_BACKDROP_CLASS_NAME,
onClick: props.onClose,
}}
className={cn(
SHEET_CLASS_NAME_BY_SIZE[size],
isElectron && "pe-[var(--app-window-resize-edge-inset)]",
// The panel keeps out of what the window's right edge is kept
// for: the desktop app's resize grip, or a phone's strip down that
// side, where the sheet shows the panel's own fill.
isElectron ? "pe-[var(--app-window-resize-edge-inset)]" : "bg-rail pr-safe",
)}
style={{ "--right-panel-rail-width": `${RIGHT_PANEL_RAIL_WIDTH}px` } as CSSProperties}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ function ComputerConnectionOverlay({
};

return (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6">
<div className="window-gutters fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-background py-10 text-foreground [--side-gutter:--spacing(4)] sm:[--side-gutter:--spacing(6)]">
<div className="pointer-events-none absolute inset-0 opacity-80">
<div className="absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-amber-500)_12%,transparent),transparent)]" />
<div className="absolute inset-0 bg-[linear-gradient(145deg,color-mix(in_srgb,var(--background)_90%,var(--color-black))_0%,var(--background)_55%)]" />
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1846,7 +1846,7 @@ export default function ThreadTerminalDrawer({
<div className="h-px w-full bg-transparent transition-colors group-hover/terminal-edge:bg-ring group-focus-visible/terminal-edge:bg-ring" />
</div>

<div className="flex h-8 shrink-0 items-stretch gap-1 bg-rail pt-1 pr-1.5 pl-1.5">
<div className="flex h-8 shrink-0 items-stretch gap-1 window-gutters bg-rail pt-1 [--side-gutter:--spacing(1.5)]">
<TerminalTabStrip
groups={stripGroups}
activeTerminalId={resolvedActiveTerminalId}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/auth/PairingRouteSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function PairingRouteSurface({
}, [submitCredential]);

return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6">
<div className="window-gutters relative flex min-h-screen items-center justify-center overflow-hidden bg-background py-10 text-foreground [--side-gutter:--spacing(4)] sm:[--side-gutter:--spacing(6)]">
<div className="pointer-events-none absolute inset-0 opacity-80">
<div className="absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-emerald-500)_14%,transparent),transparent)]" />
<div className="absolute inset-y-0 left-0 w-72 bg-[radial-gradient(28rem_18rem_at_left,color-mix(in_srgb,var(--primary)_10%,transparent),transparent)]" />
Expand Down Expand Up @@ -305,7 +305,7 @@ export function HostedPairingRouteSurface() {
const linkInvalid = probeNotice?.terminal === true;

return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6">
<div className="window-gutters relative flex min-h-screen items-center justify-center overflow-hidden bg-background py-10 text-foreground [--side-gutter:--spacing(4)] sm:[--side-gutter:--spacing(6)]">
<div className="pointer-events-none absolute inset-0 opacity-80">
<div className="absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-emerald-500)_14%,transparent),transparent)]" />
<div className="absolute inset-y-0 left-0 w-72 bg-[radial-gradient(28rem_18rem_at_left,color-mix(in_srgb,var(--primary)_10%,transparent),transparent)]" />
Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/components/chat/ExpandedImageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
<div
ref={dialogRef}
tabIndex={-1}
className="fixed inset-0 z-50 flex touch-none items-center justify-center overflow-hidden bg-black/75 px-4 py-6 outline-none [-webkit-app-region:no-drag]"
className="fixed inset-0 z-50 flex touch-none items-center justify-center overflow-hidden bg-black/75 window-gutters py-6 outline-none [--side-gutter:--spacing(4)] [-webkit-app-region:no-drag]"
role="dialog"
aria-modal="true"
aria-label="Expanded image preview"
Expand All @@ -137,14 +137,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
type="button"
size="icon"
variant="ghost"
className="absolute left-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6"
className="absolute left-[calc(--spacing(2)+var(--safe-area-left))] top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-[calc(--spacing(6)+var(--safe-area-left))]"
aria-label="Previous image"
onClick={() => navigateImage(-1)}
>
<ChevronLeftIcon className="size-5" />
</Button>
)}
<div className="relative isolate z-10 max-h-[92vh] max-w-[92vw]">
<div className="relative isolate z-10 max-h-[92vh] max-w-[calc(92vw-var(--safe-area-left)-var(--safe-area-right))]">
<Button
type="button"
size="icon-xs"
Expand Down Expand Up @@ -189,20 +189,20 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
onClose={onClose}
/>
) : (
<div className="flex h-[40vh] w-[min(560px,80vw)] items-center justify-center rounded-lg border border-white/10 bg-black/40 text-sm text-white/70">
<div className="flex h-[40vh] w-[min(560px,calc(80vw-var(--safe-area-left)-var(--safe-area-right)))] items-center justify-center rounded-lg border border-white/10 bg-black/40 text-sm text-white/70">
{picture.status === "loading"
? "Loading picture…"
: side === "before"
? "No earlier version of this picture."
: "Couldn't load this picture."}
</div>
)}
<p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
<p className="mt-2 max-w-[calc(92vw-var(--safe-area-left)-var(--safe-area-right))] truncate text-center text-xs text-muted-foreground/80">
{item.name}
{preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""}
</p>
{item.detail ? (
<p className="max-w-[92vw] truncate text-center text-xs text-muted-foreground/60">
<p className="max-w-[calc(92vw-var(--safe-area-left)-var(--safe-area-right))] truncate text-center text-xs text-muted-foreground/60">
{item.detail}
</p>
) : null}
Expand All @@ -212,7 +212,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
type="button"
size="icon"
variant="ghost"
className="absolute right-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6"
className="absolute right-[calc(--spacing(2)+var(--safe-area-right))] top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-[calc(--spacing(6)+var(--safe-area-right))]"
aria-label="Next image"
onClick={() => navigateImage(1)}
>
Expand Down Expand Up @@ -575,7 +575,7 @@ function ZoomableImage(props: {
src={props.src}
alt={props.alt}
className={cn(
"block max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl",
"block max-h-[86vh] max-w-[calc(92vw-var(--safe-area-left)-var(--safe-area-right))] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl",
zoomed ? (dragging ? "cursor-grabbing" : "cursor-grab") : "cursor-zoom-in",
)}
style={{
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/chat/chatFind/ChatFind.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,10 @@ function ChatFindSession(props: ChatFindProps) {
: { kind: "matches", current: chatFindMatchNumber(result, current), total: result.total };

return (
<div ref={barRef} className="absolute top-2 right-3 z-20 max-w-[calc(100%-1.5rem)]">
<div
ref={barRef}
className="absolute top-2 right-[calc(--spacing(3)+var(--safe-area-right))] z-20 max-w-[calc(100%-1.5rem-var(--safe-area-right))]"
>
<FindBar
query={query}
onQueryChange={setQuery}
Expand Down
Loading
Loading