fix: limit refresh controls to PWA mode

This commit is contained in:
Federico Jaramillo Martinez
2026-05-23 23:03:00 +02:00
parent c2200da0b2
commit a33c51d164
5 changed files with 77 additions and 5 deletions
+1 -1
View File
@@ -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.
+24 -3
View File
@@ -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 {
<header>
<strong>PI WEB</strong>
<div class="header-actions">
${this.isMobileNavigationLayout ? null : this.renderAppRefresh()}
${this.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : null}
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.setState({ actionPaletteOpen: true }); }}>Actions</button>
</div>
</header>
@@ -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 {
</button>
</li>
</ol>
<div class="context-actions">${this.isMobileNavigationLayout ? this.renderAppRefresh() : null}</div>
${showRefresh ? html`<div class="context-actions">${this.renderAppRefresh()}</div>` : null}
</nav>
`;
}
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 {
+2 -1
View File
@@ -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; }
+24
View File
@@ -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)",
]);
});
});
+26
View File
@@ -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;
}