From b61a9c0c54a239ae50954bc489044cd487e840ce Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 2 Jul 2026 13:27:26 +0200 Subject: [PATCH] feat(settings): unify settings panel layout --- .changeset/settings-panel-layout.md | 5 + .../src/components/SettingsDialog.test.ts | 46 ++++- src/client/src/components/SettingsDialog.ts | 18 -- .../settings/SettingsGeneralPanel.test.ts | 25 +++ .../settings/SettingsGeneralPanel.ts | 55 +++--- .../settings/SettingsPackagesPanel.test.ts | 157 ++++++++++++++++++ .../settings/SettingsPackagesPanel.ts | 115 +++++++------ .../settings/SettingsPanelFrame.test.ts | 147 ++++++++++++++++ .../components/settings/SettingsPanelFrame.ts | 137 +++++++++++++++ .../settings/SettingsPluginsPanel.test.ts | 135 ++++++++++----- .../settings/SettingsPluginsPanel.ts | 91 +++++----- .../settings/SettingsSessiondPanel.test.ts | 126 +++++++++++++- .../settings/SettingsSessiondPanel.ts | 156 ++++++++--------- .../settings/SettingsShortcutsPanel.test.ts | 150 +++++++++++++++++ .../settings/SettingsShortcutsPanel.ts | 83 +++++---- 15 files changed, 1140 insertions(+), 306 deletions(-) create mode 100644 .changeset/settings-panel-layout.md create mode 100644 src/client/src/components/settings/SettingsPackagesPanel.test.ts create mode 100644 src/client/src/components/settings/SettingsPanelFrame.test.ts create mode 100644 src/client/src/components/settings/SettingsPanelFrame.ts create mode 100644 src/client/src/components/settings/SettingsShortcutsPanel.test.ts diff --git a/.changeset/settings-panel-layout.md b/.changeset/settings-panel-layout.md new file mode 100644 index 0000000..0135be0 --- /dev/null +++ b/.changeset/settings-panel-layout.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Standardize Settings tabs so descriptions, notices, and controls render in a consistent order, with unavailable remote settings hiding blocked controls. diff --git a/src/client/src/components/SettingsDialog.test.ts b/src/client/src/components/SettingsDialog.test.ts index 83fc6e6..5fdca5b 100644 --- a/src/client/src/components/SettingsDialog.test.ts +++ b/src/client/src/components/SettingsDialog.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TemplateResult } from "lit"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { configApi, pluginsApi, type Machine, type MachineRuntime, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api"; import { SettingsDialog } from "./SettingsDialog"; @@ -146,12 +147,16 @@ describe("settings-dialog session daemon machine targeting", () => { }); describe("settings-dialog general settings machine targeting", () => { - it("describes the General tab as gateway server plus selected-machine file/upload settings", () => { + it("renders the active settings panel without the old global scope note", () => { const dialog = new SettingsDialog(); dialog.section = "general"; dialog.machine = remoteMachine; - expect(callDialogMethod(dialog, "settingsScopeMessage")).toBe("Gateway server config and selected machine file/upload config: Lab Mac (remote machine)."); + const strings = collectTemplateStrings(dialog.render()).join(""); + + expect(strings).toContain(" { @@ -533,6 +538,43 @@ function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args return typeof value === "function"; } +function collectTemplateStrings(template: TemplateResult): string[] { + const strings: string[] = []; + visitTemplate(template); + return strings; + + function visitTemplate(current: TemplateResult): void { + strings.push(...templateStrings(current)); + for (const value of templateValues(current)) { + if (Array.isArray(value)) { + for (const item of value) if (isTemplateResult(item)) visitTemplate(item); + } else if (isTemplateResult(value)) { + visitTemplate(value); + } + } + } +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} + function configResponse(config: PiWebConfigValues): PiWebConfigResponse { return { path: "/tmp/pi-web/config.json", diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 4993a8a..7139b30 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -119,7 +119,6 @@ export class SettingsDialog extends LitElement { ${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
- ${this.renderScopeNote()} ${this.renderActiveSection()}
@@ -218,22 +217,6 @@ export class SettingsDialog extends LitElement { `; } - private renderScopeNote(): TemplateResult { - return html` -
- This tab edits: ${this.settingsScopeMessage()} -
- `; - } - - private settingsScopeMessage(): string { - if (this.section === "packages") return `Selected machine packages: ${piPackageTargetLabel(this.packageTarget())}.`; - if (this.section === "sessiond") return `Selected machine session-daemon config: ${settingsMachineTargetLabel(this.settingsTarget())}.`; - if (this.section === "plugins") return `Selected machine PI WEB plugin enablement: ${settingsMachineTargetLabel(this.settingsTarget())}.`; - if (this.section === "shortcuts") return "Local gateway keyboard shortcuts."; - return `Gateway server config and selected machine file/upload config: ${settingsMachineTargetLabel(this.settingsTarget())}.`; - } - private navigate(section: SettingsSection): void { this.onNavigate?.(section); } @@ -657,7 +640,6 @@ export class SettingsDialog extends LitElement { .settings-nav button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } .settings-nav small { color: var(--pi-muted); } .settings-content { min-width: 0; min-height: 0; overflow: auto; padding: 18px; } - .scope-note { margin-bottom: 14px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); color: var(--pi-text); padding: 10px 12px; line-height: 1.45; } @media (max-width: 760px) { .backdrop { padding: 0; place-items: stretch; } diff --git a/src/client/src/components/settings/SettingsGeneralPanel.test.ts b/src/client/src/components/settings/SettingsGeneralPanel.test.ts index 2939589..9c0250a 100644 --- a/src/client/src/components/settings/SettingsGeneralPanel.test.ts +++ b/src/client/src/components/settings/SettingsGeneralPanel.test.ts @@ -15,6 +15,7 @@ describe("settings-general-panel copy", () => { const strings = collectTemplateStrings(template).join(""); const values = collectTemplateValues(template); + expect(strings).toContain(" { const template = panel.render(); const values = collectTemplateValues(template); + expect(values).toContain("Save gateway server config"); + expect(values).not.toContain("Save file/upload config"); expect(values).toContain("Selected-machine file access config is unavailable. Reload before saving file/upload settings."); expect(values).toContain("Failed to load file access/upload config from Lab Mac (remote machine): unsupported"); }); + + it("uses frame notices for saved and gateway messages while keeping selected-machine errors scoped", () => { + const panel = new SettingsGeneralPanel(); + panel.error = "Gateway failed"; + panel.machineError = "Selected-machine failed"; + panel.savedMessage = "Config saved."; + + const values = collectTemplateValues(panel.render()); + const notices = values.find(isSettingsNoticeArray); + + expect(notices).toEqual([ + { type: "error", title: "Gateway server", content: "Gateway failed" }, + { type: "success", content: "Config saved." }, + ]); + expect(values).toContain("Selected-machine failed"); + }); }); describe("settings-general-panel save payloads", () => { @@ -190,6 +209,12 @@ function isTemplateResult(value: unknown): value is TemplateResult { return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); } +function isSettingsNoticeArray(value: unknown): value is readonly { type: string; content: unknown; title?: string }[] { + return Array.isArray(value) + && value.length > 0 + && value.every((item: unknown) => typeof item === "object" && item !== null && typeof Reflect.get(item, "type") === "string" && Reflect.has(item, "content")); +} + function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); } diff --git a/src/client/src/components/settings/SettingsGeneralPanel.ts b/src/client/src/components/settings/SettingsGeneralPanel.ts index 3f690ef..859ca3c 100644 --- a/src/client/src/components/settings/SettingsGeneralPanel.ts +++ b/src/client/src/components/settings/SettingsGeneralPanel.ts @@ -1,6 +1,8 @@ import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { DEFAULT_WORKSPACE_UPLOADS_FOLDER, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues } from "../../api"; +import "./SettingsPanelFrame"; +import type { SettingsNotice } from "./SettingsPanelFrame"; import { emptyGatewayServerConfigDraft, emptyMachineAccessConfigDraft, @@ -12,6 +14,10 @@ import { type MachineAccessConfigDraft, } from "./settingsConfigDraft"; +function generalDescription(targetLabel: string): TemplateResult { + return html`Gateway server fields edit this local gateway. File access and upload defaults edit ${targetLabel}.`; +} + @customElement("settings-general-panel") export class SettingsGeneralPanel extends LitElement { @property({ attribute: false }) configResponse: PiWebConfigResponse | undefined; @@ -45,18 +51,19 @@ export class SettingsGeneralPanel extends LitElement { override render(): TemplateResult { return html` -
-
-

General configuration

-

Gateway server fields edit this local gateway. File access and upload defaults edit ${this.targetLabel}.

+ { this.reloadAll(); }} + > +
+ ${this.renderGatewayServerSettings()} + ${this.renderSelectedMachineAccessSettings()}
- -
- ${this.renderSavedMessage()} -
- ${this.renderGatewayServerSettings()} - ${this.renderSelectedMachineAccessSettings()} -
+ `; } @@ -68,7 +75,6 @@ export class SettingsGeneralPanel extends LitElement {

Gateway server

Host, port, and allowed hosts are saved in the gateway config. Address changes require the web service to restart before the running server binds to the new address.

- ${this.renderGatewayMessages()} ${config === undefined && this.loading ? html`
Loading gateway configuration…
` : html`
Gateway config file @@ -161,15 +167,12 @@ export class SettingsGeneralPanel extends LitElement { `; } - private renderSavedMessage(): TemplateResult | null { - if (this.savedMessage === "") return null; - return html`
${this.savedMessage}
`; - } - - private renderGatewayMessages(): TemplateResult | null { - const error = this.gatewayLocalError || this.error; - if (error === "") return null; - return html`
${error}
`; + private panelNotices(): readonly SettingsNotice[] { + const notices: SettingsNotice[] = []; + const gatewayError = this.gatewayLocalError || this.error; + if (gatewayError !== "") notices.push({ type: "error", title: "Gateway server", content: gatewayError }); + if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage }); + return notices; } private renderMachineMessages(): TemplateResult | null { @@ -247,23 +250,19 @@ export class SettingsGeneralPanel extends LitElement { static override styles = css` :host { display: block; } - .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } - .section-heading > div, .card-heading { display: grid; gap: 6px; min-width: 0; } - h2, h3, p { margin: 0; } - h2 { font-size: 17px; line-height: 1.25; } + .card-heading { display: grid; gap: 6px; min-width: 0; } + h3, p { margin: 0; } h3 { font-size: 13px; line-height: 1.3; } p { color: var(--pi-muted); line-height: 1.45; } button, input, select, textarea { font: inherit; } button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } button:disabled { opacity: .55; cursor: not-allowed; } - .secondary { flex: 0 0 auto; } .settings-sections { display: grid; gap: 14px; } .settings-card, .message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } .settings-card { display: grid; gap: 14px; } .message { margin-bottom: 12px; } .settings-card .message { margin-bottom: 0; } .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } - .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } .loading-card { color: var(--pi-muted); } .config-path-card { display: grid; gap: 5px; } .config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } @@ -286,8 +285,6 @@ export class SettingsGeneralPanel extends LitElement { .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); } @media (max-width: 760px) { - .section-heading { display: grid; gap: 12px; } - .section-heading .secondary { justify-self: start; } .effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } } `; diff --git a/src/client/src/components/settings/SettingsPackagesPanel.test.ts b/src/client/src/components/settings/SettingsPackagesPanel.test.ts new file mode 100644 index 0000000..7e97ea9 --- /dev/null +++ b/src/client/src/components/settings/SettingsPackagesPanel.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import type { TemplateResult } from "lit"; +import type { PiPackageInfo } from "../../api"; +import { SettingsPackagesPanel } from "./SettingsPackagesPanel"; +import type { SettingsNotice } from "./SettingsPanelFrame"; +import type { PiPackageManagementSupport, PiPackageTargetContext } from "./piPackageSettings"; + +const remoteTarget: PiPackageTargetContext = { id: "lab-mac", name: "Lab Mac", kind: "remote" }; +const unsupportedMessage = "Pi package management is not available on Lab Mac. Update and restart Pi-Web on that machine, then try again."; + +describe("settings-packages-panel layout", () => { + it("suppresses package controls and trust warnings when package management is unsupported", () => { + const panel = new SettingsPackagesPanel(); + panel.targetMachine = remoteTarget; + panel.managementSupport = unsupportedPackageManagement(); + panel.error = unsupportedMessage; + + const rendered = flattenTemplateContent(panel.render()); + + expect(rendered).toContain(unsupportedMessage); + expect(rendered).not.toContain("Trusted code warning"); + expect(rendered).not.toContain("Pi package source"); + expect(rendered).not.toContain("Configured Pi packages"); + expect(rendered).not.toContain("No Pi packages configured"); + }); + + it("shows a load-unavailable state instead of an empty package state when no response loaded", () => { + const panel = new SettingsPackagesPanel(); + panel.targetMachine = remoteTarget; + panel.error = "Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac."; + + const rendered = flattenTemplateContent(panel.render()); + + expectTextOrder(rendered, [ + "Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac.", + "Pi package list unavailable for Lab Mac (remote machine). Use Reload to try again.", + ]); + expect(rendered).not.toContain("No Pi packages configured"); + expect(rendered).not.toContain("Trusted code warning"); + expect(rendered).not.toContain("Pi package source"); + expect(rendered).not.toContain("Configured Pi packages"); + }); + + it("shows trust guidance, install controls, and empty state only after a package response loaded", () => { + const panel = new SettingsPackagesPanel(); + panel.packagesResponse = { packages: [] }; + + const rendered = flattenTemplateContent(panel.render()); + + expectTextOrder(rendered, [ + "Pi packages", + "Managing Pi packages on ", + "local (local gateway)", + "Trusted code warning:", + "Pi package source", + "Configured Pi packages", + "No Pi packages configured in Pi settings on local (local gateway) yet.", + ]); + expect(rendered).not.toContain("Pi package list unavailable"); + }); + + it("orders package load errors before the trusted-code warning while preserving loaded data", () => { + const panel = new SettingsPackagesPanel(); + panel.targetMachine = remoteTarget; + panel.packagesResponse = { packages: [packageInfo("npm:@acme/tools")] }; + panel.error = "Failed to refresh gateway PI WEB plugins after updating packages."; + + const rendered = flattenTemplateContent(panel.render()); + + expectTextOrder(rendered, [ + "Failed to refresh gateway PI WEB plugins after updating packages.", + "Trusted code warning:", + "Pi package source", + "Configured Pi packages", + "npm:@acme/tools", + ]); + }); +}); + +function flattenTemplateContent(template: TemplateResult): string { + const chunks: string[] = []; + visitTemplate(template); + return chunks.join(""); + + function visitTemplate(current: TemplateResult): void { + const strings = templateStrings(current); + const values = templateValues(current); + for (let index = 0; index < values.length; index += 1) { + const staticChunk = strings[index]; + if (staticChunk !== undefined) chunks.push(staticChunk); + visitValue(values[index]); + } + const finalChunk = strings[values.length]; + if (finalChunk !== undefined) chunks.push(finalChunk); + } + + function visitValue(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) visitValue(item); + return; + } + if (isSettingsNotice(value)) { + visitValue(value.title); + visitValue(value.content); + return; + } + if (isTemplateResult(value)) { + visitTemplate(value); + return; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + chunks.push(String(value)); + } + } +} + +function expectTextOrder(content: string, labels: readonly string[]): void { + let previousIndex = -1; + for (const label of labels) { + const currentIndex = content.indexOf(label, previousIndex + 1); + if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`); + expect(currentIndex).toBeGreaterThan(previousIndex); + previousIndex = currentIndex; + } +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isSettingsNotice(value: unknown): value is SettingsNotice { + return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content"); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} + +function unsupportedPackageManagement(): PiPackageManagementSupport { + return { state: "unsupported", message: unsupportedMessage }; +} + +function packageInfo(source: string): PiPackageInfo { + return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` }; +} diff --git a/src/client/src/components/settings/SettingsPackagesPanel.ts b/src/client/src/components/settings/SettingsPackagesPanel.ts index 865b022..e86ab71 100644 --- a/src/client/src/components/settings/SettingsPackagesPanel.ts +++ b/src/client/src/components/settings/SettingsPackagesPanel.ts @@ -1,6 +1,8 @@ import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import type { PiPackageInfo, PiPackageScope, PiPackagesResponse } from "../../api"; +import "./SettingsPanelFrame"; +import type { SettingsNotice } from "./SettingsPanelFrame"; import { isPiPackageManagementUnsupported, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageInstalledPathLabel, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./piPackageSettings"; @customElement("settings-packages-panel") @@ -24,48 +26,70 @@ export class SettingsPackagesPanel extends LitElement { const target = this.packageTarget; const targetLabel = piPackageTargetLabel(target); const packageManagementUnavailable = this.packageManagementUnavailable; + const showPackageControls = this.packagesResponse !== undefined && !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.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. -
+ + ${this.renderPanelContent(packages, target, targetLabel)} + + `; + } + + private panelNotices(targetLabel: string, showTrustedCodeWarning: boolean): readonly SettingsNotice[] { + const notices: SettingsNotice[] = []; + if (this.packageManagementUnavailable) { + notices.push({ type: "availability", content: this.packageManagementUnavailableMessage(targetLabel) }); + } else if (this.error !== "") { + notices.push({ type: "error", content: this.error }); + } + if (this.operationMessage !== "") notices.push({ type: "success", content: this.operationMessage }); + if (showTrustedCodeWarning) { + notices.push({ + type: "security", + content: html`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.`, + }); + } + return notices; + } + + private renderPanelContent(packages: PiPackageInfo[], target: PiPackageTargetContext, targetLabel: string): TemplateResult | null { + if (this.packageManagementUnavailable) return null; + if (this.packagesResponse === undefined) { + return html`
${this.loading ? `Loading Pi packages from ${targetLabel}…` : `Pi package list unavailable for ${targetLabel}. Use Reload to try again.`}
`; + } + return html` + ${this.renderInstallForm(targetLabel)} ${this.renderPackageList(packages, target)} `; } - private renderMessages(): TemplateResult | null { - if (this.error !== "") return html`
${this.error}
`; - if (this.operationMessage !== "") return html`
${this.operationMessage}
`; - 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 renderInstallForm(targetLabel: string): TemplateResult { + return html` +
{ void this.installPackage(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. +
+ `; } private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult { const targetLabel = piPackageTargetLabel(target); - const packageListUnavailable = this.error !== "" && packages.length === 0; 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"; + const showUpdateAllReason = updateAllReason !== undefined && packages.length > 0; + const updateAllTitle = updateAllReason ?? "Update all user-scope Pi packages"; return html`
@@ -86,10 +110,6 @@ 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) { - 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`
@@ -178,10 +198,9 @@ export class SettingsPackagesPanel extends LitElement { static override styles = css` :host { display: block; } - .section-heading, .package-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } - .section-heading > div, .package-toolbar > div, .package-main { display: grid; gap: 6px; min-width: 0; } - h2, h3, p { margin: 0; } - h2 { font-size: 17px; line-height: 1.25; } + .package-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } + .package-toolbar > div, .package-main { display: grid; gap: 6px; min-width: 0; } + h3, p { margin: 0; } h3 { font-size: 15px; line-height: 1.25; } p, small { color: var(--pi-muted); line-height: 1.45; } button, input { font: inherit; } @@ -191,17 +210,11 @@ export class SettingsPackagesPanel extends LitElement { label { font-weight: 700; } .secondary { flex: 0 0 auto; } .danger { border-color: color-mix(in srgb, var(--pi-danger) 55%, var(--pi-border)); color: var(--pi-danger); } - .message, .loading-card, .trust-warning, .install-card, .package-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } - .message, .trust-warning, .install-card { margin-bottom: 12px; } - .trust-warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); line-height: 1.45; } - .error-message, .field-error { color: var(--pi-danger); } - .error-message { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } - .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } + .loading-card, .install-card, .package-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .field-error { color: var(--pi-danger); font-size: 12px; } .install-card { display: grid; gap: 8px; } .install-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; } - .field-error { font-size: 12px; } .package-section { display: block; } - .package-toolbar { margin-top: 16px; } .loading-card, .action-note { color: var(--pi-muted); } .action-note { margin-bottom: 10px; font-size: 12px; } .package-list { display: grid; gap: 10px; } @@ -212,11 +225,15 @@ export class SettingsPackagesPanel extends LitElement { code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } @media (max-width: 760px) { - .section-heading, .package-toolbar { display: grid; gap: 12px; } - .section-heading .secondary, .package-toolbar .secondary { justify-self: start; } + .package-toolbar { display: grid; gap: 12px; } + .package-toolbar .secondary { justify-self: start; } .install-row, .package-card { grid-template-columns: minmax(0, 1fr); align-items: start; } .package-actions { justify-self: start; flex-wrap: wrap; } .package-main strong, .package-main small { white-space: normal; } } `; } + +function packagesDescription(targetLabel: string): TemplateResult { + return html`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.`; +} diff --git a/src/client/src/components/settings/SettingsPanelFrame.test.ts b/src/client/src/components/settings/SettingsPanelFrame.test.ts new file mode 100644 index 0000000..46cc2d0 --- /dev/null +++ b/src/client/src/components/settings/SettingsPanelFrame.test.ts @@ -0,0 +1,147 @@ +import { html, type TemplateResult } from "lit"; +import { describe, expect, it } from "vitest"; +import { SettingsPanelFrame, settingsNoticeTone, type SettingsNotice } from "./SettingsPanelFrame"; + +describe("settings-panel-frame", () => { + it("renders header, ordered notices, and settings content in the shared order", () => { + const frame = new SettingsPanelFrame(); + frame.heading = "Pi packages"; + frame.description = "Manage packages on Lab Mac."; + frame.actionLabel = "Reload"; + frame.notices = [ + { type: "availability", title: "Unavailable", content: "Package management is unavailable." }, + { type: "success", content: "Saved package settings." }, + { type: "security", content: html`Trusted code warning: Install packages only from sources you trust.` }, + ]; + + const rendered = flattenTemplateContent(frame.render()); + + expectTextOrder(rendered, [ + "Pi packages", + "Manage packages on Lab Mac.", + "Reload", + "Unavailable", + "Package management is unavailable.", + "Saved package settings.", + "Trusted code warning:", + ]); + expect(rendered.indexOf('class="notice-stack"')).toBeLessThan(rendered.indexOf('class="content"')); + }); + + it("maps notice types to consistent default tones and roles", () => { + const notices: readonly SettingsNotice[] = [ + { type: "availability", content: "Configuration unavailable." }, + { type: "success", content: "Saved." }, + { type: "security", content: "Trusted code warning." }, + { type: "info", content: "Loading…" }, + ]; + const frame = new SettingsPanelFrame(); + frame.notices = notices; + + const values = collectTemplateValues(frame.render()); + + expect(notices.map(settingsNoticeTone)).toEqual(["error", "success", "warning", "info"]); + expect(values).toEqual(expect.arrayContaining(["notice error", "alert", "notice success", "status", "notice warning", "note", "notice info"])); + }); + + it("wires the default header action through the frame", () => { + const frame = new SettingsPanelFrame(); + let reloads = 0; + frame.actionLabel = "Reload"; + frame.actionTitle = "Reload settings"; + frame.actionDisabled = true; + frame.onAction = () => { reloads += 1; }; + + const values = collectTemplateValues(frame.render()); + const action = values.find(isActionHandler); + + expect(values).toEqual(expect.arrayContaining(["Reload settings", true, "Reload"])); + if (action === undefined) throw new Error("Action handler was not rendered"); + action(); + expect(reloads).toBe(1); + }); +}); + +function flattenTemplateContent(template: TemplateResult): string { + const chunks: string[] = []; + visitTemplate(template); + return chunks.join(""); + + function visitTemplate(current: TemplateResult): void { + const strings = templateStrings(current); + const values = templateValues(current); + for (let index = 0; index < values.length; index += 1) { + const staticChunk = strings[index]; + if (staticChunk !== undefined) chunks.push(staticChunk); + visitValue(values[index]); + } + const finalChunk = strings[values.length]; + if (finalChunk !== undefined) chunks.push(finalChunk); + } + + function visitValue(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) visitValue(item); + return; + } + if (isTemplateResult(value)) { + visitTemplate(value); + return; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + chunks.push(String(value)); + } + } +} + +function collectTemplateValues(template: TemplateResult): unknown[] { + const values: unknown[] = []; + visit(template); + return values; + + function visit(current: unknown): void { + if (Array.isArray(current)) { + for (const item of current) visit(item); + return; + } + if (!isTemplateResult(current)) return; + for (const value of templateValues(current)) { + values.push(value); + visit(value); + } + } +} + +function expectTextOrder(content: string, labels: readonly string[]): void { + let previousIndex = -1; + for (const label of labels) { + const currentIndex = content.indexOf(label, previousIndex + 1); + if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`); + expect(currentIndex).toBeGreaterThan(previousIndex); + previousIndex = currentIndex; + } +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} + +function isActionHandler(value: unknown): value is () => void { + return typeof value === "function"; +} diff --git a/src/client/src/components/settings/SettingsPanelFrame.ts b/src/client/src/components/settings/SettingsPanelFrame.ts new file mode 100644 index 0000000..28bf479 --- /dev/null +++ b/src/client/src/components/settings/SettingsPanelFrame.ts @@ -0,0 +1,137 @@ +import { css, html, LitElement, nothing, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +export const SETTINGS_NOTICE_TONES = ["error", "success", "warning", "info"] as const; +export type SettingsNoticeTone = (typeof SETTINGS_NOTICE_TONES)[number]; + +export const SETTINGS_NOTICE_TYPES = ["availability", "error", "success", "security", "warning", "info"] as const; +export type SettingsNoticeType = (typeof SETTINGS_NOTICE_TYPES)[number]; + +export type SettingsNoticeRole = "alert" | "note" | "status"; +export type SettingsNoticeContent = string | TemplateResult; + +export interface SettingsNotice { + readonly type: SettingsNoticeType; + readonly content: SettingsNoticeContent; + readonly tone?: SettingsNoticeTone; + readonly title?: string; + readonly role?: SettingsNoticeRole; +} + +const DEFAULT_NOTICE_TONE: Record = { + availability: "error", + error: "error", + success: "success", + security: "warning", + warning: "warning", + info: "info", +}; + +export function settingsNoticeTone(notice: SettingsNotice): SettingsNoticeTone { + return notice.tone ?? DEFAULT_NOTICE_TONE[notice.type]; +} + +@customElement("settings-panel-frame") +export class SettingsPanelFrame extends LitElement { + @property() heading = ""; + @property({ attribute: false }) description: SettingsNoticeContent = ""; + @property() actionLabel = ""; + @property() actionTitle = ""; + @property({ type: Boolean }) actionDisabled = false; + @property({ attribute: false }) notices: readonly SettingsNotice[] = []; + @property({ attribute: false }) onAction?: () => void | Promise; + + override render(): TemplateResult { + return html` +
+
+
+ ${this.heading === "" ? nothing : html`

${this.heading}

`} +
${this.description}
+
+
${this.renderDefaultAction()}
+
+ ${this.renderNoticeStack()} +
+
+ `; + } + + private renderDefaultAction(): TemplateResult | typeof nothing { + if (this.actionLabel === "") return nothing; + return html` + + `; + } + + private renderNoticeStack(): TemplateResult | typeof nothing { + if (this.notices.length === 0) return nothing; + return html` +
+ ${this.notices.map((notice) => this.renderNotice(notice))} +
+ `; + } + + private renderNotice(notice: SettingsNotice): TemplateResult { + const tone = settingsNoticeTone(notice); + const role = notice.role ?? defaultNoticeRole(tone); + const title = notice.title; + return html` +
+ ${title === undefined || title === "" ? nothing : html`${title}`} +
${notice.content}
+
+ `; + } + + static override styles = css` + :host { display: block; } + .panel { display: block; } + .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } + .heading-copy { display: grid; gap: 6px; min-width: 0; } + .heading-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; } + h2 { margin: 0; font-size: 17px; line-height: 1.25; } + .description { color: var(--pi-muted); line-height: 1.45; } + .description ::slotted(*) { margin: 0; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; font: inherit; cursor: pointer; } + button:disabled { opacity: .55; cursor: not-allowed; } + .secondary { flex: 0 0 auto; } + .notice-stack { display: grid; gap: 12px; margin-bottom: 14px; } + .notice { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; line-height: 1.45; } + .notice.error { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } + .notice.success { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } + .notice.warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); } + .notice.info { color: var(--pi-muted); } + .notice-title { display: block; margin-bottom: 4px; color: inherit; } + .notice-content { min-width: 0; } + code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } + .content { display: grid; gap: 14px; min-width: 0; } + .content ::slotted(*) { min-width: 0; } + + @media (max-width: 760px) { + .section-heading { display: grid; gap: 12px; } + .heading-actions { justify-self: start; } + } + `; +} + +function defaultNoticeRole(tone: SettingsNoticeTone): SettingsNoticeRole { + switch (tone) { + case "error": return "alert"; + case "success": return "status"; + case "warning": + case "info": return "note"; + } +} + +declare global { + interface HTMLElementTagNameMap { + "settings-panel-frame": SettingsPanelFrame; + } +} diff --git a/src/client/src/components/settings/SettingsPluginsPanel.test.ts b/src/client/src/components/settings/SettingsPluginsPanel.test.ts index 2d83ce7..5424cab 100644 --- a/src/client/src/components/settings/SettingsPluginsPanel.test.ts +++ b/src/client/src/components/settings/SettingsPluginsPanel.test.ts @@ -2,42 +2,67 @@ import { describe, expect, it } from "vitest"; import type { TemplateResult } from "lit"; import type { PiWebConfigResponse, PiWebConfigValues, PiWebPluginInfo } from "../../api"; import { SettingsPluginsPanel } from "./SettingsPluginsPanel"; +import type { SettingsNotice } from "./SettingsPanelFrame"; -describe("settings-plugins-panel copy", () => { - it("names the selected machine in plugin scope copy", () => { +describe("settings-plugins-panel layout", () => { + it("orders load and save notices before the trusted-code warning and plugin content", () => { + const panel = new SettingsPluginsPanel(); + panel.targetLabel = "Lab Mac (remote machine)"; + panel.configResponse = configResponse({ plugins: { "remote-enabled": { enabled: true } } }); + panel.pluginsResponse = { plugins: [pluginInfo("remote-enabled", true)] }; + panel.error = "Failed to load PI WEB plugin settings from Lab Mac: PI WEB plugins: timed out."; + panel.savedMessage = "Config saved."; + + const rendered = flattenTemplateContent(panel.render()); + + expectTextOrder(rendered, [ + "PI WEB plugins", + "Enable or disable discovered PI WEB browser plugins on ", + "Lab Mac (remote machine)", + "Failed to load PI WEB plugin settings from Lab Mac: PI WEB plugins: timed out.", + "Config saved. Reload the browser tab to apply plugin changes.", + "Trusted code warning:", + "Config key on Lab Mac (remote machine):", + "remote-enabled", + ]); + }); + + it("does not show a false empty state when the plugin response is missing", () => { const panel = new SettingsPluginsPanel(); panel.targetLabel = "Lab Mac (remote machine)"; - const template = panel.render(); - const strings = collectTemplateStrings(template).join(""); - const values = collectTemplateValues(template); + const rendered = flattenTemplateContent(panel.render()); - expect(strings).toContain("Enable or disable discovered PI WEB browser plugins on "); - expect(strings).toContain("Config key on "); - expect(strings).toContain("No PI WEB browser plugins discovered on "); - expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(3); + expect(rendered).toContain("PI WEB plugin list unavailable for Lab Mac (remote machine). Use Reload to try again."); + expect(rendered).not.toContain("No PI WEB browser plugins discovered"); + expect(rendered).not.toContain("Trusted code warning"); }); -}); -describe("settings-plugins-panel state", () => { - it("shows disabled remote plugins from the selected machine plugin list", () => { + it("shows the empty plugin state only after a plugin response has loaded", () => { const panel = new SettingsPluginsPanel(); panel.targetLabel = "Lab Mac (remote machine)"; - panel.configResponse = configResponse({ plugins: { "remote-disabled": { enabled: false } } }); - panel.pluginsResponse = { plugins: [pluginInfo("remote-disabled", false)] }; + panel.pluginsResponse = { plugins: [] }; - const values = collectTemplateValues(panel.render()); + const rendered = flattenTemplateContent(panel.render()); - expect(values).toContain("remote-disabled"); - expect(values).toContain("Config disabled"); - expect(values).toContain("Disabled"); + expect(rendered).toContain("No PI WEB browser plugins discovered on Lab Mac (remote machine)."); + expect(rendered).not.toContain("PI WEB plugin list unavailable"); + expect(rendered).not.toContain("Trusted code warning"); }); - it("disables plugin toggles while selected-machine config is unavailable", () => { + it("keeps loaded plugins visible but disabled when selected-machine config is unavailable", () => { const panel = new SettingsPluginsPanel(); + panel.targetLabel = "Lab Mac (remote machine)"; panel.pluginsResponse = { plugins: [pluginInfo("remote-disabled", false)] }; - expect(collectTemplateStrings(panel.render()).join("")).toContain("Configuration is unavailable. Reload to try again before changing plugin enablement."); + const rendered = flattenTemplateContent(panel.render()); + + expectTextOrder(rendered, [ + "Configuration is unavailable. Reload to try again before changing plugin enablement.", + "Trusted code warning:", + "remote-disabled", + ]); + expect(countOccurrences(rendered, "Configuration is unavailable. Reload to try again before changing plugin enablement.")).toBe(1); expect(templateValues(renderPluginTemplate(panel, pluginInfo("remote-disabled", false))).filter(isBoolean)).toEqual([false, true]); }); }); @@ -52,41 +77,57 @@ function isPanelRenderPlugin(value: unknown): value is (this: SettingsPluginsPan return typeof value === "function"; } -function collectTemplateStrings(template: TemplateResult): string[] { - const strings: string[] = []; +function flattenTemplateContent(template: TemplateResult): string { + const chunks: string[] = []; visitTemplate(template); - return strings; + return chunks.join(""); function visitTemplate(current: TemplateResult): void { - strings.push(...templateStrings(current)); - for (const value of templateValues(current)) { - if (Array.isArray(value)) { - for (const item of value) if (isTemplateResult(item)) visitTemplate(item); - } else if (isTemplateResult(value)) { - visitTemplate(value); - } + const strings = templateStrings(current); + const values = templateValues(current); + for (let index = 0; index < values.length; index += 1) { + const staticChunk = strings[index]; + if (staticChunk !== undefined) chunks.push(staticChunk); + visitValue(values[index]); + } + const finalChunk = strings[values.length]; + if (finalChunk !== undefined) chunks.push(finalChunk); + } + + function visitValue(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) visitValue(item); + return; + } + if (isSettingsNotice(value)) { + visitValue(value.title); + visitValue(value.content); + return; + } + if (isTemplateResult(value)) { + visitTemplate(value); + return; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + chunks.push(String(value)); } } } -function collectTemplateValues(template: TemplateResult): unknown[] { - const values: unknown[] = []; - visit(template); - return values; - - function visit(current: unknown): void { - if (Array.isArray(current)) { - for (const item of current) visit(item); - return; - } - if (!isTemplateResult(current)) return; - for (const value of templateValues(current)) { - values.push(value); - visit(value); - } +function expectTextOrder(content: string, labels: readonly string[]): void { + let previousIndex = -1; + for (const label of labels) { + const currentIndex = content.indexOf(label, previousIndex + 1); + if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`); + expect(currentIndex).toBeGreaterThan(previousIndex); + previousIndex = currentIndex; } } +function countOccurrences(content: string, needle: string): number { + return content.split(needle).length - 1; +} + function templateStrings(template: TemplateResult): readonly string[] { const strings = Reflect.get(template, "strings"); if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); @@ -103,6 +144,10 @@ function isTemplateResult(value: unknown): value is TemplateResult { return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); } +function isSettingsNotice(value: unknown): value is SettingsNotice { + return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content"); +} + function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); } diff --git a/src/client/src/components/settings/SettingsPluginsPanel.ts b/src/client/src/components/settings/SettingsPluginsPanel.ts index 46f1f35..8f4aa13 100644 --- a/src/client/src/components/settings/SettingsPluginsPanel.ts +++ b/src/client/src/components/settings/SettingsPluginsPanel.ts @@ -1,6 +1,8 @@ import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import type { PiWebConfigResponse, PiWebPluginInfo, PiWebPluginsResponse } from "../../api"; +import "./SettingsPanelFrame"; +import type { SettingsNotice } from "./SettingsPanelFrame"; @customElement("settings-plugins-panel") export class SettingsPluginsPanel extends LitElement { @@ -16,30 +18,55 @@ export class SettingsPluginsPanel extends LitElement { override render(): TemplateResult { const plugins = this.pluginsResponse?.plugins ?? []; + const hasPluginResponse = this.pluginsResponse !== undefined; return html` -
-
-

PI WEB plugins

-

Enable or disable discovered PI WEB browser plugins on ${this.targetLabel}. This is separate from installing Pi packages. Reload the browser tab to apply plugin runtime changes.

-
- -
- ${this.renderMessages()} -
Trusted code warning: PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.
-
Config key on ${this.targetLabel}: plugins. Plugins are enabled unless their entry sets enabled to false.
- ${this.configResponse === undefined && !this.loading ? html`
Configuration is unavailable. Reload to try again before changing plugin enablement.
` : null} - ${this.loading && plugins.length === 0 ? html`
Loading PI WEB plugins…
` : plugins.length === 0 ? html`
No PI WEB browser plugins discovered on ${this.targetLabel}.
` : html` -
- ${plugins.map((plugin) => this.renderPlugin(plugin))} -
- `} + 0)} + .onAction=${this.onReload} + > + ${this.renderPanelContent(plugins, hasPluginResponse)} + `; } - private renderMessages(): TemplateResult | null { - if (this.error !== "") return html`
${this.error}
`; - if (this.savedMessage !== "") return html`
${this.savedMessage} Reload the browser tab to apply plugin changes.
`; - return null; + private panelNotices(showTrustedCodeWarning: boolean): readonly SettingsNotice[] { + const notices: SettingsNotice[] = []; + if (this.error !== "") notices.push({ type: "error", content: this.error }); + if (this.shouldShowConfigUnavailableNotice(showTrustedCodeWarning)) { + notices.push({ type: "availability", content: "Configuration is unavailable. Reload to try again before changing plugin enablement." }); + } + if (this.savedMessage !== "") notices.push({ type: "success", content: `${this.savedMessage} Reload the browser tab to apply plugin changes.` }); + if (showTrustedCodeWarning) { + notices.push({ + type: "security", + content: html`Trusted code warning: PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.`, + }); + } + return notices; + } + + private shouldShowConfigUnavailableNotice(hasLoadedPlugins: boolean): boolean { + return hasLoadedPlugins && this.configResponse === undefined && !this.loading && this.error === ""; + } + + private renderPanelContent(plugins: PiWebPluginInfo[], hasPluginResponse: boolean): TemplateResult { + if (!hasPluginResponse) { + return html`
${this.loading ? "Loading PI WEB plugins…" : `PI WEB plugin list unavailable for ${this.targetLabel}. Use Reload to try again.`}
`; + } + if (plugins.length === 0) { + return html`
No PI WEB browser plugins discovered on ${this.targetLabel}.
`; + } + return html` +
Config key on ${this.targetLabel}: plugins. Plugins are enabled unless their entry sets enabled to false.
+
+ ${plugins.map((plugin) => this.renderPlugin(plugin))} +
+ `; } private renderPlugin(plugin: PiWebPluginInfo): TemplateResult { @@ -67,22 +94,10 @@ export class SettingsPluginsPanel extends LitElement { static override styles = css` :host { display: block; } - .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } - .section-heading > div { display: grid; gap: 6px; min-width: 0; } - h2, p { margin: 0; } - h2 { font-size: 17px; line-height: 1.25; } - p { color: var(--pi-muted); line-height: 1.45; } - button, input { font: inherit; } - button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } - button:disabled, input:disabled { opacity: .55; cursor: not-allowed; } - .secondary { flex: 0 0 auto; } - .message, .loading-card, .trust-warning, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } - .message, .trust-warning { margin-bottom: 12px; } - .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } - .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } + input { font: inherit; } + input:disabled { opacity: .55; cursor: not-allowed; } + .loading-card, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } .loading-card, .plugin-note { color: var(--pi-muted); } - .trust-warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); line-height: 1.45; } - .plugin-note { margin-bottom: 14px; } code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } .plugin-list { display: grid; gap: 10px; } .plugin-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; } @@ -94,10 +109,12 @@ export class SettingsPluginsPanel extends LitElement { .toggle input { width: 18px; height: 18px; accent-color: var(--pi-accent); } @media (max-width: 760px) { - .section-heading { display: grid; gap: 12px; } - .section-heading .secondary { justify-self: start; } .plugin-card { grid-template-columns: minmax(0, 1fr); align-items: start; } .toggle { justify-self: start; } } `; } + +function pluginsDescription(targetLabel: string): TemplateResult { + return html`Enable or disable discovered PI WEB browser plugins on ${targetLabel}. This is separate from installing Pi packages. Reload the browser tab to apply plugin runtime changes.`; +} diff --git a/src/client/src/components/settings/SettingsSessiondPanel.test.ts b/src/client/src/components/settings/SettingsSessiondPanel.test.ts index 266bcf9..8b53892 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.test.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.test.ts @@ -1,23 +1,113 @@ import { describe, expect, it } from "vitest"; import type { TemplateResult } from "lit"; +import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; import { SettingsSessiondPanel } from "./SettingsSessiondPanel"; +import type { SettingsNotice } from "./SettingsPanelFrame"; -describe("settings-sessiond-panel copy", () => { - it("names the selected machine in the scope and restart copy", () => { +describe("settings-sessiond-panel layout", () => { + it("names the selected machine in the scope and restart notice when config is available", () => { const panel = new SettingsSessiondPanel(); panel.targetLabel = "Lab Mac (remote machine)"; + panel.configResponse = configResponse({ spawnSessions: true, subsessions: false }); - const template = panel.render(); - const strings = templateStrings(template); - const values = templateValues(template); + const rendered = flattenTemplateContent(panel.render()); - expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(2); - expect(strings.join("")).toContain("These settings affect the long-lived session runtime on "); - expect(strings.join("")).toContain("Restart required on "); - expect(strings.join("")).toContain("run pi-web restart on that machine"); + expectTextOrder(rendered, [ + "Session daemon", + "These settings affect the long-lived session runtime on Lab Mac (remote machine).", + "Reload", + "Restart required on Lab Mac (remote machine)", + "run pi-web restart on that machine", + "Config file", + "Allow agents to start sessions", + ]); + }); + + it("orders save/load notices before the restart notice and settings content", () => { + const panel = new SettingsSessiondPanel(); + panel.configResponse = configResponse({ spawnSessions: false }); + panel.error = "Failed to save session-daemon config."; + panel.savedMessage = "Session daemon settings saved."; + + const rendered = flattenTemplateContent(panel.render()); + + expectTextOrder(rendered, [ + "Failed to save session-daemon config.", + "Session daemon settings saved.", + "Restart required on local (local gateway)", + "Config file", + ]); + }); + + it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => { + const panel = new SettingsSessiondPanel(); + panel.targetLabel = "Lab Mac (remote machine)"; + panel.error = "Selected-machine settings are not available on Lab Mac."; + + const rendered = flattenTemplateContent(panel.render()); + + expectTextOrder(rendered, [ + "Selected-machine settings are not available on Lab Mac.", + "Configuration is unavailable. Reload to try again.", + ]); + expect(countOccurrences(rendered, "Configuration is unavailable. Reload to try again.")).toBe(1); + expect(rendered).not.toContain("Restart required on"); + expect(rendered).not.toContain("Allow agents to start sessions"); + expect(rendered).not.toContain("Effective after environment overrides"); }); }); +function flattenTemplateContent(template: TemplateResult): string { + const chunks: string[] = []; + visitTemplate(template); + return chunks.join(""); + + function visitTemplate(current: TemplateResult): void { + const strings = templateStrings(current); + const values = templateValues(current); + for (let index = 0; index < values.length; index += 1) { + const staticChunk = strings[index]; + if (staticChunk !== undefined) chunks.push(staticChunk); + visitValue(values[index]); + } + const finalChunk = strings[values.length]; + if (finalChunk !== undefined) chunks.push(finalChunk); + } + + function visitValue(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) visitValue(item); + return; + } + if (isSettingsNotice(value)) { + visitValue(value.title); + visitValue(value.content); + return; + } + if (isTemplateResult(value)) { + visitTemplate(value); + return; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + chunks.push(String(value)); + } + } +} + +function expectTextOrder(content: string, labels: readonly string[]): void { + let previousIndex = -1; + for (const label of labels) { + const currentIndex = content.indexOf(label, previousIndex + 1); + if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`); + expect(currentIndex).toBeGreaterThan(previousIndex); + previousIndex = currentIndex; + } +} + +function countOccurrences(content: string, needle: string): number { + return content.split(needle).length - 1; +} + function templateStrings(template: TemplateResult): readonly string[] { const strings = Reflect.get(template, "strings"); if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); @@ -30,6 +120,24 @@ function templateValues(template: TemplateResult): readonly unknown[] { return values.map((value: unknown) => value); } +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isSettingsNotice(value: unknown): value is SettingsNotice { + return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content"); +} + function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); } + +function configResponse(config: PiWebConfigValues): PiWebConfigResponse { + return { + path: "/tmp/pi-web/config.json", + exists: true, + config, + effectiveConfig: config, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + }; +} diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts index 3b0cc5c..ff02d41 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.ts @@ -1,6 +1,8 @@ import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import "./SettingsPanelFrame"; +import type { SettingsNotice } from "./SettingsPanelFrame"; import { spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; @customElement("settings-sessiond-panel") @@ -24,68 +26,80 @@ export class SettingsSessiondPanel extends LitElement { // Beta, off by default; also requires spawn to be enabled. const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn; return html` -
-
-

Session daemon

-

These settings affect the long-lived session runtime on ${this.targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.

-
- -
- ${this.renderMessages()} -
Restart required on ${this.targetLabel}: run pi-web restart on that machine (or restart its session daemon service) after changing these settings.
- ${config === undefined ? html`
${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}
` : html` -
- Config file - ${config.path} -
-
- - Allow agents to start sessions - ${spawnOverridden ? html`environment override` : null} - - - When enabled, LLMs can start new sessions, constrained to a workspace (any worktree) of the same registered project so every spawned session stays visible here. On by default. -
-
- - Allow agents to start tracked subsessions - beta - ${subsessionsOverridden ? html`environment override` : null} - - - Beta: agents can start child sessions they stay attached to (spawn_subsession, list_subsessions, check_subsession, read_subsession) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default. -
-
-

Effective after environment overrides

-
-
Spawn sessions
${effectiveSpawn ? "Enabled" : html`Disabled`}
-
Subsessions
${effectiveSubsessions ? "Enabled" : html`Disabled`}
-
-
- `} + + ${config === undefined ? this.renderUnavailableConfigState() : html` +
+ Config file + ${config.path} +
+
+ + Allow agents to start sessions + ${spawnOverridden ? html`environment override` : null} + + + When enabled, LLMs can start new sessions, constrained to a workspace (any worktree) of the same registered project so every spawned session stays visible here. On by default. +
+
+ + Allow agents to start tracked subsessions + beta + ${subsessionsOverridden ? html`environment override` : null} + + + Beta: agents can start child sessions they stay attached to (spawn_subsession, list_subsessions, check_subsession, read_subsession) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default. +
+
+

Effective after environment overrides

+
+
Spawn sessions
${effectiveSpawn ? "Enabled" : html`Disabled`}
+
Subsessions
${effectiveSubsessions ? "Enabled" : html`Disabled`}
+
+
+ `} +
`; } - private renderMessages(): TemplateResult | null { - if (this.error !== "") return html`
${this.error}
`; - if (this.savedMessage !== "") return html`
${this.savedMessage}
`; - return null; + private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] { + const notices: SettingsNotice[] = []; + if (this.error !== "") notices.push({ type: "error", content: this.error }); + if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage }); + if (config !== undefined) { + notices.push({ + type: "warning", + title: `Restart required on ${this.targetLabel}`, + content: html`run pi-web restart on that machine (or restart its session daemon service) after changing these settings.`, + }); + } + return notices; + } + + private renderUnavailableConfigState(): TemplateResult { + return html`
${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}
`; } private async toggleSpawnSessions(event: Event): Promise { @@ -100,26 +114,16 @@ export class SettingsSessiondPanel extends LitElement { static override styles = css` :host { display: block; } - .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } - .section-heading > div { display: grid; gap: 6px; min-width: 0; } - h2, h3, p { margin: 0; } - h2 { font-size: 17px; line-height: 1.25; } - h3 { font-size: 13px; line-height: 1.3; } - p { color: var(--pi-muted); line-height: 1.45; } + h3 { margin: 0; font-size: 13px; line-height: 1.3; } button, input { font: inherit; } button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } button:disabled { opacity: .55; cursor: not-allowed; } - .secondary { flex: 0 0 auto; } - .message, .loading-card, .config-path-card, .effective-card, .restart-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } - .message { margin-bottom: 12px; } - .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } - .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } + .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } .loading-card { color: var(--pi-muted); } - .restart-note { margin-bottom: 14px; border-color: var(--pi-warning-border); color: var(--pi-warning); background: var(--pi-warning-surface); line-height: 1.45; } - .config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } + .config-path-card { display: grid; gap: 5px; } .config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } - .field { display: grid; gap: 7px; margin-bottom: 14px; } + .field { display: grid; gap: 7px; } .field small { color: var(--pi-muted); line-height: 1.45; } .field-heading { display: flex; align-items: center; gap: 8px; } .toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; } @@ -134,9 +138,11 @@ export class SettingsSessiondPanel extends LitElement { .muted { color: var(--pi-muted); } @media (max-width: 760px) { - .section-heading { display: grid; gap: 12px; } - .section-heading .secondary { justify-self: start; } .effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } } `; } + +function sessiondDescription(targetLabel: string): string { + return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`; +} diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.test.ts b/src/client/src/components/settings/SettingsShortcutsPanel.test.ts new file mode 100644 index 0000000..da082e2 --- /dev/null +++ b/src/client/src/components/settings/SettingsShortcutsPanel.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import type { TemplateResult } from "lit"; +import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; +import { SettingsShortcutsPanel } from "./SettingsShortcutsPanel"; +import type { SettingsNotice } from "./SettingsPanelFrame"; + +describe("settings-shortcuts-panel layout", () => { + it("renders header, ordered notices, and shortcut settings through the shared frame", () => { + const panel = new SettingsShortcutsPanel(); + panel.configResponse = configResponse({ shortcuts: {} }); + panel.error = "Failed to load shortcut settings."; + panel.savedMessage = "Shortcut settings saved."; + + const template = panel.render(); + const rendered = flattenTemplateContent(template); + + expect(rendered).toContain(" notice.type)).toEqual(["error", "success"]); + expectTextOrder(rendered, [ + "Keyboard shortcuts", + "Edit app shortcuts by action.", + "mod+k", + "Reload", + "Failed to load shortcut settings.", + "Shortcut settings saved.", + "Chat composer", + "Config file", + "No actions registered.", + ]); + }); + + it("keeps the prompt-enter card before the loading shortcuts state", () => { + const panel = new SettingsShortcutsPanel(); + panel.loading = true; + + const rendered = flattenTemplateContent(panel.render()); + + expectTextOrder(rendered, ["Keyboard shortcuts", "Chat composer", "Loading shortcuts…"]); + expect(rendered).not.toContain("Config file"); + }); +}); + +function frameNotices(template: TemplateResult): readonly SettingsNotice[] { + const notices = collectTemplateValues(template).find(isSettingsNoticeArray); + if (notices === undefined) throw new Error("Expected settings-panel-frame notices to be rendered"); + return notices; +} + +function flattenTemplateContent(template: TemplateResult): string { + const chunks: string[] = []; + visitTemplate(template); + return chunks.join(""); + + function visitTemplate(current: TemplateResult): void { + const strings = templateStrings(current); + const values = templateValues(current); + for (let index = 0; index < values.length; index += 1) { + const staticChunk = strings[index]; + if (staticChunk !== undefined) chunks.push(staticChunk); + visitValue(values[index]); + } + const finalChunk = strings[values.length]; + if (finalChunk !== undefined) chunks.push(finalChunk); + } + + function visitValue(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) visitValue(item); + return; + } + if (isSettingsNotice(value)) { + visitValue(value.title); + visitValue(value.content); + return; + } + if (isTemplateResult(value)) { + visitTemplate(value); + return; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + chunks.push(String(value)); + } + } +} + +function collectTemplateValues(template: TemplateResult): unknown[] { + const values: unknown[] = []; + visit(template); + return values; + + function visit(current: unknown): void { + if (Array.isArray(current)) { + for (const item of current) visit(item); + return; + } + if (!isTemplateResult(current)) return; + for (const value of templateValues(current)) { + values.push(value); + visit(value); + } + } +} + +function expectTextOrder(content: string, labels: readonly string[]): void { + let previousIndex = -1; + for (const label of labels) { + const currentIndex = content.indexOf(label, previousIndex + 1); + if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`); + expect(currentIndex).toBeGreaterThan(previousIndex); + previousIndex = currentIndex; + } +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isSettingsNotice(value: unknown): value is SettingsNotice { + return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content"); +} + +function isSettingsNoticeArray(value: unknown): value is readonly SettingsNotice[] { + return Array.isArray(value) && value.every(isSettingsNotice); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} + +function configResponse(config: PiWebConfigValues): PiWebConfigResponse { + return { + path: "/tmp/pi-web/config.json", + exists: true, + config, + effectiveConfig: config, + envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false }, + }; +} diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.ts b/src/client/src/components/settings/SettingsShortcutsPanel.ts index f937ede..6d8c172 100644 --- a/src/client/src/components/settings/SettingsShortcutsPanel.ts +++ b/src/client/src/components/settings/SettingsShortcutsPanel.ts @@ -4,6 +4,8 @@ import type { AppAction } from "../../actions"; import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api"; import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts"; import { readPromptEnterPreference, writePromptEnterPreference, type PromptEnterPreference } from "../../promptEnterBehavior"; +import "./SettingsPanelFrame"; +import type { SettingsNotice } from "./SettingsPanelFrame"; const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; @@ -25,6 +27,10 @@ const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: stri }, ]; +function renderShortcutsDescription(): TemplateResult { + return html`Edit app shortcuts by action. Type a shortcut such as mod+k or mod+g p, record one from the keyboard, disable it with None, or reset it to the default. When shortcuts conflict, custom shortcuts win before defaults; ties are resolved by action id, and shorter shortcuts shadow longer sequences with the same prefix.`; +} + @customElement("settings-shortcuts-panel") export class SettingsShortcutsPanel extends LitElement { @property({ attribute: false }) actions: AppAction[] = []; @@ -87,38 +93,40 @@ export class SettingsShortcutsPanel extends LitElement { const groups = shortcutGroups(this.actions); const shortcutResolutions = this.shortcutResolutions(); return html` -
-
-

Keyboard shortcuts

-

Edit app shortcuts by action. Type a shortcut such as mod+k or mod+g p, record one from the keyboard, disable it with None, or reset it to the default. When shortcuts conflict, custom shortcuts win before defaults; ties are resolved by action id, and shorter shortcuts shadow longer sequences with the same prefix.

-
- -
- ${this.renderMessages()} - ${this.renderPromptEnterPreferenceCard()} - ${this.configResponse === undefined && this.loading ? html`
Loading shortcuts…
` : html` -
- Config file - ${this.configResponse?.path ?? "Unknown"} - Shortcut overrides are saved under shortcuts. A value of null disables the action shortcut. -
- ${groups.length === 0 ? html`
No actions registered.
` : groups.map((group) => html` -
-

${group.name}

-
- ${group.actions.map((action) => this.renderShortcutRow(action, shortcutResolutions.get(action.id)))} -
-
- `)} - `} + + ${this.renderPromptEnterPreferenceCard()} + ${this.configResponse === undefined && this.loading ? html`
Loading shortcuts…
` : html` +
+ Config file + ${this.configResponse?.path ?? "Unknown"} + Shortcut overrides are saved under shortcuts. A value of null disables the action shortcut. +
+ ${groups.length === 0 ? html`
No actions registered.
` : groups.map((group) => html` +
+

${group.name}

+
+ ${group.actions.map((action) => this.renderShortcutRow(action, shortcutResolutions.get(action.id)))} +
+
+ `)} + `} +
`; } - private renderMessages(): TemplateResult | null { + private panelNotices(): readonly SettingsNotice[] { + const notices: SettingsNotice[] = []; const error = this.localError || this.error; - if (error !== "") return html`
${error}
`; - if (this.savedMessage !== "") return html`
${this.savedMessage}
`; - return null; + if (error !== "") notices.push({ type: "error", content: error }); + if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage }); + return notices; } private renderPromptEnterPreferenceCard(): TemplateResult { @@ -335,25 +343,18 @@ export class SettingsShortcutsPanel extends LitElement { static override styles = css` :host { display: block; } - .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } - .section-heading > div { display: grid; gap: 6px; min-width: 0; } - h2, h3, p { margin: 0; } - h2 { font-size: 17px; line-height: 1.25; } + h3, p { margin: 0; } h3 { font-size: 13px; line-height: 1.3; } p { color: var(--pi-muted); line-height: 1.45; } button, input { font: inherit; } button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } button:disabled, input:disabled { opacity: .55; cursor: not-allowed; } .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); } - .secondary { flex: 0 0 auto; } - .message, .loading-card, .config-path-card, .prompt-enter-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } - .message { margin-bottom: 12px; } - .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } - .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } + .loading-card, .config-path-card, .prompt-enter-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } .loading-card, .config-path-card { color: var(--pi-muted); } - .config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } + .config-path-card { display: grid; gap: 5px; } .config-path-card span, .card-eyebrow { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } - .prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; margin-bottom: 14px; } + .prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; } .prompt-enter-copy { display: grid; gap: 5px; min-width: 0; } .prompt-enter-copy p, .prompt-enter-option small { font-size: 12px; } .prompt-enter-options { display: grid; gap: 7px; } @@ -363,7 +364,7 @@ export class SettingsShortcutsPanel extends LitElement { .prompt-enter-option span { display: grid; gap: 2px; } .prompt-enter-option small { color: var(--pi-muted); line-height: 1.35; } code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } - .shortcut-group { margin: 0 0 16px; } + .shortcut-group { margin: 0; } .shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } .shortcut-list { border: 1px solid var(--pi-border); border-radius: 10px; overflow: hidden; } .shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 48%); gap: 14px; align-items: start; padding: 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); } @@ -392,8 +393,6 @@ export class SettingsShortcutsPanel extends LitElement { .recording-hint { color: var(--pi-accent); font-size: 12px; } @media (max-width: 760px) { - .section-heading { display: grid; gap: 12px; } - .section-heading .secondary { justify-self: start; } .prompt-enter-card { grid-template-columns: minmax(0, 1fr); } .shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; } .shortcut-status, .shortcut-actions { justify-content: flex-start; }