diff --git a/src/client/src/components/settings/SettingsSessiondPanel.test.ts b/src/client/src/components/settings/SettingsSessiondPanel.test.ts
index 60d692d..3696f5a 100644
--- a/src/client/src/components/settings/SettingsSessiondPanel.test.ts
+++ b/src/client/src/components/settings/SettingsSessiondPanel.test.ts
@@ -1,66 +1,77 @@
import { describe, expect, it, vi } from "vitest";
-import type { TemplateResult } from "lit";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
-import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
-import type { SettingsNotice } from "./SettingsPanelFrame";
+import { SettingsSessiondPanel, sessiondDescription, sessiondPanelNotices, type SessiondPanelNoticeContext } from "./SettingsSessiondPanel";
-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)";
- setPanelConfig(panel, configResponse({
+// This suite asserts the session-daemon panel's dynamic behavior through public
+// seams rather than by inspecting rendered Lit `TemplateResult` internals:
+// notice composition/ordering and the description string come from the exported
+// `sessiondPanelNotices`/`sessiondDescription` helpers, and profile-save and
+// draft-preservation behavior are observed via injected callbacks and public
+// state. Static labels and layout are intentionally not asserted here (no DOM
+// harness); per the testing-guide skill those are not verified by scraping
+// template internals.
+
+describe("session daemon panel notices", () => {
+ it("names the selected machine in the scope description and restart notice", () => {
+ const targetLabel = "Lab Mac (remote machine)";
+ const config = configResponse({
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
spawnSessions: true,
subsessions: false,
+ });
+
+ expect(sessiondDescription(targetLabel)).toContain("Lab Mac (remote machine)");
+
+ const notices = sessiondPanelNotices(config, noticeContext({
+ activeProfile: activeProfile("pi", "/srv/pi"),
+ targetLabel,
}));
- panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
- const rendered = flattenTemplateContent(panel.render());
+ expect(notices).toHaveLength(1);
+ expect(notices[0]?.type).toBe("warning");
+ expect(notices[0]?.title).toBe("Pi-compatible agent profile restart required on Lab Mac (remote machine)");
+ expect(notices[0]?.content).not.toBe("");
+ });
- expectTextOrder(rendered, [
- "Session daemon",
- "Select the Pi-compatible agent profile and companion CLI for Lab Mac (remote machine).",
- "Reload",
- "Pi-compatible agent profile restart required on Lab Mac (remote machine)",
- "Run pi-web restart on that machine",
- "Config file",
- "Companion CLI command",
- "agent-lab",
- "Profile state directory",
- "/srv/agent-lab",
- "Allow agents to start sessions",
+ it("orders save/load notices before the restart notice", () => {
+ const config = configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: false });
+
+ const notices = sessiondPanelNotices(config, noticeContext({
+ activeProfile: activeProfile("pi", "/srv/pi"),
+ error: "Failed to save session-daemon config.",
+ savedMessage: "Session daemon settings saved.",
+ }));
+
+ expect(notices.map((notice) => notice.type)).toEqual(["error", "success", "warning"]);
+ expect(notices[0]?.content).toBe("Failed to save session-daemon config.");
+ expect(notices[1]?.content).toBe("Session daemon settings saved.");
+ expect(notices[2]?.title).toBe("Pi-compatible agent profile restart required on local (local gateway)");
+ });
+
+ it("adds no restart or activation guidance when the desired and active profiles match", () => {
+ const config = configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
+
+ const notices = sessiondPanelNotices(config, noticeContext({
+ activeProfile: activeProfile("agent-lab", "/srv/agent-lab"),
+ }));
+
+ expect(notices).toEqual([]);
+ });
+
+ it("reports only the blocking error and no activation guidance when config is unavailable", () => {
+ const notices = sessiondPanelNotices(undefined, noticeContext({
+ activeProfile: undefined,
+ error: "Selected-machine settings are not available on Lab Mac.",
+ targetLabel: "Lab Mac (remote machine)",
+ }));
+
+ expect(notices).toEqual([
+ { type: "error", content: "Selected-machine settings are not available on Lab Mac." },
]);
});
+});
- it("orders save/load notices before the restart notice and settings content", () => {
- const panel = new SettingsSessiondPanel();
- setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: false }));
- panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
- 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.",
- "Pi-compatible agent profile restart required on local (local gateway)",
- "Config file",
- ]);
- });
-
- it("shows the profile as active without restart guidance when desired and active match", () => {
- const panel = new SettingsSessiondPanel();
- setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }));
- panel.activeAgentProfile = activeProfile("agent-lab", "/srv/agent-lab");
-
- const rendered = flattenTemplateContent(panel.render());
-
- expect(rendered).toContain("Profile status");
- expect(rendered).toContain("Active");
- expect(rendered).not.toContain("restart required on");
- });
-
+describe("session daemon panel save behavior", () => {
it("submits command and directory together as one profile save", async () => {
const panel = new SettingsSessiondPanel();
const onSave = vi.fn();
@@ -92,25 +103,19 @@ describe("settings-sessiond-panel layout", () => {
callPanelMethod(panel, "willUpdate", new Map([["configResponse", toggled]]));
expect(Reflect.get(panel, "agentDraftDirty")).toBe(false);
});
-
- 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 noticeContext(overrides: Partial): SessiondPanelNoticeContext {
+ return {
+ error: "",
+ savedMessage: "",
+ activeProfile: undefined,
+ targetLabel: "local (local gateway)",
+ profileEditingSupported: true,
+ ...overrides,
+ };
+}
+
function activeProfile(command: string, dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
@@ -142,81 +147,6 @@ function callPanelMethod(panel: SettingsSessiondPanel, methodName: string, ...ar
return Reflect.apply(method, panel, args);
}
-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");
- 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 configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts
index 72adc5f..e15e489 100644
--- a/src/client/src/components/settings/SettingsSessiondPanel.ts
+++ b/src/client/src/components/settings/SettingsSessiondPanel.ts
@@ -159,25 +159,13 @@ export class SettingsSessiondPanel extends LitElement {
}
private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] {
- const notices: SettingsNotice[] = [];
- const error = this.agentLocalError || this.error;
- if (error !== "") notices.push({ type: "error", content: error });
- if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
- const activation = agentProfileActivationState(config, this.activeAgentProfile);
- if (activation === "restart-required") {
- notices.push({
- type: "warning",
- title: `Pi-compatible agent profile restart required on ${this.targetLabel}`,
- content: html`The desired profile differs from the active session-daemon profile. Run pi-web restart on that machine (or restart its session daemon service) to apply the command and state directory together.`,
- });
- } else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") {
- notices.push({
- type: "info",
- title: `Active Pi-compatible agent profile unavailable on ${this.targetLabel}`,
- content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.",
- });
- }
- return notices;
+ return sessiondPanelNotices(config, {
+ error: this.agentLocalError || this.error,
+ savedMessage: this.savedMessage,
+ activeProfile: this.activeAgentProfile,
+ targetLabel: this.targetLabel,
+ profileEditingSupported: this.agentProfileSupport.state === "supported",
+ });
}
private renderUnavailableConfigState(): TemplateResult {
@@ -273,6 +261,44 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
-function sessiondDescription(targetLabel: string): string {
+export function sessiondDescription(targetLabel: string): string {
return `Select the Pi-compatible agent profile and companion CLI for ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
}
+
+export interface SessiondPanelNoticeContext {
+ readonly error: string;
+ readonly savedMessage: string;
+ readonly activeProfile: ActiveAgentProfileDescriptor | undefined;
+ readonly targetLabel: string;
+ readonly profileEditingSupported: boolean;
+}
+
+/**
+ * Compute the session-daemon panel's notice stack (error, saved, and
+ * profile-activation guidance) as a pure, publicly testable seam so tests assert
+ * the dynamic notice logic and ordering here instead of scraping rendered
+ * `TemplateResult` internals.
+ */
+export function sessiondPanelNotices(
+ config: PiWebConfigResponse | undefined,
+ context: SessiondPanelNoticeContext,
+): readonly SettingsNotice[] {
+ const notices: SettingsNotice[] = [];
+ if (context.error !== "") notices.push({ type: "error", content: context.error });
+ if (context.savedMessage !== "") notices.push({ type: "success", content: context.savedMessage });
+ const activation = agentProfileActivationState(config, context.activeProfile);
+ if (activation === "restart-required") {
+ notices.push({
+ type: "warning",
+ title: `Pi-compatible agent profile restart required on ${context.targetLabel}`,
+ content: html`The desired profile differs from the active session-daemon profile. Run pi-web restart on that machine (or restart its session daemon service) to apply the command and state directory together.`,
+ });
+ } else if (config !== undefined && activation === "unavailable" && context.profileEditingSupported) {
+ notices.push({
+ type: "info",
+ title: `Active Pi-compatible agent profile unavailable on ${context.targetLabel}`,
+ content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.",
+ });
+ }
+ return notices;
+}