From 7ce96e36a0a13c0e4abc8b59ab8be451fc10fa32 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 1 Jun 2026 17:28:34 +0000 Subject: [PATCH] fix(ui): add standalone PWA browser controls Co-Authored-By: Paperclip --- ui/src/components/Layout.tsx | 2 + .../StandaloneBrowserControls.test.tsx | 65 +++++++++++ .../components/StandaloneBrowserControls.tsx | 103 ++++++++++++++++++ ui/src/lib/pwa-display-mode.test.ts | 20 ++++ ui/src/lib/pwa-display-mode.ts | 26 +++++ 5 files changed, 216 insertions(+) create mode 100644 ui/src/components/StandaloneBrowserControls.test.tsx create mode 100644 ui/src/components/StandaloneBrowserControls.tsx create mode 100644 ui/src/lib/pwa-display-mode.test.ts create mode 100644 ui/src/lib/pwa-display-mode.ts diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index d5a76fcd..02bec3de 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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", )} > + {isMobile && isCompanySettingsRoute ? (
diff --git a/ui/src/components/StandaloneBrowserControls.test.tsx b/ui/src/components/StandaloneBrowserControls.test.tsx new file mode 100644 index 00000000..1eff16b7 --- /dev/null +++ b/ui/src/components/StandaloneBrowserControls.test.tsx @@ -0,0 +1,65 @@ +// @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) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("StandaloneBrowserControls", () => { + let container: HTMLDivElement; + + 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; + 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( + + + + + , + ); + }); + 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(); + }); + }); +}); diff --git a/ui/src/components/StandaloneBrowserControls.tsx b/ui/src/components/StandaloneBrowserControls.tsx new file mode 100644 index 00000000..2b0f1e36 --- /dev/null +++ b/ui/src/components/StandaloneBrowserControls.tsx @@ -0,0 +1,103 @@ +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 { isChromelessDisplayMode } from "../lib/pwa-display-mode"; + +function ControlButton({ + label, + children, + onClick, +}: { + label: string; + children: ReactNode; + onClick: () => void | Promise; +}) { + return ( + + + + + {label} + + ); +} + +export function StandaloneBrowserControls({ mobile }: { mobile: boolean }) { + const [chromeless, setChromeless] = useState(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 media = window.matchMedia("(display-mode: standalone)"); + if (typeof media.addEventListener === "function") { + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + } + + media.addListener(update); + return () => 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 ( +
+ + + + + + + + + +
+ ); +} diff --git a/ui/src/lib/pwa-display-mode.test.ts b/ui/src/lib/pwa-display-mode.test.ts new file mode 100644 index 00000000..9e47b2e8 --- /dev/null +++ b/ui/src/lib/pwa-display-mode.test.ts @@ -0,0 +1,20 @@ +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 iOS home-screen standalone launches", () => { + expect(isChromelessDisplayMode(matchMode(null), true)).toBe(true); + }); + + it("ignores normal browser launches", () => { + expect(isChromelessDisplayMode(matchMode("browser"), false)).toBe(false); + }); +}); diff --git a/ui/src/lib/pwa-display-mode.ts b/ui/src/lib/pwa-display-mode.ts new file mode 100644 index 00000000..4752d733 --- /dev/null +++ b/ui/src/lib/pwa-display-mode.ts @@ -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; + +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); +}