feat: add mobile refresh control

This commit is contained in:
Federico Jaramillo Martinez
2026-05-23 21:18:22 +02:00
parent 1546143038
commit df20563abb
7 changed files with 195 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add a mobile refresh control and action palette commands for refreshing app data or reloading the page.
+2
View File
@@ -50,6 +50,8 @@ export interface PluginRuntimeContext {
openTerminal: (options?: { terminalId?: string | undefined }) => void;
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
refreshAppData: () => void | Promise<void>;
reloadPage: () => void;
startSession: () => void | Promise<void>;
archiveSession: () => void | Promise<void>;
stopActiveWork: () => void | Promise<void>;
+146
View File
@@ -36,6 +36,7 @@ import "./AuthDialog";
import "./ProjectDialog";
import "./WorkspacePanel";
import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import { actionMenuPanelStyle } from "./actionMenu";
import { appStyles } from "./shared";
type NavigationSection = "projects" | "workspaces" | "sessions";
@@ -46,6 +47,7 @@ const THEME_AUTO_ON_VALUE = "auto:on";
const THEME_AUTO_OFF_VALUE = "auto:off";
const THEME_OPTION_PREFIX = "theme:";
const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal");
const REFRESH_LONG_PRESS_MS = 550;
@customElement("pi-web-app")
export class PiWebApp extends LitElement {
@@ -54,6 +56,7 @@ export class PiWebApp extends LitElement {
@query("prompt-editor") private promptEditor?: PromptEditor;
@query(".context-items") private contextItems?: HTMLElement | null;
@query(".mobile-tabs") private mobileTabs?: HTMLElement | null;
@query(".app-refresh") private appRefresh?: HTMLElement | null;
private readonly sessions = new SessionController(
() => this.state,
@@ -106,8 +109,13 @@ export class PiWebApp extends LitElement {
private restoringRouteTerminalId: string | undefined;
private readonly plugins = createPluginRegistry();
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
private refreshLongPressTimer: number | undefined;
private suppressNextRefreshClick = false;
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
@state() private isRefreshingApp = false;
@state() private refreshMenuOpen = false;
@state() private refreshMenuStyle = "";
@state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined;
@state() private contextCanScrollLeft = false;
@state() private contextCanScrollRight = false;
@@ -140,7 +148,20 @@ export class PiWebApp extends LitElement {
private readonly onMobileTabsScroll = () => {
this.updateMobileTabsScrollState();
};
private readonly onDocumentClick = (event: MouseEvent) => {
const refresh = this.appRefreshElement();
if (refresh !== undefined && event.composedPath().includes(refresh)) return;
this.refreshMenuOpen = false;
this.suppressNextRefreshClick = false;
};
private readonly onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && this.refreshMenuOpen) {
event.preventDefault();
event.stopPropagation();
this.refreshMenuOpen = false;
this.suppressNextRefreshClick = false;
return;
}
if (this.keyboard.handle(event, this.getActions())) {
event.preventDefault();
event.stopPropagation();
@@ -151,6 +172,7 @@ export class PiWebApp extends LitElement {
super.connectedCallback();
window.addEventListener("popstate", this.onPopState);
window.addEventListener("focus", this.onFocus);
document.addEventListener("click", this.onDocumentClick);
document.addEventListener("visibilitychange", this.onVisibilityChange);
window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
@@ -167,6 +189,7 @@ export class PiWebApp extends LitElement {
override disconnectedCallback(): void {
window.removeEventListener("popstate", this.onPopState);
window.removeEventListener("focus", this.onFocus);
document.removeEventListener("click", this.onDocumentClick);
document.removeEventListener("visibilitychange", this.onVisibilityChange);
window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange);
@@ -184,6 +207,7 @@ export class PiWebApp extends LitElement {
this.mobileTabsResizeObserver?.disconnect();
this.mobileTabsResizeObserver = undefined;
this.observedMobileTabs = undefined;
this.clearRefreshLongPressTimer();
super.disconnectedCallback();
}
@@ -230,6 +254,35 @@ export class PiWebApp extends LitElement {
}
}
private async refreshAppData(): Promise<void> {
if (this.isRefreshingApp) return;
this.refreshMenuOpen = false;
this.suppressNextRefreshClick = false;
this.isRefreshingApp = true;
try {
await Promise.all([
this.sessions.refreshSelectedSession(),
this.refreshPiWebStatus(),
this.refreshWorkspaceActivity(),
this.refreshCurrentWorkspaceSurface(),
]);
} finally {
this.isRefreshingApp = false;
}
}
private async refreshCurrentWorkspaceSurface(): Promise<void> {
const workspace = this.state.selectedWorkspace;
const tool = this.state.mainView !== "chat" && this.state.mainView !== "navigation" ? this.state.mainView : this.state.workspaceTool;
if (tool === "core:workspace.files") await this.files.refreshFiles();
else if (tool === "core:workspace.git") await this.git.refreshGit();
else if (tool === "core:workspace.terminal" && workspace !== undefined) await this.refreshActiveTerminals(workspace);
}
private hardReloadApp(): void {
window.location.reload();
}
private async restoreRoute(updateUrl: boolean) {
const route = readRoute();
const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file");
@@ -627,6 +680,8 @@ export class PiWebApp extends LitElement {
openTerminal: (options) => { this.openTerminal(options); },
refreshFiles: () => this.files.refreshFiles(),
refreshGit: () => this.git.refreshGit(),
refreshAppData: () => this.refreshAppData(),
reloadPage: () => { this.hardReloadApp(); },
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
archiveSession: () => this.sessions.archiveSession(),
stopActiveWork: () => this.sessions.stopActiveWork(),
@@ -803,6 +858,7 @@ export class PiWebApp extends LitElement {
<span class="context-value">${sessionLabel}</span>
</li>
</ol>
<div class="context-actions">${this.renderAppRefresh()}</div>
</nav>
`;
}
@@ -869,6 +925,95 @@ export class PiWebApp extends LitElement {
return mobileTabs instanceof HTMLElement ? mobileTabs : undefined;
}
private appRefreshElement(): HTMLElement | undefined {
const appRefresh = this.appRefresh;
return appRefresh instanceof HTMLElement ? appRefresh : undefined;
}
private renderAppRefresh() {
const label = this.isRefreshingApp ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options.";
return html`
<div class="app-refresh">
<button
class=${`app-refresh-button${this.isRefreshingApp ? " refreshing" : ""}`}
title=${label}
aria-label=${label}
aria-haspopup="menu"
aria-expanded=${String(this.refreshMenuOpen)}
aria-busy=${String(this.isRefreshingApp)}
@click=${(event: MouseEvent) => { this.onRefreshClick(event); }}
@contextmenu=${(event: MouseEvent) => { this.onRefreshContextMenu(event); }}
@pointerdown=${(event: PointerEvent) => { this.onRefreshPointerDown(event); }}
@pointerup=${() => { this.clearRefreshLongPressTimer(); }}
@pointercancel=${() => { this.clearRefreshLongPressTimer(); }}
@pointerleave=${() => { this.clearRefreshLongPressTimer(); }}
>${this.renderRefreshIcon()}</button>
</div>
`;
}
private renderRefreshMenu() {
if (!this.refreshMenuOpen) return null;
return html`
<div class="app-refresh-menu" role="menu" style=${this.refreshMenuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
<button role="menuitem" @click=${() => { void this.refreshAppData(); }}>Refresh app data</button>
<button role="menuitem" @click=${() => { this.refreshMenuOpen = false; this.suppressNextRefreshClick = false; this.hardReloadApp(); }}>Full page reload</button>
</div>
`;
}
private renderRefreshIcon() {
return html`
<svg class="app-refresh-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M20 6v5h-5"></path>
<path d="M4 18v-5h5"></path>
<path d="M18.2 9A7 7 0 0 0 6.1 6.8L4 9"></path>
<path d="M5.8 15a7 7 0 0 0 12.1 2.2L20 15"></path>
</svg>
`;
}
private onRefreshClick(event: MouseEvent): void {
event.stopPropagation();
if (this.suppressNextRefreshClick) {
this.suppressNextRefreshClick = false;
return;
}
void this.refreshAppData();
}
private onRefreshPointerDown(event: PointerEvent): void {
if (!event.isPrimary || event.button !== 0) return;
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
this.clearRefreshLongPressTimer();
this.suppressNextRefreshClick = false;
this.refreshLongPressTimer = window.setTimeout(() => {
this.refreshLongPressTimer = undefined;
this.suppressNextRefreshClick = true;
this.openRefreshMenu(target);
}, REFRESH_LONG_PRESS_MS);
}
private onRefreshContextMenu(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.clearRefreshLongPressTimer();
this.suppressNextRefreshClick = true;
this.openRefreshMenu(event.currentTarget);
}
private openRefreshMenu(target: EventTarget | null): void {
this.refreshMenuStyle = actionMenuPanelStyle(target);
this.refreshMenuOpen = true;
}
private clearRefreshLongPressTimer(): void {
if (this.refreshLongPressTimer === undefined) return;
window.clearTimeout(this.refreshLongPressTimer);
this.refreshLongPressTimer = undefined;
}
override render() {
const state = this.state;
return html`
@@ -901,6 +1046,7 @@ export class PiWebApp extends LitElement {
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
${state.projectDialogOpen ? html`<project-dialog .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.renderRefreshMenu()}
</div>
`;
}
+12 -2
View File
@@ -57,13 +57,22 @@ export const appStyles = css`
project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid var(--pi-border-muted); }
session-list { flex: 1 1 auto; min-height: 0; overflow: auto; }
main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: none; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
.context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: none; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
.context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
.context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.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 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; }
.context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 0 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; }
.context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; background: var(--pi-bg); }
.app-refresh { position: relative; display: flex; align-items: center; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
.app-refresh, .app-refresh * { -webkit-user-select: none; user-select: none; }
.app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 999px; padding: 0; line-height: 1; touch-action: manipulation; -webkit-touch-callout: none; }
.app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
.app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; }
.app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; }
.app-refresh-menu button { display: block; width: 100%; border: 0; background: transparent; color: var(--pi-text); text-align: left; white-space: normal; overflow-wrap: anywhere; }
.app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); }
.context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; }
.context-chip.empty { border-style: dashed; color: var(--pi-muted); }
.context-kind { display: none; }
@@ -113,6 +122,7 @@ export const appStyles = css`
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
.empty { margin: auto; color: var(--pi-muted); }
.error { padding: 10px 16px; border-bottom: 1px solid var(--pi-border); color: var(--pi-danger); }
@keyframes app-refresh-spin { to { transform: rotate(360deg); } }
`;
export const workspacePanelStyles = css`
+14
View File
@@ -47,6 +47,20 @@ export function createCoreActions(): PluginAction[] {
group: "Preferences",
run: (context) => { context.openThemePicker(); },
},
{
id: "app.refresh-data",
title: "Refresh App Data",
description: "Refresh session, status, activity, and the current workspace surface without reloading the page",
group: "General",
run: (context) => context.refreshAppData(),
},
{
id: "app.reload-page",
title: "Full Page Reload",
description: "Reload the PI WEB browser page",
group: "General",
run: (context) => { context.reloadPage(); },
},
{
id: "view.chat",
title: "Go to Chat",
+14
View File
@@ -21,6 +21,8 @@ function createContext(statePatch: Partial<AppState> = {}) {
openTerminal: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`openTerminal:${options?.terminalId ?? ""}`); }),
refreshFiles: vi.fn(() => { calls.push("refreshFiles"); }),
refreshGit: vi.fn(() => { calls.push("refreshGit"); }),
refreshAppData: vi.fn(() => { calls.push("refreshAppData"); }),
reloadPage: vi.fn(() => { calls.push("reloadPage"); }),
startSession: vi.fn(() => { calls.push("startSession"); }),
archiveSession: vi.fn(() => { calls.push("archiveSession"); }),
stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }),
@@ -86,6 +88,18 @@ describe("PluginRegistry", () => {
expect(calls).toEqual(["refreshGit"]);
});
it("routes app refresh and reload actions through the runtime context", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const { context, calls } = createContext();
const actions = registry.getActions(context);
void actions.find((candidate) => candidate.id === "core:app.refresh-data")?.run();
void actions.find((candidate) => candidate.id === "core:app.reload-page")?.run();
expect(calls).toEqual(["refreshAppData", "reloadPage"]);
});
it("exposes terminal navigation as a shortcut-backed action", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
+2
View File
@@ -50,6 +50,8 @@ export interface PluginRuntimeContext {
openTerminal: (options?: { terminalId?: string | undefined }) => void;
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
refreshAppData: () => void | Promise<void>;
reloadPage: () => void;
startSession: () => void | Promise<void>;
archiveSession: () => void | Promise<void>;
stopActiveWork: () => void | Promise<void>;