Archived
Merge remote-tracking branch 'origin/main' into feat/docker-runtime-host-admin
# Conflicts: # src/client/src/api.ts # src/client/src/api/parsers.test.ts # src/shared/piWebStatusParsing.test.ts
This commit is contained in:
@@ -11,6 +11,9 @@
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--pi-control-font-size: 16px;
|
||||
--pi-control-font-family: system-ui, sans-serif;
|
||||
--pi-control-monospace-font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--pi-bg: #0d1117;
|
||||
--pi-surface: #161b22;
|
||||
--pi-surface-hover: #21262d;
|
||||
|
||||
@@ -5,5 +5,7 @@ export interface AppAction {
|
||||
shortcut?: string;
|
||||
group?: string;
|
||||
enabled?: boolean;
|
||||
/** When present on a disabled action, keep it visible and explain why it cannot run. */
|
||||
disabledReason?: string;
|
||||
run: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
|
||||
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w/1",
|
||||
@@ -60,7 +60,143 @@ describe("machine-scoped runtime API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings config and plugin APIs", () => {
|
||||
it("preserves gateway config and plugin routes by default", async () => {
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse(piWebConfigResponse({ host: "127.0.0.1" })),
|
||||
jsonResponse(piWebConfigResponse({ spawnSessions: true })),
|
||||
jsonResponse(piWebPluginsResponse()),
|
||||
]);
|
||||
|
||||
await expect(configApi.config()).resolves.toMatchObject({ config: { host: "127.0.0.1" } });
|
||||
await expect(configApi.saveConfig({ spawnSessions: true })).resolves.toMatchObject({ config: { spawnSessions: true } });
|
||||
await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse());
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/config",
|
||||
"/api/config",
|
||||
"/api/plugins",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
|
||||
});
|
||||
|
||||
it("uses machine-scoped config and plugin routes when a machine id is provided", async () => {
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse(piWebConfigResponse({ spawnSessions: false })),
|
||||
jsonResponse(piWebConfigResponse({ spawnSessions: true })),
|
||||
jsonResponse(piWebPluginsResponse()),
|
||||
]);
|
||||
|
||||
await expect(configApi.config("remote a")).resolves.toMatchObject({ config: { spawnSessions: false } });
|
||||
await expect(configApi.saveConfig({ spawnSessions: true }, "remote a")).resolves.toMatchObject({ config: { spawnSessions: true } });
|
||||
await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse());
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/machines/remote%20a/config",
|
||||
"/api/machines/remote%20a/config",
|
||||
"/api/machines/remote%20a/plugins",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi package API", () => {
|
||||
it("preserves the legacy local Pi package-management routes by default", async () => {
|
||||
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse({ packages }),
|
||||
jsonResponse({ action: "install", source: "npm:@acme/new-tools", packages }),
|
||||
jsonResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages }),
|
||||
jsonResponse({ action: "update", source: "npm:@acme/tools", packages }),
|
||||
jsonResponse({ action: "update", packages }),
|
||||
]);
|
||||
|
||||
await expect(piPackagesApi.packages()).resolves.toEqual({ packages });
|
||||
await piPackagesApi.install("npm:@acme/new-tools");
|
||||
await piPackagesApi.remove("../project-tools", "project");
|
||||
await piPackagesApi.update("npm:@acme/tools");
|
||||
await piPackagesApi.update();
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/pi-packages",
|
||||
"/api/pi-packages/install",
|
||||
"/api/pi-packages/remove",
|
||||
"/api/pi-packages/update",
|
||||
"/api/pi-packages/update",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" });
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "../project-tools", scope: "project" });
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "npm:@acme/tools" });
|
||||
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses machine-scoped Pi package-management routes when a machine id is provided", async () => {
|
||||
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse({ packages }),
|
||||
jsonResponse({ packages }),
|
||||
jsonResponse({ action: "install", source: "npm:@acme/new-tools", packages }),
|
||||
jsonResponse({ action: "remove", source: "../project-tools", removed: true, packages }),
|
||||
jsonResponse({ action: "update", packages }),
|
||||
]);
|
||||
|
||||
await expect(piPackagesApi.packages("local")).resolves.toEqual({ packages });
|
||||
await expect(piPackagesApi.packages("remote a")).resolves.toEqual({ packages });
|
||||
await piPackagesApi.install("npm:@acme/new-tools", "remote a");
|
||||
await piPackagesApi.remove("../project-tools", undefined, "remote a");
|
||||
await piPackagesApi.update(undefined, "remote a");
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/machines/local/pi-packages",
|
||||
"/api/machines/remote%20a/pi-packages",
|
||||
"/api/machines/remote%20a/pi-packages/install",
|
||||
"/api/machines/remote%20a/pi-packages/remove",
|
||||
"/api/machines/remote%20a/pi-packages/update",
|
||||
]);
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "npm:@acme/new-tools" });
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "../project-tools" });
|
||||
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session API compatibility", () => {
|
||||
it("posts session cleanup preview and execute requests through the selected machine", async () => {
|
||||
const preview = { generatedAt: "2026-06-25T12:00:00.000Z", thresholds: { archiveIdleDays: 7 }, projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } };
|
||||
const executed = { ...preview, archivedSessionIds: ["s1", "s2"], deletedSessionIds: [] };
|
||||
const fetchMock = stubSequenceFetch([jsonResponse(preview), jsonResponse(executed)]);
|
||||
|
||||
await expect(sessionsApi.cleanupPreview({ archiveIdleDays: 7, deleteArchivedDays: null }, "remote a")).resolves.toEqual(preview);
|
||||
await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/cleanup/preview");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null });
|
||||
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/cleanup");
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] });
|
||||
});
|
||||
|
||||
it("posts bulk session mutation requests through the selected machine", async () => {
|
||||
const archived = { archived: true, archivedSessionIds: ["s 1"], failures: [{ sessionId: "s 2", error: "busy" }], generatedAt: "now" };
|
||||
const deleted = { deleted: true, deletedSessionIds: ["s 1"], failures: [], generatedAt: "later" };
|
||||
const fetchMock = stubSequenceFetch([jsonResponse(archived), jsonResponse(deleted)]);
|
||||
|
||||
await expect(sessionsApi.archiveMany([{ id: "s 1", cwd: "/repo" }, "s 2"], "remote a")).resolves.toEqual(archived);
|
||||
await expect(sessionsApi.deleteArchivedMany([{ id: "s 1", cwd: "/repo" }], "remote a")).resolves.toEqual(deleted);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/bulk/archive");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }, { id: "s 2" }] });
|
||||
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/bulk/delete-archived");
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
|
||||
});
|
||||
|
||||
it("keeps legacy session-id calls free of cwd context", async () => {
|
||||
const fetchMock = stubJsonFetch({ accepted: true });
|
||||
|
||||
@@ -264,6 +400,20 @@ function requestBody(init: RequestInit | undefined): string {
|
||||
return init.body;
|
||||
}
|
||||
|
||||
function piWebConfigResponse(config: PiWebConfigValues) {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
|
||||
function piWebPluginsResponse() {
|
||||
return { plugins: [{ id: "info", module: "/pi-web-plugins/info/plugin.js", source: "test", scope: "local", machineSpecific: false, enabled: true }] };
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
arrayOf,
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
parseModelSelectionResponse,
|
||||
parseMoveWorkspaceFileResponse,
|
||||
parseOAuthFlowState,
|
||||
parsePiPackageMutationResponse,
|
||||
parsePiPackagesResponse,
|
||||
parsePiWebConfigResponse,
|
||||
parsePiWebPluginsResponse,
|
||||
parsePiWebRuntimeResponse,
|
||||
@@ -32,6 +34,10 @@ import {
|
||||
parseReloaded,
|
||||
parseRestored,
|
||||
parseSavedAttachments,
|
||||
parseSessionBulkArchiveResponse,
|
||||
parseSessionBulkDeleteArchivedResponse,
|
||||
parseSessionCleanupExecuteResponse,
|
||||
parseSessionCleanupPreviewResponse,
|
||||
parseSessionInfo,
|
||||
parseSessionStatus,
|
||||
parseSlashCommand,
|
||||
@@ -83,6 +89,16 @@ function sessionBody(session: SessionLookup, fields: Record<string, unknown> = {
|
||||
return JSON.stringify(cwd === undefined || cwd === "" ? fields : { cwd, ...fields });
|
||||
}
|
||||
|
||||
function sessionBulkMutationBody(sessions: readonly SessionLookup[]): string {
|
||||
return JSON.stringify({ sessions: sessions.map(sessionBulkMutationRef) });
|
||||
}
|
||||
|
||||
function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef {
|
||||
const id = sessionId(session);
|
||||
const cwd = sessionCwd(session);
|
||||
return cwd === undefined || cwd === "" ? { id } : { id, cwd };
|
||||
}
|
||||
|
||||
export const piWebApi = {
|
||||
piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse),
|
||||
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
|
||||
@@ -96,13 +112,42 @@ export const machinesApi = {
|
||||
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
|
||||
};
|
||||
|
||||
function configUrl(machineId?: string): string {
|
||||
return machineId === undefined ? "/api/config" : `${machinePrefix(machineId)}/config`;
|
||||
}
|
||||
|
||||
function pluginsUrl(machineId?: string): string {
|
||||
return machineId === undefined ? "/api/plugins" : `${machinePrefix(machineId)}/plugins`;
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
config: () => request("/api/config", parsePiWebConfigResponse),
|
||||
saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
||||
config: (machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse),
|
||||
saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
||||
};
|
||||
|
||||
export const pluginsApi = {
|
||||
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
|
||||
plugins: (machineId?: string) => request(pluginsUrl(machineId), parsePiWebPluginsResponse),
|
||||
};
|
||||
|
||||
function piPackageUrl(endpoint = "", machineId?: string): string {
|
||||
const baseUrl = machineId === undefined ? "/api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`;
|
||||
return endpoint === "" ? baseUrl : `${baseUrl}/${endpoint}`;
|
||||
}
|
||||
|
||||
export const piPackagesApi = {
|
||||
packages: (machineId?: string) => request(piPackageUrl("", machineId), parsePiPackagesResponse),
|
||||
install: (source: string, machineId?: string) => {
|
||||
const body: PiPackageInstallRequest = { source };
|
||||
return request(piPackageUrl("install", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
},
|
||||
remove: (source: string, scope?: PiPackageScope, machineId?: string) => {
|
||||
const body: PiPackageRemoveRequest = scope === undefined ? { source } : { source, scope };
|
||||
return request(piPackageUrl("remove", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
},
|
||||
update: (source?: string, machineId?: string) => {
|
||||
const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source };
|
||||
return request(piPackageUrl("update", machineId), parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
},
|
||||
};
|
||||
|
||||
export const activityApi = {
|
||||
@@ -152,6 +197,10 @@ export const workspacesApi = {
|
||||
export const sessionsApi = {
|
||||
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
||||
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
|
||||
deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
|
||||
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage),
|
||||
status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus),
|
||||
models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse),
|
||||
@@ -267,6 +316,7 @@ export const api = {
|
||||
...machinesApi,
|
||||
...configApi,
|
||||
...pluginsApi,
|
||||
...piPackagesApi,
|
||||
...activityApi,
|
||||
...projectsApi,
|
||||
...workspacesApi,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Workspace } from "../../../shared/apiTypes";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||
import { activityApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { activityApi, configApi, filesApi, gitApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||
import { workspaceImagePreviewUrl } from "./urls";
|
||||
|
||||
@@ -28,6 +28,13 @@ describe("federated route contract", () => {
|
||||
|
||||
await Promise.all([
|
||||
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
|
||||
ignoreParseFailure(configApi.config(machineId)),
|
||||
ignoreParseFailure(configApi.saveConfig({ spawnSessions: true }, machineId)),
|
||||
ignoreParseFailure(pluginsApi.plugins(machineId)),
|
||||
ignoreParseFailure(piPackagesApi.packages(machineId)),
|
||||
ignoreParseFailure(piPackagesApi.install("npm:@acme/tools", machineId)),
|
||||
ignoreParseFailure(piPackagesApi.remove("npm:@acme/tools", "user", machineId)),
|
||||
ignoreParseFailure(piPackagesApi.update("npm:@acme/tools", machineId)),
|
||||
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
|
||||
ignoreParseFailure(projectsApi.projects(machineId)),
|
||||
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
||||
@@ -46,6 +53,10 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
|
||||
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
|
||||
ignoreParseFailure(sessionsApi.cleanupPreview({ archiveIdleDays: 14 }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.cleanup({ archiveIdleDays: 14, deleteArchivedDays: 30, projectCwds: ["/repo"] }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.archiveMany([session], machineId)),
|
||||
ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)),
|
||||
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.status(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.models(session, machineId)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
|
||||
|
||||
describe("API parsers", () => {
|
||||
it("parses PI WEB config responses", () => {
|
||||
@@ -24,11 +24,33 @@ describe("API parsers", () => {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
|
||||
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
|
||||
});
|
||||
|
||||
it("parses Pi package list and mutation responses", () => {
|
||||
const packages = [
|
||||
{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" },
|
||||
{ source: "../project-tools", scope: "project", filtered: true },
|
||||
];
|
||||
|
||||
expect(parsePiPackagesResponse({ packages })).toEqual({ packages });
|
||||
expect(parsePiPackageMutationResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages })).toEqual({
|
||||
action: "remove",
|
||||
source: "../project-tools",
|
||||
scope: "project",
|
||||
removed: true,
|
||||
packages,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed Pi package responses", () => {
|
||||
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "global", filtered: false }] })).toThrow("Invalid Pi package scope");
|
||||
expect(() => parsePiPackageMutationResponse({ action: "sync", packages: [] })).toThrow("Invalid Pi package mutation action");
|
||||
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: "no" }] })).toThrow("Expected boolean field: filtered");
|
||||
});
|
||||
|
||||
it("parses Docker PI WEB installation metadata", () => {
|
||||
@@ -71,9 +93,81 @@ describe("API parsers", () => {
|
||||
expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 });
|
||||
});
|
||||
|
||||
it("parses session cleanup preview and execute responses", () => {
|
||||
const preview = {
|
||||
generatedAt: "2026-06-25T12:00:00.000Z",
|
||||
thresholds: { archiveIdleDays: 14, deleteArchivedDays: 30 },
|
||||
projects: [
|
||||
{ cwd: "/repo-a", archiveCount: 2, deleteCount: 1 },
|
||||
{ cwd: "/repo-b", archiveCount: 0, deleteCount: 3 },
|
||||
],
|
||||
totals: { archiveCount: 2, deleteCount: 4 },
|
||||
skippedBusySessionIds: ["busy-1"],
|
||||
};
|
||||
|
||||
expect(parseSessionCleanupPreviewResponse(preview)).toEqual(preview);
|
||||
expect(parseSessionCleanupExecuteResponse({ ...preview, archivedSessionIds: ["s1", "s2"], deletedSessionIds: ["a1"] })).toEqual({
|
||||
...preview,
|
||||
archivedSessionIds: ["s1", "s2"],
|
||||
deletedSessionIds: ["a1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed session cleanup responses", () => {
|
||||
expect(() => parseSessionCleanupPreviewResponse({ generatedAt: "now", thresholds: {}, projects: [{ cwd: "/repo", archiveCount: "2", deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } })).toThrow("Expected number field: archiveCount");
|
||||
expect(() => parseSessionCleanupExecuteResponse({ generatedAt: "now", thresholds: {}, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: ["s1"], deletedSessionIds: [1] })).toThrow("Expected string array field: deletedSessionIds");
|
||||
});
|
||||
|
||||
it("parses bulk session mutation responses", () => {
|
||||
const failure = { sessionId: "busy", error: "Session is busy" };
|
||||
expect(parseSessionBulkArchiveResponse({ archived: true, archivedSessionIds: ["s1"], failures: [failure], generatedAt: "now" })).toEqual({
|
||||
archived: true,
|
||||
archivedSessionIds: ["s1"],
|
||||
failures: [failure],
|
||||
generatedAt: "now",
|
||||
});
|
||||
expect(parseSessionBulkDeleteArchivedResponse({ deleted: true, deletedSessionIds: ["s2"], failures: [], generatedAt: "later" })).toEqual({
|
||||
deleted: true,
|
||||
deletedSessionIds: ["s2"],
|
||||
failures: [],
|
||||
generatedAt: "later",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed bulk session mutation responses", () => {
|
||||
expect(() => parseSessionBulkArchiveResponse({ archived: true, archivedSessionIds: ["s1"], failures: [{ sessionId: "s2" }], generatedAt: "now" })).toThrow("Expected string field: error");
|
||||
expect(() => parseSessionBulkDeleteArchivedResponse({ deleted: true, deletedSessionIds: [1], failures: [], generatedAt: "now" })).toThrow("Expected string array field: deletedSessionIds");
|
||||
});
|
||||
|
||||
it("parses session info including optional persistence signals", () => {
|
||||
expect(parseSessionInfo({
|
||||
id: "s1",
|
||||
path: "/sessions/s1.jsonl",
|
||||
cwd: "/repo",
|
||||
persisted: false,
|
||||
name: "Draft session",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:01:00.000Z",
|
||||
messageCount: 0,
|
||||
firstMessage: "",
|
||||
})).toEqual({
|
||||
id: "s1",
|
||||
path: "/sessions/s1.jsonl",
|
||||
cwd: "/repo",
|
||||
persisted: false,
|
||||
name: "Draft session",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:01:00.000Z",
|
||||
messageCount: 0,
|
||||
firstMessage: "",
|
||||
});
|
||||
expect(() => parseSessionInfo({ id: "s1", path: "", cwd: "/repo", persisted: "yes", created: "now", modified: "now", messageCount: 0, firstMessage: "" })).toThrow("Expected optional boolean field: persisted");
|
||||
});
|
||||
|
||||
it("validates session status including optional model and nullable context usage", () => {
|
||||
expect(parseSessionStatus({
|
||||
sessionId: "s1",
|
||||
persisted: true,
|
||||
isStreaming: false,
|
||||
isCompacting: true,
|
||||
isBashRunning: false,
|
||||
@@ -87,6 +181,7 @@ describe("API parsers", () => {
|
||||
thinkingLevel: "medium",
|
||||
})).toEqual({
|
||||
sessionId: "s1",
|
||||
persisted: true,
|
||||
isStreaming: false,
|
||||
isCompacting: true,
|
||||
isBashRunning: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import { isPiWebCapability } from "../../../shared/capabilities";
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
|
||||
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
@@ -156,12 +157,14 @@ function optionalWorkspaceEffectiveConfig(value: unknown): Workspace["effectiveC
|
||||
export function parseSessionInfo(value: unknown): SessionInfo {
|
||||
const record = requireRecord(value);
|
||||
const name = optionalString(record, "name");
|
||||
const persisted = parseOptionalBoolean(record["persisted"], "persisted");
|
||||
const parentSessionPath = optionalString(record, "parentSessionPath");
|
||||
const archivedAt = optionalString(record, "archivedAt");
|
||||
return {
|
||||
id: requireString(record, "id"),
|
||||
path: requireString(record, "path"),
|
||||
cwd: requireString(record, "cwd"),
|
||||
...(persisted === undefined ? {} : { persisted }),
|
||||
...(name === undefined ? {} : { name }),
|
||||
created: requireString(record, "created"),
|
||||
modified: requireString(record, "modified"),
|
||||
@@ -177,6 +180,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
sessionId: requireString(record, "sessionId"),
|
||||
...optionalField("persisted", parseOptionalBoolean(record["persisted"], "persisted")),
|
||||
isStreaming: requireBoolean(record, "isStreaming"),
|
||||
isCompacting: requireBoolean(record, "isCompacting"),
|
||||
isBashRunning: requireBoolean(record, "isBashRunning"),
|
||||
@@ -191,6 +195,79 @@ export function parseSessionStatus(value: unknown): SessionStatus {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionCleanupPreviewResponse(value: unknown): SessionCleanupPreviewResponse {
|
||||
const record = requireRecord(value);
|
||||
const skippedBusySessionIds = record["skippedBusySessionIds"] === undefined ? undefined : arrayOfString(record["skippedBusySessionIds"], "skippedBusySessionIds");
|
||||
return {
|
||||
generatedAt: requireString(record, "generatedAt"),
|
||||
thresholds: parseSessionCleanupThresholds(record["thresholds"]),
|
||||
projects: arrayOf(parseSessionCleanupProjectSummary)(record["projects"]),
|
||||
totals: parseSessionCleanupTotals(record["totals"]),
|
||||
...(skippedBusySessionIds === undefined ? {} : { skippedBusySessionIds }),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionCleanupExecuteResponse(value: unknown): SessionCleanupExecuteResponse {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
...parseSessionCleanupPreviewResponse(record),
|
||||
archivedSessionIds: arrayOfString(record["archivedSessionIds"], "archivedSessionIds"),
|
||||
deletedSessionIds: arrayOfString(record["deletedSessionIds"], "deletedSessionIds"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionBulkArchiveResponse(value: unknown): SessionBulkArchiveResponse {
|
||||
const record = requireRecord(value);
|
||||
if (record["archived"] !== true) throw new Error("Expected bulk archived response");
|
||||
return {
|
||||
archived: true,
|
||||
archivedSessionIds: arrayOfString(record["archivedSessionIds"], "archivedSessionIds"),
|
||||
failures: arrayOf(parseSessionBulkFailure)(record["failures"]),
|
||||
generatedAt: requireString(record, "generatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionBulkDeleteArchivedResponse(value: unknown): SessionBulkDeleteArchivedResponse {
|
||||
const record = requireRecord(value);
|
||||
if (record["deleted"] !== true) throw new Error("Expected bulk deleted response");
|
||||
return {
|
||||
deleted: true,
|
||||
deletedSessionIds: arrayOfString(record["deletedSessionIds"], "deletedSessionIds"),
|
||||
failures: arrayOf(parseSessionBulkFailure)(record["failures"]),
|
||||
generatedAt: requireString(record, "generatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionBulkFailure(value: unknown): SessionBulkFailure {
|
||||
const record = requireRecord(value);
|
||||
return { sessionId: requireString(record, "sessionId"), error: requireString(record, "error") };
|
||||
}
|
||||
|
||||
function parseSessionCleanupThresholds(value: unknown): SessionCleanupThresholds {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
...optionalField("archiveIdleDays", optionalNumber(record, "archiveIdleDays")),
|
||||
...optionalField("deleteArchivedDays", optionalNumber(record, "deleteArchivedDays")),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionCleanupProjectSummary(value: unknown): SessionCleanupProjectSummary {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
cwd: requireString(record, "cwd"),
|
||||
archiveCount: requireNumber(record, "archiveCount"),
|
||||
deleteCount: requireNumber(record, "deleteCount"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionCleanupTotals(value: unknown): SessionCleanupTotals {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
archiveCount: requireNumber(record, "archiveCount"),
|
||||
deleteCount: requireNumber(record, "deleteCount"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseQueuedSessionMessage(value: unknown): QueuedSessionMessage {
|
||||
const record = requireRecord(value);
|
||||
const kind = requireString(record, "kind");
|
||||
@@ -555,6 +632,45 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
|
||||
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
|
||||
}
|
||||
|
||||
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
|
||||
const record = requireRecord(value);
|
||||
return { packages: arrayOf(parsePiPackageInfo)(record["packages"]) };
|
||||
}
|
||||
|
||||
export function parsePiPackageMutationResponse(value: unknown): PiPackageMutationResponse {
|
||||
const record = requireRecord(value);
|
||||
const source = optionalString(record, "source");
|
||||
const scope = record["scope"] === undefined ? undefined : parsePiPackageScope(record["scope"]);
|
||||
const removed = parseOptionalBoolean(record["removed"], "removed");
|
||||
return {
|
||||
action: parsePiPackageMutationAction(record["action"]),
|
||||
...optionalField("source", source),
|
||||
...optionalField("scope", scope),
|
||||
...optionalField("removed", removed),
|
||||
packages: arrayOf(parsePiPackageInfo)(record["packages"]),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiPackageInfo(value: unknown): PiPackageInfo {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
source: requireString(record, "source"),
|
||||
scope: parsePiPackageScope(record["scope"]),
|
||||
filtered: requireBoolean(record, "filtered"),
|
||||
...optionalField("installedPath", optionalString(record, "installedPath")),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiPackageScope(value: unknown): PiPackageScope {
|
||||
if (value !== "user" && value !== "project") throw new Error("Invalid Pi package scope");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePiPackageMutationAction(value: unknown): PiPackageMutationAction {
|
||||
if (value !== "install" && value !== "remove" && value !== "update") throw new Error("Invalid Pi package mutation action");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
|
||||
const record = requireRecord(value);
|
||||
return { plugins: arrayOf(parsePiWebPluginInfo)(record["plugins"]) };
|
||||
@@ -700,8 +816,9 @@ function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent {
|
||||
}
|
||||
|
||||
function parsePiWebCapabilities(value: unknown): PiWebCapability[] {
|
||||
if (!Array.isArray(value) || !value.every(isPiWebCapability)) throw new Error("Invalid PI WEB capabilities");
|
||||
return value;
|
||||
const capabilities = parseKnownPiWebCapabilities(value);
|
||||
if (capabilities === undefined) throw new Error("Invalid PI WEB capabilities");
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
import type { QualifiedContributionId } from "./plugins/ids";
|
||||
import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
|
||||
@@ -20,6 +20,10 @@ export interface AppState {
|
||||
isReceivingPartialStream: boolean;
|
||||
/** Sessions with a prompt upload in flight, keyed by sessionId (client-owned). */
|
||||
sendingPrompts: Record<string, true>;
|
||||
/** Client-side queued sends waiting for a just-created backend session, keyed by sessionId. */
|
||||
clientQueuedSessionMessages: Record<string, QueuedSessionMessage[]>;
|
||||
/** Client-initiated session creation requests waiting for the server. */
|
||||
startingSessionCount: number;
|
||||
isLoadingProjects: boolean;
|
||||
isLoadingWorkspaces: boolean;
|
||||
selectedProject: Project | undefined;
|
||||
@@ -72,6 +76,8 @@ export type AuthDialogState =
|
||||
|
||||
export type WorkspaceScopedStateReset = Pick<AppState,
|
||||
| "sessions"
|
||||
| "clientQueuedSessionMessages"
|
||||
| "startingSessionCount"
|
||||
| "fileTree"
|
||||
| "expandedDirs"
|
||||
| "selectedFilePath"
|
||||
@@ -89,6 +95,8 @@ export type WorkspaceScopedStateReset = Pick<AppState,
|
||||
export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
|
||||
return {
|
||||
sessions: [],
|
||||
clientQueuedSessionMessages: {},
|
||||
startingSessionCount: 0,
|
||||
fileTree: [],
|
||||
expandedDirs: {},
|
||||
selectedFilePath: undefined,
|
||||
@@ -121,6 +129,8 @@ export function initialAppState(): AppState {
|
||||
isLoadingEarlierMessages: false,
|
||||
isReceivingPartialStream: false,
|
||||
sendingPrompts: {},
|
||||
clientQueuedSessionMessages: {},
|
||||
startingSessionCount: 0,
|
||||
isLoadingProjects: false,
|
||||
isLoadingWorkspaces: false,
|
||||
selectedProject: undefined,
|
||||
|
||||
@@ -48,6 +48,7 @@ export function stripCachedNewSessionMarker(session: SessionInfo): SessionInfo {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
cwd: session.cwd,
|
||||
...(session.persisted === undefined ? {} : { persisted: session.persisted }),
|
||||
...(session.name === undefined ? {} : { name: session.name }),
|
||||
created: session.created,
|
||||
modified: session.modified,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AppAction } from "../actions";
|
||||
import { filterActionPaletteActions } from "./ActionPalette";
|
||||
|
||||
describe("filterActionPaletteActions", () => {
|
||||
it("keeps disabled actions visible when they have an explanation", () => {
|
||||
const actions: AppAction[] = [
|
||||
action("enabled", "Enabled action"),
|
||||
action("hidden", "Disabled without reason", { enabled: false }),
|
||||
action("explained", "Disabled with reason", { enabled: false, disabledReason: "Update and restart the selected machine." }),
|
||||
];
|
||||
|
||||
expect(filterActionPaletteActions(actions, "").map((item) => item.id)).toEqual(["enabled", "explained"]);
|
||||
});
|
||||
|
||||
it("matches disabled reasons in search", () => {
|
||||
const actions: AppAction[] = [
|
||||
action("cleanup", "Clean Up Sessions", { enabled: false, disabledReason: "Selected server does not support cleanup." }),
|
||||
];
|
||||
|
||||
expect(filterActionPaletteActions(actions, "support cleanup").map((item) => item.id)).toEqual(["cleanup"]);
|
||||
});
|
||||
});
|
||||
|
||||
function action(id: string, title: string, patch: Partial<AppAction> = {}): AppAction {
|
||||
return { id, title, run: () => undefined, ...patch };
|
||||
}
|
||||
@@ -34,10 +34,11 @@ export class ActionPalette extends LitElement {
|
||||
</header>
|
||||
<div class="options">
|
||||
${actions.length === 0 ? html`<div class="empty">No actions found.</div>` : actions.map((action, index) => html`
|
||||
<button class=${index === this.selectedIndex ? "selected" : ""} ${scrollWhenSelected(index === this.selectedIndex, action.id)} @click=${() => { this.run(action); }}>
|
||||
<button class=${`${index === this.selectedIndex ? "selected" : ""} ${action.enabled === false ? "disabled" : ""}`} ?disabled=${action.enabled === false} title=${action.disabledReason ?? action.title} ${scrollWhenSelected(index === this.selectedIndex, action.id)} @click=${() => { this.run(action); }}>
|
||||
<span class="main">
|
||||
<strong>${action.title}</strong>
|
||||
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
|
||||
${action.enabled === false && action.disabledReason !== undefined ? html`<small class="disabled-reason">${action.disabledReason}</small>` : null}
|
||||
</span>
|
||||
${action.shortcut !== undefined ? html`<kbd>${formatShortcut(action.shortcut)}</kbd>` : null}
|
||||
${action.group !== undefined && action.group !== "" ? html`<small class="group">${action.group}</small>` : null}
|
||||
@@ -60,14 +61,7 @@ export class ActionPalette extends LitElement {
|
||||
}
|
||||
|
||||
private filteredActions(): AppAction[] {
|
||||
const query = this.queryText.trim().toLowerCase();
|
||||
return this.actions
|
||||
.filter((action) => action.enabled !== false)
|
||||
.filter((action) => {
|
||||
if (query === "") return true;
|
||||
const haystack = [action.title, action.description ?? "", action.group ?? "", action.shortcut ?? ""].join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
return filterActionPaletteActions(this.actions, this.queryText);
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -89,8 +83,20 @@ export class ActionPalette extends LitElement {
|
||||
}
|
||||
|
||||
private run(action: AppAction) {
|
||||
if (action.enabled === false) return;
|
||||
this.onRun?.(action);
|
||||
}
|
||||
|
||||
static override styles = actionPaletteStyles;
|
||||
}
|
||||
|
||||
export function filterActionPaletteActions(actions: readonly AppAction[], queryText: string): AppAction[] {
|
||||
const query = queryText.trim().toLowerCase();
|
||||
return actions
|
||||
.filter((action) => action.enabled !== false || action.disabledReason !== undefined)
|
||||
.filter((action) => {
|
||||
if (query === "") return true;
|
||||
const haystack = [action.title, action.description ?? "", action.disabledReason ?? "", action.group ?? "", action.shortcut ?? ""].join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chatQueuedMessageSections } from "./ChatView";
|
||||
|
||||
describe("chatQueuedMessageSections", () => {
|
||||
it("labels client-side pending-start sends separately from server queued messages", () => {
|
||||
const sections = chatQueuedMessageSections(
|
||||
[{ kind: "followUp", text: "queued before start" }],
|
||||
[{ kind: "steer", text: "server queued" }],
|
||||
);
|
||||
|
||||
expect(sections).toEqual([
|
||||
{
|
||||
heading: "Queued until session starts",
|
||||
detail: "Will send once the backend session is ready",
|
||||
messages: [{ kind: "followUp", text: "queued before start" }],
|
||||
},
|
||||
{
|
||||
heading: "Queued messages",
|
||||
detail: "1 pending · Stop clears the queue",
|
||||
messages: [{ kind: "steer", text: "server queued" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGr
|
||||
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
||||
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
||||
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
|
||||
import type { SessionActivity, SessionStatus } from "../api";
|
||||
import type { QueuedSessionMessage, SessionActivity, SessionStatus } from "../api";
|
||||
import type { ChatLine, ChatPart } from "./shared";
|
||||
import { chatStyles } from "./shared";
|
||||
import "./ConversationMeter";
|
||||
@@ -38,6 +38,19 @@ function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export interface QueuedMessageSection {
|
||||
heading: string;
|
||||
detail: string;
|
||||
messages: QueuedSessionMessage[];
|
||||
}
|
||||
|
||||
export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] {
|
||||
return [
|
||||
clientQueued.length === 0 ? undefined : { heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued },
|
||||
serverQueued.length === 0 ? undefined : { heading: "Queued messages", detail: `${String(serverQueued.length)} pending · Stop clears the queue`, messages: serverQueued },
|
||||
].filter((section): section is QueuedMessageSection => section !== undefined);
|
||||
}
|
||||
|
||||
@customElement("chat-view")
|
||||
export class ChatView extends LitElement {
|
||||
@property({ attribute: false }) messages: ChatLine[] = [];
|
||||
@@ -51,6 +64,7 @@ export class ChatView extends LitElement {
|
||||
@property({ type: Boolean }) isSendingPrompt = false;
|
||||
@property({ type: Boolean }) isCompacting = false;
|
||||
@property({ type: Number }) pendingMessageCount = 0;
|
||||
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
|
||||
@property({ attribute: false }) status?: SessionStatus;
|
||||
@property({ attribute: false }) activity?: SessionActivity;
|
||||
@property({ attribute: false }) onLoadMore?: () => void;
|
||||
@@ -220,15 +234,18 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
|
||||
private renderQueuedMessages() {
|
||||
const queued = this.status?.queuedMessages ?? [];
|
||||
if (queued.length === 0) return null;
|
||||
const serverQueued = this.status?.queuedMessages ?? [];
|
||||
return html`${chatQueuedMessageSections(this.clientQueuedMessages, serverQueued).map((section) => this.renderQueuedMessageList(section))}`;
|
||||
}
|
||||
|
||||
private renderQueuedMessageList(section: QueuedMessageSection) {
|
||||
return html`
|
||||
<aside class="queued-messages" aria-live="polite">
|
||||
<div class="queued-header">
|
||||
<strong>Queued messages</strong>
|
||||
<small>${queued.length} pending · Stop clears the queue</small>
|
||||
<strong>${section.heading}</strong>
|
||||
<small>${section.detail}</small>
|
||||
</div>
|
||||
${queued.map((message, index) => html`
|
||||
${section.messages.map((message, index) => html`
|
||||
<div class="queued-message">
|
||||
<span class="queued-kind">${message.kind === "steer" ? "Steer" : "Follow-up"} ${String(index + 1)}</span>
|
||||
<formatted-text .text=${message.text}></formatted-text>
|
||||
|
||||
@@ -55,6 +55,7 @@ export class CodeViewer extends LitElement {
|
||||
EditorView.editable.of(false),
|
||||
EditorView.lineWrapping,
|
||||
viewerTheme,
|
||||
...bidiTextExtensions(this.language),
|
||||
...languageExtensions(this.language),
|
||||
],
|
||||
}),
|
||||
@@ -97,6 +98,19 @@ const viewerTheme = EditorView.theme({
|
||||
},
|
||||
});
|
||||
|
||||
const bidiTextTheme = EditorView.theme({
|
||||
".cm-content": {
|
||||
textAlign: "start",
|
||||
},
|
||||
".cm-line": {
|
||||
unicodeBidi: "plaintext",
|
||||
},
|
||||
});
|
||||
|
||||
function bidiTextExtensions(language: string | undefined): Extension[] {
|
||||
return language === "markdown" ? [EditorView.contentAttributes.of({ dir: "auto" }), bidiTextTheme] : [];
|
||||
}
|
||||
|
||||
function languageExtensions(language: string | undefined): Extension[] {
|
||||
if (language === undefined) return [];
|
||||
switch (language) {
|
||||
|
||||
@@ -9,7 +9,7 @@ export class FormattedText extends LitElement {
|
||||
@property() text = "";
|
||||
|
||||
override render() {
|
||||
return html`<div class="formatted" @click=${this.onFormattedClick}>${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`;
|
||||
return html`<div class="formatted" dir="auto" @click=${this.onFormattedClick}>${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`;
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
|
||||
@@ -137,7 +137,7 @@ export class MachineDialog extends LitElement {
|
||||
footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; }
|
||||
.body { display: grid; gap: 8px; padding: 12px; min-height: 0; overflow: auto; }
|
||||
label { display: grid; gap: 6px; color: var(--pi-muted); }
|
||||
input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
|
||||
input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
|
||||
.hint { color: var(--pi-muted); }
|
||||
.intro { margin: 4px 0 0; line-height: 1.4; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, piWebApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, piWebApi, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
@@ -19,6 +19,7 @@ import { SessionStorageTerminalSelectionMemory } from "../controllers/terminalSe
|
||||
import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspaceSelection";
|
||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||
import { selectedMachineId } from "../controllers/types";
|
||||
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
|
||||
import { RealtimeSocket } from "../sessionSocket";
|
||||
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||
@@ -40,6 +41,7 @@ import "./MachineList";
|
||||
import "./ProjectList";
|
||||
import "./WorkspaceList";
|
||||
import "./SessionList";
|
||||
import "./SessionCleanupDialog";
|
||||
import "./ChatView";
|
||||
import type { ChatView } from "./ChatView";
|
||||
import "./PromptEditor";
|
||||
@@ -77,6 +79,15 @@ const MIN_RESIZABLE_CHAT_WIDTH_PX = 320;
|
||||
const PANEL_EDGE_COLUMNS_WIDTH_PX = 2;
|
||||
const DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY = "(min-width: 1181px)";
|
||||
|
||||
interface SessionCleanupDialogState {
|
||||
preview?: SessionCleanupPreviewResponse | undefined;
|
||||
previewRequest?: SessionCleanupRequest | undefined;
|
||||
result?: SessionCleanupExecuteResponse | undefined;
|
||||
loading?: boolean | undefined;
|
||||
running?: boolean | undefined;
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
@customElement("pi-web-app")
|
||||
export class PiWebApp extends LitElement {
|
||||
@state() private state: AppState = initialAppState();
|
||||
@@ -167,6 +178,7 @@ export class PiWebApp extends LitElement {
|
||||
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
|
||||
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
|
||||
@state() private isRefreshingApp = false;
|
||||
@state() private sessionCleanupDialog: SessionCleanupDialogState | undefined;
|
||||
@state() private settingsSection: SettingsSection | undefined = readSettingsSection();
|
||||
@state() private shortcutConfig: PiWebShortcutConfig = {};
|
||||
@state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined);
|
||||
@@ -875,6 +887,7 @@ export class PiWebApp extends LitElement {
|
||||
this.realtime.close();
|
||||
this.connectRealtime();
|
||||
this.activeTerminalIds.clear();
|
||||
this.sessionCleanupDialog = undefined;
|
||||
this.setState({ piWebStatus: undefined });
|
||||
this.git.updatePolling();
|
||||
void this.loadPluginsForSelectedMachine();
|
||||
@@ -1011,6 +1024,11 @@ export class PiWebApp extends LitElement {
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
|
||||
}
|
||||
|
||||
private canCleanupSessions(): boolean {
|
||||
const runtime = this.selectedMachineRuntime();
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
|
||||
}
|
||||
|
||||
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
|
||||
if (machineId === "local") return true;
|
||||
// COMPAT-CAP workspace.fileSuggestions: remote machines without this
|
||||
@@ -1024,10 +1042,60 @@ export class PiWebApp extends LitElement {
|
||||
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
|
||||
}
|
||||
|
||||
private sessionCleanupUnavailableMessage(): string {
|
||||
return sessionCleanupUnavailableMessage(this.state.selectedMachine?.name);
|
||||
}
|
||||
|
||||
private selectedMachineRuntime() {
|
||||
return this.state.machineRuntimes[selectedMachineId(this.state)];
|
||||
}
|
||||
|
||||
private openSessionCleanupDialog(): void {
|
||||
this.sessionCleanupDialog = { error: "" };
|
||||
}
|
||||
|
||||
private closeSessionCleanupDialog(): void {
|
||||
this.sessionCleanupDialog = undefined;
|
||||
}
|
||||
|
||||
private async previewSessionCleanup(request: SessionCleanupRequest): Promise<void> {
|
||||
if (!this.canCleanupSessions()) {
|
||||
this.sessionCleanupDialog = { ...(this.sessionCleanupDialog ?? {}), error: this.sessionCleanupUnavailableMessage(), preview: undefined, previewRequest: undefined, result: undefined, loading: false };
|
||||
return;
|
||||
}
|
||||
const machineId = selectedMachineId(this.state);
|
||||
this.sessionCleanupDialog = { ...(this.sessionCleanupDialog ?? {}), loading: true, error: "", preview: undefined, previewRequest: undefined, result: undefined };
|
||||
try {
|
||||
const preview = await sessionsApi.cleanupPreview(request, machineId);
|
||||
if (selectedMachineId(this.state) !== machineId) return;
|
||||
this.sessionCleanupDialog = { ...this.sessionCleanupDialog, preview, previewRequest: request, result: undefined, loading: false, error: "" };
|
||||
} catch (error) {
|
||||
if (selectedMachineId(this.state) === machineId) this.sessionCleanupDialog = { ...this.sessionCleanupDialog, loading: false, error: `Failed to preview cleanup: ${errorMessage(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
private async runSessionCleanup(request: SessionCleanupRequest): Promise<void> {
|
||||
const dialog = this.sessionCleanupDialog;
|
||||
if (dialog?.preview === undefined || sessionCleanupRequestKey(dialog.previewRequest) !== sessionCleanupRequestKey(request)) {
|
||||
this.sessionCleanupDialog = { ...(dialog ?? {}), error: "Preview cleanup before running it." };
|
||||
return;
|
||||
}
|
||||
if (!this.canCleanupSessions()) {
|
||||
this.sessionCleanupDialog = { ...dialog, error: this.sessionCleanupUnavailableMessage(), running: false };
|
||||
return;
|
||||
}
|
||||
const machineId = selectedMachineId(this.state);
|
||||
this.sessionCleanupDialog = { ...dialog, running: true, error: "" };
|
||||
try {
|
||||
const result = await sessionsApi.cleanup(request, machineId);
|
||||
if (selectedMachineId(this.state) !== machineId) return;
|
||||
this.sessionCleanupDialog = { ...this.sessionCleanupDialog, preview: result, previewRequest: request, result, running: false, error: "" };
|
||||
await this.sessions.applySessionCleanupResult(result, machineId);
|
||||
} catch (error) {
|
||||
if (selectedMachineId(this.state) === machineId) this.sessionCleanupDialog = { ...this.sessionCleanupDialog, running: false, error: `Failed to run cleanup: ${errorMessage(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
private renderNavigationPanel() {
|
||||
return html`
|
||||
<app-navigation-panel
|
||||
@@ -1051,10 +1119,13 @@ export class PiWebApp extends LitElement {
|
||||
.sessionActivities=${this.state.sessionActivities}
|
||||
.sendingPrompts=${this.state.sendingPrompts}
|
||||
.selectedSession=${this.state.selectedSession}
|
||||
.startingSessionCount=${this.state.startingSessionCount}
|
||||
.canStartSession=${!!this.state.selectedWorkspace}
|
||||
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
|
||||
.canReloadSessions=${this.canReloadSessions()}
|
||||
.canCleanupSessions=${this.canCleanupSessions()}
|
||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
|
||||
.cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()}
|
||||
.collapsible=${true}
|
||||
.compact=${this.appShell.isMobileNavigationLayout}
|
||||
.projectsCollapsed=${this.navigationSections.isCollapsed("projects")}
|
||||
@@ -1071,7 +1142,7 @@ export class PiWebApp extends LitElement {
|
||||
.onSelectWorkspace=${(workspace: Workspace) => this.selectNavigationItem("workspaces", "sessions", () => this.workspaces.selectWorkspace(workspace))}
|
||||
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
|
||||
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
|
||||
.onStartSession=${() => this.selectNavigationItem("sessions", "chat", () => this.sessions.startSession())}
|
||||
.onStartSession=${() => this.startSessionFromNavigation()}
|
||||
.onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.selectSession(session))}
|
||||
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
|
||||
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
|
||||
@@ -1082,6 +1153,7 @@ export class PiWebApp extends LitElement {
|
||||
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
|
||||
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
||||
.onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)}
|
||||
.onCleanupSessions=${() => { this.openSessionCleanupDialog(); }}
|
||||
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
|
||||
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
|
||||
></app-navigation-panel>
|
||||
@@ -1105,6 +1177,24 @@ export class PiWebApp extends LitElement {
|
||||
await this.focusNavigationTarget(nextTarget);
|
||||
}
|
||||
|
||||
private async startSessionFromNavigation(): Promise<void> {
|
||||
const seq = ++this.navigationSelectionSeq;
|
||||
const isCurrentSelection = () => seq === this.navigationSelectionSeq;
|
||||
|
||||
this.navigationSections.advanceAfterSelection("sessions");
|
||||
await this.startSessionAndOpenChat(isCurrentSelection);
|
||||
}
|
||||
|
||||
private async startSessionAndOpenChat(shouldComplete: () => boolean = () => true): Promise<void> {
|
||||
// `startSession()` remains in flight until the backend session resolves;
|
||||
// open the chat as soon as the controller has inserted the temporary row.
|
||||
const start = this.sessions.startSession().catch((error: unknown) => {
|
||||
if (shouldComplete()) this.setState({ error: String(error) });
|
||||
});
|
||||
if (shouldComplete()) await this.focusChatComposer();
|
||||
void start;
|
||||
}
|
||||
|
||||
private async focusNavigationTarget(target: NavigationFocusTarget): Promise<void> {
|
||||
if (target === "chat") {
|
||||
await this.focusChatComposer();
|
||||
@@ -1293,7 +1383,21 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private getDefaultActions(): AppAction[] {
|
||||
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions(), ...this.panelLayoutActions()];
|
||||
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.sessionActions(), ...this.navigationFocusActions(), ...this.panelLayoutActions()];
|
||||
}
|
||||
|
||||
private sessionActions(): AppAction[] {
|
||||
const canCleanup = this.canCleanupSessions();
|
||||
return [
|
||||
{
|
||||
id: "app.sessions.cleanup",
|
||||
title: "Clean Up Sessions",
|
||||
description: "Preview and manually clean up idle or archived sessions on the selected machine",
|
||||
group: "Sessions",
|
||||
...(canCleanup ? {} : { enabled: false, disabledReason: this.sessionCleanupUnavailableMessage() }),
|
||||
run: () => { this.openSessionCleanupDialog(); },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private panelLayoutActions(): AppAction[] {
|
||||
@@ -1462,7 +1566,7 @@ export class PiWebApp extends LitElement {
|
||||
refreshAppData: () => this.refreshAppData(),
|
||||
reloadPage: () => { this.hardReloadApp(); },
|
||||
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
|
||||
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
|
||||
startSession: () => this.withChatScrollTransition(() => this.startSessionAndOpenChat()),
|
||||
archiveSession: () => this.sessions.archiveSession(),
|
||||
reloadSession: () => this.sessions.reloadSession(),
|
||||
deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(),
|
||||
@@ -1745,6 +1849,25 @@ export class PiWebApp extends LitElement {
|
||||
void this.sessions.send(text, streamingBehavior, attachments, delivery);
|
||||
}
|
||||
|
||||
// Stable handler identities for <prompt-editor>. Inlined arrow closures would
|
||||
// be a fresh reference on every render, forcing Lit to re-commit the bindings
|
||||
// each time the app re-renders; bound class fields keep them constant.
|
||||
private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => {
|
||||
this.sendPrompt(text, streamingBehavior, attachments, delivery);
|
||||
};
|
||||
|
||||
private readonly handleStopActiveWork = (): void => {
|
||||
void this.sessions.stopActiveWork();
|
||||
};
|
||||
|
||||
private readonly handleSelectModel = (): void => {
|
||||
void this.openModelDialog();
|
||||
};
|
||||
|
||||
private readonly handleSelectThinking = (): void => {
|
||||
void this.openThinkingDialog();
|
||||
};
|
||||
|
||||
private renderContextBar() {
|
||||
if (!this.appShell.isMobileNavigationLayout) return null;
|
||||
return html`
|
||||
@@ -1803,8 +1926,8 @@ export class PiWebApp extends LitElement {
|
||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[state.selectedSession.id] ?? []} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}></prompt-editor>
|
||||
<status-bar .status=${state.status}></status-bar>
|
||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
||||
@@ -1817,8 +1940,9 @@ export class PiWebApp extends LitElement {
|
||||
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
||||
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
|
||||
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
|
||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export class ProjectDialog extends LitElement {
|
||||
footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; }
|
||||
.body { display: grid; gap: 12px; padding: 12px; min-height: 0; }
|
||||
label { display: grid; gap: 6px; color: var(--pi-muted); }
|
||||
input[type="text"], input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
input[type="text"], input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
|
||||
.check { display: flex; grid-template-columns: auto 1fr; align-items: center; color: var(--pi-text); }
|
||||
.suggestions { min-height: 90px; max-height: 320px; overflow: auto; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); }
|
||||
.suggestions button { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 0; border-bottom: 1px solid var(--pi-border); border-radius: 0; background: transparent; color: var(--pi-text); padding: 8px 10px; text-align: left; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
|
||||
@@ -7,25 +7,19 @@ import { LitElement, html, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
|
||||
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
|
||||
import { captureImageAttachments } from "../promptAttachmentCapture";
|
||||
import { inputModeForDraft } from "../inputModes";
|
||||
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
|
||||
import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
|
||||
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
|
||||
import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior";
|
||||
import { promptEditorStyles, type CompletionItem } from "./shared";
|
||||
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons";
|
||||
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
|
||||
import "./AutocompleteMenu";
|
||||
|
||||
interface PendingAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
/** Base64 payload without the data: URL prefix. */
|
||||
data: string;
|
||||
size: number;
|
||||
}
|
||||
type PendingAttachment = CapturedAttachment & { id: string };
|
||||
|
||||
@customElement("prompt-editor")
|
||||
export class PromptEditor extends LitElement {
|
||||
@@ -48,7 +42,14 @@ export class PromptEditor extends LitElement {
|
||||
@property({ attribute: false }) availableThinkingLevels: readonly string[] = [];
|
||||
@query(".markdown-editor") private editorHost?: HTMLDivElement;
|
||||
@query(".attachment-input") private attachmentInput?: HTMLInputElement;
|
||||
@state() private draft = "";
|
||||
// `draft` is the live document text but is intentionally NOT reactive: it
|
||||
// changes on every keystroke and the visible text is owned by CodeMirror, not
|
||||
// by Lit's render. Re-rendering the surrounding template on each keystroke is
|
||||
// wasted work and, on iOS, can interrupt an in-progress touch gesture (the
|
||||
// long-press edit/paste callout). Only `currentInputMode` (shell vs. normal)
|
||||
// is reactive, since that is the only draft-derived value the template shows.
|
||||
private draft = "";
|
||||
@state() private currentInputMode: InputMode = { kind: "normal" };
|
||||
@state() private completions: CompletionItem[] = [];
|
||||
@state() private selectedIndex = 0;
|
||||
@state() private attachments: PendingAttachment[] = [];
|
||||
@@ -59,6 +60,8 @@ export class PromptEditor extends LitElement {
|
||||
private editor: EditorView | undefined;
|
||||
private readonly editableCompartment = new Compartment();
|
||||
private readonly readOnlyCompartment = new Compartment();
|
||||
private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia();
|
||||
private explicitShiftKeyActive = false;
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>) {
|
||||
if (!changed.has("sessionId") && !changed.has("machineId")) return;
|
||||
@@ -68,17 +71,29 @@ export class PromptEditor extends LitElement {
|
||||
if (previousKey !== undefined) saveDraft(previousKey, this.draft);
|
||||
const currentKey = draftStorageKey(this.machineId, this.sessionId);
|
||||
this.draft = currentKey !== undefined ? loadDraft(currentKey) : "";
|
||||
this.currentInputMode = inputModeForDraft(this.draft);
|
||||
this.completions = [];
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
protected override shouldUpdate(changed: PropertyValues<this>): boolean {
|
||||
// Status updates churn once per token during streaming and hand us a fresh
|
||||
// object reference each time. When nothing else changed, only re-render if a
|
||||
// status field the template actually displays differs, so streaming does not
|
||||
// disturb the editor DOM (and any in-progress touch gesture survives).
|
||||
if (changed.has("status") && changed.size === 1) {
|
||||
return !sessionStatusRenderEqual(changed.get("status"), this.status);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
this.createEditor();
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues) {
|
||||
if (changed.has("disabled")) this.updateEditorDisabledState();
|
||||
if (changed.has("draft") || changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc();
|
||||
if (changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
@@ -88,17 +103,17 @@ export class PromptEditor extends LitElement {
|
||||
}
|
||||
|
||||
override render() {
|
||||
const inputMode = inputModeForDraft(this.draft);
|
||||
const shellMode = inputMode.kind === "shell";
|
||||
const shellInputMode = this.currentInputMode.kind === "shell" ? this.currentInputMode : undefined;
|
||||
const shellMode = shellInputMode !== undefined;
|
||||
const queuesInput = this.canSteer || this.isCompacting;
|
||||
const busy = this.disabled || this.sending;
|
||||
return html`
|
||||
<footer class=${shellMode ? "shell-mode" : ""} @paste=${(event: ClipboardEvent) => { void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}>
|
||||
<div class="editor-wrap">
|
||||
<div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div>
|
||||
<input class="attachment-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
|
||||
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach images" aria-label="Attach images" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
|
||||
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
|
||||
<input class="attachment-input" type="file" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
|
||||
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach files" aria-label="Attach files" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
|
||||
${shellMode ? html`<div class="mode-hint">Shell command${shellInputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
|
||||
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
|
||||
${this.renderAttachments()}
|
||||
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
|
||||
@@ -137,18 +152,20 @@ export class PromptEditor extends LitElement {
|
||||
|
||||
private renderAttachments() {
|
||||
if (this.attachments.length === 0 && this.attachmentError === undefined) return null;
|
||||
const canUseInlineDelivery = promptAttachmentsCanUseInlineDelivery(this.attachments);
|
||||
const delivery = this.effectiveAttachmentDelivery();
|
||||
return html`
|
||||
<div class="attachments" aria-label="Pending attachments">
|
||||
${this.attachments.map((attachment) => html`
|
||||
<div class="attachment-chip" title=${attachment.name}>
|
||||
<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} />
|
||||
<div class=${`attachment-chip ${isInlinePromptAttachment(attachment) ? "attachment-chip-image" : "attachment-chip-file"}`} title=${attachment.name}>
|
||||
${this.renderAttachmentPreview(attachment)}
|
||||
<button type="button" class="attachment-remove" title="Remove attachment" aria-label=${`Remove ${attachment.name}`} @click=${() => { this.removeAttachment(attachment.id); }}>×</button>
|
||||
</div>
|
||||
`)}
|
||||
${this.attachments.length > 0 ? html`
|
||||
<label class="attachment-delivery" title="How attachments are delivered to the agent">
|
||||
<select .value=${this.attachmentDelivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
|
||||
<option value="inline">Attach to message</option>
|
||||
<label class="attachment-delivery" title=${canUseInlineDelivery ? "How attachments are delivered to the agent" : "General files are saved and mentioned from the workspace"}>
|
||||
<select .value=${delivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
|
||||
<option value="inline" ?disabled=${!canUseInlineDelivery}>Attach to message${canUseInlineDelivery ? "" : " (images only)"}</option>
|
||||
<option value="folder">Save to .pi-web/attachments</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -158,9 +175,24 @@ export class PromptEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAttachmentPreview(attachment: PendingAttachment) {
|
||||
if (isInlinePromptAttachment(attachment)) {
|
||||
return html`<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} />`;
|
||||
}
|
||||
return html`
|
||||
<div class="attachment-file-preview" aria-hidden="true">${fileExtensionLabel(attachment.name)}</div>
|
||||
<span class="attachment-file-name">${attachment.name}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private changeDelivery(event: Event) {
|
||||
if (!(event.target instanceof HTMLSelectElement)) return;
|
||||
this.attachmentDelivery = event.target.value === "folder" ? "folder" : "inline";
|
||||
const requested = event.target.value === "folder" ? "folder" : "inline";
|
||||
if (requested === "inline" && !promptAttachmentsCanUseInlineDelivery(this.attachments)) {
|
||||
event.target.value = "folder";
|
||||
return;
|
||||
}
|
||||
this.attachmentDelivery = requested;
|
||||
saveAttachmentDelivery(this.attachmentDelivery);
|
||||
}
|
||||
|
||||
@@ -169,7 +201,7 @@ export class PromptEditor extends LitElement {
|
||||
}
|
||||
|
||||
private async handlePaste(event: ClipboardEvent) {
|
||||
const files = imageFilesFromDataTransfer(event.clipboardData);
|
||||
const files = filesFromDataTransfer(event.clipboardData);
|
||||
if (files.length === 0) return;
|
||||
event.preventDefault();
|
||||
await this.addAttachmentFiles(files);
|
||||
@@ -177,13 +209,11 @@ export class PromptEditor extends LitElement {
|
||||
|
||||
private handleDragOver(event: DragEvent) {
|
||||
if (event.dataTransfer === null) return;
|
||||
if (Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (dataTransferHasFiles(event.dataTransfer)) event.preventDefault();
|
||||
}
|
||||
|
||||
private async handleDrop(event: DragEvent) {
|
||||
const files = imageFilesFromDataTransfer(event.dataTransfer);
|
||||
const files = filesFromDataTransfer(event.dataTransfer);
|
||||
if (files.length === 0) return;
|
||||
event.preventDefault();
|
||||
await this.addAttachmentFiles(files);
|
||||
@@ -198,7 +228,7 @@ export class PromptEditor extends LitElement {
|
||||
|
||||
private async addAttachmentFiles(files: File[]) {
|
||||
this.attachmentError = undefined;
|
||||
const { attachments, error } = await captureImageAttachments(files, readFileAsBase64);
|
||||
const { attachments, error } = await capturePromptAttachments(files, readFileAsBase64);
|
||||
if (attachments.length > 0) {
|
||||
this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))];
|
||||
}
|
||||
@@ -206,12 +236,11 @@ export class PromptEditor extends LitElement {
|
||||
}
|
||||
|
||||
private currentAttachments(): PromptAttachment[] {
|
||||
return this.attachments.map((attachment) => ({
|
||||
kind: "image",
|
||||
mimeType: attachment.mimeType,
|
||||
data: attachment.data,
|
||||
name: attachment.name,
|
||||
}));
|
||||
return this.attachments.map((attachment) => pendingToPromptAttachment(attachment));
|
||||
}
|
||||
|
||||
private effectiveAttachmentDelivery(): PromptAttachmentDelivery {
|
||||
return effectivePromptAttachmentDelivery(this.attachmentDelivery, this.attachments);
|
||||
}
|
||||
|
||||
private createEditor() {
|
||||
@@ -228,6 +257,10 @@ export class PromptEditor extends LitElement {
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))),
|
||||
EditorView.domEventHandlers({
|
||||
keyup: (event) => this.handleEditorKeyUp(event),
|
||||
blur: () => this.resetEditorModifierState(),
|
||||
}),
|
||||
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
|
||||
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
|
||||
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
|
||||
@@ -235,11 +268,10 @@ export class PromptEditor extends LitElement {
|
||||
if (update.docChanged) this.updateDraft(update.state.doc.toString());
|
||||
}),
|
||||
keymap.of([
|
||||
{ any: (view, event) => this.handleEditorKeyDown(event, view) },
|
||||
{ key: "ArrowDown", run: () => this.moveCompletion(1) },
|
||||
{ key: "ArrowUp", run: () => this.moveCompletion(-1) },
|
||||
{ key: "Escape", run: () => this.closeCompletions() },
|
||||
{ key: "Enter", run: () => this.handleEditorEnter() },
|
||||
{ key: "Shift-Enter", run: (view) => insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view) },
|
||||
{ key: "Tab", run: (view) => this.handleEditorTab(view) },
|
||||
{ key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false },
|
||||
{ key: "Backspace", run: (view) => deleteMarkupBackward(view) },
|
||||
@@ -275,6 +307,8 @@ export class PromptEditor extends LitElement {
|
||||
this.draft = value;
|
||||
const key = draftStorageKey(this.machineId, this.sessionId);
|
||||
if (key !== undefined) saveDraft(key, this.draft);
|
||||
const nextInputMode = inputModeForDraft(this.draft);
|
||||
if (!inputModesEqual(nextInputMode, this.currentInputMode)) this.currentInputMode = nextInputMode;
|
||||
void this.refreshCompletions();
|
||||
}
|
||||
|
||||
@@ -335,12 +369,41 @@ export class PromptEditor extends LitElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private handleEditorEnter(): boolean {
|
||||
if (this.completions.length) {
|
||||
private handleEditorKeyDown(event: KeyboardEvent, view: EditorView): boolean {
|
||||
if (event.key === "Shift") {
|
||||
this.explicitShiftKeyActive = true;
|
||||
return false;
|
||||
}
|
||||
if (event.key !== "Enter") {
|
||||
this.explicitShiftKeyActive = false;
|
||||
return false;
|
||||
}
|
||||
if (event.defaultPrevented || event.isComposing || view.composing) return false;
|
||||
|
||||
const shiftKey = shouldUsePromptEnterShiftShortcut(event.shiftKey, this.explicitShiftKeyActive, this.mobilePromptEnterMedia);
|
||||
this.explicitShiftKeyActive = false;
|
||||
return this.handleEditorEnter(view, shiftKey);
|
||||
}
|
||||
|
||||
private handleEditorKeyUp(event: KeyboardEvent): boolean {
|
||||
if (event.key === "Shift") this.explicitShiftKeyActive = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
private resetEditorModifierState(): boolean {
|
||||
this.explicitShiftKeyActive = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
private handleEditorEnter(view: EditorView, shiftKey: boolean): boolean {
|
||||
if (!shiftKey && this.completions.length) {
|
||||
const completion = this.completions[this.selectedIndex];
|
||||
if (completion !== undefined) this.pick(completion);
|
||||
return true;
|
||||
}
|
||||
if (!shouldSendPromptOnEnterShortcut(shiftKey, this.mobilePromptEnterMedia, readPromptEnterPreference())) {
|
||||
return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view);
|
||||
}
|
||||
this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
|
||||
return true;
|
||||
}
|
||||
@@ -380,7 +443,7 @@ export class PromptEditor extends LitElement {
|
||||
if (text === "" && pending.length === 0) return;
|
||||
const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined;
|
||||
const attachments = pending.length > 0 ? this.currentAttachments() : undefined;
|
||||
const delivery = this.attachmentDelivery;
|
||||
const delivery = this.effectiveAttachmentDelivery();
|
||||
this.resetComposer();
|
||||
// Sending is owned by the controller (it drives the chat activity dock and,
|
||||
// for folder mode, orchestrates the upload + reference rewrite), so this is
|
||||
@@ -390,16 +453,33 @@ export class PromptEditor extends LitElement {
|
||||
|
||||
private resetComposer() {
|
||||
this.draft = "";
|
||||
this.currentInputMode = { kind: "normal" };
|
||||
const key = draftStorageKey(this.machineId, this.sessionId);
|
||||
if (key !== undefined) clearDraft(key);
|
||||
this.completions = [];
|
||||
this.attachments = [];
|
||||
this.attachmentError = undefined;
|
||||
// `draft` is not reactive, so the cleared text will not flow to CodeMirror
|
||||
// via `updated()`; push it to the editor document explicitly.
|
||||
this.syncEditorDoc();
|
||||
}
|
||||
|
||||
static override styles = promptEditorStyles;
|
||||
}
|
||||
|
||||
// The only `status` fields the template reads directly are the model identity
|
||||
// and thinking level (shown in renderCompactStatus). Everything else the editor
|
||||
// cares about (canSteer/canStop/isCompacting/sending) is passed as a separate
|
||||
// property that Lit already diffs by value. Comparing just these fields lets us
|
||||
// ignore the per-token status churn that does not change anything on screen.
|
||||
function sessionStatusRenderEqual(a: SessionStatus | undefined, b: SessionStatus | undefined): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === undefined || b === undefined) return false;
|
||||
return a.model?.id === b.model?.id
|
||||
&& a.model?.provider === b.model?.provider
|
||||
&& a.thinkingLevel === b.thinkingLevel;
|
||||
}
|
||||
|
||||
function draftStorageKey(machineId: unknown, sessionId: unknown): string | undefined {
|
||||
if (typeof machineId !== "string" || machineId === "") return undefined;
|
||||
if (typeof sessionId !== "string" || sessionId === "") return undefined;
|
||||
@@ -414,9 +494,29 @@ function emptyFileSuggestions(): FileSuggestion[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function imageFilesFromDataTransfer(data: DataTransfer | null): File[] {
|
||||
function filesFromDataTransfer(data: DataTransfer | null): File[] {
|
||||
if (data === null) return [];
|
||||
return Array.from(data.files).filter((file) => file.type.startsWith("image/"));
|
||||
return Array.from(data.files);
|
||||
}
|
||||
|
||||
function dataTransferHasFiles(data: DataTransfer): boolean {
|
||||
const items = Array.from(data.items);
|
||||
if (items.length > 0) return items.some((item) => item.kind === "file");
|
||||
return Array.from(data.types).includes("Files");
|
||||
}
|
||||
|
||||
function pendingToPromptAttachment(attachment: PendingAttachment): PromptAttachment {
|
||||
if (attachment.kind === "image") {
|
||||
return { kind: "image", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
|
||||
}
|
||||
return { kind: "file", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
|
||||
}
|
||||
|
||||
function fileExtensionLabel(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
const dotIndex = trimmed.lastIndexOf(".");
|
||||
if (dotIndex >= 0 && dotIndex < trimmed.length - 1) return trimmed.slice(dotIndex + 1, dotIndex + 5).toUpperCase();
|
||||
return "FILE";
|
||||
}
|
||||
|
||||
function readFileAsBase64(file: File): Promise<string> {
|
||||
@@ -438,6 +538,7 @@ const proseInputAssistanceAttributes: Record<string, string> = {
|
||||
autocorrect: "on",
|
||||
autocapitalize: "sentences",
|
||||
writingsuggestions: "true",
|
||||
dir: "auto",
|
||||
};
|
||||
|
||||
const codeLikeInputAssistanceAttributes: Record<string, string> = {
|
||||
@@ -445,6 +546,7 @@ const codeLikeInputAssistanceAttributes: Record<string, string> = {
|
||||
autocorrect: "off",
|
||||
autocapitalize: "off",
|
||||
writingsuggestions: "false",
|
||||
dir: "auto",
|
||||
};
|
||||
|
||||
function inputAssistanceContentAttributes(draftBeforeCursor: string): Record<string, string> {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest } from "../api";
|
||||
import { canRunSessionCleanup, confirmSessionCleanup, DEFAULT_SESSION_CLEANUP_DRAFT, selectedSessionCleanupProjectCwds, sessionCleanupPreviewForSelectedProjects, sessionCleanupPreviewHasTargets, sessionCleanupRequestKey, validateSessionCleanupDraft, type SessionCleanupDraft } from "../sessionCleanupUi";
|
||||
|
||||
@customElement("session-cleanup-dialog")
|
||||
export class SessionCleanupDialog extends LitElement {
|
||||
@property({ type: Boolean }) canCleanup = true;
|
||||
@property({ type: String }) unavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
|
||||
@property({ attribute: false }) preview?: SessionCleanupPreviewResponse;
|
||||
@property({ attribute: false }) previewRequest?: SessionCleanupRequest;
|
||||
@property({ attribute: false }) result?: SessionCleanupExecuteResponse;
|
||||
@property({ type: Boolean }) loading = false;
|
||||
@property({ type: Boolean }) running = false;
|
||||
@property({ type: String }) error = "";
|
||||
@property({ attribute: false }) onPreview?: (request: SessionCleanupRequest) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRun?: (request: SessionCleanupRequest) => void | Promise<void>;
|
||||
@property({ attribute: false }) onClose?: () => void;
|
||||
|
||||
@state() private draft: SessionCleanupDraft = { ...DEFAULT_SESSION_CLEANUP_DRAFT };
|
||||
@state() private formError = "";
|
||||
@state() private selectedProjectCwds: string[] | undefined;
|
||||
|
||||
override willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.has("preview")) this.selectedProjectCwds = this.preview?.projects.map((project) => project.cwd);
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
const validation = validateSessionCleanupDraft(this.draft);
|
||||
const selectedPreview = this.selectedPreview();
|
||||
const runEnabled = canRunSessionCleanup({ canCleanup: this.canCleanup, draft: this.draft, preview: selectedPreview, previewRequest: this.previewRequest, loading: this.loading, running: this.running });
|
||||
const runTitle = runEnabled ? "Run cleanup" : selectedPreview !== undefined && !sessionCleanupPreviewHasTargets(selectedPreview) ? "Select at least one project to run cleanup" : "Preview cleanup before running it";
|
||||
return html`
|
||||
<div class="backdrop" @mousedown=${() => { this.onClose?.(); }}>
|
||||
<section role="dialog" aria-modal="true" aria-label="Clean up sessions" @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">Sessions</span>
|
||||
<h1>Clean up sessions</h1>
|
||||
</div>
|
||||
<button class="close-button" title="Close cleanup" aria-label="Close cleanup" @click=${() => { this.onClose?.(); }}>×</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p class="intro">Preview manual cleanup for this machine before archiving idle sessions or permanently deleting old archived sessions.</p>
|
||||
${this.canCleanup ? this.renderForm(validation.ok ? "" : validation.error) : this.renderUnavailable()}
|
||||
${this.renderMessage()}
|
||||
${this.preview === undefined ? null : this.renderPreview(this.preview)}
|
||||
${this.result === undefined ? null : this.renderResult(this.result)}
|
||||
</div>
|
||||
<footer>
|
||||
<button @click=${() => { this.onClose?.(); }}>${this.result === undefined ? "Cancel" : "Close"}</button>
|
||||
<button ?disabled=${!this.canCleanup || this.loading || this.running} @click=${() => { this.previewCleanup(); }}>${this.loading ? "Previewing…" : "Preview"}</button>
|
||||
<button class="danger" ?disabled=${!runEnabled} title=${runTitle} @click=${() => { this.runCleanup(); }}>${this.running ? "Running…" : "Run cleanup"}</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderForm(validationError: string): TemplateResult {
|
||||
const disabled = this.loading || this.running;
|
||||
const validation = validateSessionCleanupDraft(this.draft);
|
||||
const previewOutOfDate = this.preview !== undefined && validation.ok && sessionCleanupRequestKey(validation.request) !== sessionCleanupRequestKey(this.previewRequest) && sessionCleanupPreviewHasTargets(this.preview);
|
||||
return html`
|
||||
<fieldset ?disabled=${disabled}>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" .checked=${this.draft.archiveIdleEnabled} @change=${(event: Event) => { this.updateDraft({ archiveIdleEnabled: checkedValue(event) }); }}>
|
||||
<span>Archive non-archived sessions idle for more than</span>
|
||||
<input class="days" type="number" min="0" step="1" inputmode="numeric" .value=${this.draft.archiveIdleDays} ?disabled=${disabled || !this.draft.archiveIdleEnabled} @input=${(event: Event) => { this.updateDraft({ archiveIdleDays: inputValue(event) }); }}>
|
||||
<span>days</span>
|
||||
</label>
|
||||
<label class="toggle-row delete-row">
|
||||
<input type="checkbox" .checked=${this.draft.deleteArchivedEnabled} @change=${(event: Event) => { this.updateDraft({ deleteArchivedEnabled: checkedValue(event) }); }}>
|
||||
<span>Delete archived sessions archived for more than</span>
|
||||
<input class="days" type="number" min="0" step="1" inputmode="numeric" .value=${this.draft.deleteArchivedDays} ?disabled=${disabled || !this.draft.deleteArchivedEnabled} @input=${(event: Event) => { this.updateDraft({ deleteArchivedDays: inputValue(event) }); }}>
|
||||
<span>days</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<p class="warning"><strong>Deletion is permanent.</strong> Cleanup only deletes sessions that are already archived.</p>
|
||||
${validationError === "" ? null : html`<div class="dialog-error" role="alert">${validationError}</div>`}
|
||||
${previewOutOfDate ? html`<div class="hint" role="status">Thresholds changed. Preview again before running cleanup.</div>` : null}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderUnavailable(): TemplateResult {
|
||||
return html`<div class="unavailable" role="status">${this.unavailableMessage}</div>`;
|
||||
}
|
||||
|
||||
private renderMessage(): TemplateResult | null {
|
||||
const message = this.formError || this.error;
|
||||
return message === "" ? null : html`<div class="dialog-error" role="alert">${message}</div>`;
|
||||
}
|
||||
|
||||
private renderPreview(preview: SessionCleanupPreviewResponse): TemplateResult {
|
||||
const selectedCwds = this.selectedProjectCwdsForPreview();
|
||||
const selected = new Set(selectedCwds);
|
||||
const selectedPreview = sessionCleanupPreviewForSelectedProjects(preview, selectedCwds);
|
||||
return html`
|
||||
<section class="preview" aria-label="Cleanup preview">
|
||||
<h2>Preview</h2>
|
||||
${preview.projects.length === 0 ? html`<p class="empty">No sessions match these thresholds.</p>` : html`
|
||||
${this.renderSelectionControls(preview, selectedCwds)}
|
||||
<div class="table-scroll" tabindex="0" aria-label="Cleanup projects table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Clean up</th><th>Project/workspace path</th><th>Archive</th><th>Delete archived</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${preview.projects.map((project) => this.renderProjectRow(project, selected.has(project.cwd)))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><th colspan="2">Selected totals</th><td>${selectedPreview.totals.archiveCount}</td><td>${selectedPreview.totals.deleteCount}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
${preview.skippedBusySessionIds === undefined || preview.skippedBusySessionIds.length === 0 ? null : html`<p class="hint">${preview.skippedBusySessionIds.length} busy ${preview.skippedBusySessionIds.length === 1 ? "session was" : "sessions were"} skipped.</p>`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSelectionControls(preview: SessionCleanupPreviewResponse, selectedCwds: readonly string[]): TemplateResult {
|
||||
const disabled = this.loading || this.running;
|
||||
return html`
|
||||
<div class="selection-controls" role="group" aria-label="Project selection">
|
||||
<span>${selectedCwds.length} of ${preview.projects.length} projects selected</span>
|
||||
<button ?disabled=${disabled || selectedCwds.length === preview.projects.length} @click=${() => { this.selectAllProjects(); }}>Select all</button>
|
||||
<button ?disabled=${disabled || selectedCwds.length === 0} @click=${() => { this.deselectAllProjects(); }}>Deselect all</button>
|
||||
</div>
|
||||
${selectedCwds.length === 0 ? html`<p class="hint" role="status">Select at least one project to run cleanup.</p>` : null}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProjectRow(project: SessionCleanupProjectSummary, selected: boolean): TemplateResult {
|
||||
return html`
|
||||
<tr class=${selected ? "" : "unselected"}>
|
||||
<td class="select-cell"><input type="checkbox" aria-label=${`Clean up ${project.cwd}`} .checked=${selected} ?disabled=${this.running} @change=${(event: Event) => { this.setProjectSelected(project.cwd, checkedValue(event)); }}></td>
|
||||
<th title=${project.cwd} dir="auto">${project.cwd}</th>
|
||||
<td>${project.archiveCount}</td>
|
||||
<td>${project.deleteCount}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderResult(result: SessionCleanupExecuteResponse): TemplateResult {
|
||||
return html`
|
||||
<section class="result" aria-label="Cleanup result">
|
||||
<h2>Cleanup complete</h2>
|
||||
<p>Archived ${result.archivedSessionIds.length} ${result.archivedSessionIds.length === 1 ? "session" : "sessions"}; permanently deleted ${result.deletedSessionIds.length} archived ${result.deletedSessionIds.length === 1 ? "session" : "sessions"}.</p>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private updateDraft(patch: Partial<SessionCleanupDraft>): void {
|
||||
this.draft = { ...this.draft, ...patch };
|
||||
this.formError = "";
|
||||
}
|
||||
|
||||
private selectedPreview(): SessionCleanupPreviewResponse | undefined {
|
||||
return this.preview === undefined ? undefined : sessionCleanupPreviewForSelectedProjects(this.preview, this.selectedProjectCwdsForPreview());
|
||||
}
|
||||
|
||||
private selectedProjectCwdsForPreview(): string[] {
|
||||
return this.preview === undefined ? [] : selectedSessionCleanupProjectCwds(this.preview, this.selectedProjectCwds);
|
||||
}
|
||||
|
||||
private selectAllProjects(): void {
|
||||
this.selectedProjectCwds = this.preview?.projects.map((project) => project.cwd) ?? [];
|
||||
this.formError = "";
|
||||
}
|
||||
|
||||
private deselectAllProjects(): void {
|
||||
this.selectedProjectCwds = [];
|
||||
this.formError = "";
|
||||
}
|
||||
|
||||
private setProjectSelected(cwd: string, selected: boolean): void {
|
||||
const preview = this.preview;
|
||||
if (preview === undefined) return;
|
||||
const selectedCwds = new Set(this.selectedProjectCwdsForPreview());
|
||||
if (selected) selectedCwds.add(cwd);
|
||||
else selectedCwds.delete(cwd);
|
||||
this.selectedProjectCwds = preview.projects.map((project) => project.cwd).filter((projectCwd) => selectedCwds.has(projectCwd));
|
||||
this.formError = "";
|
||||
}
|
||||
|
||||
private previewCleanup(): void {
|
||||
const validation = validateSessionCleanupDraft(this.draft);
|
||||
if (!validation.ok) {
|
||||
this.formError = validation.error;
|
||||
return;
|
||||
}
|
||||
this.formError = "";
|
||||
void this.onPreview?.(validation.request);
|
||||
}
|
||||
|
||||
private runCleanup(): void {
|
||||
const validation = validateSessionCleanupDraft(this.draft);
|
||||
if (!validation.ok) {
|
||||
this.formError = validation.error;
|
||||
return;
|
||||
}
|
||||
const selectedPreview = this.selectedPreview();
|
||||
const selectedProjectCwds = this.selectedProjectCwdsForPreview();
|
||||
if (!canRunSessionCleanup({ canCleanup: this.canCleanup, draft: this.draft, preview: selectedPreview, previewRequest: this.previewRequest })) {
|
||||
this.formError = selectedPreview !== undefined && !sessionCleanupPreviewHasTargets(selectedPreview) ? "Select at least one project to run cleanup." : "Preview cleanup before running it.";
|
||||
return;
|
||||
}
|
||||
if (selectedPreview === undefined || !confirmSessionCleanup(selectedPreview, (message) => confirm(message))) return;
|
||||
this.formError = "";
|
||||
void this.onRun?.({ ...validation.request, projectCwds: selectedProjectCwds });
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.onClose?.();
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { position: fixed; inset: 0; z-index: 30; color: var(--pi-text); font: 14px system-ui, sans-serif; }
|
||||
.backdrop { box-sizing: border-box; width: 100%; height: 100dvh; display: grid; place-items: center; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); background: var(--pi-overlay); overflow: hidden; }
|
||||
section[role="dialog"] { width: min(760px, 100%); max-height: min(760px, 100%); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border: 1px solid var(--pi-border); border-radius: 14px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; }
|
||||
header, footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pi-border); }
|
||||
footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; }
|
||||
.body { min-height: 0; overflow: auto; display: grid; gap: 14px; padding: 16px; }
|
||||
.eyebrow { display: block; color: var(--pi-muted); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: 20px; line-height: 1.2; }
|
||||
h2 { font-size: 15px; }
|
||||
.intro, .hint, .empty { color: var(--pi-muted); }
|
||||
fieldset { margin: 0; padding: 0; border: 0; display: grid; gap: 10px; }
|
||||
.toggle-row { display: grid; grid-template-columns: auto minmax(0, max-content) 88px auto; align-items: center; gap: 8px; color: var(--pi-text); }
|
||||
input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--pi-accent); }
|
||||
input.days { box-sizing: border-box; width: 88px; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); }
|
||||
input.days:disabled { opacity: .55; }
|
||||
.warning, .unavailable, .dialog-error, .result { border: 1px solid var(--pi-border); border-radius: 10px; padding: 10px 12px; }
|
||||
.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-text); }
|
||||
.unavailable { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); }
|
||||
.dialog-error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); }
|
||||
.result { border-color: var(--pi-success-border); background: var(--pi-success-bg); }
|
||||
.preview { display: grid; gap: 10px; min-width: 0; }
|
||||
.selection-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.selection-controls span { color: var(--pi-muted); }
|
||||
.selection-controls button { padding: 5px 7px; font-size: 12px; }
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; -webkit-overflow-scrolling: touch; border: 1px solid var(--pi-border); border-radius: 10px; }
|
||||
table { width: 100%; min-width: 620px; border-collapse: collapse; }
|
||||
th, td { border-bottom: 1px solid var(--pi-border-muted); padding: 8px 10px; text-align: right; }
|
||||
thead th:first-child, td.select-cell { width: 72px; text-align: center; }
|
||||
th:nth-child(2), td:nth-child(2) { text-align: left; }
|
||||
tbody tr.unselected { opacity: .58; }
|
||||
tbody th { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 500; }
|
||||
tfoot th, tfoot td { border-bottom: 0; font-weight: 700; }
|
||||
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: .5; cursor: not-allowed; }
|
||||
button.danger { color: var(--pi-danger); }
|
||||
button.danger:not(:disabled):hover { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
.close-button { width: 34px; height: 34px; display: grid; place-items: center; border: 0; background: transparent; color: var(--pi-muted); padding: 0; font-size: 24px; }
|
||||
.close-button:hover, .close-button:focus { color: var(--pi-text); background: var(--pi-surface-hover); }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.backdrop { padding: 0; place-items: stretch; }
|
||||
section[role="dialog"] { width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; }
|
||||
.toggle-row { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.toggle-row input.days { grid-column: 2; }
|
||||
.toggle-row span:last-child { grid-column: 2; }
|
||||
table { min-width: 560px; }
|
||||
thead th:first-child, td.select-cell { width: 58px; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function checkedValue(event: Event): boolean {
|
||||
return event.target instanceof HTMLInputElement ? event.target.checked : false;
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
return event.target instanceof HTMLInputElement ? event.target.value : "";
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionInfo, SessionStatus } from "../api";
|
||||
import { markCachedNewSessionInfo } from "../cachedNewSessions";
|
||||
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
|
||||
import { sessionRowActivityKind, sessionRowsForCurrentTree } from "./SessionList";
|
||||
|
||||
describe("sessionRowActivityKind", () => {
|
||||
const idle: SessionStatus = { sessionId: "s", isStreaming: false, isCompacting: false, isBashRunning: false, pendingMessageCount: 0, queuedMessages: [], tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 };
|
||||
const idle = sessionStatus("s");
|
||||
|
||||
it("reports 'sending' for an uploading session, taking precedence over server activity", () => {
|
||||
expect(sessionRowActivityKind(session("s"), idle, undefined, true)).toBe("sending");
|
||||
@@ -25,6 +26,34 @@ describe("sessionRowActivityKind", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session action eligibility", () => {
|
||||
it("requires a persisted server signal before archiving", () => {
|
||||
expect(isArchivableSessionInfo(session("persisted", { persisted: true }))).toBe(true);
|
||||
expect(isArchivableSessionInfo(session("unknown"))).toBe(false);
|
||||
expect(isArchivableSessionInfo(session("transient", { persisted: false }))).toBe(false);
|
||||
expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false);
|
||||
});
|
||||
|
||||
it("allows deleting transient non-archived sessions from server or browser-cached signals", () => {
|
||||
expect(isTransientNewSessionInfo(session("transient", { persisted: false }))).toBe(true);
|
||||
expect(isTransientNewSessionInfo(markCachedNewSessionInfo(session("cached")))).toBe(true);
|
||||
expect(isTransientNewSessionInfo(session("persisted", { persisted: true }))).toBe(false);
|
||||
expect(isTransientNewSessionInfo({ ...session("archived", { persisted: false }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false);
|
||||
});
|
||||
|
||||
it("uses matching status as the freshest persistence signal", () => {
|
||||
const staleTransient = session("s", { persisted: false });
|
||||
expect(isArchivableSessionInfo(staleTransient, sessionStatus("s", { persisted: true }))).toBe(true);
|
||||
expect(isTransientNewSessionInfo(staleTransient, sessionStatus("s", { persisted: true }))).toBe(false);
|
||||
|
||||
const stalePersisted = session("s", { persisted: true });
|
||||
expect(isArchivableSessionInfo(stalePersisted, sessionStatus("s", { persisted: false }))).toBe(false);
|
||||
expect(isTransientNewSessionInfo(stalePersisted, sessionStatus("s", { persisted: false }))).toBe(true);
|
||||
|
||||
expect(isArchivableSessionInfo(staleTransient, sessionStatus("other", { persisted: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionRowsForCurrentTree", () => {
|
||||
it("keeps archived ancestors visible while they have unarchived descendants", () => {
|
||||
const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
|
||||
@@ -58,6 +87,20 @@ function rowSummaries(rows: ReturnType<typeof sessionRowsForCurrentTree>) {
|
||||
return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent }));
|
||||
}
|
||||
|
||||
function sessionStatus(sessionId: string, overrides: Partial<SessionStatus> = {}): SessionStatus {
|
||||
return {
|
||||
sessionId,
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { LitElement, css, html, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
||||
import { isCachedNewSessionInfo } from "../cachedNewSessions";
|
||||
import { shortSessionId } from "../sessionLabels";
|
||||
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./activityBadge";
|
||||
@@ -11,7 +13,7 @@ import { listStyles } from "./shared";
|
||||
|
||||
function sessionLabel(session: SessionInfo): string {
|
||||
if (session.name !== undefined && session.name !== "") return session.name;
|
||||
return session.firstMessage !== "" ? session.firstMessage : session.id.slice(0, 8);
|
||||
return session.firstMessage !== "" ? session.firstMessage : shortSessionId(session.id);
|
||||
}
|
||||
|
||||
export interface SessionRow {
|
||||
@@ -29,10 +31,13 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
|
||||
@property({ attribute: false }) sending: Record<string, true> = {};
|
||||
@property({ attribute: false }) selected?: SessionInfo;
|
||||
@property({ type: Number }) startingCount = 0;
|
||||
@property({ type: Boolean }) canStart = false;
|
||||
@property({ type: Boolean }) canDeleteArchived = false;
|
||||
@property({ type: Boolean }) canReload = false;
|
||||
@property({ type: Boolean }) canCleanup = false;
|
||||
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
||||
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
|
||||
@@ -51,6 +56,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onReload?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onCleanup?: () => void;
|
||||
|
||||
@state() private openMenuSessionId: string | undefined;
|
||||
@state() private menuStyle = "";
|
||||
@@ -106,6 +112,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
${this.collapsed ? null : html`
|
||||
<div class="list-body">
|
||||
${this.renderCurrentSelectionToolbar(currentSelectableSessions)}
|
||||
${this.startingCount > 0 ? this.renderStartingSession() : null}
|
||||
${currentRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "current"))}
|
||||
${archivedRows.length > 0 ? html`
|
||||
${this.renderArchivedHeading(archivedRows.map((row) => row.session))}
|
||||
@@ -126,7 +133,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
<h2>
|
||||
Sessions
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
<button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button>
|
||||
${this.renderCleanupButton()}
|
||||
${this.renderStartButton()}
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
@@ -134,10 +142,11 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
const selectedTitle = this.selected?.path ?? selectedSummary;
|
||||
return html`
|
||||
<h2>
|
||||
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
|
||||
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
<small class="section-count">${sessionCount}</small>
|
||||
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
|
||||
${this.renderCleanupButton()}
|
||||
${this.renderStartButton()}
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
@@ -148,6 +157,27 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
return html`<button class="bulk-select-entry ${active ? "selected" : ""}" title=${active ? "Close current session selection" : "Select current sessions"} aria-label=${active ? "Close current session selection" : "Select current sessions"} aria-expanded=${String(active)} aria-pressed=${String(active)} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleSelection("current", currentSessions); }}>☑</button>`;
|
||||
}
|
||||
|
||||
private renderCleanupButton() {
|
||||
return html`<button class="cleanup-entry" title=${this.canCleanup ? "Preview session cleanup" : this.cleanupUnavailableMessage} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onCleanup?.(); }}>Clean up</button>`;
|
||||
}
|
||||
|
||||
private renderStartButton() {
|
||||
const title = this.startingCount > 0 ? "Start another session" : "Start a new session";
|
||||
return html`<button class="start-session-button" title=${title} aria-label=${title} ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>`;
|
||||
}
|
||||
|
||||
private renderStartingSession() {
|
||||
const plural = this.startingCount !== 1;
|
||||
return html`
|
||||
<div class="pending-session-row starting-session" role="status" aria-live="polite">
|
||||
<div class="action-main">
|
||||
<span class="action-name"><span class="activity-indicator sending" aria-hidden="true"></span>${plural ? `Starting ${String(this.startingCount)} sessions…` : "Starting session…"}</span>
|
||||
<small>Waiting for ${plural ? "new sessions" : "the new session"} to be created</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderArchivedHeading(archivedSessions: SessionInfo[]) {
|
||||
const active = this.selectionScopes.has("archived");
|
||||
return html`
|
||||
@@ -163,7 +193,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null;
|
||||
|
||||
const selectedSessions = this.selectedSessions("current");
|
||||
const archivableSessions = selectedSessions.filter((session) => !isCachedNewSessionInfo(session));
|
||||
const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id]));
|
||||
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
|
||||
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
|
||||
return html`
|
||||
@@ -202,6 +232,11 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
const selectionActive = this.selectionScopes.has(scope);
|
||||
const showsCheckbox = selectionActive && canBulkSelect;
|
||||
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
|
||||
const status = this.statuses[session.id];
|
||||
const activity = this.activities[session.id];
|
||||
const canArchive = isArchivableSessionInfo(session, status);
|
||||
const canDeleteTransient = isTransientNewSessionInfo(session, status);
|
||||
const canReloadSession = canArchive && this.canReload;
|
||||
return html`
|
||||
<div
|
||||
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""}"
|
||||
@@ -213,25 +248,27 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
>
|
||||
<div class="action-main ${selectionActive ? "selecting" : ""}">
|
||||
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
|
||||
<span class="action-name">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages</small>
|
||||
<span class="action-name" dir="auto">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session, status, activity)}${String(session.messageCount)} messages</small>
|
||||
${this.renderActivity(session)}
|
||||
</div>
|
||||
<div class="action-menu">
|
||||
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
|
||||
${this.openMenuSessionId === session.id ? html`
|
||||
<div class="action-menu-panel" style=${this.menuStyle}>
|
||||
${isCachedNewSessionInfo(session)
|
||||
? html`<button title="Delete browser-cached new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
|
||||
: session.archived === true
|
||||
? html`
|
||||
<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
|
||||
`
|
||||
${session.archived === true
|
||||
? html`
|
||||
<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
|
||||
`
|
||||
: canDeleteTransient
|
||||
? html`<button title="Delete transient new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
|
||||
: html`
|
||||
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
||||
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
||||
${canArchive ? html`
|
||||
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
||||
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
||||
` : null}
|
||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
|
||||
${canReloadSession ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading from disk" : "Reload session from disk without refreshing Pi runtime resources"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload from disk</button>` : null}
|
||||
`}
|
||||
</div>
|
||||
` : null}
|
||||
@@ -278,7 +315,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
}
|
||||
|
||||
private archiveSelectedCurrent(): void {
|
||||
const sessions = this.selectedSessions("current").filter((session) => !isCachedNewSessionInfo(session));
|
||||
const sessions = this.selectedSessions("current").filter((session) => isArchivableSessionInfo(session, this.statuses[session.id]));
|
||||
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
|
||||
void this.onArchiveMany?.(sessions);
|
||||
}
|
||||
@@ -357,8 +394,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
private renderSessionMetaPrefix(session: SessionInfo) {
|
||||
if (isCachedNewSessionInfo(session)) return "new · ";
|
||||
private renderSessionMetaPrefix(session: SessionInfo, status: SessionStatus | undefined, activity: SessionActivity | undefined) {
|
||||
if (isTransientNewSessionInfo(session, status)) {
|
||||
if (activity?.phase === "active") return "creating · ";
|
||||
if (activity?.phase === "error") return "error · ";
|
||||
return "new · ";
|
||||
}
|
||||
if (session.archived === true) return "read-only · ";
|
||||
return "";
|
||||
}
|
||||
@@ -372,14 +413,21 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
h2 { min-height: 30px; }
|
||||
h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; }
|
||||
.bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; }
|
||||
.start-session-button { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; min-width: 30px; height: 30px; padding: 0 9px; }
|
||||
.cleanup-entry { flex: 0 0 auto; padding: 5px 7px; font-size: 12px; text-transform: none; }
|
||||
.bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; }
|
||||
.bulk-row button { padding: 5px 7px; font-size: 12px; }
|
||||
.bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); }
|
||||
.action-name, .section-selected { text-align: start; unicode-bidi: plaintext; }
|
||||
.bulk-row .capability-hint { flex: 1 0 100%; color: var(--pi-warning); }
|
||||
.bulk-row.selecting { padding: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: color-mix(in srgb, var(--pi-surface) 65%, transparent); }
|
||||
button.danger, .action-menu-panel button.danger { color: var(--pi-danger); }
|
||||
button.danger:hover, .action-menu-panel button.danger:hover { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
.action-row.bulk-selected .action-main { border-color: var(--pi-accent); box-shadow: inset 3px 0 0 var(--pi-accent); }
|
||||
.pending-session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr); margin: 6px 0; cursor: default; }
|
||||
.pending-session-row.starting-session .action-main { border-radius: 8px; border-style: dashed; color: var(--pi-muted); }
|
||||
.pending-session-row.starting-session .action-name { display: flex; align-items: center; gap: 6px; max-height: none; -webkit-line-clamp: 1; }
|
||||
.pending-session-row.starting-session .activity-indicator { flex: 0 0 auto; margin: 0; }
|
||||
.action-main.selecting { padding-left: calc(32px + var(--depth, 0) * 16px); }
|
||||
.session-checkbox { position: absolute; top: 9px; left: calc(8px + var(--depth, 0) * 16px); z-index: 2; margin: 0; }
|
||||
`];
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
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";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog session daemon machine targeting", () => {
|
||||
it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => {
|
||||
const config = configResponse({ host: "127.0.0.1" });
|
||||
const plugins: PiWebPluginsResponse = { plugins: [] };
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
|
||||
const dialog = new SettingsDialog();
|
||||
|
||||
await callDialogPromise(dialog, "loadConfig");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([[]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([[]]);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins);
|
||||
expect(getDialogProperty(dialog, "error")).toBe("");
|
||||
expect(getDialogProperty(dialog, "loading")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads session-daemon config from the selected machine", async () => {
|
||||
const config = configResponse({ spawnSessions: false, subsessions: true });
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
|
||||
const savedConfig = configResponse({ spawnSessions: true });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const dialog = new SettingsDialog();
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
|
||||
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]);
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } });
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
|
||||
const load = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
load.resolve(configResponse({ spawnSessions: false }));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale session-daemon save responses after the selected machine changes", async () => {
|
||||
stubWindowTimers();
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true });
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
save.resolve(configResponse({ subsessions: true }));
|
||||
await savePromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => {
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true }));
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)]));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(configSpy).not.toHaveBeenCalled();
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
});
|
||||
|
||||
it("does not save remote selected-machine settings when runtime support is missing", async () => {
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true }));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
|
||||
|
||||
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } });
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
});
|
||||
|
||||
it("shows selected-machine settings errors with the selected target name", async () => {
|
||||
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-dialog general settings machine targeting", () => {
|
||||
it("renders the active settings panel without the old global scope note", () => {
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.section = "general";
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const strings = collectTemplateStrings(dialog.render()).join("");
|
||||
|
||||
expect(strings).toContain("<settings-general-panel");
|
||||
expect(strings).not.toContain("scope-note");
|
||||
expect(strings).not.toContain("This tab edits:");
|
||||
});
|
||||
|
||||
it("keeps gateway server config saves on the gateway config endpoint", async () => {
|
||||
stubWindowTimers();
|
||||
const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
|
||||
await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig);
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads file access and upload config from the selected machine", async () => {
|
||||
const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => {
|
||||
stubWindowTimers();
|
||||
const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } };
|
||||
const savedConfig = configResponse(patch);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
});
|
||||
const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} };
|
||||
const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[patch, "local"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
});
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale file access load responses after the selected machine changes", async () => {
|
||||
const load = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } }));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale file access save responses after the selected machine changes", async () => {
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } });
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }));
|
||||
await savePromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows selected-machine file access errors with the selected target name", async () => {
|
||||
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-dialog plugin settings machine targeting", () => {
|
||||
it("loads plugin config and plugin list from the selected machine", async () => {
|
||||
const config = configResponse({ plugins: { info: { enabled: true } } });
|
||||
const plugins = pluginsResponse([pluginInfo("info", true)]);
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins);
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => {
|
||||
const config = configResponse({ plugins: { info: { enabled: true } } });
|
||||
vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => {
|
||||
stubWindowTimers();
|
||||
const baseConfig = configResponse({
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { settings: { color: "blue" } },
|
||||
},
|
||||
});
|
||||
const savedConfig = configResponse({
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { enabled: false, settings: { color: "blue" } },
|
||||
},
|
||||
});
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig);
|
||||
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[
|
||||
{
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { enabled: false, settings: { color: "blue" } },
|
||||
},
|
||||
},
|
||||
"remote-a",
|
||||
]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } },
|
||||
});
|
||||
const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } });
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } }));
|
||||
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", true);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
},
|
||||
});
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale plugin load responses after the selected machine changes", async () => {
|
||||
const configLoad = deferred<PiWebConfigResponse>();
|
||||
const pluginsLoad = deferred<PiWebPluginsResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise);
|
||||
vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } }));
|
||||
pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)]));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale plugin save responses after the selected machine changes", async () => {
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)]));
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
save.resolve(configResponse({ plugins: { info: { enabled: false } } }));
|
||||
await savePromise;
|
||||
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const remoteMachine: Machine = {
|
||||
id: "remote-a",
|
||||
name: "Lab Mac",
|
||||
kind: "remote",
|
||||
baseUrl: "https://lab.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const secondRemoteMachine: Machine = {
|
||||
id: "remote-b",
|
||||
name: "Build Box",
|
||||
kind: "remote",
|
||||
baseUrl: "https://build.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const runtimeWithoutSelectedMachineSettings: MachineRuntime = {
|
||||
machineId: "remote-a",
|
||||
ok: true,
|
||||
checkedAt: "2026-07-01T00:00:00.000Z",
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
|
||||
};
|
||||
|
||||
function getDialogProperty(dialog: SettingsDialog, property: string): unknown {
|
||||
return Reflect.get(dialog, property);
|
||||
}
|
||||
|
||||
function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void {
|
||||
if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`);
|
||||
}
|
||||
|
||||
async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise<void> {
|
||||
const result = callDialogMethod(dialog, methodName, ...args);
|
||||
if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`);
|
||||
await result;
|
||||
}
|
||||
|
||||
function callDialogUpdated(dialog: SettingsDialog, changed: Map<string, unknown>): void {
|
||||
const result = callDialogMethod(dialog, "updated", changed);
|
||||
if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value");
|
||||
}
|
||||
|
||||
function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown {
|
||||
const method: unknown = Reflect.get(dialog, methodName);
|
||||
if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`);
|
||||
return method.call(dialog, ...args);
|
||||
}
|
||||
|
||||
function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown {
|
||||
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",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
|
||||
function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse {
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
|
||||
return {
|
||||
id,
|
||||
module: `/pi-web-plugins/${id}/plugin.js`,
|
||||
source: "test",
|
||||
scope: "local",
|
||||
machineSpecific: false,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
let rejectDeferred: ((error: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolveDeferred = resolve;
|
||||
rejectDeferred = reject;
|
||||
});
|
||||
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
|
||||
}
|
||||
|
||||
function stubWindowTimers(): void {
|
||||
vi.stubGlobal("window", {
|
||||
clearTimeout: vi.fn(),
|
||||
setTimeout: vi.fn(() => 1),
|
||||
});
|
||||
}
|
||||
@@ -1,31 +1,65 @@
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { AppAction } from "../actions";
|
||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
|
||||
import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
|
||||
import type { SettingsSection } from "../settingsRoute";
|
||||
import "./settings/SettingsGeneralPanel";
|
||||
import "./settings/SettingsSessiondPanel";
|
||||
import "./settings/SettingsPackagesPanel";
|
||||
import "./settings/SettingsPluginsPanel";
|
||||
import "./settings/SettingsShortcutsPanel";
|
||||
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
|
||||
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
|
||||
import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig";
|
||||
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
|
||||
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig";
|
||||
import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig";
|
||||
|
||||
@customElement("settings-dialog")
|
||||
export class SettingsDialog extends LitElement {
|
||||
@property({ attribute: false }) section: SettingsSection = "general";
|
||||
@property({ attribute: false }) actions: AppAction[] = [];
|
||||
@property({ attribute: false }) machine: Machine | undefined;
|
||||
@property({ attribute: false }) machineRuntime: MachineRuntime | undefined;
|
||||
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
|
||||
@property({ attribute: false }) onClose?: () => void;
|
||||
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
|
||||
@state() private configResponse: PiWebConfigResponse | undefined;
|
||||
@state() private accessConfigResponse: PiWebConfigResponse | undefined;
|
||||
@state() private sessiondConfigResponse: PiWebConfigResponse | undefined;
|
||||
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
|
||||
@state() private selectedPluginConfigResponse: PiWebConfigResponse | undefined;
|
||||
@state() private selectedPluginsResponse: PiWebPluginsResponse | undefined;
|
||||
@state() private packagesResponse: PiPackagesResponse | undefined;
|
||||
@state() private loading = true;
|
||||
@state() private accessLoading = true;
|
||||
@state() private sessiondLoading = true;
|
||||
@state() private pluginLoading = true;
|
||||
@state() private packageLoading = true;
|
||||
@state() private saving = false;
|
||||
@state() private packageOperation: PiPackageOperationState | undefined;
|
||||
@state() private error = "";
|
||||
@state() private accessError = "";
|
||||
@state() private sessiondError = "";
|
||||
@state() private pluginError = "";
|
||||
@state() private packageError = "";
|
||||
@state() private savedMessage = "";
|
||||
@state() private packageMessage = "";
|
||||
private savedMessageTimer: number | undefined;
|
||||
private loadRequestSeq = 0;
|
||||
private accessLoadRequestSeq = 0;
|
||||
private sessiondLoadRequestSeq = 0;
|
||||
private pluginLoadRequestSeq = 0;
|
||||
private packageLoadRequestSeq = 0;
|
||||
private packageMutationSeq = 0;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.loadConfig();
|
||||
void this.loadAccessConfigForTarget();
|
||||
void this.loadSessiondConfigForTarget();
|
||||
void this.loadPluginsForTarget();
|
||||
void this.loadPackagesForTarget();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
@@ -34,6 +68,37 @@ export class SettingsDialog extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>): void {
|
||||
const currentTarget = this.settingsTarget();
|
||||
if (changed.has("machine")) {
|
||||
const previousTarget = settingsMachineTarget(changed.get("machine"));
|
||||
if (previousTarget.id !== currentTarget.id) {
|
||||
this.resetAccessStateForTargetChange();
|
||||
if (this.isConnected) void this.loadAccessConfigForTarget(currentTarget);
|
||||
this.resetSessiondStateForTargetChange();
|
||||
if (this.isConnected) void this.loadSessiondConfigForTarget(currentTarget);
|
||||
this.resetPluginStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPluginsForTarget(currentTarget);
|
||||
this.resetPackageStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed.has("machineRuntime")) return;
|
||||
if (this.selectedMachineSettingsSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) {
|
||||
this.resetAccessStateForTargetChange();
|
||||
if (this.isConnected) void this.loadAccessConfigForTarget(currentTarget);
|
||||
this.resetSessiondStateForTargetChange();
|
||||
if (this.isConnected) void this.loadSessiondConfigForTarget(currentTarget);
|
||||
this.resetPluginStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPluginsForTarget(currentTarget);
|
||||
}
|
||||
if (!this.packageManagementSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) return;
|
||||
this.resetPackageStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
return html`
|
||||
<div class="backdrop" @mousedown=${() => this.onClose?.()}>
|
||||
@@ -47,10 +112,11 @@ export class SettingsDialog extends LitElement {
|
||||
</header>
|
||||
<div class="settings-body">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
${this.renderNavButton("general", "General", "Server config")}
|
||||
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
|
||||
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
|
||||
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
|
||||
${this.renderNavButton("general", "General", "Gateway + selected machine")}
|
||||
${this.renderNavButton("sessiond", "Session daemon", "Selected machine")}
|
||||
${this.renderNavButton("packages", "Pi packages", "Selected machine")}
|
||||
${this.renderNavButton("plugins", "PI WEB plugins", "Selected machine")}
|
||||
${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
|
||||
</nav>
|
||||
<main class="settings-content">
|
||||
${this.renderActiveSection()}
|
||||
@@ -65,13 +131,14 @@ export class SettingsDialog extends LitElement {
|
||||
if (this.section === "sessiond") {
|
||||
return html`
|
||||
<settings-sessiond-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.loading=${this.loading}
|
||||
.configResponse=${this.sessiondConfigResponse}
|
||||
.loading=${this.sessiondLoading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.error=${this.sessiondError}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
||||
.onReload=${() => this.loadSessiondConfigForTarget()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)}
|
||||
></settings-sessiond-panel>
|
||||
`;
|
||||
}
|
||||
@@ -89,16 +156,34 @@ export class SettingsDialog extends LitElement {
|
||||
></settings-shortcuts-panel>
|
||||
`;
|
||||
}
|
||||
if (this.section === "packages") {
|
||||
return html`
|
||||
<settings-packages-panel
|
||||
.packagesResponse=${this.packagesResponse}
|
||||
.targetMachine=${this.packageTarget()}
|
||||
.managementSupport=${this.packageManagementSupport()}
|
||||
.loading=${this.packageLoading}
|
||||
.operation=${this.packageOperation}
|
||||
.error=${this.packageError}
|
||||
.operationMessage=${this.packageMessage}
|
||||
.onReload=${() => this.loadPackagesForTarget()}
|
||||
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
|
||||
.onRemovePackage=${(source: string, scope: PiPackageScope) => this.removePiPackage(source, scope)}
|
||||
.onUpdatePackage=${(source?: string) => this.updatePiPackage(source)}
|
||||
></settings-packages-panel>
|
||||
`;
|
||||
}
|
||||
if (this.section === "plugins") {
|
||||
return html`
|
||||
<settings-plugins-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.pluginsResponse=${this.pluginsResponse}
|
||||
.loading=${this.loading}
|
||||
.configResponse=${this.selectedPluginConfigResponse}
|
||||
.pluginsResponse=${this.selectedPluginsResponse}
|
||||
.loading=${this.pluginLoading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.error=${this.pluginError}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
||||
.onReload=${() => this.loadPluginsForTarget()}
|
||||
.onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)}
|
||||
></settings-plugins-panel>
|
||||
`;
|
||||
@@ -106,12 +191,18 @@ export class SettingsDialog extends LitElement {
|
||||
return html`
|
||||
<settings-general-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.machineConfigResponse=${this.accessConfigResponse}
|
||||
.loading=${this.loading}
|
||||
.machineLoading=${this.accessLoading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.machineError=${this.accessError}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onReloadMachine=${() => this.loadAccessConfigForTarget()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
.onSaveMachineConfig=${(config: PiWebConfigValues) => this.saveMachineAccessConfig(config)}
|
||||
></settings-general-panel>
|
||||
`;
|
||||
}
|
||||
@@ -131,31 +222,152 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
|
||||
private async loadConfig(): Promise<void> {
|
||||
const requestSeq = ++this.loadRequestSeq;
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
const [config, plugins] = await Promise.all([configApi.config(), pluginsApi.plugins()]);
|
||||
this.configResponse = config;
|
||||
this.pluginsResponse = plugins;
|
||||
} catch (error) {
|
||||
this.error = `Failed to load settings: ${errorMessage(error)}`;
|
||||
const result = await loadGatewaySettingsData({
|
||||
loadConfig: () => configApi.config(),
|
||||
loadPlugins: () => pluginsApi.plugins(),
|
||||
});
|
||||
if (!this.isCurrentLoad(requestSeq)) return;
|
||||
|
||||
if (result.config !== undefined) this.configResponse = result.config;
|
||||
if (result.plugins !== undefined) this.pluginsResponse = result.plugins;
|
||||
this.error = result.error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
if (this.isCurrentLoad(requestSeq)) this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAccessConfigForTarget(target = this.settingsTarget()): Promise<void> {
|
||||
const requestSeq = ++this.accessLoadRequestSeq;
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.accessConfigResponse = undefined;
|
||||
this.accessLoading = false;
|
||||
this.accessError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.accessLoading = true;
|
||||
this.accessError = "";
|
||||
try {
|
||||
const response = await configApi.config(target.id);
|
||||
if (!this.isCurrentAccessLoad(requestSeq, target)) return;
|
||||
this.accessConfigResponse = response;
|
||||
} catch (error) {
|
||||
if (this.isCurrentAccessLoad(requestSeq, target)) {
|
||||
this.accessError = `Failed to load file access/upload config from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
if (this.isCurrentAccessLoad(requestSeq, target)) this.accessLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
|
||||
const requestSeq = ++this.sessiondLoadRequestSeq;
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.sessiondConfigResponse = undefined;
|
||||
this.sessiondLoading = false;
|
||||
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.sessiondLoading = true;
|
||||
this.sessiondError = "";
|
||||
try {
|
||||
const response = await configApi.config(target.id);
|
||||
if (!this.isCurrentSessiondLoad(requestSeq, target)) return;
|
||||
this.sessiondConfigResponse = response;
|
||||
} catch (error) {
|
||||
if (this.isCurrentSessiondLoad(requestSeq, target)) {
|
||||
this.sessiondError = `Failed to load session-daemon config from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
if (this.isCurrentSessiondLoad(requestSeq, target)) this.sessiondLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPluginsForTarget(target = this.settingsTarget()): Promise<void> {
|
||||
const requestSeq = ++this.pluginLoadRequestSeq;
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.selectedPluginConfigResponse = undefined;
|
||||
this.selectedPluginsResponse = undefined;
|
||||
this.pluginLoading = false;
|
||||
this.pluginError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.pluginLoading = true;
|
||||
this.pluginError = "";
|
||||
try {
|
||||
const [config, plugins] = await Promise.allSettled([configApi.config(target.id), pluginsApi.plugins(target.id)]);
|
||||
if (!this.isCurrentPluginLoad(requestSeq, target)) return;
|
||||
|
||||
const errors: string[] = [];
|
||||
if (config.status === "fulfilled") this.selectedPluginConfigResponse = config.value;
|
||||
else errors.push(`config: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(config.reason), target)}`);
|
||||
|
||||
if (plugins.status === "fulfilled") this.selectedPluginsResponse = plugins.value;
|
||||
else errors.push(`PI WEB plugins: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(plugins.reason), target)}`);
|
||||
|
||||
this.pluginError = errors.length === 0 ? "" : `Failed to load PI WEB plugin settings from ${settingsMachineTargetLabel(target)}: ${errors.join("; ")}`;
|
||||
} finally {
|
||||
if (this.isCurrentPluginLoad(requestSeq, target)) this.pluginLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPackagesForTarget(target = this.packageTarget()): Promise<void> {
|
||||
const requestSeq = ++this.packageLoadRequestSeq;
|
||||
this.packageLoading = true;
|
||||
this.packageError = "";
|
||||
this.packageMessage = "";
|
||||
try {
|
||||
const result = await loadPiPackagesData(target, (targetId) => piPackagesApi.packages(targetId), this.packageManagementSupport(target));
|
||||
if (!this.isCurrentPackageLoad(requestSeq, target)) return;
|
||||
|
||||
this.packagesResponse = result.packagesResponse;
|
||||
this.packageError = result.error;
|
||||
} finally {
|
||||
if (this.isCurrentPackageLoad(requestSeq, target)) this.packageLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async togglePlugin(pluginId: string, enabled: boolean): Promise<void> {
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
const currentPlugins = baseConfig.plugins ?? {};
|
||||
const currentPluginConfig = currentPlugins[pluginId] ?? {};
|
||||
await this.saveConfig({
|
||||
...baseConfig,
|
||||
plugins: {
|
||||
...currentPlugins,
|
||||
[pluginId]: { ...currentPluginConfig, enabled },
|
||||
},
|
||||
});
|
||||
await this.refreshPlugins();
|
||||
if (this.saving) return;
|
||||
const target = this.settingsTarget();
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.pluginError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
if (this.selectedPluginConfigResponse === undefined) {
|
||||
this.pluginError = `Plugin config is not loaded for ${settingsMachineTargetLabel(target)}. Reload before changing plugin enablement.`;
|
||||
return;
|
||||
}
|
||||
const patch = pluginEnabledConfigPatch(this.selectedPluginConfigResponse.config, pluginId, enabled);
|
||||
this.saving = true;
|
||||
this.pluginError = "";
|
||||
this.savedMessage = "";
|
||||
try {
|
||||
const response = await configApi.saveConfig(patch, target.id);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
this.selectedPluginConfigResponse = response;
|
||||
if (target.kind === "local" && this.configResponse !== undefined) {
|
||||
this.configResponse = mergeSelectedMachinePluginConfig(this.configResponse, response);
|
||||
this.onConfigSaved?.(this.configResponse.effectiveConfig);
|
||||
}
|
||||
const pluginRefreshError = await this.refreshPluginsForTarget(target);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
if (pluginRefreshError !== undefined) this.pluginError = pluginRefreshError;
|
||||
this.showSavedMessage();
|
||||
} catch (error) {
|
||||
if (this.isCurrentSettingsTarget(target)) {
|
||||
this.pluginError = `Failed to save PI WEB plugin config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveConfig(config: PiWebConfigValues): Promise<void> {
|
||||
@@ -175,14 +387,226 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshPlugins(): Promise<void> {
|
||||
private async saveMachineAccessConfig(config: PiWebConfigValues): Promise<void> {
|
||||
if (this.saving) return;
|
||||
const target = this.settingsTarget();
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.accessError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.accessError = "";
|
||||
this.savedMessage = "";
|
||||
try {
|
||||
const response = await configApi.saveConfig(config, target.id);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
this.accessConfigResponse = response;
|
||||
if (target.kind === "local" && this.configResponse !== undefined) {
|
||||
this.configResponse = mergeSelectedMachineAccessConfig(this.configResponse, response);
|
||||
this.onConfigSaved?.(this.configResponse.effectiveConfig);
|
||||
}
|
||||
this.showSavedMessage();
|
||||
} catch (error) {
|
||||
if (this.isCurrentSettingsTarget(target)) {
|
||||
this.accessError = `Failed to save file access/upload config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSessiondConfig(config: PiWebConfigValues): Promise<void> {
|
||||
if (this.saving) return;
|
||||
const target = this.settingsTarget();
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.sessiondError = "";
|
||||
this.savedMessage = "";
|
||||
try {
|
||||
const response = await configApi.saveConfig(config, target.id);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
this.sessiondConfigResponse = response;
|
||||
if (target.kind === "local" && this.configResponse !== undefined) this.configResponse = mergeSelectedMachineSessiondConfig(this.configResponse, response);
|
||||
this.showSavedMessage();
|
||||
} catch (error) {
|
||||
if (this.isCurrentSettingsTarget(target)) {
|
||||
this.sessiondError = `Failed to save session-daemon config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async installPiPackage(source: string): Promise<void> {
|
||||
const target = this.packageTarget();
|
||||
await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", target, () => piPackagesApi.install(source, target.id));
|
||||
}
|
||||
|
||||
private async removePiPackage(source: string, scope: PiPackageScope): Promise<void> {
|
||||
const target = this.packageTarget();
|
||||
await this.runPiPackageMutation({ kind: "remove", source }, "remove Pi package", target, () => piPackagesApi.remove(source, scope, target.id));
|
||||
}
|
||||
|
||||
private async updatePiPackage(source?: string): Promise<void> {
|
||||
const target = this.packageTarget();
|
||||
await this.runPiPackageMutation(source === undefined ? { kind: "update-all" } : { kind: "update", source }, "update Pi packages", target, () => piPackagesApi.update(source, target.id));
|
||||
}
|
||||
|
||||
private async runPiPackageMutation(operation: PiPackageOperationState, label: string, target: PiPackageTargetContext, mutate: () => Promise<PiPackageMutationResponse>): Promise<void> {
|
||||
const support = this.packageManagementSupport(target);
|
||||
if (isPiPackageManagementUnsupported(support)) {
|
||||
this.packageError = support.message ?? `Pi package management is not available on ${piPackageTargetLabel(target)}.`;
|
||||
throw new Error(this.packageError);
|
||||
}
|
||||
if (this.saving) throw new Error("A settings operation is already running.");
|
||||
const requestSeq = ++this.packageMutationSeq;
|
||||
this.packageLoadRequestSeq += 1;
|
||||
this.packageLoading = false;
|
||||
this.saving = true;
|
||||
this.packageOperation = operation;
|
||||
this.packageError = "";
|
||||
this.packageMessage = "";
|
||||
try {
|
||||
const response = await mutate();
|
||||
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
|
||||
this.packagesResponse = { packages: response.packages };
|
||||
const pluginRefreshError = shouldRefreshGatewayPluginsAfterPiPackageMutation(target) ? await this.refreshGatewayPlugins() : undefined;
|
||||
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
|
||||
if (pluginRefreshError !== undefined) this.packageError = pluginRefreshError;
|
||||
this.packageMessage = piPackageMutationFollowUpMessage(response.action, target);
|
||||
} catch (error) {
|
||||
if (this.isCurrentPackageMutation(requestSeq, target)) this.packageError = `Failed to ${label} on ${piPackageTargetLabel(target)}: ${friendlyPiPackageErrorMessage(errorMessage(error), target)}`;
|
||||
throw error;
|
||||
} finally {
|
||||
if (this.packageMutationSeq === requestSeq) {
|
||||
this.packageOperation = undefined;
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshGatewayPlugins(): Promise<string | undefined> {
|
||||
try {
|
||||
this.pluginsResponse = await pluginsApi.plugins();
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
this.error = `Failed to refresh plugins: ${errorMessage(error)}`;
|
||||
return `Failed to refresh gateway PI WEB plugins: ${errorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshPluginsForTarget(target: SettingsMachineTarget): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await pluginsApi.plugins(target.id);
|
||||
if (this.isCurrentSettingsTarget(target)) this.selectedPluginsResponse = response;
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return `Config saved, but failed to refresh PI WEB plugins from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private settingsTarget(): SettingsMachineTarget {
|
||||
return settingsMachineTarget(this.machine);
|
||||
}
|
||||
|
||||
private packageTarget(): PiPackageTargetContext {
|
||||
return this.settingsTarget();
|
||||
}
|
||||
|
||||
private selectedMachineSettingsSupport(target = this.settingsTarget()): SelectedMachineSettingsSupport {
|
||||
return selectedMachineSettingsSupport(target, this.machineRuntime);
|
||||
}
|
||||
|
||||
private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean {
|
||||
const previousSupport = selectedMachineSettingsSupport(target, previousRuntime);
|
||||
const currentSupport = this.selectedMachineSettingsSupport(target);
|
||||
return selectedMachineSettingsSupportKey(previousSupport) !== selectedMachineSettingsSupportKey(currentSupport);
|
||||
}
|
||||
|
||||
private packageManagementSupport(target = this.packageTarget()): PiPackageManagementSupport {
|
||||
return piPackageManagementSupport(target, this.machineRuntime);
|
||||
}
|
||||
|
||||
private packageManagementSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: PiPackageTargetContext): boolean {
|
||||
const previousSupport = piPackageManagementSupport(target, previousRuntime);
|
||||
const currentSupport = this.packageManagementSupport(target);
|
||||
if (piPackageManagementSupportKey(previousSupport) === piPackageManagementSupportKey(currentSupport)) return false;
|
||||
return previousSupport.state === "unsupported" || currentSupport.state === "unsupported";
|
||||
}
|
||||
|
||||
private isCurrentLoad(requestSeq: number): boolean {
|
||||
return requestSeq === this.loadRequestSeq;
|
||||
}
|
||||
|
||||
private isCurrentAccessLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
|
||||
return requestSeq === this.accessLoadRequestSeq && this.isCurrentSettingsTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentSessiondLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
|
||||
return requestSeq === this.sessiondLoadRequestSeq && this.isCurrentSettingsTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentPluginLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
|
||||
return requestSeq === this.pluginLoadRequestSeq && this.isCurrentSettingsTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentPackageLoad(requestSeq: number, target: PiPackageTargetContext): boolean {
|
||||
return requestSeq === this.packageLoadRequestSeq && this.isCurrentPackageTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentPackageMutation(requestSeq: number, target: PiPackageTargetContext): boolean {
|
||||
return requestSeq === this.packageMutationSeq && this.isCurrentPackageTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentPackageTarget(target: PiPackageTargetContext): boolean {
|
||||
return this.packageTarget().id === target.id;
|
||||
}
|
||||
|
||||
private isCurrentSettingsTarget(target: SettingsMachineTarget): boolean {
|
||||
return this.settingsTarget().id === target.id;
|
||||
}
|
||||
|
||||
private resetAccessStateForTargetChange(): void {
|
||||
this.accessLoadRequestSeq += 1;
|
||||
this.accessLoading = false;
|
||||
this.accessError = "";
|
||||
this.accessConfigResponse = undefined;
|
||||
this.savedMessage = "";
|
||||
}
|
||||
|
||||
private resetSessiondStateForTargetChange(): void {
|
||||
this.sessiondLoadRequestSeq += 1;
|
||||
this.sessiondLoading = false;
|
||||
this.sessiondError = "";
|
||||
this.sessiondConfigResponse = undefined;
|
||||
this.savedMessage = "";
|
||||
}
|
||||
|
||||
private resetPluginStateForTargetChange(): void {
|
||||
this.pluginLoadRequestSeq += 1;
|
||||
this.pluginLoading = false;
|
||||
this.pluginError = "";
|
||||
this.selectedPluginConfigResponse = undefined;
|
||||
this.selectedPluginsResponse = undefined;
|
||||
this.savedMessage = "";
|
||||
}
|
||||
|
||||
private resetPackageStateForTargetChange(): void {
|
||||
const hadPackageOperation = this.packageOperation !== undefined;
|
||||
this.packageLoadRequestSeq += 1;
|
||||
this.packageMutationSeq += 1;
|
||||
this.packageLoading = false;
|
||||
this.packageOperation = undefined;
|
||||
this.packageMessage = "";
|
||||
this.packageError = "";
|
||||
this.packagesResponse = undefined;
|
||||
if (hadPackageOperation) this.saving = false;
|
||||
}
|
||||
|
||||
private showSavedMessage(): void {
|
||||
this.savedMessage = "Config saved.";
|
||||
if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer);
|
||||
|
||||
@@ -4,6 +4,11 @@ import type { ToolExecutionPart } from "./shared";
|
||||
|
||||
const MAX_COLLAPSED_DIFF_LINES = 180;
|
||||
|
||||
interface ToolTarget {
|
||||
label: "Command" | "File" | "Input";
|
||||
text: string;
|
||||
}
|
||||
|
||||
@customElement("tool-execution-view")
|
||||
export class ToolExecutionView extends LitElement {
|
||||
@property({ attribute: false }) execution: ToolExecutionPart | undefined;
|
||||
@@ -15,7 +20,6 @@ export class ToolExecutionView extends LitElement {
|
||||
const execution = this.execution;
|
||||
if (execution === undefined) return null;
|
||||
|
||||
const edit = execution.toolName === "edit";
|
||||
const path = pathFromArgs(execution.args);
|
||||
const actualDiff = diffFromDetails(execution.details);
|
||||
const preview = execution.preview;
|
||||
@@ -24,6 +28,7 @@ export class ToolExecutionView extends LitElement {
|
||||
const previewMismatch = actualDiff !== undefined && preview?.diff !== undefined && actualDiff !== preview.diff;
|
||||
const errorText = execution.status === "error" ? execution.resultText : preview?.error;
|
||||
const bodyText = visibleDiff === undefined ? execution.resultText : undefined;
|
||||
const target = toolTarget(execution, path);
|
||||
|
||||
return html`
|
||||
<section class=${`tool-card ${execution.status}`}>
|
||||
@@ -31,7 +36,7 @@ export class ToolExecutionView extends LitElement {
|
||||
<div class="tool-title">
|
||||
<span class="status-icon" aria-hidden="true">${statusIcon(execution.status)}</span>
|
||||
<strong>${execution.toolName}</strong>
|
||||
${path === undefined ? html`<span class="summary">${execution.summary}</span>` : html`<span class="path">${path}</span>`}
|
||||
${this.renderHeaderTarget(target)}
|
||||
</div>
|
||||
<div class="tool-meta">
|
||||
${editCountLabel(execution) === undefined ? null : html`<span>${editCountLabel(execution)}</span>`}
|
||||
@@ -42,23 +47,44 @@ export class ToolExecutionView extends LitElement {
|
||||
|
||||
${previewMismatch ? html`<p class="notice">Applied diff differs from the preview.</p>` : null}
|
||||
${errorText === undefined || errorText === "" ? null : html`<pre class="error-text">${errorText}</pre>`}
|
||||
${visibleDiff === undefined ? this.renderTextBody(bodyText, execution.status === "error") : this.renderDiffBody(visibleDiff, actualDiff === undefined ? "Preview diff" : "Applied diff")}
|
||||
${!edit && visibleDiff === undefined && (bodyText === undefined || bodyText === "") ? html`<p class="muted">${execution.summary}</p>` : null}
|
||||
${visibleDiff === undefined ? this.renderTextBody(bodyText, execution.status === "error", target) : this.renderDiffBody(visibleDiff, actualDiff === undefined ? "Preview diff" : "Applied diff", target)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTextBody(text: string | undefined, open: boolean) {
|
||||
if (text === undefined || text === "") return null;
|
||||
private renderHeaderTarget(target: ToolTarget | undefined) {
|
||||
if (target === undefined) return null;
|
||||
const className = target.label === "File" ? "path" : "summary";
|
||||
return html`<span class=${className} title=${target.text} aria-label=${`${target.label}: ${target.text}`}>${target.text}</span>`;
|
||||
}
|
||||
|
||||
private renderExpandedTarget(target: ToolTarget | undefined) {
|
||||
if (target === undefined) return null;
|
||||
return html`
|
||||
<div class="detail-target">
|
||||
<span class="detail-label">${target.label}</span>
|
||||
<pre class="detail-target-value">${target.text}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTextBody(text: string | undefined, open: boolean, target: ToolTarget | undefined) {
|
||||
if ((text === undefined || text === "") && target === undefined) return null;
|
||||
return html`
|
||||
<details class="text-body" ?open=${open}>
|
||||
<summary>Result</summary>
|
||||
<pre>${text}</pre>
|
||||
<summary>Details</summary>
|
||||
${this.renderExpandedTarget(target)}
|
||||
${text === undefined || text === "" ? null : html`
|
||||
<div class="detail-result">
|
||||
<span class="detail-label">Result</span>
|
||||
<pre>${text}</pre>
|
||||
</div>
|
||||
`}
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDiffBody(diff: string, label: string) {
|
||||
private renderDiffBody(diff: string, label: string, target: ToolTarget | undefined) {
|
||||
const lines = diff.split("\n");
|
||||
const truncated = !this.showFullDiff && lines.length > MAX_COLLAPSED_DIFF_LINES;
|
||||
const visibleLines = truncated ? lines.slice(0, MAX_COLLAPSED_DIFF_LINES) : lines;
|
||||
@@ -68,6 +94,7 @@ export class ToolExecutionView extends LitElement {
|
||||
<span>${label}</span>
|
||||
<small>${String(lines.length)} ${lines.length === 1 ? "line" : "lines"}</small>
|
||||
</summary>
|
||||
${this.renderExpandedTarget(target)}
|
||||
<div class="diff-toolbar">
|
||||
<span>${truncated ? `Showing ${String(visibleLines.length)} of ${String(lines.length)} lines` : "Full diff"}</span>
|
||||
<button type="button" @click=${() => { void this.copyDiff(diff); }}>${this.copied ? "Copied" : "Copy diff"}</button>
|
||||
@@ -104,10 +131,10 @@ export class ToolExecutionView extends LitElement {
|
||||
.tool-card.success { border-color: var(--pi-success-border); background: var(--pi-success-bg); }
|
||||
.tool-card.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); }
|
||||
.tool-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; min-width: 0; }
|
||||
.tool-title { display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; }
|
||||
.tool-title { flex: 1 1 auto; display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; }
|
||||
.status-icon { flex: 0 0 auto; color: var(--pi-muted); }
|
||||
strong { color: var(--pi-text); }
|
||||
.path, .summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-accent); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
strong { flex: 0 0 auto; color: var(--pi-text); }
|
||||
.path, .summary { display: block; flex: 1 1 auto; min-width: 0; max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; white-space: pre; color: var(--pi-accent); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; }
|
||||
.summary { color: var(--pi-muted); font-family: inherit; }
|
||||
.tool-meta { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 8px; color: var(--pi-muted); font-size: 12px; }
|
||||
.diff-stats { display: inline-flex; gap: 3px; }
|
||||
@@ -118,7 +145,11 @@ export class ToolExecutionView extends LitElement {
|
||||
.muted { margin: 0; color: var(--pi-muted); }
|
||||
.error-text { margin: 0; border: 1px solid var(--pi-danger); border-radius: 7px; background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); padding: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.text-body { border-top: 1px solid var(--pi-border-muted); padding-top: 6px; }
|
||||
.text-body pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--pi-text); }
|
||||
.detail-target, .detail-result { display: grid; gap: 4px; margin-top: 8px; min-width: 0; }
|
||||
.detail-label { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.text-body pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--pi-text); }
|
||||
.detail-result pre { box-sizing: border-box; max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; border: 1px solid var(--pi-border-muted); border-radius: 7px; background: var(--pi-bg); padding: 8px; white-space: pre; overflow-wrap: normal; direction: ltr; text-align: left; unicode-bidi: isolate; }
|
||||
.detail-target-value { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--pi-accent); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; }
|
||||
.diff-details { min-width: 0; max-width: 100%; border-top: 1px solid var(--pi-border-muted); padding-top: 6px; }
|
||||
.diff-details > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; color: var(--pi-muted); cursor: pointer; }
|
||||
.diff-details > summary span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -140,6 +171,14 @@ export class ToolExecutionView extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
function toolTarget(execution: ToolExecutionPart, path: string | undefined): ToolTarget | undefined {
|
||||
if (path !== undefined && path !== "") return { label: "File", text: path };
|
||||
const command = getString(execution.args, "command");
|
||||
if (command !== undefined && command !== "") return { label: "Command", text: command };
|
||||
if (execution.summary !== "") return { label: "Input", text: execution.summary };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pathFromArgs(args: unknown): string | undefined {
|
||||
return getString(args, "path") ?? getString(args, "file_path");
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ export class WorkspaceFilesPanel extends LitElement {
|
||||
form { min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: auto; padding: 16px; }
|
||||
form > label { display: grid; gap: 6px; }
|
||||
form > label > span, .review-files > strong { font-weight: 600; }
|
||||
input[type="text"], form > label > input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 8px 9px; font: inherit; }
|
||||
input[type="text"], form > label > input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 8px 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); }
|
||||
input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
|
||||
.dialog-options { display: grid; gap: 8px; }
|
||||
.dialog-options label { display: flex; align-items: center; gap: 8px; color: var(--pi-text); }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import type { Machine, Project, SessionInfo, Workspace } from "../../api";
|
||||
import { shortSessionId } from "../../sessionLabels";
|
||||
import type { NavigationSection } from "../../appShell/navigationState";
|
||||
|
||||
@customElement("app-context-bar")
|
||||
@@ -193,7 +194,7 @@ function workspaceContextTitle(workspace: Workspace | undefined): string {
|
||||
function sessionContextLabel(session: SessionInfo | undefined): string {
|
||||
const name = session?.name?.trim();
|
||||
const firstMessage = session?.firstMessage.trim();
|
||||
return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session";
|
||||
return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session === undefined ? "No session" : shortSessionId(session.id);
|
||||
}
|
||||
|
||||
function sessionContextTitle(session: SessionInfo | undefined): string {
|
||||
|
||||
@@ -39,10 +39,13 @@ export class AppNavigationPanel extends LitElement {
|
||||
@property({ type: Boolean }) projectsCollapsed = false;
|
||||
@property({ type: Boolean }) workspacesCollapsed = false;
|
||||
@property({ type: Boolean }) sessionsCollapsed = false;
|
||||
@property({ type: Number }) startingSessionCount = 0;
|
||||
@property({ type: Boolean }) canStartSession = false;
|
||||
@property({ type: Boolean }) canDeleteArchivedSessions = false;
|
||||
@property({ type: Boolean }) canReloadSessions = false;
|
||||
@property({ type: Boolean }) canCleanupSessions = false;
|
||||
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
||||
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
|
||||
@property({ attribute: false }) onShowActions?: () => void;
|
||||
@property({ attribute: false }) onToggleMachines?: () => void;
|
||||
@property({ attribute: false }) onToggleProjects?: () => void;
|
||||
@@ -63,6 +66,7 @@ export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onCleanupSessions?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
|
||||
@@ -156,10 +160,13 @@ export class AppNavigationPanel extends LitElement {
|
||||
.activities=${this.sessionActivities}
|
||||
.sending=${this.sendingPrompts}
|
||||
.selected=${this.selectedSession}
|
||||
.startingCount=${this.startingSessionCount}
|
||||
.canStart=${this.canStartSession}
|
||||
.canDeleteArchived=${this.canDeleteArchivedSessions}
|
||||
.canReload=${this.canReloadSessions}
|
||||
.canCleanup=${this.canCleanupSessions}
|
||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
|
||||
.cleanupUnavailableMessage=${this.cleanupUnavailableMessage}
|
||||
.collapsible=${this.collapsible}
|
||||
.collapsed=${this.sessionsCollapsed}
|
||||
.onToggleCollapsed=${() => { this.onToggleSessions?.(); }}
|
||||
@@ -175,6 +182,7 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
|
||||
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
|
||||
.onReload=${(session: SessionInfo) => this.onReloadSession?.(session)}
|
||||
.onCleanup=${() => this.onCleanupSessions?.()}
|
||||
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("sessions"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { SettingsGeneralPanel } from "./SettingsGeneralPanel";
|
||||
import type { GatewayServerConfigDraft, MachineAccessConfigDraft } from "./settingsConfigDraft";
|
||||
|
||||
describe("settings-general-panel copy", () => {
|
||||
it("uses factual scope copy for gateway and selected-machine settings", () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
panel.targetLabel = "Lab Mac (remote machine)";
|
||||
panel.configResponse = configResponse({ host: "127.0.0.1" });
|
||||
panel.machineConfigResponse = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
|
||||
|
||||
const template = panel.render();
|
||||
const strings = collectTemplateStrings(template).join("");
|
||||
const values = collectTemplateValues(template);
|
||||
|
||||
expect(strings).toContain("<settings-panel-frame");
|
||||
expect(strings).toContain("Gateway server fields edit this local gateway. File access and upload defaults edit ");
|
||||
expect(strings).toContain("Host, port, and allowed hosts are saved in the gateway config.");
|
||||
expect(strings).toContain("External filesystem roots and upload defaults are saved on ");
|
||||
expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("shows reload copy when selected-machine access config is unavailable", () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
panel.targetLabel = "Lab Mac (remote machine)";
|
||||
panel.configResponse = configResponse({ host: "127.0.0.1" });
|
||||
panel.machineError = "Failed to load file access/upload config from Lab Mac (remote machine): unsupported";
|
||||
|
||||
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", () => {
|
||||
it("saves gateway server fields through the gateway save callback only", async () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
const onSave = vi.fn();
|
||||
const onSaveMachineConfig = vi.fn();
|
||||
const event = new Event("submit", { cancelable: true });
|
||||
panel.configResponse = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["old.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/gateway"] },
|
||||
uploads: { defaultFolder: "gateway/uploads" },
|
||||
spawnSessions: true,
|
||||
});
|
||||
panel.onSave = onSave;
|
||||
panel.onSaveMachineConfig = onSaveMachineConfig;
|
||||
setPanelProperty(panel, "gatewayDraft", {
|
||||
host: " 0.0.0.0 ",
|
||||
port: "9000",
|
||||
allowedHostsMode: "all",
|
||||
allowedHostsText: "ignored.local",
|
||||
} satisfies GatewayServerConfigDraft);
|
||||
|
||||
await callPanelPromise(panel, "saveGatewayConfig", event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(onSave.mock.calls).toEqual([[
|
||||
{
|
||||
host: "0.0.0.0",
|
||||
port: 9000,
|
||||
allowedHosts: true,
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/gateway"] },
|
||||
uploads: { defaultFolder: "gateway/uploads" },
|
||||
spawnSessions: true,
|
||||
},
|
||||
]]);
|
||||
expect(onSaveMachineConfig).not.toHaveBeenCalled();
|
||||
expect(getPanelProperty(panel, "gatewayLocalError")).toBe("");
|
||||
});
|
||||
|
||||
it("saves external roots and upload defaults through the selected-machine save callback only", async () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
const onSave = vi.fn();
|
||||
const onSaveMachineConfig = vi.fn();
|
||||
const event = new Event("submit", { cancelable: true });
|
||||
panel.onSave = onSave;
|
||||
panel.onSaveMachineConfig = onSaveMachineConfig;
|
||||
setPanelProperty(panel, "machineDraft", {
|
||||
allowedPathsText: "/tmp\n~/SDKs\n",
|
||||
uploadDefaultFolder: " manual\\uploads/. ",
|
||||
} satisfies MachineAccessConfigDraft);
|
||||
|
||||
await callPanelPromise(panel, "saveMachineAccessConfig", event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(onSaveMachineConfig.mock.calls).toEqual([[
|
||||
{
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
},
|
||||
]]);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(getPanelProperty(panel, "machineLocalError")).toBe("");
|
||||
});
|
||||
|
||||
it("clears upload defaults with a selected-machine-safe patch", async () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
const onSaveMachineConfig = vi.fn();
|
||||
panel.onSaveMachineConfig = onSaveMachineConfig;
|
||||
setPanelProperty(panel, "machineDraft", {
|
||||
allowedPathsText: "",
|
||||
uploadDefaultFolder: "",
|
||||
} satisfies MachineAccessConfigDraft);
|
||||
|
||||
await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true }));
|
||||
|
||||
expect(onSaveMachineConfig.mock.calls).toEqual([[
|
||||
{
|
||||
pathAccess: { allowedPaths: [] },
|
||||
uploads: {},
|
||||
},
|
||||
]]);
|
||||
});
|
||||
|
||||
it("keeps invalid upload folders local and does not save selected-machine config", async () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
const onSaveMachineConfig = vi.fn();
|
||||
panel.onSaveMachineConfig = onSaveMachineConfig;
|
||||
setPanelProperty(panel, "machineDraft", {
|
||||
allowedPathsText: "",
|
||||
uploadDefaultFolder: "/tmp/uploads",
|
||||
} satisfies MachineAccessConfigDraft);
|
||||
|
||||
await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true }));
|
||||
|
||||
expect(onSaveMachineConfig).not.toHaveBeenCalled();
|
||||
expect(getPanelProperty(panel, "machineLocalError")).toBe("Upload default folder must be workspace-relative.");
|
||||
});
|
||||
});
|
||||
|
||||
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 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 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 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");
|
||||
}
|
||||
|
||||
function setPanelProperty(panel: SettingsGeneralPanel, property: string, value: unknown): void {
|
||||
if (!Reflect.set(panel, property, value)) throw new Error(`Failed to set SettingsGeneralPanel property ${property}`);
|
||||
}
|
||||
|
||||
function getPanelProperty(panel: SettingsGeneralPanel, property: string): unknown {
|
||||
return Reflect.get(panel, property);
|
||||
}
|
||||
|
||||
async function callPanelPromise(panel: SettingsGeneralPanel, methodName: string, ...args: readonly unknown[]): Promise<void> {
|
||||
const result = callPanelMethod(panel, methodName, ...args);
|
||||
if (!(result instanceof Promise)) throw new Error(`SettingsGeneralPanel.${methodName} did not return a promise`);
|
||||
await result;
|
||||
}
|
||||
|
||||
function callPanelMethod(panel: SettingsGeneralPanel, methodName: string, ...args: readonly unknown[]): unknown {
|
||||
const method: unknown = Reflect.get(panel, methodName);
|
||||
if (!isPanelMethod(method)) throw new Error(`SettingsGeneralPanel.${methodName} is not callable`);
|
||||
return method.call(panel, ...args);
|
||||
}
|
||||
|
||||
function isPanelMethod(value: unknown): value is (this: SettingsGeneralPanel, ...args: readonly unknown[]) => unknown {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
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 },
|
||||
};
|
||||
}
|
||||
@@ -1,99 +1,184 @@
|
||||
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { configFromDraft, draftFromConfig, emptyConfigDraft, type ConfigDraft } from "./settingsConfigDraft";
|
||||
import { DEFAULT_WORKSPACE_UPLOADS_FOLDER, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues } from "../../api";
|
||||
import "./SettingsPanelFrame";
|
||||
import type { SettingsNotice } from "./SettingsPanelFrame";
|
||||
import {
|
||||
emptyGatewayServerConfigDraft,
|
||||
emptyMachineAccessConfigDraft,
|
||||
gatewayServerConfigFromDraft,
|
||||
gatewayServerDraftFromConfig,
|
||||
machineAccessConfigPatchFromDraft,
|
||||
machineAccessDraftFromConfig,
|
||||
type GatewayServerConfigDraft,
|
||||
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;
|
||||
@property({ attribute: false }) machineConfigResponse: PiWebConfigResponse | undefined;
|
||||
@property({ type: Boolean }) loading = false;
|
||||
@property({ type: Boolean }) machineLoading = false;
|
||||
@property({ type: Boolean }) saving = false;
|
||||
@property() error = "";
|
||||
@property() machineError = "";
|
||||
@property() savedMessage = "";
|
||||
@property() targetLabel = "selected machine";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onReloadMachine?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
@state() private draft: ConfigDraft = emptyConfigDraft();
|
||||
@state() private localError = "";
|
||||
@property({ attribute: false }) onSaveMachineConfig?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
@state() private gatewayDraft: GatewayServerConfigDraft = emptyGatewayServerConfigDraft();
|
||||
@state() private machineDraft: MachineAccessConfigDraft = emptyMachineAccessConfigDraft();
|
||||
@state() private gatewayLocalError = "";
|
||||
@state() private machineLocalError = "";
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>): void {
|
||||
if (changed.has("configResponse") && this.configResponse !== undefined) {
|
||||
this.draft = draftFromConfig(this.configResponse.config);
|
||||
this.localError = "";
|
||||
this.gatewayDraft = gatewayServerDraftFromConfig(this.configResponse.config);
|
||||
this.gatewayLocalError = "";
|
||||
}
|
||||
if (changed.has("machineConfigResponse") && this.machineConfigResponse !== undefined) {
|
||||
this.machineDraft = machineAccessDraftFromConfig(this.machineConfigResponse.config);
|
||||
this.machineLocalError = "";
|
||||
}
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
const config = this.configResponse;
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>General configuration</h2>
|
||||
<p>Update the JSON config file PI WEB is using. Host and port changes are saved immediately, but require the web service to restart before the running server binds to the new address.</p>
|
||||
<settings-panel-frame
|
||||
heading="General configuration"
|
||||
.description=${generalDescription(this.targetLabel)}
|
||||
actionLabel="Reload"
|
||||
.actionDisabled=${this.loading || this.machineLoading}
|
||||
.notices=${this.panelNotices()}
|
||||
.onAction=${() => { this.reloadAll(); }}
|
||||
>
|
||||
<div class="settings-sections">
|
||||
${this.renderGatewayServerSettings()}
|
||||
${this.renderSelectedMachineAccessSettings()}
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${config?.path ?? "Unknown"}</code>
|
||||
<small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small>
|
||||
</div>
|
||||
<form class="config-form" @submit=${(event: Event) => { void this.saveConfig(event); }}>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Host</span>
|
||||
${this.renderOverrideBadge("host")}
|
||||
</span>
|
||||
<input .value=${this.draft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ host: inputValue(event) }); }}>
|
||||
<small>Address the web server should bind to. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Port</span>
|
||||
${this.renderOverrideBadge("port")}
|
||||
</span>
|
||||
<input .value=${this.draft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateDraft({ port: inputValue(event) }); }}>
|
||||
<small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allowed hosts</span>
|
||||
${this.renderOverrideBadge("allowedHosts")}
|
||||
</span>
|
||||
<select .value=${this.draft.allowedHostsMode} @change=${(event: Event) => { this.updateDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}>
|
||||
<option value="list">Only listed hosts</option>
|
||||
<option value="all">Allow every host</option>
|
||||
</select>
|
||||
<textarea .value=${this.draft.allowedHostsText} ?disabled=${this.draft.allowedHostsMode === "all"} rows="4" placeholder="example.local 192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>External filesystem roots</span>
|
||||
</span>
|
||||
<textarea .value=${this.draft.allowedPathsText} rows="4" placeholder="~/SDKs /opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Global allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
|
||||
</label>
|
||||
|
||||
${this.renderEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save config"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
`}
|
||||
</settings-panel-frame>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessages(): TemplateResult | null {
|
||||
const error = this.localError || this.error;
|
||||
if (error !== "") return html`<div class="message error-message">${error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
|
||||
return null;
|
||||
private renderGatewayServerSettings(): TemplateResult {
|
||||
const config = this.configResponse;
|
||||
return html`
|
||||
<section class="settings-card" aria-label="Gateway server settings">
|
||||
<div class="card-heading">
|
||||
<h3>Gateway server</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
${config === undefined && this.loading ? html`<div class="loading-card">Loading gateway configuration…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Gateway config file</span>
|
||||
<code>${config?.path ?? "Unknown"}</code>
|
||||
<small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small>
|
||||
</div>
|
||||
<form class="config-form" @submit=${(event: Event) => { void this.saveGatewayConfig(event); }}>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Host</span>
|
||||
${this.renderOverrideBadge("host")}
|
||||
</span>
|
||||
<input .value=${this.gatewayDraft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateGatewayDraft({ host: inputValue(event) }); }}>
|
||||
<small>Address the web server should bind to. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Port</span>
|
||||
${this.renderOverrideBadge("port")}
|
||||
</span>
|
||||
<input .value=${this.gatewayDraft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateGatewayDraft({ port: inputValue(event) }); }}>
|
||||
<small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allowed hosts</span>
|
||||
${this.renderOverrideBadge("allowedHosts")}
|
||||
</span>
|
||||
<select .value=${this.gatewayDraft.allowedHostsMode} @change=${(event: Event) => { this.updateGatewayDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}>
|
||||
<option value="list">Only listed hosts</option>
|
||||
<option value="all">Allow every host</option>
|
||||
</select>
|
||||
<textarea .value=${this.gatewayDraft.allowedHostsText} ?disabled=${this.gatewayDraft.allowedHostsMode === "all"} rows="4" placeholder="example.local 192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateGatewayDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
|
||||
</div>
|
||||
|
||||
${this.renderGatewayEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save gateway server config"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSelectedMachineAccessSettings(): TemplateResult {
|
||||
const config = this.machineConfigResponse;
|
||||
return html`
|
||||
<section class="settings-card" aria-label="Selected machine file access and upload settings">
|
||||
<div class="card-heading">
|
||||
<h3>Selected machine file access and uploads</h3>
|
||||
<p>External filesystem roots and upload defaults are saved on ${this.targetLabel}.</p>
|
||||
</div>
|
||||
${this.renderMachineMessages()}
|
||||
${config === undefined ? html`<div class="loading-card">${this.machineLoading ? "Loading selected-machine file access config…" : "Selected-machine file access config is unavailable. Reload before saving file/upload settings."}</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Selected machine config file</span>
|
||||
<code>${config.path}</code>
|
||||
<small>${config.exists ? "Existing file" : "This file will be created on save"}</small>
|
||||
</div>
|
||||
<form class="config-form" @submit=${(event: Event) => { void this.saveMachineAccessConfig(event); }}>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>External filesystem roots</span>
|
||||
</span>
|
||||
<textarea .value=${this.machineDraft.allowedPathsText} rows="4" placeholder="~/SDKs /opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateMachineDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace on ${this.targetLabel}. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Default upload folder</span>
|
||||
</span>
|
||||
<input .value=${this.machineDraft.uploadDefaultFolder} placeholder=${DEFAULT_WORKSPACE_UPLOADS_FOLDER} autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateMachineDraft({ uploadDefaultFolder: inputValue(event) }); }}>
|
||||
<small>Workspace-relative folder for manual file uploads on ${this.targetLabel}. Leave empty to use PI WEB's default <code>${DEFAULT_WORKSPACE_UPLOADS_FOLDER}</code>.</small>
|
||||
</label>
|
||||
|
||||
${this.renderMachineEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
<button class="primary" ?disabled=${this.machineLoading || this.saving}>${this.saving ? "Saving…" : "Save file/upload config"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
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 {
|
||||
const error = this.machineLocalError || this.machineError;
|
||||
if (error === "") return null;
|
||||
return html`<div class="message error-message">${error}</div>`;
|
||||
}
|
||||
|
||||
private renderOverrideBadge(key: keyof PiWebConfigEnvOverrides): TemplateResult | null {
|
||||
@@ -101,63 +186,94 @@ export class SettingsGeneralPanel extends LitElement {
|
||||
return html`<span class="override-badge">environment override</span>`;
|
||||
}
|
||||
|
||||
private renderEffectiveConfig(): TemplateResult {
|
||||
private renderGatewayEffectiveConfig(): TemplateResult {
|
||||
const effective = this.configResponse?.effectiveConfig ?? {};
|
||||
return html`
|
||||
<section class="effective-card" aria-label="Effective configuration summary">
|
||||
<h3>Effective after environment overrides</h3>
|
||||
<section class="effective-card" aria-label="Effective gateway configuration summary">
|
||||
<h3>Effective gateway settings after environment overrides</h3>
|
||||
<dl>
|
||||
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
|
||||
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
|
||||
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
|
||||
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private async saveConfig(event: Event): Promise<void> {
|
||||
private renderMachineEffectiveConfig(): TemplateResult {
|
||||
const effective = this.machineConfigResponse?.effectiveConfig ?? {};
|
||||
return html`
|
||||
<section class="effective-card" aria-label="Effective selected machine file access and upload summary">
|
||||
<h3>Effective selected-machine settings</h3>
|
||||
<dl>
|
||||
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
|
||||
<div><dt>Upload folder</dt><dd>${effective.uploads?.defaultFolder ?? html`<span class="muted">${DEFAULT_WORKSPACE_UPLOADS_FOLDER} default</span>`}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private reloadAll(): void {
|
||||
void this.onReload?.();
|
||||
void this.onReloadMachine?.();
|
||||
}
|
||||
|
||||
private async saveGatewayConfig(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
this.localError = "";
|
||||
this.gatewayLocalError = "";
|
||||
try {
|
||||
await this.onSave?.(configFromDraft(this.draft, this.configResponse?.config ?? {}));
|
||||
await this.onSave?.(gatewayServerConfigFromDraft(this.gatewayDraft, this.configResponse?.config ?? {}));
|
||||
} catch (error) {
|
||||
this.localError = errorMessage(error);
|
||||
this.gatewayLocalError = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
private updateDraft(patch: Partial<ConfigDraft>): void {
|
||||
this.draft = { ...this.draft, ...patch };
|
||||
this.localError = "";
|
||||
private async saveMachineAccessConfig(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
this.machineLocalError = "";
|
||||
try {
|
||||
await this.onSaveMachineConfig?.(machineAccessConfigPatchFromDraft(this.machineDraft));
|
||||
} catch (error) {
|
||||
this.machineLocalError = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
private updateGatewayDraft(patch: Partial<GatewayServerConfigDraft>): void {
|
||||
this.gatewayDraft = { ...this.gatewayDraft, ...patch };
|
||||
this.gatewayLocalError = "";
|
||||
}
|
||||
|
||||
private updateMachineDraft(patch: Partial<MachineAccessConfigDraft>): void {
|
||||
this.machineDraft = { ...this.machineDraft, ...patch };
|
||||
this.machineLocalError = "";
|
||||
}
|
||||
|
||||
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; }
|
||||
.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; }
|
||||
.message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.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; 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; }
|
||||
.config-path-card small, .field small { color: var(--pi-muted); }
|
||||
.config-form { display: grid; gap: 14px; }
|
||||
.field { display: grid; gap: 7px; }
|
||||
.field-heading { display: flex; align-items: center; gap: 8px; }
|
||||
input, select, textarea { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px 10px; outline: none; }
|
||||
input, select, textarea { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px 10px; outline: none; font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); }
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
|
||||
textarea { resize: vertical; min-height: 94px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
textarea { resize: vertical; min-height: 94px; font-family: var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
|
||||
textarea:disabled { opacity: .55; }
|
||||
.override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; }
|
||||
.effective-card { display: grid; gap: 10px; }
|
||||
@@ -169,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; }
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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")
|
||||
export class SettingsPackagesPanel extends LitElement {
|
||||
@property({ attribute: false }) packagesResponse: PiPackagesResponse | undefined;
|
||||
@property({ type: Boolean }) loading = false;
|
||||
@property({ attribute: false }) operation: PiPackageOperationState | undefined;
|
||||
@property({ attribute: false }) targetMachine: PiPackageTargetContext | undefined;
|
||||
@property({ attribute: false }) managementSupport: PiPackageManagementSupport | undefined;
|
||||
@property() error = "";
|
||||
@property() operationMessage = "";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onInstallPackage?: (source: string) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRemovePackage?: (source: string, scope: PiPackageScope) => void | Promise<void>;
|
||||
@property({ attribute: false }) onUpdatePackage?: (source?: string) => void | Promise<void>;
|
||||
@state() private installSource = "";
|
||||
@state() private validationMessage = "";
|
||||
|
||||
override render(): TemplateResult {
|
||||
const packages = this.packagesResponse?.packages ?? [];
|
||||
const target = this.packageTarget;
|
||||
const targetLabel = piPackageTargetLabel(target);
|
||||
const packageManagementUnavailable = this.packageManagementUnavailable;
|
||||
const showPackageControls = this.packagesResponse !== undefined && !packageManagementUnavailable;
|
||||
return html`
|
||||
<settings-panel-frame
|
||||
heading="Pi packages"
|
||||
.description=${packagesDescription(targetLabel)}
|
||||
actionLabel="Reload"
|
||||
actionTitle=${packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : `Reload Pi packages from ${targetLabel}`}
|
||||
.actionDisabled=${this.loading || this.isOperating || packageManagementUnavailable}
|
||||
.notices=${this.panelNotices(targetLabel, showPackageControls)}
|
||||
.onAction=${this.onReload}
|
||||
>
|
||||
${this.renderPanelContent(packages, target, targetLabel)}
|
||||
</settings-panel-frame>
|
||||
`;
|
||||
}
|
||||
|
||||
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`<strong>Trusted code warning:</strong> 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`<div class="loading-card">${this.loading ? `Loading Pi packages from ${targetLabel}…` : `Pi package list unavailable for ${targetLabel}. Use Reload to try again.`}</div>`;
|
||||
}
|
||||
return html`
|
||||
${this.renderInstallForm(targetLabel)}
|
||||
${this.renderPackageList(packages, target)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderInstallForm(targetLabel: string): TemplateResult {
|
||||
return html`
|
||||
<form class="install-card" @submit=${(event: Event) => { void this.installPackage(event); }}>
|
||||
<label for="package-source">Pi package source</label>
|
||||
<div class="install-row">
|
||||
<input id="package-source" .value=${this.installSource} ?disabled=${this.isOperating} placeholder="npm:@scope/package, git URL, or local path" @input=${(event: Event) => { this.updateInstallSource(event); }}>
|
||||
<button type="submit" title="Install this Pi package" ?disabled=${this.isOperating}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
|
||||
</div>
|
||||
${this.validationMessage === "" ? null : html`<div class="field-error">${this.validationMessage}</div>`}
|
||||
<small>Installs run on ${targetLabel} and use Pi's default package location, equivalent to <code>pi install <source></code>. PI WEB does not ask you to choose an install location.</small>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult {
|
||||
const targetLabel = piPackageTargetLabel(target);
|
||||
const packageManagementUnavailable = this.packageManagementUnavailable;
|
||||
const updateAllReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : updateAllPiPackagesDisabledReason(packages);
|
||||
const showUpdateAllReason = updateAllReason !== undefined && packages.length > 0;
|
||||
const updateAllTitle = updateAllReason ?? "Update all user-scope Pi packages";
|
||||
return html`
|
||||
<section class="package-section" aria-label="Configured Pi packages">
|
||||
<div class="package-toolbar">
|
||||
<div>
|
||||
<h3>Configured Pi packages</h3>
|
||||
<p>This list comes from Pi's package manager settings on ${targetLabel}.</p>
|
||||
</div>
|
||||
<button class="secondary" title=${updateAllTitle} ?disabled=${this.isOperating || updateAllReason !== undefined} @click=${() => { void this.updatePackage(); }}>
|
||||
${isPiPackageOperationPending(this.operation, "update-all") ? "Updating…" : "Update all"}
|
||||
</button>
|
||||
</div>
|
||||
${showUpdateAllReason ? html`<div class="action-note">${updateAllReason}</div>` : null}
|
||||
${this.loading && packages.length > 0 ? html`<div class="action-note">Refreshing Pi packages from ${targetLabel}…</div>` : null}
|
||||
${this.renderPackageListContent(packages, targetLabel)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPackageListContent(packages: PiPackageInfo[], targetLabel: string): TemplateResult {
|
||||
if (this.loading && packages.length === 0) return html`<div class="loading-card">Loading Pi packages from ${targetLabel}…</div>`;
|
||||
if (packages.length === 0) return html`<div class="loading-card">No Pi packages configured in Pi settings on ${targetLabel} yet.</div>`;
|
||||
return html`
|
||||
<div class="package-list">
|
||||
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPackage(packageInfo: PiPackageInfo): TemplateResult {
|
||||
const targetLabel = piPackageTargetLabel(this.packageTarget);
|
||||
const packageManagementUnavailable = this.packageManagementUnavailable;
|
||||
const updateReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : piPackageUpdateDisabledReason(packageInfo);
|
||||
const removeReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : "Remove this Pi package";
|
||||
const updating = isPiPackageOperationPending(this.operation, "update", packageInfo.source);
|
||||
const removing = isPiPackageOperationPending(this.operation, "remove", packageInfo.source);
|
||||
return html`
|
||||
<article class=${`package-card${packageInfo.filtered ? " filtered" : ""}`}>
|
||||
<div class="package-main">
|
||||
<strong>${packageInfo.source}</strong>
|
||||
<small>${piPackageScopeLabel(packageInfo)} · ${piPackageFilteredLabel(packageInfo)}</small>
|
||||
<small>Installed path: <code>${piPackageInstalledPathLabel(packageInfo)}</code></small>
|
||||
${updateReason === undefined ? null : html`<small class="action-note">${updateReason}</small>`}
|
||||
</div>
|
||||
<div class="package-actions">
|
||||
<button class="secondary" title=${updateReason ?? "Update this Pi package"} ?disabled=${this.isOperating || updateReason !== undefined} @click=${() => { void this.updatePackage(packageInfo.source); }}>${updating ? "Updating…" : "Update"}</button>
|
||||
<button class="danger" title=${removeReason} ?disabled=${this.isOperating || packageManagementUnavailable} @click=${() => { void this.removePackage(packageInfo); }}>${removing ? "Removing…" : "Remove"}</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
private updateInstallSource(event: Event): void {
|
||||
this.installSource = event.target instanceof HTMLInputElement ? event.target.value : "";
|
||||
this.validationMessage = "";
|
||||
}
|
||||
|
||||
private async installPackage(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
const validationMessage = piPackageSourceValidationMessage(this.installSource);
|
||||
if (validationMessage !== undefined) {
|
||||
this.validationMessage = validationMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
const source = normalizePiPackageSource(this.installSource);
|
||||
try {
|
||||
await this.onInstallPackage?.(source);
|
||||
this.installSource = "";
|
||||
this.validationMessage = "";
|
||||
} catch {
|
||||
// The parent owns network error presentation so package errors are consistent across Settings.
|
||||
}
|
||||
}
|
||||
|
||||
private async removePackage(packageInfo: PiPackageInfo): Promise<void> {
|
||||
try {
|
||||
await this.onRemovePackage?.(packageInfo.source, packageInfo.scope);
|
||||
} catch {
|
||||
// The parent owns network error presentation so package errors are consistent across Settings.
|
||||
}
|
||||
}
|
||||
|
||||
private async updatePackage(source?: string): Promise<void> {
|
||||
try {
|
||||
await this.onUpdatePackage?.(source);
|
||||
} catch {
|
||||
// The parent owns network error presentation so package errors are consistent across Settings.
|
||||
}
|
||||
}
|
||||
|
||||
private get packageTarget(): PiPackageTargetContext {
|
||||
return this.targetMachine ?? piPackageTargetContext(undefined);
|
||||
}
|
||||
|
||||
private get packageManagementUnavailable(): boolean {
|
||||
return isPiPackageManagementUnsupported(this.managementSupport);
|
||||
}
|
||||
|
||||
private packageManagementUnavailableMessage(targetLabel: string): string {
|
||||
return this.managementSupport?.message ?? `Pi package management is not available on ${targetLabel}.`;
|
||||
}
|
||||
|
||||
private get isOperating(): boolean {
|
||||
return this.operation !== undefined;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { display: block; }
|
||||
.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; }
|
||||
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; }
|
||||
input { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; }
|
||||
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); }
|
||||
.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; }
|
||||
.package-section { display: block; }
|
||||
.loading-card, .action-note { color: var(--pi-muted); }
|
||||
.action-note { margin-bottom: 10px; font-size: 12px; }
|
||||
.package-list { display: grid; gap: 10px; }
|
||||
.package-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; }
|
||||
.package-card.filtered { opacity: .82; }
|
||||
.package-main strong, .package-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.package-actions { display: flex; align-items: center; gap: 8px; }
|
||||
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) {
|
||||
.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 <strong>${targetLabel}</strong>. 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.`;
|
||||
}
|
||||
@@ -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`<strong>Trusted code warning:</strong> 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";
|
||||
}
|
||||
@@ -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<SettingsNoticeType, SettingsNoticeTone> = {
|
||||
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<void>;
|
||||
|
||||
override render(): TemplateResult {
|
||||
return html`
|
||||
<section class="panel" aria-label=${this.heading || "Settings panel"}>
|
||||
<header class="section-heading">
|
||||
<div class="heading-copy">
|
||||
${this.heading === "" ? nothing : html`<h2>${this.heading}</h2>`}
|
||||
<div class="description"><slot name="description">${this.description}</slot></div>
|
||||
</div>
|
||||
<div class="heading-actions"><slot name="actions">${this.renderDefaultAction()}</slot></div>
|
||||
</header>
|
||||
${this.renderNoticeStack()}
|
||||
<div class="content"><slot></slot></div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDefaultAction(): TemplateResult | typeof nothing {
|
||||
if (this.actionLabel === "") return nothing;
|
||||
return html`
|
||||
<button
|
||||
class="secondary"
|
||||
title=${this.actionTitle || this.actionLabel}
|
||||
?disabled=${this.actionDisabled}
|
||||
@click=${() => { void this.onAction?.(); }}
|
||||
>${this.actionLabel}</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNoticeStack(): TemplateResult | typeof nothing {
|
||||
if (this.notices.length === 0) return nothing;
|
||||
return html`
|
||||
<div class="notice-stack" aria-label="Settings notices">
|
||||
${this.notices.map((notice) => this.renderNotice(notice))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNotice(notice: SettingsNotice): TemplateResult {
|
||||
const tone = settingsNoticeTone(notice);
|
||||
const role = notice.role ?? defaultNoticeRole(tone);
|
||||
const title = notice.title;
|
||||
return html`
|
||||
<article class=${`notice ${tone}`} role=${role}>
|
||||
${title === undefined || title === "" ? nothing : html`<strong class="notice-title">${title}</strong>`}
|
||||
<div class="notice-content">${notice.content}</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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 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 rendered = flattenTemplateContent(panel.render());
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("shows the empty plugin state only after a plugin response has loaded", () => {
|
||||
const panel = new SettingsPluginsPanel();
|
||||
panel.targetLabel = "Lab Mac (remote machine)";
|
||||
panel.pluginsResponse = { plugins: [] };
|
||||
|
||||
const rendered = flattenTemplateContent(panel.render());
|
||||
|
||||
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("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)] };
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
function renderPluginTemplate(panel: SettingsPluginsPanel, plugin: PiWebPluginInfo): TemplateResult {
|
||||
const renderPlugin: unknown = Reflect.get(panel, "renderPlugin");
|
||||
if (!isPanelRenderPlugin(renderPlugin)) throw new Error("SettingsPluginsPanel.renderPlugin is not callable");
|
||||
return renderPlugin.call(panel, plugin);
|
||||
}
|
||||
|
||||
function isPanelRenderPlugin(value: unknown): value is (this: SettingsPluginsPanel, plugin: PiWebPluginInfo) => TemplateResult {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
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 isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === "boolean";
|
||||
}
|
||||
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
|
||||
return {
|
||||
id,
|
||||
module: `/pi-web-plugins/${id}/plugin.js`,
|
||||
source: "test",
|
||||
scope: "local",
|
||||
machineSpecific: false,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -10,33 +12,61 @@ export class SettingsPluginsPanel extends LitElement {
|
||||
@property({ type: Boolean }) saving = false;
|
||||
@property() error = "";
|
||||
@property() savedMessage = "";
|
||||
@property() targetLabel = "local (local gateway)";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onTogglePlugin?: (pluginId: string, enabled: boolean) => void | Promise<void>;
|
||||
|
||||
override render(): TemplateResult {
|
||||
const plugins = this.pluginsResponse?.plugins ?? [];
|
||||
const hasPluginResponse = this.pluginsResponse !== undefined;
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Plugins</h2>
|
||||
<p>Enable or disable discovered PI WEB plugins. Changes apply after reloading the browser tab; already-loaded plugin code is not unloaded from the current page.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
<div class="plugin-note">Config key: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
|
||||
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No external or bundled plugins discovered.</div>` : html`
|
||||
<div class="plugin-list">
|
||||
${plugins.map((plugin) => this.renderPlugin(plugin))}
|
||||
</div>
|
||||
`}
|
||||
<settings-panel-frame
|
||||
heading="PI WEB plugins"
|
||||
.description=${pluginsDescription(this.targetLabel)}
|
||||
actionLabel="Reload"
|
||||
actionTitle=${`Reload PI WEB plugins from ${this.targetLabel}`}
|
||||
.actionDisabled=${this.loading}
|
||||
.notices=${this.panelNotices(plugins.length > 0)}
|
||||
.onAction=${this.onReload}
|
||||
>
|
||||
${this.renderPanelContent(plugins, hasPluginResponse)}
|
||||
</settings-panel-frame>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessages(): TemplateResult | null {
|
||||
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage} Reload the browser tab to apply plugin changes.</div>`;
|
||||
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`<strong>Trusted code warning:</strong> 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`<div class="loading-card">${this.loading ? "Loading PI WEB plugins…" : `PI WEB plugin list unavailable for ${this.targetLabel}. Use Reload to try again.`}</div>`;
|
||||
}
|
||||
if (plugins.length === 0) {
|
||||
return html`<div class="loading-card">No PI WEB browser plugins discovered on ${this.targetLabel}.</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="plugin-note">Config key on ${this.targetLabel}: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
|
||||
<div class="plugin-list">
|
||||
${plugins.map((plugin) => this.renderPlugin(plugin))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlugin(plugin: PiWebPluginInfo): TemplateResult {
|
||||
@@ -50,7 +80,7 @@ export class SettingsPluginsPanel extends LitElement {
|
||||
<small>${configuredState}</small>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}>
|
||||
<input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving || this.configResponse === undefined} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}>
|
||||
<span>${plugin.enabled ? "Enabled" : "Disabled"}</span>
|
||||
</label>
|
||||
</article>
|
||||
@@ -64,21 +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, .plugin-note, .plugin-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); }
|
||||
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); }
|
||||
.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; }
|
||||
@@ -90,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 <strong>${targetLabel}</strong>. This is separate from installing Pi packages. Reload the browser tab to apply plugin runtime changes.`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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 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 rendered = flattenTemplateContent(panel.render());
|
||||
|
||||
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 <code>pi-web restart</code> 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");
|
||||
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",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
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")
|
||||
export class SettingsSessiondPanel extends LitElement {
|
||||
@@ -9,6 +12,7 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
@property({ type: Boolean }) saving = false;
|
||||
@property() error = "";
|
||||
@property() savedMessage = "";
|
||||
@property() targetLabel = "local (local gateway)";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
|
||||
@@ -22,104 +26,104 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
// Beta, off by default; also requires spawn to be enabled.
|
||||
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Session daemon</h2>
|
||||
<p>These settings affect the long-lived session runtime. Changes are saved to the config file immediately but only take effect after the session daemon restarts.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
<div class="restart-note" role="note">Restart required: run <code>pi-web restart</code> (or restart the session daemon service) after changing these settings.</div>
|
||||
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${config?.path ?? "Unknown"}</code>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allow agents to start sessions</span>
|
||||
${spawnOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${effectiveSpawn}
|
||||
?disabled=${this.loading || this.saving || spawnOverridden}
|
||||
@change=${(event: Event) => { void this.toggleSpawnSessions(event); }}
|
||||
>
|
||||
<span>Enable the <code>spawn_session</code> tool</span>
|
||||
</label>
|
||||
<small>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.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allow agents to start tracked subsessions</span>
|
||||
<span class="beta-badge">beta</span>
|
||||
${subsessionsOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${effectiveSubsessions}
|
||||
?disabled=${this.loading || this.saving || subsessionsOverridden || !effectiveSpawn}
|
||||
@change=${(event: Event) => { void this.toggleSubsessions(event); }}
|
||||
>
|
||||
<span>Enable the <code>spawn_subsession</code> tools</span>
|
||||
</label>
|
||||
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
|
||||
</div>
|
||||
<section class="effective-card" aria-label="Effective configuration summary">
|
||||
<h3>Effective after environment overrides</h3>
|
||||
<dl>
|
||||
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`}
|
||||
<settings-panel-frame
|
||||
heading="Session daemon"
|
||||
.description=${sessiondDescription(this.targetLabel)}
|
||||
actionLabel="Reload"
|
||||
.actionDisabled=${this.loading}
|
||||
.notices=${this.panelNotices(config)}
|
||||
.onAction=${this.onReload}
|
||||
>
|
||||
${config === undefined ? this.renderUnavailableConfigState() : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${config.path}</code>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allow agents to start sessions</span>
|
||||
${spawnOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${effectiveSpawn}
|
||||
?disabled=${this.loading || this.saving || spawnOverridden}
|
||||
@change=${(event: Event) => { void this.toggleSpawnSessions(event); }}
|
||||
>
|
||||
<span>Enable the <code>spawn_session</code> tool</span>
|
||||
</label>
|
||||
<small>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.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allow agents to start tracked subsessions</span>
|
||||
<span class="beta-badge">beta</span>
|
||||
${subsessionsOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${effectiveSubsessions}
|
||||
?disabled=${this.loading || this.saving || subsessionsOverridden || !effectiveSpawn}
|
||||
@change=${(event: Event) => { void this.toggleSubsessions(event); }}
|
||||
>
|
||||
<span>Enable the <code>spawn_subsession</code> tools</span>
|
||||
</label>
|
||||
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
|
||||
</div>
|
||||
<section class="effective-card" aria-label="Effective configuration summary">
|
||||
<h3>Effective after environment overrides</h3>
|
||||
<dl>
|
||||
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`}
|
||||
</settings-panel-frame>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessages(): TemplateResult | null {
|
||||
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
|
||||
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 <code>pi-web restart</code> on that machine (or restart its session daemon service) after changing these settings.`,
|
||||
});
|
||||
}
|
||||
return notices;
|
||||
}
|
||||
|
||||
private renderUnavailableConfigState(): TemplateResult {
|
||||
return html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>`;
|
||||
}
|
||||
|
||||
private async toggleSpawnSessions(event: Event): Promise<void> {
|
||||
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
await this.onSave?.({ ...baseConfig, spawnSessions: enabled });
|
||||
await this.onSave?.(spawnSessionsConfigPatch(enabled));
|
||||
}
|
||||
|
||||
private async toggleSubsessions(event: Event): Promise<void> {
|
||||
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
await this.onSave?.({ ...baseConfig, subsessions: enabled });
|
||||
await this.onSave?.(subsessionsConfigPatch(enabled));
|
||||
}
|
||||
|
||||
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.`;
|
||||
}
|
||||
|
||||
@@ -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("<settings-panel-frame");
|
||||
expect(frameNotices(template).map((notice) => notice.type)).toEqual(["error", "success"]);
|
||||
expectTextOrder(rendered, [
|
||||
"Keyboard shortcuts",
|
||||
"Edit app shortcuts by action.",
|
||||
"<code>mod+k</code>",
|
||||
"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 },
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,34 @@ import { customElement, property, state } from "lit/decorators.js";
|
||||
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;
|
||||
|
||||
const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: string; description: string }[] = [
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto/default",
|
||||
description: "Desktop-like Enter sends; mobile, coarse pointer, or narrow screens insert a new line.",
|
||||
},
|
||||
{
|
||||
value: "send",
|
||||
label: "Enter sends message",
|
||||
description: "Enter sends the chat message; Shift+Enter adds a new line when supported.",
|
||||
},
|
||||
{
|
||||
value: "newline",
|
||||
label: "Enter inserts new line",
|
||||
description: "Enter adds a line break; Shift+Enter sends the chat message when supported.",
|
||||
},
|
||||
];
|
||||
|
||||
function renderShortcutsDescription(): TemplateResult {
|
||||
return html`Edit app shortcuts by action. Type a shortcut such as <code>mod+k</code> or <code>mod+g p</code>, 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[] = [];
|
||||
@@ -18,6 +43,7 @@ export class SettingsShortcutsPanel extends LitElement {
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
@state() private drafts: Record<string, string> = {};
|
||||
@state() private localError = "";
|
||||
@state() private promptEnterPreference: PromptEnterPreference = readPromptEnterPreference();
|
||||
@state() private recording: RecordingState | undefined;
|
||||
private recordingTimer: number | undefined;
|
||||
private recordingListenerActive = false;
|
||||
@@ -67,37 +93,74 @@ export class SettingsShortcutsPanel extends LitElement {
|
||||
const groups = shortcutGroups(this.actions);
|
||||
const shortcutResolutions = this.shortcutResolutions();
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<p>Edit app shortcuts by action. Type a shortcut such as <code>mod+k</code> or <code>mod+g p</code>, 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.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${this.configResponse?.path ?? "Unknown"}</code>
|
||||
<small>Shortcut overrides are saved under <code>shortcuts</code>. A value of <code>null</code> disables the action shortcut.</small>
|
||||
</div>
|
||||
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
|
||||
<section class="shortcut-group">
|
||||
<h3>${group.name}</h3>
|
||||
<div class="shortcut-list">
|
||||
${group.actions.map((action) => this.renderShortcutRow(action, shortcutResolutions.get(action.id)))}
|
||||
</div>
|
||||
</section>
|
||||
`)}
|
||||
`}
|
||||
<settings-panel-frame
|
||||
heading="Keyboard shortcuts"
|
||||
.description=${renderShortcutsDescription()}
|
||||
actionLabel="Reload"
|
||||
.actionDisabled=${this.loading}
|
||||
.notices=${this.panelNotices()}
|
||||
.onAction=${this.onReload}
|
||||
>
|
||||
${this.renderPromptEnterPreferenceCard()}
|
||||
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${this.configResponse?.path ?? "Unknown"}</code>
|
||||
<small>Shortcut overrides are saved under <code>shortcuts</code>. A value of <code>null</code> disables the action shortcut.</small>
|
||||
</div>
|
||||
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
|
||||
<section class="shortcut-group">
|
||||
<h3>${group.name}</h3>
|
||||
<div class="shortcut-list">
|
||||
${group.actions.map((action) => this.renderShortcutRow(action, shortcutResolutions.get(action.id)))}
|
||||
</div>
|
||||
</section>
|
||||
`)}
|
||||
`}
|
||||
</settings-panel-frame>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessages(): TemplateResult | null {
|
||||
private panelNotices(): readonly SettingsNotice[] {
|
||||
const notices: SettingsNotice[] = [];
|
||||
const error = this.localError || this.error;
|
||||
if (error !== "") return html`<div class="message error-message">${error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
|
||||
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 {
|
||||
return html`
|
||||
<section class="prompt-enter-card" aria-labelledby="prompt-enter-preference-title">
|
||||
<div class="prompt-enter-copy">
|
||||
<span class="card-eyebrow">Chat composer</span>
|
||||
<h3 id="prompt-enter-preference-title">Enter key behavior</h3>
|
||||
<p>Choose what Enter does in this browser. Shift+Enter does the opposite when supported; automatic touch-keyboard capitalization is ignored to avoid accidental sends.</p>
|
||||
</div>
|
||||
<div class="prompt-enter-options" role="radiogroup" aria-label="Enter and Shift Enter behavior in the chat composer">
|
||||
${PROMPT_ENTER_OPTIONS.map((option) => html`
|
||||
<label class="prompt-enter-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="prompt-enter-preference"
|
||||
.value=${option.value}
|
||||
.checked=${this.promptEnterPreference === option.value}
|
||||
@change=${() => { this.updatePromptEnterPreference(option.value); }}
|
||||
>
|
||||
<span>
|
||||
<strong>${option.label}</strong>
|
||||
<small>${option.description}</small>
|
||||
</span>
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private updatePromptEnterPreference(preference: PromptEnterPreference): void {
|
||||
this.promptEnterPreference = preference;
|
||||
writePromptEnterPreference(preference);
|
||||
}
|
||||
|
||||
private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult {
|
||||
@@ -280,26 +343,28 @@ 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 { 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 span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||
.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; }
|
||||
.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; }
|
||||
.prompt-enter-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: start; color: var(--pi-text); }
|
||||
.prompt-enter-option input { box-sizing: border-box; width: 14px; min-width: 14px; height: 14px; margin: 3px 0 0; padding: 0; border: 0; background: transparent; accent-color: var(--pi-accent); font-family: inherit; }
|
||||
.prompt-enter-option input:focus { border-color: transparent; box-shadow: none; outline: 2px solid var(--pi-accent-border); outline-offset: 2px; }
|
||||
.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); }
|
||||
@@ -320,7 +385,7 @@ export class SettingsShortcutsPanel extends LitElement {
|
||||
.shortcut-status small.conflict.shadowed { color: var(--pi-warning); }
|
||||
.shortcut-input-label { min-width: 0; display: grid; gap: 5px; }
|
||||
.shortcut-input-label span { color: var(--pi-muted); font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||
input { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; outline: none; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
input { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; outline: none; font: var(--pi-control-font-size, 16px) var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
|
||||
input:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
|
||||
.shortcut-actions { display: flex; justify-content: flex-end; gap: 7px; flex-wrap: wrap; }
|
||||
kbd { border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); padding: 3px 7px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
|
||||
@@ -328,8 +393,7 @@ 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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
|
||||
import type { MachineRuntime, PiPackageInfo } from "../../api";
|
||||
import { canUpdateAllPiPackages, friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageManagementSupport, piPackageMutationFollowUpMessage, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, shouldRefreshGatewayPluginsAfterPiPackageMutation, updateAllPiPackagesDisabledReason, type PiPackageTargetContext } from "./piPackageSettings";
|
||||
|
||||
const userPackage: PiPackageInfo = { source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" };
|
||||
const projectPackage: PiPackageInfo = { source: "../project-tools", scope: "project", filtered: true };
|
||||
const localTarget: PiPackageTargetContext = { id: "local", name: "local", kind: "local" };
|
||||
const remoteTarget: PiPackageTargetContext = { id: "remote-a", name: "Lab Mac", kind: "remote" };
|
||||
const runtimeWithPackageManagement: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] };
|
||||
const runtimeWithoutPackageManagement: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] };
|
||||
const unavailableRuntime: MachineRuntime = { machineId: "remote-a", ok: false, checkedAt: "now", error: "Remote runtime returned HTTP 404" };
|
||||
|
||||
describe("Pi package settings helpers", () => {
|
||||
it("normalizes and validates install sources without adding location choices", () => {
|
||||
expect(normalizePiPackageSource(" npm:@acme/tools ")).toBe("npm:@acme/tools");
|
||||
expect(piPackageSourceValidationMessage(" npm:@acme/tools ")).toBeUndefined();
|
||||
expect(piPackageSourceValidationMessage(" ")).toContain("Pi package source accepted by Pi");
|
||||
});
|
||||
|
||||
it("formats package metadata with Pi package terminology", () => {
|
||||
expect(piPackageScopeLabel(userPackage)).toBe("User scope");
|
||||
expect(piPackageScopeLabel(projectPackage)).toBe("Project scope");
|
||||
expect(piPackageFilteredLabel(userPackage)).toBe("Available in this PI WEB process");
|
||||
expect(piPackageFilteredLabel(projectPackage)).toBe("Filtered by current Pi package settings");
|
||||
});
|
||||
|
||||
it("allows updates for user-scope packages and explains project-scope limits", () => {
|
||||
expect(piPackageUpdateDisabledReason(userPackage)).toBeUndefined();
|
||||
expect(piPackageUpdateDisabledReason(projectPackage)).toContain("user-scope Pi packages");
|
||||
expect(canUpdateAllPiPackages([userPackage])).toBe(true);
|
||||
expect(canUpdateAllPiPackages([userPackage, projectPackage])).toBe(false);
|
||||
expect(updateAllPiPackagesDisabledReason([])).toBe("No Pi packages are configured yet.");
|
||||
expect(updateAllPiPackagesDisabledReason([userPackage, projectPackage])).toContain("project-scope Pi packages");
|
||||
});
|
||||
|
||||
it("matches pending operations by action and source", () => {
|
||||
expect(isPiPackageOperationPending({ kind: "remove", source: "npm:@acme/tools" }, "remove", "npm:@acme/tools")).toBe(true);
|
||||
expect(isPiPackageOperationPending({ kind: "remove", source: "npm:@acme/tools" }, "remove", "npm:@acme/other")).toBe(false);
|
||||
expect(isPiPackageOperationPending({ kind: "update-all" }, "update-all")).toBe(true);
|
||||
});
|
||||
|
||||
it("labels package targets and gateway plugin refresh scope", () => {
|
||||
expect(piPackageTargetContext(undefined)).toEqual(localTarget);
|
||||
expect(piPackageTargetLabel(localTarget)).toBe("local (local gateway)");
|
||||
expect(piPackageTargetLabel(remoteTarget)).toBe("Lab Mac (remote machine)");
|
||||
expect(shouldRefreshGatewayPluginsAfterPiPackageMutation(localTarget)).toBe(true);
|
||||
expect(shouldRefreshGatewayPluginsAfterPiPackageMutation(remoteTarget)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses runtime capabilities as package-management UX guidance without blocking older remotes", () => {
|
||||
expect(piPackageManagementSupport(localTarget, undefined)).toEqual({ state: "supported" });
|
||||
expect(piPackageManagementSupport(remoteTarget, runtimeWithPackageManagement)).toEqual({ state: "supported" });
|
||||
|
||||
const unsupported = piPackageManagementSupport(remoteTarget, runtimeWithoutPackageManagement);
|
||||
expect(isPiPackageManagementUnsupported(unsupported)).toBe(true);
|
||||
expect(unsupported.message).toContain("Update and restart Pi-Web on that machine");
|
||||
|
||||
expect(piPackageManagementSupport(remoteTarget, undefined)).toEqual({ state: "unknown" });
|
||||
expect(piPackageManagementSupport(remoteTarget, unavailableRuntime)).toEqual({ state: "unknown" });
|
||||
});
|
||||
|
||||
it("describes the browser and session reload follow-up without requiring sessiond restarts", () => {
|
||||
const message = piPackageMutationFollowUpMessage("install");
|
||||
|
||||
expect(message).toContain("Type /reload in each idle PI WEB session");
|
||||
expect(message).toContain("extensions, skills, prompt templates, themes, and context/system prompt files");
|
||||
expect(message).toContain("Reload the browser page separately for PI WEB browser plugin changes");
|
||||
expect(message).not.toContain("session daemon");
|
||||
expect(message).not.toContain("sessiond");
|
||||
});
|
||||
|
||||
it("scopes remote package mutation follow-up copy to the selected machine", () => {
|
||||
const message = piPackageMutationFollowUpMessage("update", remoteTarget);
|
||||
|
||||
expect(message).toContain("Pi package updated on Lab Mac");
|
||||
expect(message).toContain("each idle PI WEB session on Lab Mac");
|
||||
expect(message).toContain("PI WEB browser plugin changes served by Lab Mac");
|
||||
});
|
||||
|
||||
it("turns older remote route failures into package-management compatibility guidance", () => {
|
||||
expect(friendlyPiPackageErrorMessage("Not Found", remoteTarget)).toBe("Pi package management is not available on Lab Mac. Update and restart Pi-Web on that machine, then try again.");
|
||||
expect(friendlyPiPackageErrorMessage("Remote machine unavailable", remoteTarget)).toBe("Could not reach Lab Mac for Pi package management. Check the machine connection and try again.");
|
||||
expect(friendlyPiPackageErrorMessage("Remote machine timeout", remoteTarget)).toContain("may still be running remotely");
|
||||
expect(friendlyPiPackageErrorMessage("Not Found", localTarget)).toBe("Not Found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Machine, MachineKind, MachineRuntime, PiPackageInfo, PiPackageMutationAction } from "../../api";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities";
|
||||
|
||||
export type PiPackageOperationKind = PiPackageMutationAction | "update-all";
|
||||
|
||||
export interface PiPackageOperationState {
|
||||
kind: PiPackageOperationKind;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface PiPackageTargetContext {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: MachineKind;
|
||||
}
|
||||
|
||||
export type PiPackageManagementSupportState = "supported" | "unsupported" | "unknown";
|
||||
|
||||
export interface PiPackageManagementSupport {
|
||||
state: PiPackageManagementSupportState;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function piPackageTargetContext(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): PiPackageTargetContext {
|
||||
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
|
||||
return { id: "local", name: "local", kind: "local" };
|
||||
}
|
||||
|
||||
export function piPackageTargetLabel(target: PiPackageTargetContext): string {
|
||||
return target.kind === "local" ? `${target.name} (local gateway)` : `${target.name} (remote machine)`;
|
||||
}
|
||||
|
||||
export function piPackageManagementSupport(target: PiPackageTargetContext, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): PiPackageManagementSupport {
|
||||
if (target.kind === "local") return { state: "supported" };
|
||||
if (runtime?.ok !== true) return { state: "unknown" };
|
||||
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.piPackagesManage)) return { state: "supported" };
|
||||
return { state: "unsupported", message: piPackageManagementUnavailableMessage(target) };
|
||||
}
|
||||
|
||||
export function piPackageManagementSupportKey(support: PiPackageManagementSupport): string {
|
||||
return `${support.state}:${support.message ?? ""}`;
|
||||
}
|
||||
|
||||
export function isPiPackageManagementUnsupported(support: PiPackageManagementSupport | undefined): support is PiPackageManagementSupport & { state: "unsupported" } {
|
||||
return support?.state === "unsupported";
|
||||
}
|
||||
|
||||
export function piPackageManagementUnavailableMessage(target: PiPackageTargetContext): string {
|
||||
return `Pi package management is not available on ${target.name}. Update and restart Pi-Web on that machine, then try again.`;
|
||||
}
|
||||
|
||||
export function shouldRefreshGatewayPluginsAfterPiPackageMutation(target: PiPackageTargetContext): boolean {
|
||||
return target.kind === "local";
|
||||
}
|
||||
|
||||
export function normalizePiPackageSource(source: string): string {
|
||||
return source.trim();
|
||||
}
|
||||
|
||||
export function piPackageSourceValidationMessage(source: string): string | undefined {
|
||||
if (normalizePiPackageSource(source) !== "") return undefined;
|
||||
return "Enter a Pi package source accepted by Pi, such as npm:@scope/package, a git/URL source, or a local path.";
|
||||
}
|
||||
|
||||
export function piPackageScopeLabel(packageInfo: Pick<PiPackageInfo, "scope">): string {
|
||||
return packageInfo.scope === "project" ? "Project scope" : "User scope";
|
||||
}
|
||||
|
||||
export function piPackageFilteredLabel(packageInfo: Pick<PiPackageInfo, "filtered">): string {
|
||||
return packageInfo.filtered ? "Filtered by current Pi package settings" : "Available in this PI WEB process";
|
||||
}
|
||||
|
||||
export function piPackageInstalledPathLabel(packageInfo: Pick<PiPackageInfo, "installedPath">): string {
|
||||
return packageInfo.installedPath ?? "Installed path not reported by Pi";
|
||||
}
|
||||
|
||||
export function canUpdatePiPackage(packageInfo: Pick<PiPackageInfo, "scope">): boolean {
|
||||
return packageInfo.scope === "user";
|
||||
}
|
||||
|
||||
export function piPackageUpdateDisabledReason(packageInfo: Pick<PiPackageInfo, "scope">): string | undefined {
|
||||
if (canUpdatePiPackage(packageInfo)) return undefined;
|
||||
return "Project-scope Pi packages are listed for visibility, but PI WEB only updates user-scope Pi packages safely from this view.";
|
||||
}
|
||||
|
||||
export function canUpdateAllPiPackages(packages: readonly Pick<PiPackageInfo, "scope">[]): boolean {
|
||||
return packages.length > 0 && packages.every(canUpdatePiPackage);
|
||||
}
|
||||
|
||||
export function updateAllPiPackagesDisabledReason(packages: readonly Pick<PiPackageInfo, "scope">[]): string | undefined {
|
||||
if (packages.length === 0) return "No Pi packages are configured yet.";
|
||||
if (canUpdateAllPiPackages(packages)) return undefined;
|
||||
return "Update all is disabled while project-scope Pi packages are listed; update user-scope packages individually.";
|
||||
}
|
||||
|
||||
export function isPiPackageOperationPending(operation: PiPackageOperationState | undefined, kind: PiPackageOperationKind, source?: string): boolean {
|
||||
if (operation?.kind !== kind) return false;
|
||||
return source === undefined || operation.source === source;
|
||||
}
|
||||
|
||||
export function piPackageMutationFollowUpMessage(action: PiPackageMutationAction, target = piPackageTargetContext(undefined)): string {
|
||||
const verb = action === "install" ? "installed" : action === "remove" ? "removed" : "updated";
|
||||
const targetSuffix = target.kind === "local" ? "" : ` on ${target.name}`;
|
||||
const sessionScope = target.kind === "local" ? "each idle PI WEB session" : `each idle PI WEB session on ${target.name}`;
|
||||
const pluginScope = target.kind === "local" ? "PI WEB browser plugin changes" : `PI WEB browser plugin changes served by ${target.name}`;
|
||||
return `Pi package ${verb}${targetSuffix}. Type /reload in ${sessionScope} to rediscover Pi runtime resources: extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for ${pluginScope}.`;
|
||||
}
|
||||
|
||||
export function friendlyPiPackageErrorMessage(message: string, target: PiPackageTargetContext): string {
|
||||
const normalized = message.trim();
|
||||
if (target.kind !== "remote") return normalized;
|
||||
if (isUnsupportedRemotePiPackageRouteMessage(normalized)) {
|
||||
return piPackageManagementUnavailableMessage(target);
|
||||
}
|
||||
if (normalized === "Remote machine timeout") {
|
||||
return `Timed out while contacting ${target.name} for Pi package management. The package operation may still be running remotely; reload the package list before retrying.`;
|
||||
}
|
||||
if (normalized === "Remote machine unavailable") {
|
||||
return `Could not reach ${target.name} for Pi package management. Check the machine connection and try again.`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isUnsupportedRemotePiPackageRouteMessage(message: string): boolean {
|
||||
return message === "Not Found"
|
||||
|| /route\s+(GET|POST):?\/api\/pi-packages\b.*not found/iu.test(message)
|
||||
|| /cannot\s+(GET|POST)\s+.*\/api\/pi-packages\b/iu.test(message);
|
||||
}
|
||||
@@ -1,7 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
|
||||
import {
|
||||
configFromDraft,
|
||||
draftFromConfig,
|
||||
gatewayServerConfigFromDraft,
|
||||
gatewayServerDraftFromConfig,
|
||||
machineAccessConfigPatchFromDraft,
|
||||
machineAccessDraftFromConfig,
|
||||
} from "./settingsConfigDraft";
|
||||
|
||||
describe("settings config drafts", () => {
|
||||
it("splits gateway server and selected-machine access drafts", () => {
|
||||
const config = {
|
||||
host: "0.0.0.0",
|
||||
port: 8504,
|
||||
allowedHosts: ["example.local", "192.168.1.20"],
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
};
|
||||
|
||||
expect(gatewayServerDraftFromConfig(config)).toEqual({
|
||||
host: "0.0.0.0",
|
||||
port: "8504",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "example.local\n192.168.1.20",
|
||||
});
|
||||
expect(machineAccessDraftFromConfig(config)).toEqual({
|
||||
allowedPathsText: "/tmp\n~/SDKs",
|
||||
uploadDefaultFolder: "manual/uploads",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds gateway server saves without changing selected-machine-safe config values", () => {
|
||||
expect(gatewayServerConfigFromDraft({
|
||||
host: " gateway.local ",
|
||||
port: "9000",
|
||||
allowedHostsMode: "all",
|
||||
allowedHostsText: "ignored.local",
|
||||
}, {
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
spawnSessions: true,
|
||||
})).toEqual({
|
||||
host: "gateway.local",
|
||||
port: 9000,
|
||||
allowedHosts: true,
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
spawnSessions: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds selected-machine access/upload patches only from selected-machine-safe fields", () => {
|
||||
const patch = machineAccessConfigPatchFromDraft({
|
||||
allowedPathsText: "/tmp\n~/SDKs\n",
|
||||
uploadDefaultFolder: " manual\\uploads/. ",
|
||||
});
|
||||
|
||||
expect(Object.keys(patch)).toEqual(["pathAccess", "uploads"]);
|
||||
expect(patch).toEqual({
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears selected-machine access/upload settings with safe default patches", () => {
|
||||
expect(machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "" })).toEqual({
|
||||
pathAccess: { allowedPaths: [] },
|
||||
uploads: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid selected-machine upload default folders before saving", () => {
|
||||
expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "/tmp/uploads" })).toThrow("Upload default folder must be workspace-relative.");
|
||||
expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "../secret" })).toThrow("Upload default folder must not contain path traversal.");
|
||||
});
|
||||
|
||||
it("converts PI WEB config values to editable general settings drafts", () => {
|
||||
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
|
||||
host: "0.0.0.0",
|
||||
|
||||
@@ -1,36 +1,51 @@
|
||||
import type { PiWebConfigValues } from "../../api";
|
||||
|
||||
export interface ConfigDraft {
|
||||
export interface GatewayServerConfigDraft {
|
||||
host: string;
|
||||
port: string;
|
||||
allowedHostsMode: "list" | "all";
|
||||
allowedHostsText: string;
|
||||
}
|
||||
|
||||
export interface MachineAccessConfigDraft {
|
||||
allowedPathsText: string;
|
||||
uploadDefaultFolder: string;
|
||||
}
|
||||
|
||||
export interface ConfigDraft extends GatewayServerConfigDraft {
|
||||
allowedPathsText: string;
|
||||
}
|
||||
|
||||
export function emptyConfigDraft(): ConfigDraft {
|
||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "", allowedPathsText: "" };
|
||||
export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
|
||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
|
||||
}
|
||||
|
||||
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
||||
export function emptyMachineAccessConfigDraft(): MachineAccessConfigDraft {
|
||||
return { allowedPathsText: "", uploadDefaultFolder: "" };
|
||||
}
|
||||
|
||||
export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft {
|
||||
return {
|
||||
host: config.host ?? "",
|
||||
port: config.port === undefined ? "" : String(config.port),
|
||||
allowedHostsMode: config.allowedHosts === true ? "all" : "list",
|
||||
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
|
||||
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
||||
const config: PiWebConfigValues = {
|
||||
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
|
||||
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
|
||||
...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }),
|
||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||
export function machineAccessDraftFromConfig(config: PiWebConfigValues): MachineAccessConfigDraft {
|
||||
return {
|
||||
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
|
||||
uploadDefaultFolder: config.uploads?.defaultFolder ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
||||
return { ...gatewayServerDraftFromConfig(config), allowedPathsText: machineAccessDraftFromConfig(config).allowedPathsText };
|
||||
}
|
||||
|
||||
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
||||
const config = preservedGatewayConfigRemainder(baseConfig);
|
||||
const host = draft.host.trim();
|
||||
const port = draft.port.trim();
|
||||
if (host !== "") config.host = host;
|
||||
@@ -40,11 +55,38 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
|
||||
config.port = parsed;
|
||||
}
|
||||
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
|
||||
return config;
|
||||
}
|
||||
|
||||
export function machineAccessConfigPatchFromDraft(draft: MachineAccessConfigDraft): PiWebConfigValues {
|
||||
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
|
||||
const uploadDefaultFolder = normalizeWorkspaceRelativeFolder(draft.uploadDefaultFolder);
|
||||
return {
|
||||
pathAccess: { allowedPaths },
|
||||
uploads: uploadDefaultFolder === "" ? {} : { defaultFolder: uploadDefaultFolder },
|
||||
};
|
||||
}
|
||||
|
||||
export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
||||
const config = gatewayServerConfigFromDraft(draft, baseConfig);
|
||||
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
|
||||
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
|
||||
else delete config.pathAccess;
|
||||
return config;
|
||||
}
|
||||
|
||||
function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebConfigValues {
|
||||
return {
|
||||
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
|
||||
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
|
||||
...(baseConfig.pathAccess === undefined ? {} : { pathAccess: baseConfig.pathAccess }),
|
||||
...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }),
|
||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowedHostsText(value: string): string[] {
|
||||
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
|
||||
}
|
||||
@@ -56,6 +98,21 @@ function parseAllowedPathsText(value: string): string[] {
|
||||
return paths;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceRelativeFolder(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return "";
|
||||
if (isAbsoluteLike(trimmed)) throw new Error("Upload default folder must be workspace-relative.");
|
||||
const parts = trimmed.split(/[\\/]+/u).filter((part) => part !== "" && part !== ".");
|
||||
if (parts.length === 0) return "";
|
||||
if (parts.some((part) => part === "..")) throw new Error("Upload default folder must not contain path traversal.");
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function isAbsoluteishAllowedPath(path: string): boolean {
|
||||
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path);
|
||||
}
|
||||
|
||||
function isAbsoluteLike(value: string): boolean {
|
||||
const withForwardSlashes = value.replace(/\\/g, "/");
|
||||
return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PiPackagesResponse, PiWebConfigResponse, PiWebPluginsResponse } from "../../api";
|
||||
import { loadGatewaySettingsData, loadPiPackagesData } from "./settingsDataLoading";
|
||||
import type { PiPackageManagementSupport } from "./piPackageSettings";
|
||||
|
||||
const configResponse: PiWebConfigResponse = {
|
||||
path: "/home/test/.config/pi-web/config.json",
|
||||
exists: true,
|
||||
config: { host: "127.0.0.1" },
|
||||
effectiveConfig: { host: "127.0.0.1" },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
|
||||
const pluginsResponse: PiWebPluginsResponse = { plugins: [] };
|
||||
const packagesResponse: PiPackagesResponse = { packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] };
|
||||
|
||||
const remoteTarget = { id: "remote-a", name: "Lab Mac", kind: "remote" } as const;
|
||||
const unsupportedPackageManagement: PiPackageManagementSupport = {
|
||||
state: "unsupported",
|
||||
message: "Pi package management is not available on Lab Mac. Update and restart Pi-Web on that machine, then try again.",
|
||||
};
|
||||
|
||||
describe("settings data loading helpers", () => {
|
||||
it("loads gateway settings without depending on Pi package data", async () => {
|
||||
const result = await loadGatewaySettingsData({
|
||||
loadConfig: () => Promise.resolve(configResponse),
|
||||
loadPlugins: () => Promise.resolve(pluginsResponse),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ config: configResponse, plugins: pluginsResponse, error: "" });
|
||||
});
|
||||
|
||||
it("keeps gateway settings errors scoped to gateway config and plugins", async () => {
|
||||
const result = await loadGatewaySettingsData({
|
||||
loadConfig: () => Promise.resolve(configResponse),
|
||||
loadPlugins: () => Promise.reject(new Error("plugin scan failed")),
|
||||
});
|
||||
|
||||
expect(result.config).toBe(configResponse);
|
||||
expect(result.plugins).toBeUndefined();
|
||||
expect(result.error).toBe("Failed to load settings: PI WEB plugins: plugin scan failed");
|
||||
});
|
||||
|
||||
it("loads Pi packages for the selected target with package-scoped errors", async () => {
|
||||
const requestedTargets: string[] = [];
|
||||
const success = await loadPiPackagesData(remoteTarget, (targetId) => {
|
||||
requestedTargets.push(targetId);
|
||||
return Promise.resolve(packagesResponse);
|
||||
});
|
||||
const failure = await loadPiPackagesData(remoteTarget, (targetId) => {
|
||||
requestedTargets.push(targetId);
|
||||
return Promise.reject(new Error("Remote machine unavailable"));
|
||||
});
|
||||
|
||||
expect(requestedTargets).toEqual(["remote-a", "remote-a"]);
|
||||
expect(success).toEqual({ packagesResponse, error: "" });
|
||||
expect(failure.packagesResponse).toBeUndefined();
|
||||
expect(failure.error).toBe("Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac for Pi package management. Check the machine connection and try again.");
|
||||
});
|
||||
|
||||
it("skips package listing only when runtime data confirms package management is unsupported", async () => {
|
||||
const requestedTargets: string[] = [];
|
||||
const loadPackages = vi.fn((targetId: string) => {
|
||||
requestedTargets.push(targetId);
|
||||
return Promise.resolve(packagesResponse);
|
||||
});
|
||||
|
||||
const blocked = await loadPiPackagesData(remoteTarget, loadPackages, unsupportedPackageManagement);
|
||||
const unknown = await loadPiPackagesData(remoteTarget, loadPackages, { state: "unknown" });
|
||||
|
||||
expect(blocked).toEqual({ error: unsupportedPackageManagement.message, skipped: true });
|
||||
expect(unknown).toEqual({ packagesResponse, error: "" });
|
||||
expect(requestedTargets).toEqual(["remote-a"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PiPackagesResponse, PiWebConfigResponse, PiWebPluginsResponse } from "../../api";
|
||||
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageTargetLabel, type PiPackageManagementSupport, type PiPackageTargetContext } from "./piPackageSettings";
|
||||
|
||||
export interface GatewaySettingsLoaders {
|
||||
loadConfig: () => Promise<PiWebConfigResponse>;
|
||||
loadPlugins: () => Promise<PiWebPluginsResponse>;
|
||||
}
|
||||
|
||||
export interface GatewaySettingsLoadResult {
|
||||
config?: PiWebConfigResponse;
|
||||
plugins?: PiWebPluginsResponse;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface PiPackagesLoadResult {
|
||||
packagesResponse?: PiPackagesResponse;
|
||||
error: string;
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
export async function loadGatewaySettingsData(loaders: GatewaySettingsLoaders): Promise<GatewaySettingsLoadResult> {
|
||||
const [config, plugins] = await Promise.allSettled([loaders.loadConfig(), loaders.loadPlugins()]);
|
||||
const result: GatewaySettingsLoadResult = { error: "" };
|
||||
const errors: string[] = [];
|
||||
|
||||
if (config.status === "fulfilled") result.config = config.value;
|
||||
else errors.push(`config: ${errorMessage(config.reason)}`);
|
||||
|
||||
if (plugins.status === "fulfilled") result.plugins = plugins.value;
|
||||
else errors.push(`PI WEB plugins: ${errorMessage(plugins.reason)}`);
|
||||
|
||||
if (errors.length > 0) result.error = `Failed to load settings: ${errors.join("; ")}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function loadPiPackagesData(target: PiPackageTargetContext, loadPackages: (targetId: string) => Promise<PiPackagesResponse>, support?: PiPackageManagementSupport): Promise<PiPackagesLoadResult> {
|
||||
if (isPiPackageManagementUnsupported(support)) {
|
||||
return { error: support.message ?? `Pi package management is not available on ${piPackageTargetLabel(target)}.`, skipped: true };
|
||||
}
|
||||
|
||||
try {
|
||||
return { packagesResponse: await loadPackages(target.id), error: "" };
|
||||
} catch (error) {
|
||||
return { error: `Failed to load Pi packages from ${piPackageTargetLabel(target)}: ${friendlyPiPackageErrorMessage(errorMessage(error), target)}` };
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { mergeSelectedMachineAccessConfig } from "./settingsMachineAccessConfig";
|
||||
|
||||
describe("selected-machine access config helpers", () => {
|
||||
it("merges local selected-machine file/upload config into gateway config without dropping gateway-only values", () => {
|
||||
const gateway = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
});
|
||||
const selectedMachine = configResponse({
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
maxUploadBytes: 5678,
|
||||
});
|
||||
|
||||
expect(mergeSelectedMachineAccessConfig(gateway, selectedMachine)).toEqual({
|
||||
...gateway,
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges cleared selected-machine access/upload defaults without clearing gateway-only values", () => {
|
||||
const gateway = configResponse({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
});
|
||||
const selectedMachine = configResponse({ pathAccess: { allowedPaths: [] }, uploads: {} });
|
||||
|
||||
expect(mergeSelectedMachineAccessConfig(gateway, selectedMachine)).toEqual({
|
||||
...gateway,
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
pathAccess: { allowedPaths: [] },
|
||||
uploads: {},
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
pathAccess: { allowedPaths: [] },
|
||||
uploads: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
|
||||
export function mergeSelectedMachineAccessConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
|
||||
return {
|
||||
...base,
|
||||
config: mergeAccessConfig(base.config, selectedMachine.config),
|
||||
effectiveConfig: mergeAccessConfig(base.effectiveConfig, selectedMachine.effectiveConfig),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAccessConfig(base: PiWebConfigValues, selectedMachine: PiWebConfigValues): PiWebConfigValues {
|
||||
return {
|
||||
...base,
|
||||
...(selectedMachine.pathAccess === undefined ? {} : { pathAccess: selectedMachine.pathAccess }),
|
||||
...(selectedMachine.uploads === undefined ? {} : { uploads: selectedMachine.uploads }),
|
||||
...(selectedMachine.maxUploadBytes === undefined ? {} : { maxUploadBytes: selectedMachine.maxUploadBytes }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Machine, MachineRuntime } from "../../api";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
|
||||
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
|
||||
|
||||
const remoteMachine: Machine = {
|
||||
id: "remote-a",
|
||||
name: "Lab Mac",
|
||||
kind: "remote",
|
||||
baseUrl: "https://lab.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("selected-machine settings target helpers", () => {
|
||||
it("uses the selected machine when present and falls back to the local gateway", () => {
|
||||
expect(settingsMachineTarget(undefined)).toEqual({ id: "local", name: "local", kind: "local" });
|
||||
expect(settingsMachineTarget(remoteMachine)).toEqual({ id: "remote-a", name: "Lab Mac", kind: "remote" });
|
||||
});
|
||||
|
||||
it("labels local and remote settings targets factually", () => {
|
||||
expect(settingsMachineTargetLabel({ id: "local", name: "local", kind: "local" })).toBe("local (local gateway)");
|
||||
expect(settingsMachineTargetLabel(settingsMachineTarget(remoteMachine))).toBe("Lab Mac (remote machine)");
|
||||
});
|
||||
|
||||
it("gates remote selected-machine settings on advertised runtime support", () => {
|
||||
const target = settingsMachineTarget(remoteMachine);
|
||||
const supportedRuntime: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings] };
|
||||
const unsupportedRuntime: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] };
|
||||
|
||||
expect(selectedMachineSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" });
|
||||
expect(selectedMachineSettingsSupport(target, undefined)).toEqual({ state: "unknown" });
|
||||
expect(selectedMachineSettingsSupport(target, { ok: false })).toEqual({ state: "unknown" });
|
||||
expect(selectedMachineSettingsSupport(target, supportedRuntime)).toEqual({ state: "supported" });
|
||||
|
||||
const unsupported = selectedMachineSettingsSupport(target, unsupportedRuntime);
|
||||
expect(isSelectedMachineSettingsUnsupported(unsupported)).toBe(true);
|
||||
expect(unsupported.message).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`);
|
||||
});
|
||||
|
||||
it("turns older remote config route failures into selected-machine compatibility guidance", () => {
|
||||
const target = settingsMachineTarget(remoteMachine);
|
||||
|
||||
expect(selectedMachineSettingsUnavailableMessage(target)).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Not Found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("route GET:/api/config not found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Cannot PUT /api/config", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("route GET:/api/plugins not found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Cannot GET /api/plugins", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
});
|
||||
|
||||
it("scopes remote reachability errors to selected-machine settings", () => {
|
||||
const target = settingsMachineTarget(remoteMachine);
|
||||
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Remote machine unavailable", target)).toBe("Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Remote machine timeout", target)).toBe("Timed out while contacting Lab Mac for selected-machine settings. The operation may still be running remotely; reload before retrying.");
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Not Found", { id: "local", name: "local", kind: "local" })).toBe("Not Found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Machine, MachineKind, MachineRuntime } from "../../api";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities";
|
||||
|
||||
export interface SettingsMachineTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: MachineKind;
|
||||
}
|
||||
|
||||
export type SelectedMachineSettingsSupportState = "supported" | "unsupported" | "unknown";
|
||||
|
||||
export interface SelectedMachineSettingsSupport {
|
||||
state: SelectedMachineSettingsSupportState;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function settingsMachineTarget(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): SettingsMachineTarget {
|
||||
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
|
||||
return { id: "local", name: "local", kind: "local" };
|
||||
}
|
||||
|
||||
export function settingsMachineTargetLabel(target: SettingsMachineTarget): string {
|
||||
return target.kind === "local" ? `${target.name} (local gateway)` : `${target.name} (remote machine)`;
|
||||
}
|
||||
|
||||
export function selectedMachineSettingsSupport(target: SettingsMachineTarget, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): SelectedMachineSettingsSupport {
|
||||
if (target.kind === "local") return { state: "supported" };
|
||||
if (runtime?.ok !== true) return { state: "unknown" };
|
||||
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.selectedMachineSettings)) return { state: "supported" };
|
||||
return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) };
|
||||
}
|
||||
|
||||
export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string {
|
||||
return `${support.state}:${support.message ?? ""}`;
|
||||
}
|
||||
|
||||
export function isSelectedMachineSettingsUnsupported(support: SelectedMachineSettingsSupport | undefined): support is SelectedMachineSettingsSupport & { state: "unsupported" } {
|
||||
return support?.state === "unsupported";
|
||||
}
|
||||
|
||||
export function selectedMachineSettingsUnavailableMessage(target: SettingsMachineTarget): string {
|
||||
return `Selected-machine settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`;
|
||||
}
|
||||
|
||||
export function friendlySelectedMachineSettingsErrorMessage(message: string, target: SettingsMachineTarget): string {
|
||||
const normalized = message.trim();
|
||||
if (target.kind !== "remote") return normalized;
|
||||
if (isUnsupportedRemoteSelectedMachineSettingsRouteMessage(normalized)) {
|
||||
return selectedMachineSettingsUnavailableMessage(target);
|
||||
}
|
||||
if (normalized === "Remote machine timeout") {
|
||||
return `Timed out while contacting ${target.name} for selected-machine settings. The operation may still be running remotely; reload before retrying.`;
|
||||
}
|
||||
if (normalized === "Remote machine unavailable") {
|
||||
return `Could not reach ${target.name} for selected-machine settings. Check the machine connection and try again.`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isUnsupportedRemoteSelectedMachineSettingsRouteMessage(message: string): boolean {
|
||||
return message === "Not Found"
|
||||
|| /route\s+(GET|PUT):?\/api\/(config|plugins)\b.*not found/iu.test(message)
|
||||
|| /cannot\s+(GET|PUT)\s+.*\/api\/(config|plugins)\b/iu.test(message);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settingsPluginConfig";
|
||||
|
||||
describe("plugin settings config helpers", () => {
|
||||
it("builds plugin-only save patches while preserving existing plugin config", () => {
|
||||
const patch = pluginEnabledConfigPatch(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
plugins: {
|
||||
info: { enabled: true, settings: { theme: "dark" }, custom: "keep" },
|
||||
metrics: { enabled: false },
|
||||
},
|
||||
},
|
||||
"info",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(Object.keys(patch)).toEqual(["plugins"]);
|
||||
expect(patch).toEqual({
|
||||
plugins: {
|
||||
info: { enabled: false, settings: { theme: "dark" }, custom: "keep" },
|
||||
metrics: { enabled: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges local selected-machine plugin config into gateway config without dropping gateway-only values", () => {
|
||||
const gateway = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: false } },
|
||||
});
|
||||
const selectedMachine = configResponse({ plugins: { info: { enabled: true }, metrics: { enabled: false } } });
|
||||
|
||||
expect(mergeSelectedMachinePluginConfig(gateway, selectedMachine)).toEqual({
|
||||
...gateway,
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, metrics: { enabled: false } },
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, metrics: { enabled: false } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
|
||||
export function pluginEnabledConfigPatch(baseConfig: PiWebConfigValues, pluginId: string, enabled: boolean): PiWebConfigValues {
|
||||
const currentPlugins = baseConfig.plugins ?? {};
|
||||
const currentPluginConfig = currentPlugins[pluginId] ?? {};
|
||||
return {
|
||||
plugins: {
|
||||
...currentPlugins,
|
||||
[pluginId]: { ...currentPluginConfig, enabled },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeSelectedMachinePluginConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
|
||||
return {
|
||||
...base,
|
||||
config: mergePluginConfig(base.config, selectedMachine.config),
|
||||
effectiveConfig: mergePluginConfig(base.effectiveConfig, selectedMachine.effectiveConfig),
|
||||
};
|
||||
}
|
||||
|
||||
function mergePluginConfig(base: PiWebConfigValues, selectedMachine: PiWebConfigValues): PiWebConfigValues {
|
||||
if (selectedMachine.plugins === undefined) return base;
|
||||
return { ...base, plugins: selectedMachine.plugins };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
||||
|
||||
describe("session daemon settings config helpers", () => {
|
||||
it("builds daemon-only save patches for the sessiond toggles", () => {
|
||||
expect(spawnSessionsConfigPatch(false)).toEqual({ spawnSessions: false });
|
||||
expect(Object.keys(spawnSessionsConfigPatch(false))).toEqual(["spawnSessions"]);
|
||||
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
|
||||
expect(Object.keys(subsessionsConfigPatch(true))).toEqual(["subsessions"]);
|
||||
});
|
||||
|
||||
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
|
||||
const gateway = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
});
|
||||
const selectedMachine = configResponse({ spawnSessions: true, subsessions: true }, { spawnSessions: true, subsessions: false });
|
||||
|
||||
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine)).toEqual({
|
||||
...gateway,
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: true,
|
||||
subsessions: true,
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: true,
|
||||
subsessions: true,
|
||||
},
|
||||
envOverrides: {
|
||||
host: false,
|
||||
port: false,
|
||||
allowedHosts: false,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function configResponse(config: PiWebConfigValues, overrides: Partial<PiWebConfigResponse["envOverrides"]> = {}): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, ...overrides },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
|
||||
export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues {
|
||||
return { spawnSessions: enabled };
|
||||
}
|
||||
|
||||
export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues {
|
||||
return { subsessions: enabled };
|
||||
}
|
||||
|
||||
export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
|
||||
return {
|
||||
...base,
|
||||
config: { ...base.config, ...selectedMachine.config },
|
||||
effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig },
|
||||
envOverrides: {
|
||||
...base.envOverrides,
|
||||
spawnSessions: selectedMachine.envOverrides.spawnSessions,
|
||||
subsessions: selectedMachine.envOverrides.subsessions,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -345,6 +345,7 @@ export const chatStyles = css`
|
||||
.msg-meta:focus::before, .msg-meta.expanded::before { content: ""; }
|
||||
}
|
||||
formatted-text.part { display: block; }
|
||||
formatted-text.part, .queued-message formatted-text { text-align: start; unicode-bidi: plaintext; }
|
||||
.part { max-width: 100%; min-width: 0; box-sizing: border-box; overflow: visible; }
|
||||
.part + .part { margin-top: 10px; }
|
||||
.tool-line { color: var(--pi-warning); }
|
||||
@@ -355,22 +356,22 @@ export const chatStyles = css`
|
||||
.skill-invocation > summary, .skill-read > strong { color: var(--pi-purple); }
|
||||
.skill-invocation > small, .skill-read > small { display: block; margin: 6px 0 0; color: var(--pi-muted); }
|
||||
summary { cursor: pointer; color: var(--pi-muted); }
|
||||
pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
|
||||
.shell-output { color: var(--pi-text); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; }
|
||||
pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; direction: ltr; text-align: left; unicode-bidi: isolate; }
|
||||
.shell-output { color: var(--pi-text); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; direction: ltr; text-align: left; unicode-bidi: isolate; }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } }
|
||||
`;
|
||||
|
||||
export const formattedTextStyles = css`
|
||||
:host { display: block; }
|
||||
.formatted { white-space: normal; overflow-wrap: anywhere; line-height: 1.45; }
|
||||
.formatted { white-space: normal; overflow-wrap: anywhere; line-height: 1.45; text-align: start; unicode-bidi: plaintext; }
|
||||
p, ul, ol, pre, blockquote, table, .code-block-wrapper { margin: 0 0 10px; }
|
||||
:is(p, ul, ol, pre, blockquote, table, .code-block-wrapper):last-child { margin-bottom: 0; }
|
||||
ul, ol { padding-left: 22px; }
|
||||
li + li { margin-top: 3px; }
|
||||
code { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 1px 4px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
code { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 1px 4px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; }
|
||||
.code-block-wrapper { position: relative; }
|
||||
.code-block-wrapper pre { margin: 0; padding-right: 40px; }
|
||||
pre { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); padding: 10px; overflow-x: auto; overflow-y: hidden; }
|
||||
pre { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); padding: 10px; overflow-x: auto; overflow-y: hidden; direction: ltr; text-align: left; unicode-bidi: isolate; }
|
||||
pre code { border: 0; padding: 0; background: transparent; }
|
||||
.code-copy-button { position: absolute; top: 6px; right: 6px; z-index: 1; display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; }
|
||||
.code-copy-button:hover, .code-copy-button:focus { color: var(--pi-text); border-color: var(--pi-accent); }
|
||||
@@ -417,7 +418,7 @@ export const commandPickerStyles = css`
|
||||
.options { min-height: 0; overflow: auto; outline: none; }
|
||||
button { border: 0; background: transparent; color: var(--pi-text); cursor: pointer; }
|
||||
header button { font-size: 20px; color: var(--pi-muted); }
|
||||
input { margin: 10px 12px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); font: 14px system-ui, sans-serif; padding: 8px 10px; outline: none; }
|
||||
input { margin: 10px 12px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); padding: 8px 10px; outline: none; }
|
||||
input:focus { border-color: var(--pi-accent); }
|
||||
.options button { display: block; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; }
|
||||
.options button.selected, .options button:hover { background: var(--pi-selection-bg); }
|
||||
@@ -430,16 +431,19 @@ export const actionPaletteStyles = css`
|
||||
.backdrop { --palette-top: min(12dvh, 90px); --palette-bottom: max(20px, env(safe-area-inset-bottom)); display: grid; align-items: start; justify-items: center; width: 100%; height: 100dvh; background: var(--pi-overlay); padding: var(--palette-top) 20px var(--palette-bottom); box-sizing: border-box; overflow: hidden; }
|
||||
section { width: min(720px, 100%); max-height: min(640px, calc(100dvh - var(--palette-top) - var(--palette-bottom))); display: flex; flex-direction: column; border: 1px solid var(--pi-border); border-radius: 12px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; }
|
||||
header { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px; border-bottom: 1px solid var(--pi-border); }
|
||||
input { min-width: 0; border: 0; outline: none; background: transparent; color: var(--pi-text); font: 16px system-ui, sans-serif; padding: 8px; }
|
||||
input { min-width: 0; border: 0; outline: none; background: transparent; color: var(--pi-text); font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); padding: 8px; }
|
||||
input::placeholder { color: var(--pi-dim); }
|
||||
button { border: 0; background: transparent; color: var(--pi-text); cursor: pointer; }
|
||||
header button { color: var(--pi-muted); font-size: 22px; padding: 2px 8px; }
|
||||
.options { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||
.options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; }
|
||||
.options button.selected, .options button:hover { background: var(--pi-selection-bg); }
|
||||
.options button.selected, .options button:hover:not(:disabled) { background: var(--pi-selection-bg); }
|
||||
.options button:disabled { cursor: not-allowed; opacity: .68; }
|
||||
.options button.disabled.selected { background: color-mix(in srgb, var(--pi-selection-bg) 55%, transparent); }
|
||||
.main { min-width: 0; }
|
||||
strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
small { display: block; color: var(--pi-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.disabled-reason { color: var(--pi-warning); }
|
||||
.group { grid-column: 1 / -1; font-size: 12px; }
|
||||
kbd { align-self: center; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
|
||||
.empty { padding: 24px; color: var(--pi-muted); text-align: center; }
|
||||
@@ -463,11 +467,11 @@ export const promptEditorStyles = css`
|
||||
.select-thinking .prompt-thinking-gauge .gauge-bar-active { opacity: 1; }
|
||||
.editor-attach { position: absolute; right: 8px; bottom: 8px; z-index: 2; width: 30px; height: 30px; }
|
||||
.editor-attach .prompt-action-icon { width: 16px; height: 16px; }
|
||||
textarea, .markdown-editor .cm-editor { box-sizing: border-box; width: 100%; min-height: 54px; max-height: 220px; resize: none; overflow: hidden; border-radius: 8px; border: 1px solid var(--pi-border); background: var(--pi-bg); color: var(--pi-text); font: 16px/1.4 system-ui, sans-serif; }
|
||||
textarea, .markdown-editor .cm-editor { box-sizing: border-box; width: 100%; min-height: 54px; max-height: 220px; resize: none; overflow: hidden; border-radius: 8px; border: 1px solid var(--pi-border); background: var(--pi-bg); color: var(--pi-text); font: var(--pi-control-font-size, 16px)/1.4 var(--pi-control-font-family, system-ui, sans-serif); }
|
||||
textarea { overflow-y: auto; padding: 8px; }
|
||||
.markdown-editor .cm-scroller { max-height: 220px; overflow-y: auto; font-family: system-ui, sans-serif; line-height: 1.4; }
|
||||
.markdown-editor .cm-content { min-height: 38px; padding: 8px 44px 8px 8px; caret-color: var(--pi-text); }
|
||||
.markdown-editor .cm-line { padding: 0; }
|
||||
.markdown-editor .cm-scroller { max-height: 220px; overflow-y: auto; font-family: var(--pi-control-font-family, system-ui, sans-serif); line-height: 1.4; }
|
||||
.markdown-editor .cm-content { min-height: 38px; padding: 8px 44px 8px 8px; caret-color: var(--pi-text); text-align: start; unicode-bidi: plaintext; }
|
||||
.markdown-editor .cm-line { padding: 0; unicode-bidi: plaintext; }
|
||||
.markdown-editor .cm-placeholder { color: var(--pi-dim); }
|
||||
.markdown-editor .cm-focused { outline: none; }
|
||||
.shell-mode textarea, .shell-mode .markdown-editor .cm-editor { border-color: var(--pi-success); box-shadow: 0 0 0 1px var(--pi-success-ring); }
|
||||
@@ -475,8 +479,11 @@ export const promptEditorStyles = css`
|
||||
.attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
|
||||
.attachment-chip { position: relative; width: 56px; height: 56px; border: 1px solid var(--pi-border); border-radius: 8px; overflow: hidden; background: var(--pi-bg); }
|
||||
.attachment-chip img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.attachment-chip-file { display: grid; place-items: center; }
|
||||
.attachment-file-preview { display: grid; place-items: center; width: 34px; height: 26px; border: 1px solid var(--pi-border-muted); border-radius: 4px; background: var(--pi-surface); color: var(--pi-muted); font: 700 10px/1 system-ui, sans-serif; letter-spacing: .03em; }
|
||||
.attachment-file-name { position: absolute; right: 4px; bottom: 3px; left: 4px; overflow: hidden; color: var(--pi-muted); font-size: 10px; line-height: 1.2; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.attachment-remove { position: absolute; top: 1px; right: 1px; width: 18px; height: 18px; padding: 0; line-height: 16px; border-radius: 50%; border: 1px solid var(--pi-border); background: var(--pi-surface); color: var(--pi-text); font-size: 13px; cursor: pointer; }
|
||||
.attachment-delivery select { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; font: 12px system-ui, sans-serif; }
|
||||
.attachment-delivery select { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); }
|
||||
.attachment-error { flex-basis: 100%; color: var(--pi-danger); font-size: 12px; }
|
||||
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, textarea:disabled, .markdown-editor-disabled .cm-editor { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus } from "../api";
|
||||
import { api as defaultApi, type CommandResult, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api";
|
||||
import type { AppState } from "../appState";
|
||||
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
||||
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
||||
import { textMessage } from "../chatMessages";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
|
||||
@@ -8,12 +8,15 @@ import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||
import { isShellInput } from "../inputModes";
|
||||
import { fileCompletionInsertText } from "../promptCompletions";
|
||||
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
||||
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
|
||||
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
|
||||
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
|
||||
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||
|
||||
const MESSAGE_PAGE_SIZE = 100;
|
||||
const BULK_FALLBACK_CONCURRENCY = 4;
|
||||
|
||||
export interface SessionEventSocket {
|
||||
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
|
||||
@@ -27,6 +30,36 @@ export interface SessionControllerDependencies {
|
||||
transcripts?: ChatTranscriptStore;
|
||||
}
|
||||
|
||||
interface BulkSessionMutationResult {
|
||||
succeededIds: string[];
|
||||
failures: string[];
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
type ClientPendingStartSessionInfo = SessionInfo & { clientPendingStart: true; machineId: string };
|
||||
|
||||
type QueuedPendingSessionSendInput =
|
||||
| { type: "prompt"; text: string; streamingBehavior?: "steer" | "followUp" | undefined; attachments?: PromptAttachment[] | undefined; delivery: PromptAttachmentDelivery }
|
||||
| { type: "shell"; text: string }
|
||||
| { type: "command"; text: string };
|
||||
|
||||
type QueuedPendingSessionSend = QueuedPendingSessionSendInput & { id: string };
|
||||
|
||||
interface PendingSessionStart {
|
||||
tempId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
machineId: string;
|
||||
session: ClientPendingStartSessionInfo;
|
||||
queuedSends: QueuedPendingSessionSend[];
|
||||
discarded: boolean;
|
||||
}
|
||||
|
||||
interface SuppressedCreatedSession {
|
||||
session: SessionInfo;
|
||||
machineId: string;
|
||||
}
|
||||
|
||||
export class SessionController {
|
||||
private readonly socket: SessionEventSocket;
|
||||
private readonly api: typeof defaultApi;
|
||||
@@ -34,7 +67,13 @@ export class SessionController {
|
||||
private selectionSeq = 0;
|
||||
private catchupStreamSessionId: string | undefined;
|
||||
private pendingTranscriptEvents: SessionUiEvent[] = [];
|
||||
private pendingTranscriptFrame: number | undefined;
|
||||
private pendingStatusBySession = new Map<string, SessionStatus>();
|
||||
private pendingActivityBySession = new Map<string, SessionActivity>();
|
||||
private pendingFrame: number | undefined;
|
||||
private pendingSessionStartSeq = 0;
|
||||
private pendingQueuedSendSeq = 0;
|
||||
private readonly pendingSessionStarts = new Map<string, PendingSessionStart>();
|
||||
private readonly suppressedCreatedSessions = new Map<string, SuppressedCreatedSession>();
|
||||
|
||||
constructor(
|
||||
private readonly getState: GetState,
|
||||
@@ -49,22 +88,22 @@ export class SessionController {
|
||||
}
|
||||
|
||||
applyGlobalEvent(event: GlobalSessionEvent): void {
|
||||
if (event.type === "status.update") this.applyStatus(event.status);
|
||||
else if (event.type === "activity.update") this.applyActivity(event.activity);
|
||||
if (event.type === "status.update") this.queueStatusUpdate(event.status);
|
||||
else if (event.type === "activity.update") this.queueActivityUpdate(event.activity);
|
||||
else if (event.type === "session.created") this.applyCreatedSession(event.session);
|
||||
else this.applySessionName(event.sessionId, event.name);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.socket.close();
|
||||
this.clearPendingTranscriptEvents();
|
||||
this.clearPendingUpdates();
|
||||
}
|
||||
|
||||
clearActiveSession() {
|
||||
this.selectionSeq += 1;
|
||||
this.socket.close();
|
||||
this.catchupStreamSessionId = undefined;
|
||||
this.clearPendingTranscriptEvents();
|
||||
this.clearPendingUpdates();
|
||||
// Note: sendingPrompts is intentionally NOT cleared here. Deselecting a
|
||||
// session must not cancel the in-flight upload indicator of the session
|
||||
// that is still sending; the per-session entry is cleared by send()'s
|
||||
@@ -89,18 +128,15 @@ export class SessionController {
|
||||
async startSession() {
|
||||
const workspace = this.getState().selectedWorkspace;
|
||||
if (!workspace) return;
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const pending = this.createPendingSessionStart(workspace, machineId);
|
||||
this.pendingSessionStarts.set(pending.tempId, pending);
|
||||
this.insertAndSelectPendingSession(pending.session);
|
||||
try {
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const session = await this.api.startSession(workspace.path, machineId);
|
||||
rememberCachedNewSession(session, machineId);
|
||||
const cachedSession = markCachedNewSessionInfo(session, machineId);
|
||||
// Drop any entry the session.created broadcast may have inserted for this
|
||||
// same session before the HTTP response resolved, so the cached marker
|
||||
// (and its delete action) wins instead of leaving a duplicate badge.
|
||||
this.setState({ sessions: [cachedSession, ...this.getState().sessions.filter((candidate) => candidate.id !== cachedSession.id)] });
|
||||
await this.selectSession(cachedSession);
|
||||
await this.resolvePendingSessionStart(pending.tempId, session);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
this.failPendingSessionStart(pending.tempId, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,11 +145,15 @@ export class SessionController {
|
||||
}
|
||||
|
||||
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
|
||||
if (isClientPendingStartSessionInfo(session)) {
|
||||
this.selectClientPendingStartSession(session, options);
|
||||
return;
|
||||
}
|
||||
this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) });
|
||||
const seq = ++this.selectionSeq;
|
||||
this.socket.close();
|
||||
this.catchupStreamSessionId = undefined;
|
||||
this.clearPendingTranscriptEvents();
|
||||
this.clearPendingUpdates();
|
||||
const transcriptKey = this.sessionCacheKey(session.id);
|
||||
const cached = this.transcripts.cachedView(transcriptKey);
|
||||
this.setState({
|
||||
@@ -176,37 +216,25 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
async send(text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[], delivery: "inline" | "folder" = "inline") {
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = attachments !== undefined && attachments.length > 0;
|
||||
if (!hasAttachments && trimmed.startsWith("/")) return this.runCommand(text);
|
||||
if (!hasAttachments && isShellInput(text)) return this.runShell(text);
|
||||
async send(text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[], delivery: PromptAttachmentDelivery = "inline") {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = attachments !== undefined && attachments.length > 0;
|
||||
if (isClientPendingStartSessionInfo(session)) {
|
||||
if (!hasAttachments && trimmed.startsWith("/")) this.enqueuePendingSessionSend(session, { type: "command", text });
|
||||
else if (!hasAttachments && isShellInput(text)) this.enqueuePendingSessionSend(session, { type: "shell", text });
|
||||
else this.enqueuePendingSessionSend(session, { type: "prompt", text, streamingBehavior, attachments, delivery });
|
||||
return;
|
||||
}
|
||||
if (!hasAttachments && trimmed.startsWith("/")) return this.runCommand(text);
|
||||
if (!hasAttachments && isShellInput(text)) return this.runShell(text);
|
||||
|
||||
// Capture the originating session/machine before any await so the request
|
||||
// and its sending indicator stay bound to the right session even if the
|
||||
// user navigates elsewhere mid-upload.
|
||||
const sessionId = session.id;
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
// Surface a per-session optimistic sending state. It covers the pre-receipt
|
||||
// window (upload, server-side image resizing, first-session open) and is
|
||||
// superseded by real server activity/messages once api.prompt resolves.
|
||||
if (hasAttachments) this.markSendingPrompt(sessionId, true);
|
||||
try {
|
||||
if (hasAttachments && delivery === "folder") {
|
||||
const saved = await this.api.saveAttachments(session, attachments, machineId);
|
||||
const references = saved.map((file) => fileCompletionInsertText(file.path, false)).join(" ");
|
||||
const body = text === "" ? references : `${text}\n\n${references}`;
|
||||
await this.api.prompt(session, body, streamingBehavior, machineId);
|
||||
} else {
|
||||
await this.api.prompt(session, text, streamingBehavior, machineId, attachments);
|
||||
}
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
} finally {
|
||||
if (hasAttachments) this.markSendingPrompt(sessionId, false);
|
||||
}
|
||||
await this.deliverPromptToSession(session, text, streamingBehavior, attachments, delivery, selectedMachineId(this.getState()), { markSending: hasAttachments });
|
||||
}
|
||||
|
||||
private markSendingPrompt(sessionId: string, sending: boolean): void {
|
||||
@@ -221,36 +249,124 @@ export class SessionController {
|
||||
async runShell(text: string) {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||
try {
|
||||
await this.api.shell(session, text, selectedMachineId(this.getState()));
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
} catch (error) {
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
||||
if (isClientPendingStartSessionInfo(session)) {
|
||||
this.enqueuePendingSessionSend(session, { type: "shell", text });
|
||||
return;
|
||||
}
|
||||
await this.deliverShellToSession(session, text, selectedMachineId(this.getState()), { optimisticLine: true });
|
||||
}
|
||||
|
||||
async runCommand(text: string) {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session || session.archived === true) return;
|
||||
if (isClientPendingStartSessionInfo(session)) {
|
||||
this.enqueuePendingSessionSend(session, { type: "command", text });
|
||||
return;
|
||||
}
|
||||
await this.deliverCommandToSession(session, text, selectedMachineId(this.getState()), { applyResult: true });
|
||||
}
|
||||
|
||||
private enqueuePendingSessionSend(session: ClientPendingStartSessionInfo, input: QueuedPendingSessionSendInput): void {
|
||||
const pending = this.pendingSessionStarts.get(session.id);
|
||||
if (pending === undefined || pending.discarded) {
|
||||
this.setState({ error: "The backend session is not ready for queued sends. Copy your message before discarding this failed start." });
|
||||
return;
|
||||
}
|
||||
const queued: QueuedPendingSessionSend = { ...input, id: `pending-send-${String(++this.pendingQueuedSendSeq)}` };
|
||||
pending.queuedSends.push(queued);
|
||||
const state = this.getState();
|
||||
const current = state.clientQueuedSessionMessages[session.id] ?? [];
|
||||
const activity = creatingPendingSessionActivity(session.id, pending.queuedSends.length);
|
||||
this.setState({
|
||||
clientQueuedSessionMessages: { ...state.clientQueuedSessionMessages, [session.id]: [...current, queuedSessionMessagePreview(queued)] },
|
||||
sessionActivities: { ...state.sessionActivities, [session.id]: activity },
|
||||
activity: state.selectedSession?.id === session.id ? activity : state.activity,
|
||||
error: "",
|
||||
});
|
||||
}
|
||||
|
||||
private async flushQueuedPendingSends(session: SessionInfo, machineId: string, queuedSends: readonly QueuedPendingSessionSend[]): Promise<void> {
|
||||
for (const queued of queuedSends) {
|
||||
const delivered = await this.deliverQueuedPendingSend(session, machineId, queued);
|
||||
if (!delivered) return;
|
||||
this.dropNextQueuedSessionMessage(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverQueuedPendingSend(session: SessionInfo, machineId: string, queued: QueuedPendingSessionSend): Promise<boolean> {
|
||||
if (queued.type === "prompt") return this.deliverPromptToSession(session, queued.text, queued.streamingBehavior, queued.attachments, queued.delivery, machineId, { markSending: true });
|
||||
if (queued.type === "shell") return this.deliverShellToSession(session, queued.text, machineId, { optimisticLine: true });
|
||||
return this.deliverCommandToSession(session, queued.text, machineId, { applyResult: true });
|
||||
}
|
||||
|
||||
private async deliverPromptToSession(session: SessionInfo, text: string, streamingBehavior: "steer" | "followUp" | undefined, attachments: PromptAttachment[] | undefined, delivery: PromptAttachmentDelivery, machineId: string, options: { markSending: boolean }): Promise<boolean> {
|
||||
const hasAttachments = attachments !== undefined && attachments.length > 0;
|
||||
if (options.markSending) this.markSendingPrompt(session.id, true);
|
||||
try {
|
||||
if (hasAttachments && delivery === "folder") {
|
||||
const saved = await this.api.saveAttachments(session, attachments, machineId);
|
||||
const references = saved.map((file) => fileCompletionInsertText(file.path, false)).join(" ");
|
||||
const body = text === "" ? references : `${text}\n\n${references}`;
|
||||
await this.api.prompt(session, body, streamingBehavior, machineId);
|
||||
} else {
|
||||
await this.api.prompt(session, text, streamingBehavior, machineId, attachments);
|
||||
}
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
return false;
|
||||
} finally {
|
||||
if (options.markSending) this.markSendingPrompt(session.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverShellToSession(session: SessionInfo, text: string, machineId: string, options: { optimisticLine: boolean }): Promise<boolean> {
|
||||
if (options.optimisticLine && this.getState().selectedSession?.id === session.id) {
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||
}
|
||||
try {
|
||||
await this.api.shell(session, text, machineId);
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (this.getState().selectedSession?.id === session.id) this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))] });
|
||||
this.setState({ error: String(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverCommandToSession(session: SessionInfo, text: string, machineId: string, options: { applyResult: boolean }): Promise<boolean> {
|
||||
// Commands are not inserted into the transcript optimistically: a builtin
|
||||
// command produces its own result line, and a runtime/skill command is
|
||||
// forwarded to the agent, which streams back the canonical (expanded)
|
||||
// message. Inserting the raw text here would leave a line that doesn't
|
||||
// converge with server history and disappears on reload. Surface the same
|
||||
// per-session sending indicator that send() uses for the pre-receipt window.
|
||||
const sessionId = session.id;
|
||||
this.markSendingPrompt(sessionId, true);
|
||||
this.markSendingPrompt(session.id, true);
|
||||
try {
|
||||
this.applyCommandResult(await this.api.runCommand(session, text, selectedMachineId(this.getState())));
|
||||
const result = await this.api.runCommand(session, text, machineId);
|
||||
if (options.applyResult && this.getState().selectedSession?.id === session.id) this.applyCommandResult(result);
|
||||
else if (result.type === "select") this.setState({ error: `Queued command “${text}” needs input; open the session and run it again.` });
|
||||
this.markCachedNewSessionPersisted(session);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
||||
if (this.getState().selectedSession?.id === session.id) this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))] });
|
||||
this.setState({ error: String(error) });
|
||||
return false;
|
||||
} finally {
|
||||
this.markSendingPrompt(sessionId, false);
|
||||
this.markSendingPrompt(session.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
private dropNextQueuedSessionMessage(sessionId: string): void {
|
||||
const state = this.getState();
|
||||
const current = state.clientQueuedSessionMessages[sessionId] ?? [];
|
||||
if (current.length === 0) return;
|
||||
const remaining = current.slice(1);
|
||||
this.setState({ clientQueuedSessionMessages: remaining.length === 0 ? omitKey(state.clientQueuedSessionMessages, sessionId) : { ...state.clientQueuedSessionMessages, [sessionId]: remaining } });
|
||||
}
|
||||
|
||||
async respondToCommand(requestId: string, value: string) {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session) return;
|
||||
@@ -272,10 +388,12 @@ export class SessionController {
|
||||
|
||||
async archiveSession(session = this.getState().selectedSession) {
|
||||
if (!session) return;
|
||||
if (isCachedNewSessionInfo(session)) {
|
||||
const status = this.statusForSession(session);
|
||||
if (isTransientNewSessionInfo(session, status)) {
|
||||
await this.deleteCachedNewSession(session);
|
||||
return;
|
||||
}
|
||||
if (!isArchivableSessionInfo(session, status)) return;
|
||||
try {
|
||||
await this.api.archive(session, selectedMachineId(this.getState()));
|
||||
const state = this.getState();
|
||||
@@ -291,7 +409,7 @@ export class SessionController {
|
||||
}
|
||||
|
||||
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
|
||||
if (!session || isCachedNewSessionInfo(session)) return;
|
||||
if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return;
|
||||
try {
|
||||
const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState()));
|
||||
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
|
||||
@@ -308,25 +426,25 @@ export class SessionController {
|
||||
}
|
||||
|
||||
async archiveSessions(sessions: readonly SessionInfo[]): Promise<void> {
|
||||
const candidates = uniqueSessionsById(sessions).filter((session) => session.archived !== true && !isCachedNewSessionInfo(session));
|
||||
const candidates = uniqueSessionsById(sessions).filter((session) => isArchivableSessionInfo(session, this.statusForSession(session)));
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const results = await Promise.allSettled(candidates.map(async (session) => {
|
||||
await this.api.archive(session, machineId);
|
||||
return session.id;
|
||||
}));
|
||||
const archivedIds = fulfilledValues(results);
|
||||
if (archivedIds.length > 0) {
|
||||
const state = this.getState();
|
||||
const nextSessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
|
||||
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
|
||||
this.setState({ sessions: nextSessions });
|
||||
try {
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const { succeededIds: archivedIds, failures, generatedAt } = await this.archiveSessionBatch(candidates, machineId);
|
||||
if (archivedIds.length > 0) {
|
||||
const state = this.getState();
|
||||
const nextSessions = markSessionsArchived(state.sessions, archivedIds, generatedAt ?? new Date().toISOString());
|
||||
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
|
||||
this.setState({ sessions: nextSessions });
|
||||
|
||||
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
|
||||
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
|
||||
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
|
||||
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
|
||||
}
|
||||
this.applyBulkSessionFailures("Archive", failures);
|
||||
} catch (error) {
|
||||
this.setState({ error: `Archive failed: ${errorMessage(error)}` });
|
||||
}
|
||||
this.applyBulkSessionError("Archive", results);
|
||||
}
|
||||
|
||||
async deleteArchivedSessions(sessions: readonly SessionInfo[]): Promise<void> {
|
||||
@@ -339,34 +457,130 @@ export class SessionController {
|
||||
this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." });
|
||||
return;
|
||||
}
|
||||
const results = await Promise.allSettled(candidates.map(async (session) => {
|
||||
try {
|
||||
const { succeededIds: deletedIds, failures } = await this.deleteArchivedSessionBatch(candidates, machineId);
|
||||
if (deletedIds.length > 0) {
|
||||
const deletedIdSet = new Set(deletedIds);
|
||||
const state = this.getState();
|
||||
const nextSessions = state.sessions.filter((session) => !deletedIdSet.has(session.id));
|
||||
this.setState({ sessions: nextSessions });
|
||||
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
|
||||
const next = nextSessions.find((session) => session.archived !== true) ?? nextSessions[0];
|
||||
if (next !== undefined) await this.selectSession(next);
|
||||
else this.deselectSession({ forgetRememberedSelection: true });
|
||||
}
|
||||
}
|
||||
this.applyBulkSessionFailures("Delete", failures);
|
||||
} catch (error) {
|
||||
this.setState({ error: `Delete failed: ${errorMessage(error)}` });
|
||||
}
|
||||
}
|
||||
|
||||
private async archiveSessionBatch(sessions: readonly SessionInfo[], machineId: string): Promise<BulkSessionMutationResult> {
|
||||
const runtime = this.getState().machineRuntimes[machineId];
|
||||
if (runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsBulkMutations)) {
|
||||
const response = await this.api.archiveMany(sessions, machineId);
|
||||
return { succeededIds: response.archivedSessionIds, failures: bulkFailureMessages(response.failures), generatedAt: response.generatedAt };
|
||||
}
|
||||
|
||||
const results = await allSettledWithConcurrency(sessions, BULK_FALLBACK_CONCURRENCY, async (session) => {
|
||||
await this.api.archive(session, machineId);
|
||||
return session.id;
|
||||
});
|
||||
return { succeededIds: fulfilledValues(results), failures: settledSessionFailureMessages(sessions, results) };
|
||||
}
|
||||
|
||||
private async deleteArchivedSessionBatch(sessions: readonly SessionInfo[], machineId: string): Promise<BulkSessionMutationResult> {
|
||||
const runtime = this.getState().machineRuntimes[machineId];
|
||||
if (runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsBulkMutations)) {
|
||||
const response = await this.api.deleteArchivedMany(sessions, machineId);
|
||||
return { succeededIds: response.deletedSessionIds, failures: bulkFailureMessages(response.failures) };
|
||||
}
|
||||
|
||||
const results = await allSettledWithConcurrency(sessions, BULK_FALLBACK_CONCURRENCY, async (session) => {
|
||||
await this.api.deleteArchived(session, machineId);
|
||||
return session.id;
|
||||
}));
|
||||
const deletedIds = fulfilledValues(results);
|
||||
if (deletedIds.length > 0) {
|
||||
const deletedIdSet = new Set(deletedIds);
|
||||
});
|
||||
return { succeededIds: fulfilledValues(results), failures: settledSessionFailureMessages(sessions, results) };
|
||||
}
|
||||
|
||||
async applySessionCleanupResult(result: SessionCleanupExecuteResponse, machineId = selectedMachineId(this.getState())): Promise<void> {
|
||||
if (selectedMachineId(this.getState()) !== machineId) return;
|
||||
const archivedIds = result.archivedSessionIds;
|
||||
const deletedIds = result.deletedSessionIds;
|
||||
if (archivedIds.length > 0 || deletedIds.length > 0) {
|
||||
const state = this.getState();
|
||||
const nextSessions = state.sessions.filter((session) => !deletedIdSet.has(session.id));
|
||||
this.setState({ sessions: nextSessions });
|
||||
const deletedIdSet = new Set(deletedIds);
|
||||
const affectedIds = [...archivedIds, ...deletedIds];
|
||||
const nextSessions = markSessionsArchived(state.sessions, archivedIds, result.generatedAt).filter((session) => !deletedIdSet.has(session.id));
|
||||
const selectedAffected = state.selectedSession !== undefined && affectedIds.includes(state.selectedSession.id);
|
||||
this.setState({
|
||||
sessions: nextSessions,
|
||||
sessionStatuses: omitKeys(state.sessionStatuses, affectedIds),
|
||||
sessionActivities: omitKeys(state.sessionActivities, affectedIds),
|
||||
...(selectedAffected ? { status: undefined, activity: undefined } : {}),
|
||||
});
|
||||
|
||||
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
|
||||
const next = nextSessions.find((session) => session.archived !== true) ?? nextSessions[0];
|
||||
if (next !== undefined) await this.selectSession(next);
|
||||
else this.deselectSession({ forgetRememberedSelection: true });
|
||||
} else {
|
||||
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
|
||||
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
|
||||
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
|
||||
}
|
||||
}
|
||||
this.applyBulkSessionError("Delete", results);
|
||||
await this.refreshCurrentWorkspaceSessions(machineId);
|
||||
}
|
||||
|
||||
async refreshCurrentWorkspaceSessions(machineId = selectedMachineId(this.getState())): Promise<void> {
|
||||
const workspace = this.getState().selectedWorkspace;
|
||||
if (workspace === undefined) return;
|
||||
try {
|
||||
const listedSessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path, machineId), machineId)
|
||||
.filter((session) => !this.isSuppressedCreatedSession(session, machineId));
|
||||
if (selectedMachineId(this.getState()) !== machineId || this.getState().selectedWorkspace?.id !== workspace.id) return;
|
||||
const sessions = this.mergePendingStartSessions(workspace.path, listedSessions, machineId);
|
||||
const selectedSession = this.getState().selectedSession;
|
||||
this.setState({ sessions });
|
||||
if (selectedSession === undefined) return;
|
||||
const refreshedSelected = sessions.find((session) => session.id === selectedSession.id);
|
||||
if (refreshedSelected !== undefined) {
|
||||
if (refreshedSelected !== selectedSession) this.setState({ selectedSession: refreshedSelected });
|
||||
return;
|
||||
}
|
||||
const next = sessions.find((session) => session.archived !== true) ?? sessions[0];
|
||||
if (next !== undefined) await this.selectSession(next);
|
||||
else this.deselectSession({ forgetRememberedSelection: true });
|
||||
} catch (error) {
|
||||
if (selectedMachineId(this.getState()) === machineId && this.getState().selectedWorkspace?.id === workspace.id) this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCachedNewSession(session = this.getState().selectedSession) {
|
||||
if (!isCachedNewSessionInfo(session)) return;
|
||||
void this.api.stop(session, selectedMachineId(this.getState())).catch(() => {
|
||||
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore.
|
||||
});
|
||||
if (session === undefined || !isTransientNewSessionInfo(session, this.statusForSession(session))) return;
|
||||
const pendingStart = isClientPendingStartSessionInfo(session) ? this.pendingSessionStarts.get(session.id) : undefined;
|
||||
if (pendingStart !== undefined) {
|
||||
pendingStart.discarded = true;
|
||||
pendingStart.queuedSends = [];
|
||||
}
|
||||
else {
|
||||
void this.api.stop(session, selectedMachineId(this.getState())).catch(() => {
|
||||
// Best-effort cleanup for transient sessions that may not exist server-side anymore.
|
||||
});
|
||||
}
|
||||
forgetCachedNewSession(session.id, selectedMachineId(this.getState()));
|
||||
clearDraft(this.sessionCacheKey(session.id));
|
||||
const sessions = this.getState().sessions.filter((candidate) => candidate.id !== session.id);
|
||||
this.setState({ sessions });
|
||||
const state = this.getState();
|
||||
const sessions = state.sessions.filter((candidate) => candidate.id !== session.id);
|
||||
this.setState({
|
||||
sessions,
|
||||
sessionStatuses: omitKey(state.sessionStatuses, session.id),
|
||||
sessionActivities: omitSessionActivity(state.sessionActivities, session.id),
|
||||
sendingPrompts: omitKey(state.sendingPrompts, session.id),
|
||||
clientQueuedSessionMessages: omitKey(state.clientQueuedSessionMessages, session.id),
|
||||
});
|
||||
if (this.getState().selectedSession?.id !== session.id) return;
|
||||
const next = sessions.find((candidate) => candidate.archived !== true) ?? sessions[0];
|
||||
if (next !== undefined) await this.selectSession(next);
|
||||
@@ -391,11 +605,11 @@ export class SessionController {
|
||||
}
|
||||
|
||||
async reloadSession(session = this.getState().selectedSession) {
|
||||
if (session === undefined || isCachedNewSessionInfo(session) || session.archived === true) return;
|
||||
if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return;
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const runtime = this.getState().machineRuntimes[machineId];
|
||||
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) {
|
||||
this.setState({ error: "Reloading sessions requires an updated Pi-Web runtime on this machine." });
|
||||
this.setState({ error: "Reloading sessions from disk requires an updated Pi-Web runtime on this machine." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -509,9 +723,9 @@ export class SessionController {
|
||||
|
||||
async refreshSelectedSession(sessionId = this.getState().selectedSession?.id): Promise<void> {
|
||||
const session = this.getState().selectedSession;
|
||||
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
|
||||
if (sessionId === undefined || session?.id !== sessionId || session.archived === true || isClientPendingStartSessionInfo(session)) return;
|
||||
try {
|
||||
this.flushPendingTranscriptEvents();
|
||||
this.flushPendingUpdates();
|
||||
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
|
||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||
const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page);
|
||||
@@ -527,8 +741,7 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
private applyBulkSessionError(action: string, results: readonly PromiseSettledResult<string>[]): void {
|
||||
const failures = rejectedReasons(results);
|
||||
private applyBulkSessionFailures(action: string, failures: readonly string[]): void {
|
||||
if (failures.length === 0) return;
|
||||
this.setState({ error: `${action} failed for ${String(failures.length)} session${failures.length === 1 ? "" : "s"}: ${failures.join("; ")}` });
|
||||
}
|
||||
@@ -537,6 +750,13 @@ export class SessionController {
|
||||
return machineSessionKey(selectedMachineId(this.getState()), sessionId);
|
||||
}
|
||||
|
||||
private statusForSession(session: SessionInfo | undefined): SessionStatus | undefined {
|
||||
if (session === undefined) return undefined;
|
||||
const state = this.getState();
|
||||
if (state.status?.sessionId === session.id && state.selectedSession?.id === session.id) return state.status;
|
||||
return state.sessionStatuses[session.id];
|
||||
}
|
||||
|
||||
private workspaceSelectionKey(cwd: string): string {
|
||||
return `${selectedMachineId(this.getState())}:${cwd}`;
|
||||
}
|
||||
@@ -549,6 +769,169 @@ export class SessionController {
|
||||
});
|
||||
}
|
||||
|
||||
private createPendingSessionStart(workspace: Workspace, machineId: string): PendingSessionStart {
|
||||
const tempId = `pending-session-${String(++this.pendingSessionStartSeq)}-${Date.now().toString(36)}`;
|
||||
const now = new Date().toISOString();
|
||||
const session: ClientPendingStartSessionInfo = {
|
||||
id: tempId,
|
||||
path: `pi-web://pending-session/${tempId}`,
|
||||
cwd: workspace.path,
|
||||
persisted: false,
|
||||
name: "New session",
|
||||
created: now,
|
||||
modified: now,
|
||||
messageCount: 0,
|
||||
firstMessage: "",
|
||||
clientPendingStart: true,
|
||||
machineId,
|
||||
};
|
||||
return { tempId, workspaceId: workspace.id, cwd: workspace.path, machineId, session, queuedSends: [], discarded: false };
|
||||
}
|
||||
|
||||
private insertAndSelectPendingSession(session: ClientPendingStartSessionInfo): void {
|
||||
const state = this.getState();
|
||||
this.selectClientPendingStartSession(session, {
|
||||
activity: creatingPendingSessionActivity(session.id),
|
||||
sessions: [session, ...state.sessions.filter((candidate) => candidate.id !== session.id)],
|
||||
});
|
||||
}
|
||||
|
||||
private selectClientPendingStartSession(session: ClientPendingStartSessionInfo, options?: { updateUrl?: boolean | undefined; activity?: SessionActivity | undefined; sessions?: SessionInfo[] | undefined }): void {
|
||||
this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) });
|
||||
this.selectionSeq += 1;
|
||||
this.socket.close();
|
||||
this.catchupStreamSessionId = undefined;
|
||||
this.clearPendingUpdates();
|
||||
const state = this.getState();
|
||||
const pendingStart = this.pendingSessionStarts.get(session.id);
|
||||
const activity = options?.activity ?? state.sessionActivities[session.id] ?? (pendingStart !== undefined ? creatingPendingSessionActivity(session.id, pendingStart.queuedSends.length) : undefined);
|
||||
this.setState({
|
||||
...(options?.sessions === undefined ? {} : { sessions: options.sessions }),
|
||||
selectedSession: session,
|
||||
messages: [],
|
||||
messagePageStart: 0,
|
||||
messagePageEnd: 0,
|
||||
messagePageTotal: 0,
|
||||
isLoadingEarlierMessages: false,
|
||||
isReceivingPartialStream: false,
|
||||
status: undefined,
|
||||
activity,
|
||||
availableThinkingLevels: [],
|
||||
...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }),
|
||||
error: "",
|
||||
});
|
||||
if (options?.updateUrl !== false) this.updateUrl();
|
||||
}
|
||||
|
||||
private async resolvePendingSessionStart(tempId: string, session: SessionInfo): Promise<void> {
|
||||
const pending = this.pendingSessionStarts.get(tempId);
|
||||
if (pending === undefined) return;
|
||||
this.pendingSessionStarts.delete(tempId);
|
||||
const queuedSends = pending.queuedSends.splice(0);
|
||||
const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId, session.id);
|
||||
if (pending.discarded) {
|
||||
clearDraft(machineSessionKey(pending.machineId, tempId));
|
||||
this.setState({ clientQueuedSessionMessages: omitKey(this.getState().clientQueuedSessionMessages, tempId) });
|
||||
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
|
||||
void this.api.stop(session, pending.machineId).catch(() => {
|
||||
// Best-effort cleanup for a backend session whose temporary UI row was discarded before creation finished.
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
rememberCachedNewSession(session, pending.machineId);
|
||||
moveDraft(machineSessionKey(pending.machineId, tempId), machineSessionKey(pending.machineId, session.id));
|
||||
const cachedSession = markCachedNewSessionInfo(session, pending.machineId);
|
||||
if (!this.isCurrentPendingStart(pending)) {
|
||||
this.setState({ clientQueuedSessionMessages: omitKey(this.getState().clientQueuedSessionMessages, tempId) });
|
||||
await this.flushQueuedPendingSends(cachedSession, pending.machineId, queuedSends);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.getState();
|
||||
const wasSelected = state.selectedSession?.id === tempId;
|
||||
this.setState({
|
||||
sessions: replacePendingSessionInList(state.sessions, tempId, cachedSession),
|
||||
sessionActivities: omitSessionActivity(state.sessionActivities, tempId),
|
||||
sendingPrompts: moveRecordKey(state.sendingPrompts, tempId, cachedSession.id),
|
||||
clientQueuedSessionMessages: moveRecordKey(state.clientQueuedSessionMessages, tempId, cachedSession.id),
|
||||
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id] } : {}),
|
||||
error: "",
|
||||
});
|
||||
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
|
||||
if (wasSelected) {
|
||||
this.updateUrl({ replace: true });
|
||||
await this.selectSession(cachedSession, { updateUrl: false });
|
||||
}
|
||||
await this.flushQueuedPendingSends(cachedSession, pending.machineId, queuedSends);
|
||||
}
|
||||
|
||||
private failPendingSessionStart(tempId: string, error: unknown): void {
|
||||
const pending = this.pendingSessionStarts.get(tempId);
|
||||
if (pending === undefined) return;
|
||||
this.pendingSessionStarts.delete(tempId);
|
||||
const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId);
|
||||
const isCurrentPendingStart = this.isCurrentPendingStart(pending);
|
||||
if (pending.discarded || !isCurrentPendingStart) {
|
||||
if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
|
||||
return;
|
||||
}
|
||||
const state = this.getState();
|
||||
const message = errorMessage(error);
|
||||
const activity = failedPendingSessionActivity(tempId, message, pending.queuedSends.length);
|
||||
const hasPendingRow = state.sessions.some((session) => session.id === tempId);
|
||||
this.setState({
|
||||
sessions: hasPendingRow ? state.sessions : [pending.session, ...state.sessions],
|
||||
sessionActivities: { ...state.sessionActivities, [tempId]: activity },
|
||||
activity: state.selectedSession?.id === tempId ? activity : state.activity,
|
||||
error: `Failed to start session: ${message}`,
|
||||
});
|
||||
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
|
||||
}
|
||||
|
||||
private isCurrentPendingStart(pending: PendingSessionStart): boolean {
|
||||
const state = this.getState();
|
||||
return selectedMachineId(state) === pending.machineId && state.selectedWorkspace?.id === pending.workspaceId;
|
||||
}
|
||||
|
||||
private hasPendingStartFor(cwd: string, machineId: string): boolean {
|
||||
return Array.from(this.pendingSessionStarts.values()).some((pending) => pending.cwd === cwd && pending.machineId === machineId);
|
||||
}
|
||||
|
||||
private isSuppressedCreatedSession(session: SessionInfo, machineId: string): boolean {
|
||||
const suppressed = this.suppressedCreatedSessions.get(session.id);
|
||||
return suppressed?.session.cwd === session.cwd && suppressed.machineId === machineId;
|
||||
}
|
||||
|
||||
private takeSuppressedCreatedSessionsFor(cwd: string, machineId: string, resolvedSessionId?: string): SessionInfo[] {
|
||||
if (resolvedSessionId !== undefined) this.suppressedCreatedSessions.delete(resolvedSessionId);
|
||||
if (this.hasPendingStartFor(cwd, machineId)) return [];
|
||||
const released: SessionInfo[] = [];
|
||||
for (const [sessionId, suppressed] of this.suppressedCreatedSessions) {
|
||||
if (suppressed.session.cwd !== cwd || suppressed.machineId !== machineId) continue;
|
||||
this.suppressedCreatedSessions.delete(sessionId);
|
||||
released.push(suppressed.session);
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
private applyReleasedCreatedSessions(sessions: readonly SessionInfo[], machineId: string): void {
|
||||
if (sessions.length === 0 || selectedMachineId(this.getState()) !== machineId) return;
|
||||
const state = this.getState();
|
||||
if (state.selectedWorkspace === undefined) return;
|
||||
const existingIds = new Set(state.sessions.map((session) => session.id));
|
||||
const released = sessions.filter((session) => session.cwd === state.selectedWorkspace?.path && !existingIds.has(session.id));
|
||||
if (released.length === 0) return;
|
||||
this.setState({ sessions: [...released.reverse(), ...state.sessions] });
|
||||
}
|
||||
|
||||
private mergePendingStartSessions(cwd: string, sessions: SessionInfo[], machineId: string): SessionInfo[] {
|
||||
const pending = this.getState().sessions.filter((session): session is ClientPendingStartSessionInfo => isClientPendingStartSessionInfo(session) && session.cwd === cwd && session.machineId === machineId);
|
||||
if (pending.length === 0) return sessions;
|
||||
const pendingIds = new Set(pending.map((session) => session.id));
|
||||
return [...pending, ...sessions.filter((session) => !pendingIds.has(session.id))];
|
||||
}
|
||||
|
||||
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
|
||||
try {
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
@@ -594,6 +977,11 @@ export class SessionController {
|
||||
// the optimistic insert from startSession in this same tab).
|
||||
if (state.selectedWorkspace?.path !== session.cwd) return;
|
||||
if (state.sessions.some((candidate) => candidate.id === session.id)) return;
|
||||
const machineId = selectedMachineId(state);
|
||||
if (this.hasPendingStartFor(session.cwd, machineId)) {
|
||||
this.suppressedCreatedSessions.set(session.id, { session, machineId });
|
||||
return;
|
||||
}
|
||||
this.setState({ sessions: [session, ...state.sessions] });
|
||||
}
|
||||
|
||||
@@ -642,19 +1030,29 @@ export class SessionController {
|
||||
if (isTranscriptEvent(event)) return;
|
||||
}
|
||||
|
||||
// Status and activity arrive once per token (the server republishes them on
|
||||
// every transcript event). Buffer them alongside high-frequency transcript
|
||||
// deltas so the host component renders at most once per animation frame
|
||||
// instead of once per token. Coalescing these here is what keeps the prompt
|
||||
// editor's DOM stable during streaming, so in-progress touch gestures (e.g.
|
||||
// the iOS long-press edit/paste callout) are not interrupted by a re-render.
|
||||
if (event.type === "status.update") {
|
||||
this.queueStatusUpdate(event.status);
|
||||
return;
|
||||
}
|
||||
if (event.type === "activity.update") {
|
||||
this.queueActivityUpdate(event.activity);
|
||||
return;
|
||||
}
|
||||
if (isHighFrequencyTranscriptEvent(event)) {
|
||||
this.queueTranscriptEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
this.flushPendingTranscriptEvents();
|
||||
this.flushPendingUpdates();
|
||||
const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event);
|
||||
if (transcript) {
|
||||
this.setState({ messages: transcript });
|
||||
} else if (event.type === "status.update") {
|
||||
this.applyStatus(event.status);
|
||||
} else if (event.type === "activity.update") {
|
||||
this.applyActivity(event.activity);
|
||||
} else if (event.type === "session.name") {
|
||||
this.applySessionName(event.sessionId, event.name);
|
||||
}
|
||||
@@ -662,27 +1060,60 @@ export class SessionController {
|
||||
|
||||
private queueTranscriptEvent(event: SessionUiEvent): void {
|
||||
this.pendingTranscriptEvents.push(event);
|
||||
if (this.pendingTranscriptFrame !== undefined) return;
|
||||
this.pendingTranscriptFrame = requestAnimationFrame(() => {
|
||||
this.pendingTranscriptFrame = undefined;
|
||||
this.flushPendingTranscriptEvents();
|
||||
this.schedulePendingFlush();
|
||||
}
|
||||
|
||||
private queueStatusUpdate(status: SessionStatus): void {
|
||||
this.pendingStatusBySession.set(status.sessionId, status);
|
||||
this.schedulePendingFlush();
|
||||
}
|
||||
|
||||
private queueActivityUpdate(activity: SessionActivity): void {
|
||||
this.pendingActivityBySession.set(activity.sessionId, activity);
|
||||
this.schedulePendingFlush();
|
||||
}
|
||||
|
||||
private schedulePendingFlush(): void {
|
||||
if (this.pendingFrame !== undefined) return;
|
||||
this.pendingFrame = requestAnimationFrame(() => {
|
||||
this.pendingFrame = undefined;
|
||||
this.flushPendingUpdates();
|
||||
});
|
||||
}
|
||||
|
||||
private flushPendingTranscriptEvents(): void {
|
||||
if (this.pendingTranscriptEvents.length === 0) return;
|
||||
const events = this.pendingTranscriptEvents;
|
||||
this.pendingTranscriptEvents = [];
|
||||
let messages = this.getState().messages;
|
||||
for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages;
|
||||
if (messages !== this.getState().messages) this.setState({ messages });
|
||||
// Apply buffered transcript deltas, activity, and status in one task. Activity
|
||||
// is applied before status to mirror the server's publish order, so an idle
|
||||
// status can clear the now-stale active activity it supersedes. Status and
|
||||
// activity are last-write-wins per session, so iterating the maps applies only
|
||||
// the latest buffered value per session. These writes run in a single task, so
|
||||
// Lit batches them into one render.
|
||||
flushPendingUpdates(): void {
|
||||
if (this.pendingTranscriptEvents.length > 0) {
|
||||
const events = this.pendingTranscriptEvents;
|
||||
this.pendingTranscriptEvents = [];
|
||||
let messages = this.getState().messages;
|
||||
for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages;
|
||||
if (messages !== this.getState().messages) this.setState({ messages });
|
||||
}
|
||||
if (this.pendingActivityBySession.size > 0) {
|
||||
const activities = Array.from(this.pendingActivityBySession.values());
|
||||
this.pendingActivityBySession.clear();
|
||||
for (const activity of activities) this.applyActivity(activity);
|
||||
}
|
||||
if (this.pendingStatusBySession.size > 0) {
|
||||
const statuses = Array.from(this.pendingStatusBySession.values());
|
||||
this.pendingStatusBySession.clear();
|
||||
for (const status of statuses) this.applyStatus(status);
|
||||
}
|
||||
}
|
||||
|
||||
private clearPendingTranscriptEvents(): void {
|
||||
private clearPendingUpdates(): void {
|
||||
this.pendingTranscriptEvents = [];
|
||||
if (this.pendingTranscriptFrame === undefined) return;
|
||||
cancelAnimationFrame(this.pendingTranscriptFrame);
|
||||
this.pendingTranscriptFrame = undefined;
|
||||
this.pendingStatusBySession.clear();
|
||||
this.pendingActivityBySession.clear();
|
||||
if (this.pendingFrame === undefined) return;
|
||||
cancelAnimationFrame(this.pendingFrame);
|
||||
this.pendingFrame = undefined;
|
||||
}
|
||||
|
||||
// Stream catch-up is a single mode with two coupled facets that must never
|
||||
@@ -727,6 +1158,87 @@ function omitKey<T>(record: Record<string, T>, key: string): Record<string, T> {
|
||||
return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key));
|
||||
}
|
||||
|
||||
function omitKeys<T>(record: Record<string, T>, keys: readonly string[]): Record<string, T> {
|
||||
if (keys.length === 0) return record;
|
||||
const removed = new Set(keys);
|
||||
return Object.fromEntries(Object.entries(record).filter(([id]) => !removed.has(id)));
|
||||
}
|
||||
|
||||
function moveRecordKey<T>(record: Record<string, T>, fromKey: string, toKey: string): Record<string, T> {
|
||||
if (fromKey === toKey || !(fromKey in record)) return record;
|
||||
const value = record[fromKey];
|
||||
if (value === undefined) return record;
|
||||
return { ...omitKey(record, fromKey), [toKey]: value };
|
||||
}
|
||||
|
||||
function replacePendingSessionInList(sessions: readonly SessionInfo[], pendingSessionId: string, resolvedSession: SessionInfo): SessionInfo[] {
|
||||
const next: SessionInfo[] = [];
|
||||
let inserted = false;
|
||||
for (const session of sessions) {
|
||||
if (session.id === pendingSessionId) {
|
||||
if (!inserted) {
|
||||
next.push(resolvedSession);
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (session.id === resolvedSession.id) continue;
|
||||
next.push(session);
|
||||
}
|
||||
if (!inserted) return [resolvedSession, ...next];
|
||||
return next;
|
||||
}
|
||||
|
||||
function isClientPendingStartSessionInfo(session: SessionInfo | undefined): session is ClientPendingStartSessionInfo {
|
||||
return session !== undefined && "clientPendingStart" in session && session.clientPendingStart === true;
|
||||
}
|
||||
|
||||
function creatingPendingSessionActivity(sessionId: string, queuedCount = 0): SessionActivity {
|
||||
return {
|
||||
sessionId,
|
||||
phase: "active",
|
||||
label: "Creating session",
|
||||
detail: queuedCount > 0 ? `${String(queuedCount)} queued ${queuedCount === 1 ? "message" : "messages"} will send when the backend session is ready` : "Waiting for the backend session to be ready",
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function failedPendingSessionActivity(sessionId: string, message: string, queuedCount = 0): SessionActivity {
|
||||
const queuedDetail = queuedCount > 0 ? ` · ${String(queuedCount)} queued ${queuedCount === 1 ? "message" : "messages"} kept below` : "";
|
||||
return {
|
||||
sessionId,
|
||||
phase: "error",
|
||||
label: "Session creation failed",
|
||||
detail: `${message}${queuedDetail}`,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function queuedSessionMessagePreview(queued: QueuedPendingSessionSend): QueuedSessionMessage {
|
||||
if (queued.type === "prompt") {
|
||||
return { kind: queued.streamingBehavior === "steer" ? "steer" : "followUp", text: queuedPromptPreviewText(queued.text, queued.attachments) };
|
||||
}
|
||||
return { kind: "followUp", text: queued.text };
|
||||
}
|
||||
|
||||
function queuedPromptPreviewText(text: string, attachments: PromptAttachment[] | undefined): string {
|
||||
const attachmentText = queuedAttachmentSummary(attachments);
|
||||
if (attachmentText === undefined) return text;
|
||||
const trimmed = text.trim();
|
||||
return trimmed === "" ? attachmentText : `${text}\n\n${attachmentText}`;
|
||||
}
|
||||
|
||||
function queuedAttachmentSummary(attachments: PromptAttachment[] | undefined): string | undefined {
|
||||
if (attachments === undefined || attachments.length === 0) return undefined;
|
||||
const names = attachments.map((attachment) => attachment.name?.trim()).filter((name): name is string => name !== undefined && name !== "");
|
||||
const count = attachments.length;
|
||||
const label = `${String(count)} ${count === 1 ? "attachment" : "attachments"}`;
|
||||
if (names.length === 0) return `[${label} queued]`;
|
||||
const shownNames = names.slice(0, 3).join(", ");
|
||||
const suffix = names.length > 3 ? `, +${String(names.length - 3)} more` : "";
|
||||
return `[${label} queued: ${shownNames}${suffix}]`;
|
||||
}
|
||||
|
||||
function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: SessionInfo[] = [];
|
||||
@@ -742,8 +1254,39 @@ function fulfilledValues<T>(results: readonly PromiseSettledResult<T>[]): T[] {
|
||||
return results.filter(isFulfilled).map((result) => result.value);
|
||||
}
|
||||
|
||||
function rejectedReasons(results: readonly PromiseSettledResult<unknown>[]): string[] {
|
||||
return results.filter(isRejected).map((result) => errorMessage(result.reason));
|
||||
function bulkFailureMessages(failures: readonly SessionBulkFailure[]): string[] {
|
||||
return failures.map((failure) => `${failure.sessionId}: ${failure.error}`);
|
||||
}
|
||||
|
||||
function settledSessionFailureMessages(sessions: readonly SessionInfo[], results: readonly PromiseSettledResult<unknown>[]): string[] {
|
||||
return results.flatMap((result, index) => {
|
||||
if (!isRejected(result)) return [];
|
||||
const sessionId = sessions[index]?.id ?? "unknown";
|
||||
return [`${sessionId}: ${errorMessage(result.reason)}`];
|
||||
});
|
||||
}
|
||||
|
||||
async function allSettledWithConcurrency<T, R>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
|
||||
const indexedItems = items.map((item, index) => ({ item, index }));
|
||||
const results: PromiseSettledResult<R>[] = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
async function runWorker(): Promise<void> {
|
||||
while (nextIndex < indexedItems.length) {
|
||||
const entry = indexedItems[nextIndex];
|
||||
if (entry === undefined) return;
|
||||
nextIndex += 1;
|
||||
try {
|
||||
results[entry.index] = { status: "fulfilled", value: await worker(entry.item) };
|
||||
} catch (reason) {
|
||||
results[entry.index] = { status: "rejected", reason };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workerCount = Math.min(Math.max(1, concurrency), indexedItems.length);
|
||||
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
function isFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inputModeForDraft, isShellInput } from "./inputModes";
|
||||
import { inputModeForDraft, inputModesEqual, isShellInput } from "./inputModes";
|
||||
|
||||
describe("inputModeForDraft", () => {
|
||||
it("detects shell input and context-excluded shell input after leading whitespace", () => {
|
||||
@@ -14,6 +14,13 @@ describe("inputModeForDraft", () => {
|
||||
expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" });
|
||||
});
|
||||
|
||||
it("treats modes as equal only when kind and shell context-exclusion match", () => {
|
||||
expect(inputModesEqual({ kind: "normal" }, { kind: "normal" })).toBe(true);
|
||||
expect(inputModesEqual({ kind: "normal" }, { kind: "command" })).toBe(false);
|
||||
expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: false })).toBe(true);
|
||||
expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("detects file completion contexts", () => {
|
||||
expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" });
|
||||
|
||||
@@ -19,6 +19,12 @@ export function isShellInput(text: string): boolean {
|
||||
return inputModeForDraft(text).kind === "shell";
|
||||
}
|
||||
|
||||
export function inputModesEqual(a: InputMode, b: InputMode): boolean {
|
||||
if (a.kind !== b.kind) return false;
|
||||
if (a.kind === "shell" && b.kind === "shell") return a.excludeFromContext === b.excludeFromContext;
|
||||
return true;
|
||||
}
|
||||
|
||||
function currentToken(draft: string): string {
|
||||
const tokenStart = Math.max(draft.lastIndexOf(" "), draft.lastIndexOf("\n")) + 1;
|
||||
return draft.slice(tokenStart);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isSessionActive } from "../../../../shared/activity";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities";
|
||||
import type { AppState } from "../../appState";
|
||||
import { isCachedNewSessionInfo } from "../../cachedNewSessions";
|
||||
import { selectedMachineId } from "../../controllers/types";
|
||||
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../../sessionPersistence";
|
||||
import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
|
||||
import type { PluginAction } from "../types";
|
||||
|
||||
@@ -177,18 +177,19 @@ export function createCoreActions(): PluginAction[] {
|
||||
},
|
||||
{
|
||||
id: "session.reload",
|
||||
title: "Reload Session",
|
||||
description: "Re-read the selected session from disk to pick up entries written by another process",
|
||||
title: "Reload Session from Disk",
|
||||
description: "Close and re-open the selected session from its session file. Use /reload in the prompt for Pi runtime resources.",
|
||||
group: "Session",
|
||||
enabled: hasReloadableSession,
|
||||
disabledReason: reloadSessionDisabledReason,
|
||||
run: (context) => context.reloadSession(),
|
||||
},
|
||||
{
|
||||
id: "session.delete",
|
||||
title: "Delete New Session",
|
||||
description: "Delete the selected browser-cached new session",
|
||||
description: "Delete the selected transient new session",
|
||||
group: "Session",
|
||||
enabled: hasCachedNewSession,
|
||||
enabled: hasTransientNewSession,
|
||||
run: (context) => context.deleteCachedNewSession(),
|
||||
},
|
||||
{
|
||||
@@ -216,18 +217,27 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean {
|
||||
}
|
||||
|
||||
function hasArchivableSession(context: { state: AppState }): boolean {
|
||||
const session = context.state.selectedSession;
|
||||
return session !== undefined && session.archived !== true && !isCachedNewSessionInfo(session);
|
||||
return isArchivableSessionInfo(context.state.selectedSession, context.state.status);
|
||||
}
|
||||
|
||||
function hasCachedNewSession(context: { state: AppState }): boolean {
|
||||
return isCachedNewSessionInfo(context.state.selectedSession);
|
||||
function hasTransientNewSession(context: { state: AppState }): boolean {
|
||||
return isTransientNewSessionInfo(context.state.selectedSession, context.state.status);
|
||||
}
|
||||
|
||||
function hasReloadableSession(context: { state: AppState }): boolean {
|
||||
const session = context.state.selectedSession;
|
||||
if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false;
|
||||
const runtime = context.state.machineRuntimes[selectedMachineId(context.state)];
|
||||
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) return false;
|
||||
if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return false;
|
||||
if (reloadSessionDisabledReason(context) !== undefined) return false;
|
||||
return !isSessionActive(context.state.status, context.state.activity);
|
||||
}
|
||||
|
||||
function reloadSessionDisabledReason(context: { state: AppState }): string | undefined {
|
||||
if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return undefined;
|
||||
if (isSessionActive(context.state.status, context.state.activity)) return undefined;
|
||||
return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions from disk");
|
||||
}
|
||||
|
||||
function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined {
|
||||
const runtime = state.machineRuntimes[selectedMachineId(state)];
|
||||
if (runtime?.ok === true && supportsPiWebCapability(runtime, capability)) return undefined;
|
||||
return `Update and restart Pi-Web on ${state.selectedMachine?.name ?? "this machine"} to ${action}.`;
|
||||
}
|
||||
|
||||
@@ -170,45 +170,77 @@ describe("PluginRegistry", () => {
|
||||
expect(calls).toEqual(["deleteWorkspace"]);
|
||||
});
|
||||
|
||||
it("offers archive only for persisted sessions and delete only for browser-cached new sessions", () => {
|
||||
it("offers archive only for persisted sessions and delete only for transient new sessions", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
|
||||
const persistedActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
|
||||
const persistedActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }) }).context);
|
||||
expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
|
||||
expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
|
||||
|
||||
const unknownActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
|
||||
expect(unknownActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
|
||||
expect(unknownActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
|
||||
|
||||
const transientActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }) }).context);
|
||||
expect(transientActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
|
||||
expect(transientActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
|
||||
|
||||
const cachedActions = registry.getActions(createContext({ selectedSession: markCachedNewSessionInfo(testSession()) }).context);
|
||||
expect(cachedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
|
||||
expect(cachedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
|
||||
|
||||
const archivedActions = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" } }).context);
|
||||
const archivedActions = registry.getActions(createContext({ selectedSession: { ...testSession({ persisted: true }), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" } }).context);
|
||||
expect(archivedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
|
||||
expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("enables session reload only for a writable session on a capable, idle runtime", () => {
|
||||
it("uses selected session status as the freshest archive/delete persistence signal", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
|
||||
const statusPersisted = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }), status: testStatus({ persisted: true }) }).context);
|
||||
expect(statusPersisted.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
|
||||
expect(statusPersisted.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
|
||||
|
||||
const statusTransient = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: false }) }).context);
|
||||
expect(statusTransient.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
|
||||
expect(statusTransient.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("enables session disk reload only for a writable session on a capable, idle runtime", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } };
|
||||
|
||||
const reloadable = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context);
|
||||
expect(reloadable.find((action) => action.id === "core:session.reload")?.enabled).toBe(true);
|
||||
const reloadable = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: reloadRuntime }).context);
|
||||
const reloadableAction = reloadable.find((action) => action.id === "core:session.reload");
|
||||
expect(reloadableAction?.enabled).toBe(true);
|
||||
expect(reloadableAction?.title).toBe("Reload Session from Disk");
|
||||
expect(reloadableAction?.description).toContain("Use /reload in the prompt for Pi runtime resources");
|
||||
|
||||
const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context);
|
||||
expect(noCapability.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||
const noCapability = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }) }).context);
|
||||
const noCapabilityReload = noCapability.find((action) => action.id === "core:session.reload");
|
||||
expect(noCapabilityReload?.enabled).toBe(false);
|
||||
expect(noCapabilityReload?.disabledReason).toBe("Update and restart Pi-Web on this machine to reload sessions from disk.");
|
||||
|
||||
const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context);
|
||||
const unknown = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context);
|
||||
expect(unknown.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||
|
||||
const transient = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }), machineRuntimes: reloadRuntime }).context);
|
||||
expect(transient.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||
|
||||
const archived = registry.getActions(createContext({ selectedSession: { ...testSession({ persisted: true }), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context);
|
||||
expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||
|
||||
const busy = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime, status: testStatus({ isStreaming: true }) }).context);
|
||||
const busy = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: reloadRuntime, status: testStatus({ persisted: true, isStreaming: true }) }).context);
|
||||
expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("routes session reload through the runtime context", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
const { context, calls } = createContext({ selectedSession: testSession(), machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } } });
|
||||
const { context, calls } = createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } } });
|
||||
const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.reload");
|
||||
|
||||
if (action !== undefined) void action.run();
|
||||
@@ -216,10 +248,10 @@ describe("PluginRegistry", () => {
|
||||
expect(calls).toEqual(["reloadSession"]);
|
||||
});
|
||||
|
||||
it("routes browser-cached new session delete through the runtime context", () => {
|
||||
it("routes transient new session delete through the runtime context", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
const { context, calls } = createContext({ selectedSession: markCachedNewSessionInfo(testSession()) });
|
||||
const { context, calls } = createContext({ selectedSession: testSession({ persisted: false }) });
|
||||
const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.delete");
|
||||
|
||||
if (action !== undefined) void action.run();
|
||||
|
||||
@@ -60,6 +60,7 @@ export class PluginRegistry {
|
||||
return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).map((action) => {
|
||||
const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
|
||||
const enabled = action.enabled?.(scopedContext);
|
||||
const disabledReason = enabled === false ? action.disabledReason?.(scopedContext) : undefined;
|
||||
const qualified: QualifiedPluginAction = {
|
||||
id: action.id,
|
||||
pluginId: action.pluginId,
|
||||
@@ -72,6 +73,7 @@ export class PluginRegistry {
|
||||
if (action.shortcut !== undefined) qualified.shortcut = action.shortcut;
|
||||
if (action.group !== undefined) qualified.group = action.group;
|
||||
if (enabled !== undefined) qualified.enabled = enabled;
|
||||
if (disabledReason !== undefined && disabledReason !== "") qualified.disabledReason = disabledReason;
|
||||
return qualified;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,6 +128,8 @@ export interface PluginAction {
|
||||
shortcut?: string;
|
||||
group?: string;
|
||||
enabled?: (context: PluginRuntimeContext) => boolean;
|
||||
/** Explain why a disabled action is visible but unavailable. */
|
||||
disabledReason?: (context: PluginRuntimeContext) => string | undefined;
|
||||
run: (context: PluginRuntimeContext) => void | Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureImageAttachments, READ_FAILURE_MESSAGE, UNSUPPORTED_IMAGE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
|
||||
import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
|
||||
|
||||
function file(name: string, type: string, size = 10): CapturableFile {
|
||||
return { name, type, size };
|
||||
}
|
||||
|
||||
describe("captureImageAttachments", () => {
|
||||
it("reads supported images as base64 attachments", async () => {
|
||||
const result = await captureImageAttachments(
|
||||
describe("capturePromptAttachments", () => {
|
||||
it("reads supported images as native inline image attachments", async () => {
|
||||
const result = await capturePromptAttachments(
|
||||
[file("shot.png", "image/png"), file("pic.webp", "image/webp")],
|
||||
(f) => Promise.resolve(`data-for-${f.name}`),
|
||||
);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.attachments).toEqual([
|
||||
{ name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
|
||||
{ name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
|
||||
{ kind: "image", name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
|
||||
{ kind: "image", name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives a name from the mime type when the file is unnamed", async () => {
|
||||
const result = await captureImageAttachments([file("", "image/jpeg")], () => Promise.resolve("x"));
|
||||
expect(result.attachments[0]?.name).toBe("pasted-image.jpg");
|
||||
it("captures generic files with their browser MIME type", async () => {
|
||||
const result = await capturePromptAttachments(
|
||||
[file("report.pdf", "application/pdf", 1234), file("vector.svg", "image/svg+xml")],
|
||||
(f) => Promise.resolve(`data-for-${f.name}`),
|
||||
);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.attachments).toEqual([
|
||||
{ kind: "file", name: "report.pdf", mimeType: "application/pdf", data: "data-for-report.pdf", size: 1234 },
|
||||
{ kind: "file", name: "vector.svg", mimeType: "image/svg+xml", data: "data-for-vector.svg", size: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips unsupported types and reports a single error while keeping valid ones", async () => {
|
||||
const result = await captureImageAttachments(
|
||||
[file("doc.pdf", "application/pdf"), file("ok.gif", "image/gif")],
|
||||
it("uses application/octet-stream when the browser does not provide a MIME type", async () => {
|
||||
const result = await capturePromptAttachments([file("archive", "")], () => Promise.resolve("x"));
|
||||
|
||||
expect(result.attachments[0]).toMatchObject({ kind: "file", name: "archive", mimeType: DEFAULT_FILE_MIME_TYPE });
|
||||
});
|
||||
|
||||
it("derives fallback names for unnamed pasted attachments", async () => {
|
||||
const result = await capturePromptAttachments(
|
||||
[file("", "image/jpeg"), file("", "application/pdf")],
|
||||
() => Promise.resolve("x"),
|
||||
);
|
||||
|
||||
expect(result.error).toBe(UNSUPPORTED_IMAGE_MESSAGE);
|
||||
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["ok.gif"]);
|
||||
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["pasted-image.jpg", "pasted-file.bin"]);
|
||||
});
|
||||
|
||||
it("reports a read failure without dropping other attachments", async () => {
|
||||
const result = await captureImageAttachments(
|
||||
[file("bad.png", "image/png"), file("good.png", "image/png")],
|
||||
const result = await capturePromptAttachments(
|
||||
[file("bad.png", "image/png"), file("good.txt", "text/plain")],
|
||||
(f) => f.name === "bad.png" ? Promise.reject(new Error("boom")) : Promise.resolve("ok"),
|
||||
);
|
||||
|
||||
expect(result.error).toBe(READ_FAILURE_MESSAGE);
|
||||
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.png"]);
|
||||
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.txt"]);
|
||||
});
|
||||
|
||||
it("returns no attachments and no error for an empty batch", async () => {
|
||||
const result = await captureImageAttachments([], () => Promise.resolve("x"));
|
||||
const result = await capturePromptAttachments([], () => Promise.resolve("x"));
|
||||
expect(result).toEqual({ attachments: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectivePromptAttachmentDelivery", () => {
|
||||
it("preserves inline delivery when all pending attachments are supported images", () => {
|
||||
expect(effectivePromptAttachmentDelivery("inline", [{ kind: "image", mimeType: "image/png" }])).toBe("inline");
|
||||
});
|
||||
|
||||
it("preserves an explicit folder preference for supported images", () => {
|
||||
expect(effectivePromptAttachmentDelivery("folder", [{ kind: "image", mimeType: "image/png" }])).toBe("folder");
|
||||
});
|
||||
|
||||
it("forces folder delivery when any attachment is a generic file", () => {
|
||||
expect(effectivePromptAttachmentDelivery("inline", [
|
||||
{ kind: "image", mimeType: "image/png" },
|
||||
{ kind: "file", mimeType: "application/pdf" },
|
||||
])).toBe("folder");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PromptAttachmentDelivery } from "../../shared/apiTypes";
|
||||
import { extensionForImageMimeType, isSupportedImageMimeType } from "../../shared/promptAttachments";
|
||||
|
||||
/**
|
||||
@@ -11,44 +12,51 @@ export interface CapturableFile {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface CapturedAttachment {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
/** Base64 payload without the data: URL prefix. */
|
||||
data: string;
|
||||
size: number;
|
||||
}
|
||||
export type CapturedAttachment =
|
||||
| {
|
||||
kind: "image";
|
||||
name: string;
|
||||
mimeType: string;
|
||||
/** Base64 payload without the data: URL prefix. */
|
||||
data: string;
|
||||
size: number;
|
||||
}
|
||||
| {
|
||||
kind: "file";
|
||||
name: string;
|
||||
mimeType: string;
|
||||
/** Base64 payload without the data: URL prefix. */
|
||||
data: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export interface CaptureResult {
|
||||
attachments: CapturedAttachment[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const UNSUPPORTED_IMAGE_MESSAGE = "Only PNG, JPEG, GIF, and WebP images are supported.";
|
||||
export const DEFAULT_FILE_MIME_TYPE = "application/octet-stream";
|
||||
export const READ_FAILURE_MESSAGE = "Failed to read an attachment.";
|
||||
|
||||
/**
|
||||
* Validate a batch of files and read the supported images as base64.
|
||||
* Read a batch of browser files as prompt attachments.
|
||||
*
|
||||
* Pure orchestration: the actual byte reading is injected so the side effect
|
||||
* (FileReader/Blob access) stays at the component boundary and tests can supply
|
||||
* a fake reader. Unsupported types and read failures are collected into a single
|
||||
* user-facing error while still returning every attachment that did succeed.
|
||||
* a fake reader. Supported image MIME types stay marked as native inline images;
|
||||
* every other file is captured as a generic file attachment that must be saved
|
||||
* into the workspace before being mentioned in the prompt.
|
||||
*/
|
||||
export async function captureImageAttachments<T extends CapturableFile>(
|
||||
export async function capturePromptAttachments<T extends CapturableFile>(
|
||||
files: readonly T[],
|
||||
readBase64: (file: T) => Promise<string>,
|
||||
): Promise<CaptureResult> {
|
||||
const attachments: CapturedAttachment[] = [];
|
||||
let error: string | undefined;
|
||||
for (const file of files) {
|
||||
if (!isSupportedImageMimeType(file.type)) {
|
||||
error = UNSUPPORTED_IMAGE_MESSAGE;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const data = await readBase64(file);
|
||||
attachments.push({ name: attachmentName(file), mimeType: file.type, data, size: file.size });
|
||||
attachments.push(capturedAttachment(file, data));
|
||||
} catch {
|
||||
error = READ_FAILURE_MESSAGE;
|
||||
}
|
||||
@@ -56,6 +64,35 @@ export async function captureImageAttachments<T extends CapturableFile>(
|
||||
return { attachments, ...(error === undefined ? {} : { error }) };
|
||||
}
|
||||
|
||||
export function isInlinePromptAttachment(attachment: Pick<CapturedAttachment, "kind" | "mimeType">): boolean {
|
||||
return attachment.kind === "image" && isSupportedImageMimeType(attachment.mimeType);
|
||||
}
|
||||
|
||||
export function promptAttachmentsCanUseInlineDelivery(attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[]): boolean {
|
||||
return attachments.every((attachment) => isInlinePromptAttachment(attachment));
|
||||
}
|
||||
|
||||
export function effectivePromptAttachmentDelivery(
|
||||
preferredDelivery: PromptAttachmentDelivery,
|
||||
attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[],
|
||||
): PromptAttachmentDelivery {
|
||||
return promptAttachmentsCanUseInlineDelivery(attachments) ? preferredDelivery : "folder";
|
||||
}
|
||||
|
||||
function capturedAttachment(file: CapturableFile, data: string): CapturedAttachment {
|
||||
if (isSupportedImageMimeType(file.type)) {
|
||||
return { kind: "image", name: attachmentName(file), mimeType: file.type, data, size: file.size };
|
||||
}
|
||||
return { kind: "file", name: attachmentName(file), mimeType: fileMimeType(file), data, size: file.size };
|
||||
}
|
||||
|
||||
function fileMimeType(file: CapturableFile): string {
|
||||
const mimeType = file.type.trim();
|
||||
return mimeType === "" ? DEFAULT_FILE_MIME_TYPE : mimeType;
|
||||
}
|
||||
|
||||
function attachmentName(file: CapturableFile): string {
|
||||
return file.name !== "" ? file.name : `pasted-image.${extensionForImageMimeType(file.type)}`;
|
||||
if (file.name !== "") return file.name;
|
||||
if (isSupportedImageMimeType(file.type)) return `pasted-image.${extensionForImageMimeType(file.type)}`;
|
||||
return "pasted-file.bin";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MOBILE_PROMPT_ENTER_MEDIA_QUERY,
|
||||
parsePromptEnterPreference,
|
||||
PROMPT_ENTER_PREFERENCE_STORAGE_KEY,
|
||||
readPromptEnterPreference,
|
||||
shouldSendPromptOnEnter,
|
||||
shouldSendPromptOnEnterShortcut,
|
||||
shouldUsePromptEnterShiftShortcut,
|
||||
writePromptEnterPreference,
|
||||
type PromptEnterMedia,
|
||||
} from "./promptEnterBehavior";
|
||||
|
||||
describe("promptEnterBehavior", () => {
|
||||
it("uses the expected mobile media query", () => {
|
||||
expect(MOBILE_PROMPT_ENTER_MEDIA_QUERY).toBe("(pointer: coarse), (max-width: 760px)");
|
||||
});
|
||||
|
||||
it("uses the environment default when the preference is auto", () => {
|
||||
expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia, "auto")).toBe(true);
|
||||
expect(shouldSendPromptOnEnter(undefined, "auto")).toBe(true);
|
||||
expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia, "auto")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets explicit preferences override the environment", () => {
|
||||
expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia, "send")).toBe(true);
|
||||
expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia, "newline")).toBe(false);
|
||||
expect(shouldSendPromptOnEnter(undefined, "newline")).toBe(false);
|
||||
});
|
||||
|
||||
it("swaps Shift+Enter with the plain Enter behavior", () => {
|
||||
expect(shouldSendPromptOnEnterShortcut(false, { matches: false } satisfies PromptEnterMedia, "auto")).toBe(true);
|
||||
expect(shouldSendPromptOnEnterShortcut(true, { matches: false } satisfies PromptEnterMedia, "auto")).toBe(false);
|
||||
expect(shouldSendPromptOnEnterShortcut(false, { matches: true } satisfies PromptEnterMedia, "auto")).toBe(false);
|
||||
expect(shouldSendPromptOnEnterShortcut(true, { matches: true } satisfies PromptEnterMedia, "auto")).toBe(true);
|
||||
expect(shouldSendPromptOnEnterShortcut(true, undefined, "send")).toBe(false);
|
||||
expect(shouldSendPromptOnEnterShortcut(true, undefined, "newline")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores implicit Shift state on mobile-like keyboards", () => {
|
||||
expect(shouldUsePromptEnterShiftShortcut(false, true, { matches: true } satisfies PromptEnterMedia)).toBe(false);
|
||||
expect(shouldUsePromptEnterShiftShortcut(true, false, { matches: true } satisfies PromptEnterMedia)).toBe(false);
|
||||
expect(shouldUsePromptEnterShiftShortcut(true, true, { matches: true } satisfies PromptEnterMedia)).toBe(true);
|
||||
expect(shouldUsePromptEnterShiftShortcut(true, false, { matches: false } satisfies PromptEnterMedia)).toBe(true);
|
||||
expect(shouldUsePromptEnterShiftShortcut(true, false, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("parses local storage preference values", () => {
|
||||
expect(parsePromptEnterPreference("auto")).toBe("auto");
|
||||
expect(parsePromptEnterPreference("send")).toBe("send");
|
||||
expect(parsePromptEnterPreference("newline")).toBe("newline");
|
||||
expect(parsePromptEnterPreference(null)).toBe("auto");
|
||||
expect(parsePromptEnterPreference("return")).toBe("auto");
|
||||
});
|
||||
|
||||
it("reads and writes the stored preference", () => {
|
||||
const storage = new FakeStorage();
|
||||
|
||||
expect(readPromptEnterPreference(storage)).toBe("auto");
|
||||
writePromptEnterPreference("send", storage);
|
||||
expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("send");
|
||||
expect(readPromptEnterPreference(storage)).toBe("send");
|
||||
|
||||
writePromptEnterPreference("newline", storage);
|
||||
expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("newline");
|
||||
expect(readPromptEnterPreference(storage)).toBe("newline");
|
||||
|
||||
writePromptEnterPreference("auto", storage);
|
||||
expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("auto");
|
||||
expect(readPromptEnterPreference(storage)).toBe("auto");
|
||||
});
|
||||
|
||||
it("ignores storage failures", () => {
|
||||
const storage = new ThrowingStorage();
|
||||
|
||||
expect(readPromptEnterPreference(storage)).toBe("auto");
|
||||
expect(() => { writePromptEnterPreference("send", storage); }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
class FakeStorage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
value(key: string): string | undefined {
|
||||
return this.values.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
class ThrowingStorage {
|
||||
getItem(): string | null {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
|
||||
setItem(): void {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export const MOBILE_PROMPT_ENTER_MEDIA_QUERY = "(pointer: coarse), (max-width: 760px)";
|
||||
export const PROMPT_ENTER_PREFERENCE_STORAGE_KEY = "pi-web.promptEnterPreference";
|
||||
|
||||
export type PromptEnterPreference = "auto" | "send" | "newline";
|
||||
export type PromptEnterMedia = Pick<MediaQueryList, "matches">;
|
||||
export type PromptEnterPreferenceStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
export function createMobilePromptEnterMedia(): PromptEnterMedia | undefined {
|
||||
return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(MOBILE_PROMPT_ENTER_MEDIA_QUERY) : undefined;
|
||||
}
|
||||
|
||||
export function parsePromptEnterPreference(value: string | null): PromptEnterPreference {
|
||||
if (value === "send" || value === "newline") return value;
|
||||
return "auto";
|
||||
}
|
||||
|
||||
export function readPromptEnterPreference(storage = browserStorage()): PromptEnterPreference {
|
||||
if (storage === undefined) return "auto";
|
||||
try {
|
||||
return parsePromptEnterPreference(storage.getItem(PROMPT_ENTER_PREFERENCE_STORAGE_KEY));
|
||||
} catch {
|
||||
return "auto";
|
||||
}
|
||||
}
|
||||
|
||||
export function writePromptEnterPreference(preference: PromptEnterPreference, storage = browserStorage()): void {
|
||||
if (storage === undefined) return;
|
||||
try {
|
||||
storage.setItem(PROMPT_ENTER_PREFERENCE_STORAGE_KEY, preference);
|
||||
} catch {
|
||||
// Ignore localStorage quota/privacy errors; Auto remains the safe fallback.
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia(), preference = readPromptEnterPreference()): boolean {
|
||||
if (preference === "send") return true;
|
||||
if (preference === "newline") return false;
|
||||
return media?.matches !== true;
|
||||
}
|
||||
|
||||
export function shouldUsePromptEnterShiftShortcut(shiftKey: boolean, explicitShiftKeyActive: boolean, media = createMobilePromptEnterMedia()): boolean {
|
||||
// Touch keyboards can report autocapitalization as Shift on Enter after a line break.
|
||||
// On mobile-like screens, only trust Shift when the editor saw an explicit Shift keydown.
|
||||
if (!shiftKey) return false;
|
||||
if (media?.matches === true) return explicitShiftKeyActive;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldSendPromptOnEnterShortcut(shiftKey: boolean, media = createMobilePromptEnterMedia(), preference = readPromptEnterPreference()): boolean {
|
||||
const plainEnterSends = shouldSendPromptOnEnter(media, preference);
|
||||
return shiftKey ? !plainEnterSends : plainEnterSends;
|
||||
}
|
||||
|
||||
function browserStorage(): PromptEnterPreferenceStorage | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionCleanupPreviewResponse } from "./api";
|
||||
import { canRunSessionCleanup, confirmSessionCleanup, selectedSessionCleanupProjectCwds, sessionCleanupConfirmationMessage, sessionCleanupPreviewForSelectedProjects, sessionCleanupRequestKey, sessionCleanupUnavailableMessage, validateSessionCleanupDraft, type SessionCleanupDraft } from "./sessionCleanupUi";
|
||||
|
||||
const draft: SessionCleanupDraft = {
|
||||
archiveIdleEnabled: true,
|
||||
archiveIdleDays: "30",
|
||||
deleteArchivedEnabled: true,
|
||||
deleteArchivedDays: "90",
|
||||
};
|
||||
|
||||
const preview: SessionCleanupPreviewResponse = {
|
||||
generatedAt: "2026-06-25T12:00:00.000Z",
|
||||
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 90 },
|
||||
projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 1 }],
|
||||
totals: { archiveCount: 2, deleteCount: 1 },
|
||||
};
|
||||
|
||||
describe("session cleanup UI helpers", () => {
|
||||
it("builds request thresholds from enabled runtime inputs", () => {
|
||||
expect(validateSessionCleanupDraft(draft)).toEqual({
|
||||
ok: true,
|
||||
request: { archiveIdleDays: 30, deleteArchivedDays: 90 },
|
||||
});
|
||||
expect(validateSessionCleanupDraft({ ...draft, archiveIdleEnabled: false })).toEqual({
|
||||
ok: true,
|
||||
request: { archiveIdleDays: null, deleteArchivedDays: 90 },
|
||||
});
|
||||
});
|
||||
|
||||
it("validates threshold inputs before preview or execution", () => {
|
||||
expect(validateSessionCleanupDraft({ ...draft, archiveIdleDays: "1.5" })).toEqual({ ok: false, error: "Archive idle sessions after must be a non-negative whole number of days." });
|
||||
expect(validateSessionCleanupDraft({ ...draft, deleteArchivedDays: "-1" })).toEqual({ ok: false, error: "Delete archived sessions after must be a non-negative whole number of days." });
|
||||
expect(validateSessionCleanupDraft({ ...draft, archiveIdleEnabled: false, deleteArchivedEnabled: false })).toEqual({ ok: false, error: "Enable at least one cleanup action." });
|
||||
});
|
||||
|
||||
it("requires a current preview before cleanup can run", () => {
|
||||
const validation = validateSessionCleanupDraft(draft);
|
||||
if (!validation.ok) throw new Error(validation.error);
|
||||
|
||||
expect(canRunSessionCleanup({ canCleanup: true, draft, preview, previewRequest: validation.request })).toBe(true);
|
||||
expect(canRunSessionCleanup({ canCleanup: true, draft: { ...draft, archiveIdleDays: "31" }, preview, previewRequest: validation.request })).toBe(false);
|
||||
expect(canRunSessionCleanup({ canCleanup: true, draft, preview: { ...preview, totals: { archiveCount: 0, deleteCount: 0 } }, previewRequest: validation.request })).toBe(false);
|
||||
expect(canRunSessionCleanup({ canCleanup: false, draft, preview, previewRequest: validation.request })).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes request keys for null, omitted disabled actions, and selected projects", () => {
|
||||
expect(sessionCleanupRequestKey({ archiveIdleDays: 30 })).toBe(sessionCleanupRequestKey({ archiveIdleDays: 30, deleteArchivedDays: null, projectCwds: ["/repo"] }));
|
||||
});
|
||||
|
||||
it("summarizes the preview for selected projects", () => {
|
||||
const multiProjectPreview: SessionCleanupPreviewResponse = {
|
||||
...preview,
|
||||
projects: [
|
||||
{ cwd: "/repo-a", archiveCount: 2, deleteCount: 1 },
|
||||
{ cwd: "/repo-b", archiveCount: 0, deleteCount: 3 },
|
||||
],
|
||||
totals: { archiveCount: 2, deleteCount: 4 },
|
||||
};
|
||||
|
||||
expect(selectedSessionCleanupProjectCwds(multiProjectPreview, undefined)).toEqual(["/repo-a", "/repo-b"]);
|
||||
expect(selectedSessionCleanupProjectCwds(multiProjectPreview, ["/missing", "/repo-b"])).toEqual(["/repo-b"]);
|
||||
expect(sessionCleanupPreviewForSelectedProjects(multiProjectPreview, ["/repo-b"])).toMatchObject({
|
||||
projects: [{ cwd: "/repo-b", archiveCount: 0, deleteCount: 3 }],
|
||||
totals: { archiveCount: 0, deleteCount: 3 },
|
||||
});
|
||||
expect(sessionCleanupPreviewForSelectedProjects(multiProjectPreview, [])).toMatchObject({
|
||||
projects: [],
|
||||
totals: { archiveCount: 0, deleteCount: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses explicit permanent deletion copy in confirmation and unavailable messages", () => {
|
||||
const confirmMessages: string[] = [];
|
||||
expect(confirmSessionCleanup(preview, (message) => {
|
||||
confirmMessages.push(message);
|
||||
return true;
|
||||
})).toBe(true);
|
||||
expect(confirmMessages[0]).toContain("permanently delete 1 archived session");
|
||||
expect(sessionCleanupConfirmationMessage(preview)).toContain("cannot be undone");
|
||||
expect(sessionCleanupUnavailableMessage("Remote Dev")).toBe("Update and restart Pi-Web on Remote Dev to clean up sessions.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { SessionCleanupPreviewResponse, SessionCleanupRequest } from "./api";
|
||||
|
||||
export interface SessionCleanupDraft {
|
||||
archiveIdleEnabled: boolean;
|
||||
archiveIdleDays: string;
|
||||
deleteArchivedEnabled: boolean;
|
||||
deleteArchivedDays: string;
|
||||
}
|
||||
|
||||
export type SessionCleanupDraftValidation =
|
||||
| { ok: true; request: SessionCleanupRequest }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export const DEFAULT_SESSION_CLEANUP_DRAFT: SessionCleanupDraft = {
|
||||
archiveIdleEnabled: true,
|
||||
archiveIdleDays: "30",
|
||||
deleteArchivedEnabled: false,
|
||||
deleteArchivedDays: "90",
|
||||
};
|
||||
|
||||
export function validateSessionCleanupDraft(draft: SessionCleanupDraft): SessionCleanupDraftValidation {
|
||||
if (!draft.archiveIdleEnabled && !draft.deleteArchivedEnabled) return { ok: false, error: "Enable at least one cleanup action." };
|
||||
|
||||
const request: SessionCleanupRequest = {
|
||||
archiveIdleDays: null,
|
||||
deleteArchivedDays: null,
|
||||
};
|
||||
|
||||
if (draft.archiveIdleEnabled) {
|
||||
const archiveIdleDays = parseDayThreshold(draft.archiveIdleDays, "Archive idle sessions after");
|
||||
if (typeof archiveIdleDays === "string") return { ok: false, error: archiveIdleDays };
|
||||
request.archiveIdleDays = archiveIdleDays;
|
||||
}
|
||||
|
||||
if (draft.deleteArchivedEnabled) {
|
||||
const deleteArchivedDays = parseDayThreshold(draft.deleteArchivedDays, "Delete archived sessions after");
|
||||
if (typeof deleteArchivedDays === "string") return { ok: false, error: deleteArchivedDays };
|
||||
request.deleteArchivedDays = deleteArchivedDays;
|
||||
}
|
||||
|
||||
return { ok: true, request };
|
||||
}
|
||||
|
||||
export function sessionCleanupRequestKey(request: SessionCleanupRequest | undefined): string {
|
||||
// The preview freshness key is threshold-only: project selection is applied
|
||||
// to the already-previewed project list and sent separately when running.
|
||||
return JSON.stringify({
|
||||
archiveIdleDays: request?.archiveIdleDays ?? null,
|
||||
deleteArchivedDays: request?.deleteArchivedDays ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function canRunSessionCleanup(input: {
|
||||
canCleanup: boolean;
|
||||
draft: SessionCleanupDraft;
|
||||
preview: SessionCleanupPreviewResponse | undefined;
|
||||
previewRequest: SessionCleanupRequest | undefined;
|
||||
loading?: boolean;
|
||||
running?: boolean;
|
||||
}): boolean {
|
||||
if (!input.canCleanup || input.loading === true || input.running === true || input.preview === undefined) return false;
|
||||
const validation = validateSessionCleanupDraft(input.draft);
|
||||
if (!validation.ok) return false;
|
||||
if (sessionCleanupRequestKey(validation.request) !== sessionCleanupRequestKey(input.previewRequest)) return false;
|
||||
return sessionCleanupPreviewHasTargets(input.preview);
|
||||
}
|
||||
|
||||
export function sessionCleanupPreviewHasTargets(preview: Pick<SessionCleanupPreviewResponse, "totals">): boolean {
|
||||
return preview.totals.archiveCount > 0 || preview.totals.deleteCount > 0;
|
||||
}
|
||||
|
||||
export function selectedSessionCleanupProjectCwds(preview: Pick<SessionCleanupPreviewResponse, "projects">, selectedProjectCwds: readonly string[] | undefined): string[] {
|
||||
const previewCwds = preview.projects.map((project) => project.cwd);
|
||||
if (selectedProjectCwds === undefined) return previewCwds;
|
||||
const selected = new Set(selectedProjectCwds);
|
||||
return previewCwds.filter((cwd) => selected.has(cwd));
|
||||
}
|
||||
|
||||
export function sessionCleanupPreviewForSelectedProjects(preview: SessionCleanupPreviewResponse, selectedProjectCwds: readonly string[] | undefined): SessionCleanupPreviewResponse {
|
||||
const selected = new Set(selectedSessionCleanupProjectCwds(preview, selectedProjectCwds));
|
||||
const projects = preview.projects.filter((project) => selected.has(project.cwd));
|
||||
return {
|
||||
...preview,
|
||||
projects,
|
||||
totals: projects.reduce((totals, project) => ({
|
||||
archiveCount: totals.archiveCount + project.archiveCount,
|
||||
deleteCount: totals.deleteCount + project.deleteCount,
|
||||
}), { archiveCount: 0, deleteCount: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
export function confirmSessionCleanup(preview: Pick<SessionCleanupPreviewResponse, "totals">, confirmCleanup: (message: string) => boolean): boolean {
|
||||
return confirmCleanup(sessionCleanupConfirmationMessage(preview));
|
||||
}
|
||||
|
||||
export function sessionCleanupConfirmationMessage(preview: Pick<SessionCleanupPreviewResponse, "totals">): string {
|
||||
const archiveCount = preview.totals.archiveCount;
|
||||
const deleteCount = preview.totals.deleteCount;
|
||||
const parts: string[] = [];
|
||||
if (archiveCount > 0) parts.push(`archive ${String(archiveCount)} idle ${archiveCount === 1 ? "session" : "sessions"}`);
|
||||
if (deleteCount > 0) parts.push(`permanently delete ${String(deleteCount)} archived ${deleteCount === 1 ? "session" : "sessions"}`);
|
||||
const action = parts.length === 0 ? "run cleanup" : parts.join(" and ");
|
||||
return `Run cleanup and ${action}?\n\nPermanent deletion only applies to archived sessions and cannot be undone.`;
|
||||
}
|
||||
|
||||
export function sessionCleanupUnavailableMessage(machineName: string | undefined): string {
|
||||
return `Update and restart Pi-Web on ${machineName ?? "this machine"} to clean up sessions.`;
|
||||
}
|
||||
|
||||
function parseDayThreshold(value: string, label: string): number | string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return `${label} must be set.`;
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) return `${label} must be a non-negative whole number of days.`;
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shortSessionId } from "./sessionLabels";
|
||||
|
||||
describe("shortSessionId", () => {
|
||||
it("uses the random-looking suffix of UUIDv7 session ids", () => {
|
||||
expect(shortSessionId("019f22c5-d53e-7489-997f-fce1e570a202")).toBe("e570a202");
|
||||
});
|
||||
|
||||
it("keeps short ids intact", () => {
|
||||
expect(shortSessionId("abc123")).toBe("abc123");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shortSessionId(id: string): string {
|
||||
return id.slice(-8);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SessionInfo, SessionStatus } from "./api";
|
||||
import { isCachedNewSessionInfo } from "./cachedNewSessions";
|
||||
|
||||
export type SessionPersistenceState = "persisted" | "transient" | "unknown";
|
||||
|
||||
export function sessionPersistenceState(session: SessionInfo | undefined, status?: SessionStatus): SessionPersistenceState {
|
||||
if (session === undefined) return "unknown";
|
||||
const statusPersisted = status?.sessionId === session.id ? status.persisted : undefined;
|
||||
const persisted = statusPersisted ?? session.persisted;
|
||||
if (persisted === true) return "persisted";
|
||||
if (persisted === false || isCachedNewSessionInfo(session)) return "transient";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function isArchivableSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean {
|
||||
return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "persisted";
|
||||
}
|
||||
|
||||
export function isTransientNewSessionInfo(session: SessionInfo | undefined, status?: SessionStatus): boolean {
|
||||
return session !== undefined && session.archived !== true && sessionPersistenceState(session, status) === "transient";
|
||||
}
|
||||
@@ -37,6 +37,8 @@ describe("settings route helpers", () => {
|
||||
expect(parseSettingsSection("general")).toBe("general");
|
||||
expect(parseSettingsSection("sessiond")).toBe("sessiond");
|
||||
expect(parseSettingsSection("sessions")).toBe("sessiond");
|
||||
expect(parseSettingsSection("packages")).toBe("packages");
|
||||
expect(parseSettingsSection("pi-packages")).toBe("packages");
|
||||
expect(parseSettingsSection("plugins")).toBe("plugins");
|
||||
expect(parseSettingsSection("shortcuts")).toBe("shortcuts");
|
||||
expect(parseSettingsSection("keyboard")).toBe("shortcuts");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SettingsSection = "general" | "sessiond" | "plugins" | "shortcuts";
|
||||
export type SettingsSection = "general" | "sessiond" | "packages" | "plugins" | "shortcuts";
|
||||
|
||||
export function readSettingsSection(): SettingsSection | undefined {
|
||||
return parseSettingsSection(new URLSearchParams(window.location.search).get("settings"));
|
||||
@@ -18,6 +18,7 @@ export function writeSettingsSection(section: SettingsSection | undefined, optio
|
||||
export function parseSettingsSection(value: string | null): SettingsSection | undefined {
|
||||
if (value === "general") return "general";
|
||||
if (value === "sessiond" || value === "sessions") return "sessiond";
|
||||
if (value === "packages" || value === "pi-packages") return "packages";
|
||||
if (value === "plugins") return "plugins";
|
||||
if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts";
|
||||
return undefined;
|
||||
|
||||
Reference in New Issue
Block a user