Merge pull request #7360 from paperclipai/pap-10195-pwa-controls

[codex] Add standalone PWA browser controls
This commit is contained in:
Dotta 2026-06-01 15:33:42 -10:00 committed by GitHub
commit 6460ea2616
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 331 additions and 0 deletions

View file

@ -17,6 +17,7 @@ import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
import { ResizableSidebarPane } from "./ResizableSidebarPane";
import { SidebarAccountMenu } from "./SidebarAccountMenu";
import { useDialogActions } from "../context/DialogContext";
@ -424,6 +425,7 @@ export function Layout() {
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
<StandaloneBrowserControls mobile={isMobile} />
<BreadcrumbBar />
{isMobile && isCompanySettingsRoute ? (
<div className="border-b border-border px-4 pb-3">

View file

@ -0,0 +1,170 @@
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ToastProvider } from "../context/ToastContext";
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function installMatchMedia(initialMatches: Record<string, boolean> = {}) {
type Listener = (event: MediaQueryListEvent) => void;
const queries = new Map<string, { matches: boolean; listeners: Set<Listener> }>();
function getQuery(query: string) {
let entry = queries.get(query);
if (!entry) {
entry = { matches: initialMatches[query] ?? false, listeners: new Set<Listener>() };
queries.set(query, entry);
}
return {
get matches() {
return entry.matches;
},
media: query,
addEventListener: (_type: "change", listener: Listener) => {
entry.listeners.add(listener);
},
removeEventListener: (_type: "change", listener: Listener) => {
entry.listeners.delete(listener);
},
addListener: (listener: Listener) => {
entry.listeners.add(listener);
},
removeListener: (listener: Listener) => {
entry.listeners.delete(listener);
},
} as unknown as MediaQueryList;
}
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: (query: string) => getQuery(query),
});
return {
setMatches(query: string, matches: boolean) {
const entry = queries.get(query) ?? { matches: false, listeners: new Set<Listener>() };
entry.matches = matches;
queries.set(query, entry);
entry.listeners.forEach((listener) => listener({ matches, media: query } as MediaQueryListEvent));
},
};
}
describe("StandaloneBrowserControls", () => {
let container: HTMLDivElement;
const originalMatchMedia = window.matchMedia;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
Object.defineProperty(navigator, "standalone", { configurable: true, value: true });
});
afterEach(() => {
delete (navigator as Navigator & { standalone?: boolean }).standalone;
if (originalMatchMedia) {
Object.defineProperty(window, "matchMedia", { configurable: true, value: originalMatchMedia });
} else {
Object.defineProperty(window, "matchMedia", { configurable: true, value: undefined });
}
container.remove();
document.body.innerHTML = "";
});
it("shows refresh, share, and open-in-browser controls in mobile standalone mode", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<TooltipProvider>
<ToastProvider>
<StandaloneBrowserControls mobile />
</ToastProvider>
</TooltipProvider>,
);
});
await flushReact();
expect(container.querySelector('[aria-label="Refresh"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Share"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Open in Browser"]')).not.toBeNull();
await act(async () => {
root.unmount();
});
});
it("hides controls in normal mobile browser mode", async () => {
Object.defineProperty(navigator, "standalone", { configurable: true, value: false });
installMatchMedia();
const root = createRoot(container);
await act(async () => {
root.render(
<TooltipProvider>
<ToastProvider>
<StandaloneBrowserControls mobile />
</ToastProvider>
</TooltipProvider>,
);
});
await flushReact();
expect(container.querySelector('[aria-label="Refresh"]')).toBeNull();
await act(async () => {
root.unmount();
});
});
it("responds to all chromeless display-mode media query changes", async () => {
Object.defineProperty(navigator, "standalone", { configurable: true, value: false });
const media = installMatchMedia();
const root = createRoot(container);
await act(async () => {
root.render(
<TooltipProvider>
<ToastProvider>
<StandaloneBrowserControls mobile />
</ToastProvider>
</TooltipProvider>,
);
});
await flushReact();
expect(container.querySelector('[aria-label="Refresh"]')).toBeNull();
await act(() => {
media.setMatches("(display-mode: fullscreen)", true);
});
await flushReact();
expect(container.querySelector('[aria-label="Refresh"]')).not.toBeNull();
await act(async () => {
root.unmount();
});
});
});

View file

@ -0,0 +1,105 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { ExternalLink, RefreshCw, Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useOptionalToastActions } from "../context/ToastContext";
import { CHROMELESS_DISPLAY_MODES, isChromelessDisplayMode } from "../lib/pwa-display-mode";
function ControlButton({
label,
children,
onClick,
}: {
label: string;
children: ReactNode;
onClick: () => void | Promise<void>;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 text-muted-foreground hover:text-foreground"
aria-label={label}
onClick={() => void onClick()}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function StandaloneBrowserControls({ mobile }: { mobile: boolean }) {
const [chromeless, setChromeless] = useState(() =>
typeof window !== "undefined" && mobile ? isChromelessDisplayMode() : false,
);
const toastActions = useOptionalToastActions();
useEffect(() => {
if (!mobile || typeof window === "undefined") {
setChromeless(false);
return;
}
const update = () => setChromeless(isChromelessDisplayMode());
update();
if (typeof window.matchMedia !== "function") return;
const mediaQueries = CHROMELESS_DISPLAY_MODES.map((mode) => window.matchMedia(`(display-mode: ${mode})`));
if (mediaQueries.every((media) => typeof media.addEventListener === "function")) {
mediaQueries.forEach((media) => media.addEventListener("change", update));
return () => mediaQueries.forEach((media) => media.removeEventListener("change", update));
}
mediaQueries.forEach((media) => media.addListener(update));
return () => mediaQueries.forEach((media) => media.removeListener(update));
}, [mobile]);
const refresh = useCallback(() => {
window.location.reload();
}, []);
const share = useCallback(async () => {
const url = window.location.href;
try {
if (navigator.share) {
await navigator.share({ title: document.title || "Paperclip", url });
return;
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
toastActions?.pushToast({ title: "Link copied", tone: "success" });
return;
}
toastActions?.pushToast({ title: "Sharing is unavailable", body: url, tone: "warn" });
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
toastActions?.pushToast({ title: "Share failed", body: "Try opening the page in your browser.", tone: "error" });
}
}, [toastActions]);
const openInBrowser = useCallback(() => {
window.open(window.location.href, "_blank", "noopener,noreferrer");
}, []);
if (!mobile || !chromeless) return null;
return (
<div className="flex h-10 items-center justify-end gap-1 border-b border-border bg-background/95 px-3 backdrop-blur supports-[backdrop-filter]:bg-background/85">
<ControlButton label="Refresh" onClick={refresh}>
<RefreshCw className="h-4 w-4" />
</ControlButton>
<ControlButton label="Share" onClick={share}>
<Share2 className="h-4 w-4" />
</ControlButton>
<ControlButton label="Open in Browser" onClick={openInBrowser}>
<ExternalLink className="h-4 w-4" />
</ControlButton>
</div>
);
}

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { isChromelessDisplayMode } from "./pwa-display-mode";
function matchMode(activeMode: string | null) {
return (query: string) => ({ matches: query === `(display-mode: ${activeMode})` });
}
describe("isChromelessDisplayMode", () => {
it("detects standalone display mode from media queries", () => {
expect(isChromelessDisplayMode(matchMode("standalone"), false)).toBe(true);
});
it("detects fullscreen display mode from media queries", () => {
expect(isChromelessDisplayMode(matchMode("fullscreen"), false)).toBe(true);
});
it("detects window-controls-overlay display mode from media queries", () => {
expect(isChromelessDisplayMode(matchMode("window-controls-overlay"), false)).toBe(true);
});
it("detects iOS home-screen standalone launches", () => {
expect(isChromelessDisplayMode(matchMode(null), true)).toBe(true);
});
it("ignores normal browser launches", () => {
expect(isChromelessDisplayMode(matchMode("browser"), false)).toBe(false);
});
});

View file

@ -0,0 +1,26 @@
export const CHROMELESS_DISPLAY_MODES = ["standalone", "fullscreen", "window-controls-overlay"] as const;
type DisplayMode = (typeof CHROMELESS_DISPLAY_MODES)[number];
type MatchDisplayMode = (query: string) => Pick<MediaQueryList, "matches">;
function displayModeQuery(mode: DisplayMode) {
return `(display-mode: ${mode})`;
}
function defaultMatchMedia(): MatchDisplayMode | undefined {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return undefined;
return window.matchMedia.bind(window);
}
export function isChromelessDisplayMode(
matchMedia: MatchDisplayMode | undefined = defaultMatchMedia(),
iosStandalone: boolean | undefined =
typeof navigator === "undefined"
? undefined
: (navigator as Navigator & { standalone?: boolean }).standalone,
) {
if (iosStandalone === true) return true;
if (!matchMedia) return false;
return CHROMELESS_DISPLAY_MODES.some((mode) => matchMedia(displayModeQuery(mode)).matches);
}