From 7ce96e36a0a13c0e4abc8b59ab8be451fc10fa32 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 1 Jun 2026 17:28:34 +0000 Subject: [PATCH 1/5] 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); +} From cfcdf2dea9d6bd8ec65cb88c97487f00ac72db32 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 1 Jun 2026 21:56:19 +0000 Subject: [PATCH 2/5] fix(ui): align PWA display-mode listeners --- .../StandaloneBrowserControls.test.tsx | 97 +++++++++++++++++++ .../components/StandaloneBrowserControls.tsx | 14 +-- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/ui/src/components/StandaloneBrowserControls.test.tsx b/ui/src/components/StandaloneBrowserControls.test.tsx index 1eff16b7..0da94ca2 100644 --- a/ui/src/components/StandaloneBrowserControls.test.tsx +++ b/ui/src/components/StandaloneBrowserControls.test.tsx @@ -25,8 +25,47 @@ async function flushReact() { }); } +function installMatchMedia(initialMatches: Record = {}) { + type Listener = (event: MediaQueryListEvent) => void; + const queries = new Map }>(); + + function getQuery(query: string) { + let entry = queries.get(query); + if (!entry) { + entry = { matches: initialMatches[query] ?? false, listeners: new Set() }; + 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 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() }; + 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"); @@ -36,6 +75,11 @@ describe("StandaloneBrowserControls", () => { afterEach(() => { delete (navigator as Navigator & { standalone?: boolean }).standalone; + if (originalMatchMedia) { + Object.defineProperty(window, "matchMedia", { configurable: true, value: originalMatchMedia }); + } else { + delete (window as Window & { matchMedia?: Window["matchMedia"] }).matchMedia; + } container.remove(); document.body.innerHTML = ""; }); @@ -62,4 +106,57 @@ describe("StandaloneBrowserControls", () => { 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( + + + + + , + ); + }); + 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( + + + + + , + ); + }); + 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(); + }); + }); }); diff --git a/ui/src/components/StandaloneBrowserControls.tsx b/ui/src/components/StandaloneBrowserControls.tsx index 2b0f1e36..6b6fe783 100644 --- a/ui/src/components/StandaloneBrowserControls.tsx +++ b/ui/src/components/StandaloneBrowserControls.tsx @@ -3,7 +3,7 @@ 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"; +import { CHROMELESS_DISPLAY_MODES, isChromelessDisplayMode } from "../lib/pwa-display-mode"; function ControlButton({ label, @@ -48,14 +48,14 @@ export function StandaloneBrowserControls({ mobile }: { mobile: boolean }) { 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); + 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)); } - media.addListener(update); - return () => media.removeListener(update); + mediaQueries.forEach((media) => media.addListener(update)); + return () => mediaQueries.forEach((media) => media.removeListener(update)); }, [mobile]); const refresh = useCallback(() => { From 3f80d7cd25393233fc18e93c6647423bc85e4263 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 1 Jun 2026 22:00:40 +0000 Subject: [PATCH 3/5] test(ui): cover chromeless display modes --- ui/src/lib/pwa-display-mode.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ui/src/lib/pwa-display-mode.test.ts b/ui/src/lib/pwa-display-mode.test.ts index 9e47b2e8..24749c79 100644 --- a/ui/src/lib/pwa-display-mode.test.ts +++ b/ui/src/lib/pwa-display-mode.test.ts @@ -10,6 +10,14 @@ describe("isChromelessDisplayMode", () => { 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); }); From 4aa6a22686c0f9d9a3980ba1c41f604dc3737716 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 1 Jun 2026 22:06:33 +0000 Subject: [PATCH 4/5] fix(ui): initialize standalone controls synchronously --- ui/src/components/StandaloneBrowserControls.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/components/StandaloneBrowserControls.tsx b/ui/src/components/StandaloneBrowserControls.tsx index 6b6fe783..e529c4d0 100644 --- a/ui/src/components/StandaloneBrowserControls.tsx +++ b/ui/src/components/StandaloneBrowserControls.tsx @@ -34,7 +34,9 @@ function ControlButton({ } export function StandaloneBrowserControls({ mobile }: { mobile: boolean }) { - const [chromeless, setChromeless] = useState(false); + const [chromeless, setChromeless] = useState(() => + typeof window !== "undefined" && mobile ? isChromelessDisplayMode() : false, + ); const toastActions = useOptionalToastActions(); useEffect(() => { From fbfac2ff22c1927179dc7cc7f55817b6ed11f763 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 1 Jun 2026 22:12:03 +0000 Subject: [PATCH 5/5] fix(ui): type standalone controls test shim --- .../StandaloneBrowserControls.test.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/ui/src/components/StandaloneBrowserControls.test.tsx b/ui/src/components/StandaloneBrowserControls.test.tsx index 0da94ca2..829ee5ec 100644 --- a/ui/src/components/StandaloneBrowserControls.test.tsx +++ b/ui/src/components/StandaloneBrowserControls.test.tsx @@ -41,11 +41,19 @@ function installMatchMedia(initialMatches: Record = {}) { 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 MediaQueryList; + 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", { @@ -78,7 +86,7 @@ describe("StandaloneBrowserControls", () => { if (originalMatchMedia) { Object.defineProperty(window, "matchMedia", { configurable: true, value: originalMatchMedia }); } else { - delete (window as Window & { matchMedia?: Window["matchMedia"] }).matchMedia; + Object.defineProperty(window, "matchMedia", { configurable: true, value: undefined }); } container.remove(); document.body.innerHTML = "";