diff --git a/.changeset/mobile-refresh-control.md b/.changeset/mobile-refresh-control.md
index aa5e54c..95b24f8 100644
--- a/.changeset/mobile-refresh-control.md
+++ b/.changeset/mobile-refresh-control.md
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch
---
-Add mobile and tablet PWA refresh controls plus action palette commands for refreshing app data or reloading the page.
+Add refresh controls when PI WEB is launched as a PWA, with action palette commands for refreshing app data or reloading the page.
diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts
index 3de8cdf..e994b7c 100644
--- a/src/client/src/components/PiWebApp.ts
+++ b/src/client/src/components/PiWebApp.ts
@@ -21,6 +21,7 @@ import { themePackPlugin } from "../plugins/themes";
import { loadExternalPlugins } from "../plugins/external";
import { PluginRegistry } from "../plugins/registry";
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
+import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode";
import { readRoute, writeRoute, type AppRoute } from "../route";
import "./ProjectList";
import "./WorkspaceList";
@@ -99,6 +100,7 @@ export class PiWebApp extends LitElement {
private readonly terminalSelection = new InMemoryTerminalSelectionMemory();
private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined;
private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined;
+ private readonly pwaDisplayModeMedia = createPwaDisplayModeMedia();
private observedContextItems: HTMLElement | undefined;
private observedMobileTabs: HTMLElement | undefined;
private contextItemsResizeObserver: ResizeObserver | undefined;
@@ -113,6 +115,7 @@ export class PiWebApp extends LitElement {
private suppressNextRefreshClick = false;
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
+ @state() private isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia);
@state() private isRefreshingApp = false;
@state() private refreshMenuOpen = false;
@state() private refreshMenuStyle = "";
@@ -142,6 +145,9 @@ export class PiWebApp extends LitElement {
private readonly onSystemLightThemeChange = () => {
if (this.themePreference.auto) this.applyPreferredTheme(false);
};
+ private readonly onPwaDisplayModeChange = () => {
+ this.isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia);
+ };
private readonly onContextScroll = () => {
this.updateContextScrollState();
};
@@ -177,6 +183,7 @@ export class PiWebApp extends LitElement {
window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
+ for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange);
this.applyPreferredTheme(false);
this.connectRealtime();
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
@@ -194,6 +201,7 @@ export class PiWebApp extends LitElement {
window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange);
this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange);
+ for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange);
this.keyboard.reset();
this.auth.dispose();
this.sessions.dispose();
@@ -486,7 +494,7 @@ export class PiWebApp extends LitElement {
@@ -848,6 +856,7 @@ export class PiWebApp extends LitElement {
const workspace = this.state.selectedWorkspace;
const session = this.state.selectedSession;
const projectLabel = projectContextLabel(project);
+ const showRefresh = this.shouldShowAppRefreshInContextBar();
const workspaceLabel = workspaceContextLabel(workspace);
const sessionLabel = sessionContextLabel(session);
return html`
@@ -873,13 +882,25 @@ export class PiWebApp extends LitElement {
-
${this.isMobileNavigationLayout ? this.renderAppRefresh() : null}
+ ${showRefresh ? html`${this.renderAppRefresh()}
` : null}
`;
}
private contextBarClass(): string {
- return `context-bar${this.contextCanScrollLeft ? " can-scroll-left" : ""}${this.contextCanScrollRight ? " can-scroll-right" : ""}`;
+ const classes = ["context-bar"];
+ if (this.shouldShowAppRefreshInContextBar()) classes.push("has-context-actions");
+ if (this.contextCanScrollLeft) classes.push("can-scroll-left");
+ if (this.contextCanScrollRight) classes.push("can-scroll-right");
+ return classes.join(" ");
+ }
+
+ private shouldShowAppRefreshInHeader(): boolean {
+ return this.isPwaDisplayMode && !this.isMobileNavigationLayout;
+ }
+
+ private shouldShowAppRefreshInContextBar(): boolean {
+ return this.isPwaDisplayMode && this.isMobileNavigationLayout;
}
private mobileTabsFrameClass(): string {
diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts
index 99def0c..9481127 100644
--- a/src/client/src/components/shared.ts
+++ b/src/client/src/components/shared.ts
@@ -64,7 +64,8 @@ export const appStyles = css`
.context-bar::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; }
.context-bar-label { display: none; }
- .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 52px 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px 52px; scrollbar-width: thin; }
+ .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; }
+ .context-bar.has-context-actions .context-items { padding-right: 52px; scroll-padding-inline: 8px 52px; }
.context-item { flex: 0 0 auto; min-width: 0; display: flex; }
.context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; }
.context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; }
diff --git a/src/client/src/pwaDisplayMode.test.ts b/src/client/src/pwaDisplayMode.test.ts
new file mode 100644
index 0000000..55fa4cb
--- /dev/null
+++ b/src/client/src/pwaDisplayMode.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { PWA_DISPLAY_MODE_QUERIES, detectPwaDisplayMode } from "./pwaDisplayMode";
+
+describe("detectPwaDisplayMode", () => {
+ it("detects installed app display modes from media query matches", () => {
+ expect(detectPwaDisplayMode([{ matches: false }, { matches: true }], undefined)).toBe(true);
+ });
+
+ it("detects iOS standalone PWAs", () => {
+ expect(detectPwaDisplayMode([], { standalone: true })).toBe(true);
+ });
+
+ it("does not detect a normal browser tab as a PWA", () => {
+ expect(detectPwaDisplayMode([{ matches: false }], { standalone: false })).toBe(false);
+ });
+
+ it("checks the installed app display modes supported by the manifest", () => {
+ expect(PWA_DISPLAY_MODE_QUERIES).toEqual([
+ "(display-mode: standalone)",
+ "(display-mode: fullscreen)",
+ "(display-mode: minimal-ui)",
+ ]);
+ });
+});
diff --git a/src/client/src/pwaDisplayMode.ts b/src/client/src/pwaDisplayMode.ts
new file mode 100644
index 0000000..efbf615
--- /dev/null
+++ b/src/client/src/pwaDisplayMode.ts
@@ -0,0 +1,26 @@
+export const PWA_DISPLAY_MODE_QUERIES = [
+ "(display-mode: standalone)",
+ "(display-mode: fullscreen)",
+ "(display-mode: minimal-ui)",
+] as const;
+
+export interface DisplayModeMediaState {
+ readonly matches: boolean;
+}
+
+export function createPwaDisplayModeMedia(): MediaQueryList[] {
+ if (typeof window === "undefined" || !("matchMedia" in window)) return [];
+ return PWA_DISPLAY_MODE_QUERIES.map((query) => window.matchMedia(query));
+}
+
+export function detectPwaDisplayMode(media: readonly DisplayModeMediaState[], navigatorObject = currentNavigator()): boolean {
+ return media.some((query) => query.matches) || isIosStandalonePwa(navigatorObject);
+}
+
+function currentNavigator(): object | undefined {
+ return typeof navigator === "undefined" ? undefined : navigator;
+}
+
+function isIosStandalonePwa(navigatorObject: object | undefined): boolean {
+ return navigatorObject !== undefined && "standalone" in navigatorObject && navigatorObject.standalone === true;
+}