diff --git a/.changeset/responsive-package-settings.md b/.changeset/responsive-package-settings.md index 83d445e..19ae764 100644 --- a/.changeset/responsive-package-settings.md +++ b/.changeset/responsive-package-settings.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Keep gateway Settings panels responsive while selected-machine Pi packages load or fail separately, and report Pi package-management support through runtime capabilities. +Keep gateway Settings panels responsive while selected-machine Pi packages load or fail separately, report Pi package-management support through runtime capabilities, and use that capability to guide remote Pi package-management UI when support is known unavailable. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index e6b7129..fd885eb 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1923,7 +1923,7 @@ export class PiWebApp extends LitElement { ${state.machineDialogOpen ? html` this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}>` : null} ${this.sessionCleanupDialog !== undefined ? html` { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} - ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null} + ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null} `; } diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 85278ce..6fde1ae 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -1,14 +1,14 @@ import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import type { AppAction } from "../actions"; -import { configApi, piPackagesApi, pluginsApi, type Machine, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api"; +import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api"; import type { SettingsSection } from "../settingsRoute"; import "./settings/SettingsGeneralPanel"; import "./settings/SettingsSessiondPanel"; import "./settings/SettingsPackagesPanel"; import "./settings/SettingsPluginsPanel"; import "./settings/SettingsShortcutsPanel"; -import { friendlyPiPackageErrorMessage, piPackageMutationFollowUpMessage, piPackageTargetContext, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings"; +import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetContext, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings"; import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading"; @customElement("settings-dialog") @@ -16,6 +16,7 @@ export class SettingsDialog extends LitElement { @property({ attribute: false }) section: SettingsSection = "general"; @property({ attribute: false }) actions: AppAction[] = []; @property({ attribute: false }) machine: Machine | undefined; + @property({ attribute: false }) machineRuntime: MachineRuntime | undefined; @property({ attribute: false }) onNavigate?: (section: SettingsSection) => void; @property({ attribute: false }) onClose?: () => void; @property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void; @@ -48,10 +49,18 @@ export class SettingsDialog extends LitElement { } protected override updated(changed: PropertyValues): void { - if (!changed.has("machine")) return; - const previousTarget = piPackageTargetContext(changed.get("machine")); const currentTarget = this.packageTarget(); - if (previousTarget.id === currentTarget.id) return; + if (changed.has("machine")) { + const previousTarget = piPackageTargetContext(changed.get("machine")); + if (previousTarget.id !== currentTarget.id) { + this.resetPackageStateForTargetChange(); + if (this.isConnected) void this.loadPackagesForTarget(currentTarget); + return; + } + } + + if (!changed.has("machineRuntime")) return; + if (!this.packageManagementSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) return; this.resetPackageStateForTargetChange(); if (this.isConnected) void this.loadPackagesForTarget(currentTarget); } @@ -118,6 +127,7 @@ export class SettingsDialog extends LitElement { piPackagesApi.packages(targetId)); + const result = await loadPiPackagesData(target, (targetId) => piPackagesApi.packages(targetId), this.packageManagementSupport(target)); if (!this.isCurrentPackageLoad(requestSeq, target)) return; this.packagesResponse = result.packagesResponse; @@ -269,6 +279,11 @@ export class SettingsDialog extends LitElement { } private async runPiPackageMutation(operation: PiPackageOperationState, label: string, target: PiPackageTargetContext, mutate: () => Promise): Promise { + const support = this.packageManagementSupport(target); + if (isPiPackageManagementUnsupported(support)) { + this.packageError = support.message ?? `Pi package management is not available on ${piPackageTargetLabel(target)}.`; + throw new Error(this.packageError); + } if (this.saving) throw new Error("A settings operation is already running."); const requestSeq = ++this.packageMutationSeq; this.packageLoadRequestSeq += 1; @@ -309,6 +324,17 @@ export class SettingsDialog extends LitElement { return piPackageTargetContext(this.machine); } + private packageManagementSupport(target = this.packageTarget()): PiPackageManagementSupport { + return piPackageManagementSupport(target, this.machineRuntime); + } + + private packageManagementSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: PiPackageTargetContext): boolean { + const previousSupport = piPackageManagementSupport(target, previousRuntime); + const currentSupport = this.packageManagementSupport(target); + if (piPackageManagementSupportKey(previousSupport) === piPackageManagementSupportKey(currentSupport)) return false; + return previousSupport.state === "unsupported" || currentSupport.state === "unsupported"; + } + private isCurrentLoad(requestSeq: number): boolean { return requestSeq === this.loadRequestSeq; } diff --git a/src/client/src/components/settings/SettingsPackagesPanel.ts b/src/client/src/components/settings/SettingsPackagesPanel.ts index d133efe..865b022 100644 --- a/src/client/src/components/settings/SettingsPackagesPanel.ts +++ b/src/client/src/components/settings/SettingsPackagesPanel.ts @@ -1,7 +1,7 @@ import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import type { PiPackageInfo, PiPackageScope, PiPackagesResponse } from "../../api"; -import { isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageInstalledPathLabel, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageOperationState, type PiPackageTargetContext } from "./piPackageSettings"; +import { isPiPackageManagementUnsupported, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageInstalledPathLabel, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./piPackageSettings"; @customElement("settings-packages-panel") export class SettingsPackagesPanel extends LitElement { @@ -9,6 +9,7 @@ export class SettingsPackagesPanel extends LitElement { @property({ type: Boolean }) loading = false; @property({ attribute: false }) operation: PiPackageOperationState | undefined; @property({ attribute: false }) targetMachine: PiPackageTargetContext | undefined; + @property({ attribute: false }) managementSupport: PiPackageManagementSupport | undefined; @property() error = ""; @property() operationMessage = ""; @property({ attribute: false }) onReload?: () => void | Promise; @@ -22,21 +23,23 @@ export class SettingsPackagesPanel extends LitElement { const packages = this.packagesResponse?.packages ?? []; const target = this.packageTarget; const targetLabel = piPackageTargetLabel(target); + const packageManagementUnavailable = this.packageManagementUnavailable; return html`

Pi packages

Managing Pi packages on ${targetLabel}. Install, remove, and update packages managed by Pi on the selected machine. Pi packages can provide extensions, skills, prompt templates, themes, context/system prompt files, and PI WEB browser plugins.

- +
Trusted code warning: Pi packages and PI WEB plugins can run with your user permissions. Install packages and enable plugins only from sources you trust.
+ ${this.renderCompatibilityNote(targetLabel)} ${this.renderMessages()}
{ void this.installPackage(event); }}>
- { this.updateInstallSource(event); }}> - + { this.updateInstallSource(event); }}> +
${this.validationMessage === "" ? null : html`
${this.validationMessage}
`} Installs run on ${targetLabel} and use Pi's default package location, equivalent to pi install <source>. PI WEB does not ask you to choose an install location. @@ -51,10 +54,16 @@ export class SettingsPackagesPanel extends LitElement { return null; } + private renderCompatibilityNote(targetLabel: string): TemplateResult | null { + if (!this.packageManagementUnavailable || this.error !== "") return null; + return html`
Pi package management is not advertised by ${targetLabel}. Update and restart Pi-Web on that machine, then refresh the app before managing Pi packages.
`; + } + private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult { const targetLabel = piPackageTargetLabel(target); const packageListUnavailable = this.error !== "" && packages.length === 0; - const updateAllReason = updateAllPiPackagesDisabledReason(packages); + const packageManagementUnavailable = this.packageManagementUnavailable; + const updateAllReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : updateAllPiPackagesDisabledReason(packages); const showUpdateAllReason = updateAllReason !== undefined && (packages.length > 0 || (!this.loading && !packageListUnavailable)); const updateAllTitle = packageListUnavailable ? `Pi package list unavailable for ${targetLabel}` : updateAllReason ?? "Update all user-scope Pi packages"; return html` @@ -77,7 +86,10 @@ export class SettingsPackagesPanel extends LitElement { private renderPackageListContent(packages: PiPackageInfo[], targetLabel: string): TemplateResult { if (this.loading && packages.length === 0) return html`
Loading Pi packages from ${targetLabel}…
`; - if (this.error !== "" && packages.length === 0) return html`
Pi package list unavailable for ${targetLabel}. Use Reload to try again.
`; + if (this.error !== "" && packages.length === 0) { + if (this.packageManagementUnavailable) return html`
Pi package management is unavailable for ${targetLabel} until Pi-Web on that machine advertises package-management support.
`; + return html`
Pi package list unavailable for ${targetLabel}. Use Reload to try again.
`; + } if (packages.length === 0) return html`
No Pi packages configured in Pi settings on ${targetLabel} yet.
`; return html`
@@ -87,7 +99,10 @@ export class SettingsPackagesPanel extends LitElement { } private renderPackage(packageInfo: PiPackageInfo): TemplateResult { - const updateReason = piPackageUpdateDisabledReason(packageInfo); + const targetLabel = piPackageTargetLabel(this.packageTarget); + const packageManagementUnavailable = this.packageManagementUnavailable; + const updateReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : piPackageUpdateDisabledReason(packageInfo); + const removeReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : "Remove this Pi package"; const updating = isPiPackageOperationPending(this.operation, "update", packageInfo.source); const removing = isPiPackageOperationPending(this.operation, "remove", packageInfo.source); return html` @@ -100,7 +115,7 @@ export class SettingsPackagesPanel extends LitElement {
- +
`; @@ -149,6 +164,14 @@ export class SettingsPackagesPanel extends LitElement { return this.targetMachine ?? piPackageTargetContext(undefined); } + private get packageManagementUnavailable(): boolean { + return isPiPackageManagementUnsupported(this.managementSupport); + } + + private packageManagementUnavailableMessage(targetLabel: string): string { + return this.managementSupport?.message ?? `Pi package management is not available on ${targetLabel}.`; + } + private get isOperating(): boolean { return this.operation !== undefined; } diff --git a/src/client/src/components/settings/piPackageSettings.test.ts b/src/client/src/components/settings/piPackageSettings.test.ts index da5ea0b..a4e0d7a 100644 --- a/src/client/src/components/settings/piPackageSettings.test.ts +++ b/src/client/src/components/settings/piPackageSettings.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "vitest"; -import type { PiPackageInfo } from "../../api"; -import { canUpdateAllPiPackages, friendlyPiPackageErrorMessage, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageMutationFollowUpMessage, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, shouldRefreshGatewayPluginsAfterPiPackageMutation, updateAllPiPackagesDisabledReason, type PiPackageTargetContext } from "./piPackageSettings"; +import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities"; +import type { MachineRuntime, PiPackageInfo } from "../../api"; +import { canUpdateAllPiPackages, friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageManagementSupport, piPackageMutationFollowUpMessage, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, shouldRefreshGatewayPluginsAfterPiPackageMutation, updateAllPiPackagesDisabledReason, type PiPackageTargetContext } from "./piPackageSettings"; const userPackage: PiPackageInfo = { source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }; const projectPackage: PiPackageInfo = { source: "../project-tools", scope: "project", filtered: true }; const localTarget: PiPackageTargetContext = { id: "local", name: "local", kind: "local" }; const remoteTarget: PiPackageTargetContext = { id: "remote-a", name: "Lab Mac", kind: "remote" }; +const runtimeWithPackageManagement: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] }; +const runtimeWithoutPackageManagement: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] }; +const unavailableRuntime: MachineRuntime = { machineId: "remote-a", ok: false, checkedAt: "now", error: "Remote runtime returned HTTP 404" }; describe("Pi package settings helpers", () => { it("normalizes and validates install sources without adding location choices", () => { @@ -44,6 +48,18 @@ describe("Pi package settings helpers", () => { expect(shouldRefreshGatewayPluginsAfterPiPackageMutation(remoteTarget)).toBe(false); }); + it("uses runtime capabilities as package-management UX guidance without blocking older remotes", () => { + expect(piPackageManagementSupport(localTarget, undefined)).toEqual({ state: "supported" }); + expect(piPackageManagementSupport(remoteTarget, runtimeWithPackageManagement)).toEqual({ state: "supported" }); + + const unsupported = piPackageManagementSupport(remoteTarget, runtimeWithoutPackageManagement); + expect(isPiPackageManagementUnsupported(unsupported)).toBe(true); + expect(unsupported.message).toContain("Update and restart Pi-Web on that machine"); + + expect(piPackageManagementSupport(remoteTarget, undefined)).toEqual({ state: "unknown" }); + expect(piPackageManagementSupport(remoteTarget, unavailableRuntime)).toEqual({ state: "unknown" }); + }); + it("describes the browser and session reload follow-up without requiring sessiond restarts", () => { const message = piPackageMutationFollowUpMessage("install"); diff --git a/src/client/src/components/settings/piPackageSettings.ts b/src/client/src/components/settings/piPackageSettings.ts index 7356232..f96c89f 100644 --- a/src/client/src/components/settings/piPackageSettings.ts +++ b/src/client/src/components/settings/piPackageSettings.ts @@ -1,4 +1,5 @@ -import type { Machine, MachineKind, PiPackageInfo, PiPackageMutationAction } from "../../api"; +import type { Machine, MachineKind, MachineRuntime, PiPackageInfo, PiPackageMutationAction } from "../../api"; +import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities"; export type PiPackageOperationKind = PiPackageMutationAction | "update-all"; @@ -13,6 +14,13 @@ export interface PiPackageTargetContext { kind: MachineKind; } +export type PiPackageManagementSupportState = "supported" | "unsupported" | "unknown"; + +export interface PiPackageManagementSupport { + state: PiPackageManagementSupportState; + message?: string; +} + export function piPackageTargetContext(machine: Pick | undefined): PiPackageTargetContext { if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind }; return { id: "local", name: "local", kind: "local" }; @@ -22,6 +30,25 @@ export function piPackageTargetLabel(target: PiPackageTargetContext): string { return target.kind === "local" ? `${target.name} (local gateway)` : `${target.name} (remote machine)`; } +export function piPackageManagementSupport(target: PiPackageTargetContext, runtime: Pick | undefined): PiPackageManagementSupport { + if (target.kind === "local") return { state: "supported" }; + if (runtime?.ok !== true) return { state: "unknown" }; + if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.piPackagesManage)) return { state: "supported" }; + return { state: "unsupported", message: piPackageManagementUnavailableMessage(target) }; +} + +export function piPackageManagementSupportKey(support: PiPackageManagementSupport): string { + return `${support.state}:${support.message ?? ""}`; +} + +export function isPiPackageManagementUnsupported(support: PiPackageManagementSupport | undefined): support is PiPackageManagementSupport & { state: "unsupported" } { + return support?.state === "unsupported"; +} + +export function piPackageManagementUnavailableMessage(target: PiPackageTargetContext): string { + return `Pi package management is not available on ${target.name}. Update and restart Pi-Web on that machine, then try again.`; +} + export function shouldRefreshGatewayPluginsAfterPiPackageMutation(target: PiPackageTargetContext): boolean { return target.kind === "local"; } @@ -83,7 +110,7 @@ export function friendlyPiPackageErrorMessage(message: string, target: PiPackage const normalized = message.trim(); if (target.kind !== "remote") return normalized; if (isUnsupportedRemotePiPackageRouteMessage(normalized)) { - return `Pi package management is not available on ${target.name}. Update and restart Pi-Web on that machine, then try again.`; + return piPackageManagementUnavailableMessage(target); } if (normalized === "Remote machine timeout") { return `Timed out while contacting ${target.name} for Pi package management. The package operation may still be running remotely; reload the package list before retrying.`; diff --git a/src/client/src/components/settings/settingsDataLoading.test.ts b/src/client/src/components/settings/settingsDataLoading.test.ts index 0124992..64bb179 100644 --- a/src/client/src/components/settings/settingsDataLoading.test.ts +++ b/src/client/src/components/settings/settingsDataLoading.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { PiPackagesResponse, PiWebConfigResponse, PiWebPluginsResponse } from "../../api"; import { loadGatewaySettingsData, loadPiPackagesData } from "./settingsDataLoading"; +import type { PiPackageManagementSupport } from "./piPackageSettings"; const configResponse: PiWebConfigResponse = { path: "/home/test/.config/pi-web/config.json", @@ -14,6 +15,10 @@ const pluginsResponse: PiWebPluginsResponse = { plugins: [] }; const packagesResponse: PiPackagesResponse = { packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] }; const remoteTarget = { id: "remote-a", name: "Lab Mac", kind: "remote" } as const; +const unsupportedPackageManagement: PiPackageManagementSupport = { + state: "unsupported", + message: "Pi package management is not available on Lab Mac. Update and restart Pi-Web on that machine, then try again.", +}; describe("settings data loading helpers", () => { it("loads gateway settings without depending on Pi package data", async () => { @@ -52,4 +57,19 @@ describe("settings data loading helpers", () => { expect(failure.packagesResponse).toBeUndefined(); expect(failure.error).toBe("Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac for Pi package management. Check the machine connection and try again."); }); + + it("skips package listing only when runtime data confirms package management is unsupported", async () => { + const requestedTargets: string[] = []; + const loadPackages = vi.fn((targetId: string) => { + requestedTargets.push(targetId); + return Promise.resolve(packagesResponse); + }); + + const blocked = await loadPiPackagesData(remoteTarget, loadPackages, unsupportedPackageManagement); + const unknown = await loadPiPackagesData(remoteTarget, loadPackages, { state: "unknown" }); + + expect(blocked).toEqual({ error: unsupportedPackageManagement.message, skipped: true }); + expect(unknown).toEqual({ packagesResponse, error: "" }); + expect(requestedTargets).toEqual(["remote-a"]); + }); }); diff --git a/src/client/src/components/settings/settingsDataLoading.ts b/src/client/src/components/settings/settingsDataLoading.ts index 65dd032..7b2de43 100644 --- a/src/client/src/components/settings/settingsDataLoading.ts +++ b/src/client/src/components/settings/settingsDataLoading.ts @@ -1,5 +1,5 @@ import type { PiPackagesResponse, PiWebConfigResponse, PiWebPluginsResponse } from "../../api"; -import { friendlyPiPackageErrorMessage, piPackageTargetLabel, type PiPackageTargetContext } from "./piPackageSettings"; +import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageTargetLabel, type PiPackageManagementSupport, type PiPackageTargetContext } from "./piPackageSettings"; export interface GatewaySettingsLoaders { loadConfig: () => Promise; @@ -15,6 +15,7 @@ export interface GatewaySettingsLoadResult { export interface PiPackagesLoadResult { packagesResponse?: PiPackagesResponse; error: string; + skipped?: boolean; } export async function loadGatewaySettingsData(loaders: GatewaySettingsLoaders): Promise { @@ -32,7 +33,11 @@ export async function loadGatewaySettingsData(loaders: GatewaySettingsLoaders): return result; } -export async function loadPiPackagesData(target: PiPackageTargetContext, loadPackages: (targetId: string) => Promise): Promise { +export async function loadPiPackagesData(target: PiPackageTargetContext, loadPackages: (targetId: string) => Promise, support?: PiPackageManagementSupport): Promise { + if (isPiPackageManagementUnsupported(support)) { + return { error: support.message ?? `Pi package management is not available on ${piPackageTargetLabel(target)}.`, skipped: true }; + } + try { return { packagesResponse: await loadPackages(target.id), error: "" }; } catch (error) {