diff --git a/.changeset/mobile-refresh-control.md b/.changeset/mobile-refresh-control.md new file mode 100644 index 0000000..5e9e863 --- /dev/null +++ b/.changeset/mobile-refresh-control.md @@ -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. diff --git a/plugin-api.d.ts b/plugin-api.d.ts index 1192303..96cca2d 100644 --- a/plugin-api.d.ts +++ b/plugin-api.d.ts @@ -50,6 +50,8 @@ export interface PluginRuntimeContext { openTerminal: (options?: { terminalId?: string | undefined }) => void; refreshFiles: () => void | Promise; refreshGit: () => void | Promise; + refreshAppData: () => void | Promise; + reloadPage: () => void; startSession: () => void | Promise; archiveSession: () => void | Promise; stopActiveWork: () => void | Promise; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index f181dd2..1161af4 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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 { + 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 { + 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 { ${sessionLabel} +
${this.renderAppRefresh()}
`; } @@ -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` +
+ +
+ `; + } + + private renderRefreshMenu() { + if (!this.refreshMenuOpen) return null; + return html` + + `; + } + + private renderRefreshIcon() { + return html` + + `; + } + + 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` { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} + ${this.renderRefreshMenu()} `; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 368ba88..974b3dd 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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` diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index ec53dd7..3d51d5d 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -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", diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 2b4ce9c..f58f64d 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -21,6 +21,8 @@ function createContext(statePatch: Partial = {}) { 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 }); diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index e4800d9..e7e93e6 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -50,6 +50,8 @@ export interface PluginRuntimeContext { openTerminal: (options?: { terminalId?: string | undefined }) => void; refreshFiles: () => void | Promise; refreshGit: () => void | Promise; + refreshAppData: () => void | Promise; + reloadPage: () => void; startSession: () => void | Promise; archiveSession: () => void | Promise; stopActiveWork: () => void | Promise;