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;
|
||||
|
||||
@@ -112,6 +112,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>;
|
||||
}
|
||||
|
||||
|
||||
+219
-4
@@ -11,11 +11,13 @@ import { RemoteMachineRequestError, type MachineClient } from "./machines/machin
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { MachineStore } from "./machines/machineStore.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import type { PiPackageService } from "./piPackageService.js";
|
||||
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
|
||||
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -23,6 +25,7 @@ let tempDir: string;
|
||||
let projectDir: string;
|
||||
let remoteClient: MachineClient | undefined;
|
||||
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
|
||||
let piPackageRequests: CapturedPiPackageRequest[];
|
||||
let piWebConfig: PiWebConfigValues;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -30,6 +33,7 @@ beforeEach(async () => {
|
||||
projectDir = join(tempDir, "project");
|
||||
remoteClient = undefined;
|
||||
sessionDaemonRequests = [];
|
||||
piPackageRequests = [];
|
||||
piWebConfig = {};
|
||||
app = await buildApp({
|
||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||
@@ -52,6 +56,7 @@ beforeEach(async () => {
|
||||
}),
|
||||
sessionDaemon: fakeSessionDaemon(),
|
||||
config: fakeConfigService(),
|
||||
piPackages: fakePiPackageService(),
|
||||
piWebPlugins: {
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
|
||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
|
||||
@@ -122,10 +127,10 @@ describe("buildApp", () => {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
|
||||
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
|
||||
},
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
@@ -133,7 +138,7 @@ describe("buildApp", () => {
|
||||
const runtime = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
|
||||
|
||||
expect(runtime.statusCode).toBe(200);
|
||||
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
||||
});
|
||||
|
||||
@@ -155,6 +160,103 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||
});
|
||||
|
||||
it("filters remote selected-machine config reads to machine-safe keys", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
|
||||
body: piWebConfigResponse(fullPiWebConfig()),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/config` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||
expect(response.json<PiWebConfigResponse>()).toEqual({
|
||||
...piWebConfigResponse(fullPiWebConfig()),
|
||||
config: selectedMachinePiWebConfig(),
|
||||
effectiveConfig: selectedMachinePiWebConfig(),
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/config");
|
||||
});
|
||||
|
||||
it("merges remote selected-machine config updates into the target machine config", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) });
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) });
|
||||
});
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true } },
|
||||
});
|
||||
|
||||
const expectedMerged: PiWebConfigValues = {
|
||||
...fullPiWebConfig(),
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/srv/remote"] },
|
||||
uploads: { defaultFolder: "remote/uploads" },
|
||||
maxUploadBytes: 4096,
|
||||
spawnSessions: true,
|
||||
};
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
|
||||
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { config: expectedMerged });
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual({
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/srv/remote"] },
|
||||
uploads: { defaultFolder: "remote/uploads" },
|
||||
maxUploadBytes: 4096,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe remote selected-machine config keys before proxying", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>();
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { host: "0.0.0.0", allowedHosts: true, shortcuts: { "core:view.chat": "mod+1" }, spawnSessions: true } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
|
||||
expect(requestJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn<MachineClient["request"]>((method, path, body) => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify({ method, path, body })]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-packages` });
|
||||
const installBody = { source: "npm:@acme/new-tools" };
|
||||
const installResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/pi-packages/install`, payload: installBody });
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/pi-packages" });
|
||||
expect(installResponse.statusCode).toBe(200);
|
||||
expect(installResponse.json()).toEqual({ method: "POST", path: "/api/pi-packages/install", body: installBody });
|
||||
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/pi-packages", undefined);
|
||||
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS });
|
||||
});
|
||||
|
||||
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -389,6 +491,25 @@ describe("buildApp", () => {
|
||||
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]);
|
||||
});
|
||||
|
||||
it("serves Pi package management routes through the app wiring", async () => {
|
||||
const listResponse = await app.inject({ method: "GET", url: "/api/pi-packages" });
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }] });
|
||||
|
||||
const installResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools" } });
|
||||
expect(installResponse.statusCode).toBe(200);
|
||||
expect(installResponse.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
|
||||
|
||||
const localAliasResponse = await app.inject({ method: "POST", url: "/api/machines/local/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "user" } });
|
||||
expect(localAliasResponse.statusCode).toBe(200);
|
||||
expect(localAliasResponse.json()).toMatchObject({ action: "remove", source: "npm:@acme/tools", scope: "user" });
|
||||
expect(piPackageRequests).toEqual([
|
||||
{ action: "list" },
|
||||
{ action: "install", source: "npm:@acme/new-tools" },
|
||||
{ action: "remove", source: "npm:@acme/tools", scope: "user" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
||||
expect(manifestResponse.statusCode).toBe(200);
|
||||
@@ -398,6 +519,10 @@ describe("buildApp", () => {
|
||||
expect(pluginsResponse.statusCode).toBe(200);
|
||||
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
|
||||
|
||||
const localMachinePluginsResponse = await app.inject({ method: "GET", url: "/api/machines/local/plugins" });
|
||||
expect(localMachinePluginsResponse.statusCode).toBe(200);
|
||||
expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
|
||||
|
||||
const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
|
||||
expect(assetResponse.statusCode).toBe(200);
|
||||
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
|
||||
@@ -407,6 +532,24 @@ describe("buildApp", () => {
|
||||
expect(missingResponse.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("proxies remote machine plugin lists for settings", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
|
||||
body: Readable.from([JSON.stringify({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] })]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/plugins` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||
expect(response.json()).toEqual({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] });
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/plugins", undefined);
|
||||
});
|
||||
|
||||
it("rewrites and proxies remote machine plugin manifests and assets", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -885,6 +1028,12 @@ interface CapturedSessionDaemonRequest {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
interface CapturedPiPackageRequest {
|
||||
action: "list" | "install" | "remove" | "update";
|
||||
source?: string;
|
||||
scope?: "user" | "project";
|
||||
}
|
||||
|
||||
function fakeConfigService() {
|
||||
return {
|
||||
read: () => piWebConfigResponse(piWebConfig),
|
||||
@@ -895,6 +1044,32 @@ function fakeConfigService() {
|
||||
};
|
||||
}
|
||||
|
||||
function fullPiWebConfig(): PiWebConfigValues {
|
||||
return {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.example.test"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true, settings: { note: "remote" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedMachinePiWebConfig(): PiWebConfigValues {
|
||||
return {
|
||||
plugins: { info: { enabled: true, settings: { note: "remote" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: join(tempDir, "config.json"),
|
||||
@@ -905,6 +1080,46 @@ function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface MachineConfigWriteBody {
|
||||
config: PiWebConfigValues;
|
||||
}
|
||||
|
||||
function configFromMachineConfigWriteBody(body: unknown): PiWebConfigValues {
|
||||
if (!isMachineConfigWriteBody(body)) throw new Error("Expected machine config write body");
|
||||
return body.config;
|
||||
}
|
||||
|
||||
function isMachineConfigWriteBody(value: unknown): value is MachineConfigWriteBody {
|
||||
if (!isRecord(value)) return false;
|
||||
return isRecord(value["config"]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function fakePiPackageService(): PiPackageService {
|
||||
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }];
|
||||
return {
|
||||
list: () => {
|
||||
piPackageRequests.push({ action: "list" });
|
||||
return Promise.resolve({ packages });
|
||||
},
|
||||
install: (source) => {
|
||||
piPackageRequests.push({ action: "install", source });
|
||||
return Promise.resolve({ action: "install", source, packages });
|
||||
},
|
||||
remove: (source, scope = "user") => {
|
||||
piPackageRequests.push({ action: "remove", source, scope });
|
||||
return Promise.resolve({ action: "remove", source, scope, removed: true, packages });
|
||||
},
|
||||
update: (source) => {
|
||||
piPackageRequests.push({ action: "update", ...(source === undefined ? {} : { source }) });
|
||||
return Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeSessionDaemon(): SessionProxyDaemon {
|
||||
return {
|
||||
request: (method, path, body) => {
|
||||
|
||||
+9
-1
@@ -18,8 +18,10 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||
import { registerGitRoutes } from "./gitRoutes.js";
|
||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
||||
import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
@@ -34,6 +36,7 @@ export interface AppDependencies {
|
||||
machines?: MachineService;
|
||||
sessionDaemon?: SessionProxyDaemon;
|
||||
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
||||
piPackages?: PiPackageService;
|
||||
config?: PiWebConfigService;
|
||||
clientDist?: string | false;
|
||||
logger?: FastifyServerOptions["logger"];
|
||||
@@ -122,6 +125,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
||||
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
|
||||
const configService = deps.config ?? createFilePiWebConfigService();
|
||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
|
||||
@@ -145,7 +149,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
|
||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins());
|
||||
registerPiPackageRoutes(app, piPackages);
|
||||
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
|
||||
registerConfigRoutes(app, configService);
|
||||
registerLocalMachineConfigRoutes(app, configService);
|
||||
|
||||
registerMachineRoutes(app, machines);
|
||||
registerMachinePluginProxyRoutes(app, machines);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -18,6 +18,7 @@ beforeEach(async () => {
|
||||
};
|
||||
app = Fastify({ logger: false });
|
||||
registerConfigRoutes(app, service);
|
||||
registerLocalMachineConfigRoutes(app, service);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
@@ -92,8 +93,102 @@ describe("config routes", () => {
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters local machine config reads to selected-machine-safe keys", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/machines/local/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json<PiWebConfigResponse>()).toEqual({
|
||||
...responseFor(savedConfig, true),
|
||||
config: selectedMachineConfig(),
|
||||
effectiveConfig: selectedMachineConfig(),
|
||||
});
|
||||
});
|
||||
|
||||
it("merges local selected-machine config updates without dropping gateway-only keys", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/machines/local/config",
|
||||
payload: { config: { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, spawnSessions: true } },
|
||||
});
|
||||
|
||||
const expectedConfig: PiWebConfigValues = {
|
||||
...fullConfig(),
|
||||
plugins: { info: { enabled: false } },
|
||||
uploads: { defaultFolder: "uploads/manual" },
|
||||
spawnSessions: true,
|
||||
};
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(savedConfig).toEqual(expectedConfig);
|
||||
expect(service.write).toHaveBeenCalledWith(expectedConfig);
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual({
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads/manual" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe local selected-machine config keys before writing", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/machines/local/config",
|
||||
payload: { config: { host: "0.0.0.0", spawnSessions: true } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
|
||||
expect(savedConfig).toEqual(fullConfig());
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid local selected-machine config values before writing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/machines/local/config",
|
||||
payload: { config: { spawnSessions: "yes" } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config spawnSessions must be a boolean");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function fullConfig(): PiWebConfigValues {
|
||||
return {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.example.test"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true, settings: { note: "visible" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedMachineConfig(): PiWebConfigValues {
|
||||
return {
|
||||
plugins: { info: { enabled: true, settings: { note: "visible" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
|
||||
+113
-1
@@ -8,6 +8,17 @@ export interface PiWebConfigService {
|
||||
write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise<PiWebConfigResponse>;
|
||||
}
|
||||
|
||||
export const SELECTED_MACHINE_CONFIG_KEYS = [
|
||||
"plugins",
|
||||
"pathAccess",
|
||||
"uploads",
|
||||
"maxUploadBytes",
|
||||
"spawnSessions",
|
||||
"subsessions",
|
||||
] as const satisfies readonly (keyof PiWebConfigValues)[];
|
||||
|
||||
const SELECTED_MACHINE_CONFIG_KEY_SET = new Set<string>(SELECTED_MACHINE_CONFIG_KEYS);
|
||||
|
||||
export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebConfigService {
|
||||
return {
|
||||
read: () => currentPiWebConfigResponse(options),
|
||||
@@ -50,6 +61,62 @@ export function registerConfigRoutes(app: FastifyInstance, service: PiWebConfigS
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLocalMachineConfigRoutes(app: FastifyInstance, service: PiWebConfigService = createFilePiWebConfigService()): void {
|
||||
app.get("/api/machines/local/config", async (_request, reply) => {
|
||||
try {
|
||||
return selectedMachineConfigResponse(await service.read());
|
||||
} catch (error) {
|
||||
return reply.code(500).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.put<{ Body: { config?: unknown } | undefined }>("/api/machines/local/config", async (request, reply) => {
|
||||
try {
|
||||
const current = await service.read();
|
||||
const patch = parseSelectedMachineConfigRequest(request.body?.config);
|
||||
return selectedMachineConfigResponse(await service.write(mergeSelectedMachineConfig(current.config, patch)));
|
||||
} catch (error) {
|
||||
const status = isConfigValidationError(error) ? 400 : 500;
|
||||
return reply.code(status).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function parseSelectedMachineConfigRequest(value: unknown): PiWebConfig {
|
||||
if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object");
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`);
|
||||
}
|
||||
try {
|
||||
return pickSelectedMachineConfig(parseConfigRequest(value));
|
||||
} catch (error) {
|
||||
throw new Error(selectedMachineConfigErrorMessage(error), { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSelectedMachineConfig(current: PiWebConfigValues, patch: PiWebConfigValues): PiWebConfig {
|
||||
return { ...current, ...pickSelectedMachineConfig(patch) };
|
||||
}
|
||||
|
||||
export function selectedMachineConfigResponse(response: PiWebConfigResponse): PiWebConfigResponse {
|
||||
return {
|
||||
...response,
|
||||
config: pickSelectedMachineConfig(response.config),
|
||||
effectiveConfig: pickSelectedMachineConfig(response.effectiveConfig),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePiWebConfigResponseBody(value: unknown, source = "PI WEB config response"): PiWebConfigResponse {
|
||||
const record = requireResponseRecord(value, source);
|
||||
return {
|
||||
path: requireResponseString(record, "path", source),
|
||||
exists: requireResponseBoolean(record, "exists", source),
|
||||
config: parseConfigRequest(record["config"]),
|
||||
effectiveConfig: parseConfigRequest(record["effectiveConfig"]),
|
||||
envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source),
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
|
||||
const config: PiWebConfig = {};
|
||||
@@ -88,6 +155,23 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig {
|
||||
return {
|
||||
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
|
||||
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
|
||||
...(config.uploads !== undefined ? { uploads: config.uploads } : {}),
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function selectedMachineConfigErrorMessage(error: unknown): string {
|
||||
const message = errorMessage(error);
|
||||
if (message.startsWith("PI WEB config ")) return `PI WEB selected-machine config ${message.slice("PI WEB config ".length)}`;
|
||||
return `PI WEB selected-machine config ${message}`;
|
||||
}
|
||||
|
||||
function parseAllowedHostsRequest(value: unknown): string[] | true {
|
||||
if (value === true) return true;
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
||||
@@ -141,6 +225,34 @@ function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]
|
||||
}));
|
||||
}
|
||||
|
||||
function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): PiWebConfigEnvOverrides {
|
||||
const record = requireResponseRecord(value, `${source} envOverrides`);
|
||||
return {
|
||||
host: requireResponseBoolean(record, "host", source),
|
||||
port: requireResponseBoolean(record, "port", source),
|
||||
allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
|
||||
spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
|
||||
subsessions: requireResponseBoolean(record, "subsessions", source),
|
||||
};
|
||||
}
|
||||
|
||||
function requireResponseRecord(value: unknown, source: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error(`${source} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireResponseString(record: Record<string, unknown>, key: string, source: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") throw new Error(`${source} field must be a string: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireResponseBoolean(record: Record<string, unknown>, key: string, source: string): boolean {
|
||||
const value = record[key];
|
||||
if (typeof value !== "boolean") throw new Error(`${source} field must be a boolean: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
|
||||
return {
|
||||
host: isEnvSet(env["PI_WEB_HOST"]),
|
||||
@@ -156,7 +268,7 @@ function isEnvSet(value: string | undefined): boolean {
|
||||
}
|
||||
|
||||
function isConfigValidationError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith("PI WEB config");
|
||||
return error instanceof Error && (error.message.startsWith("PI WEB config") || error.message.startsWith("PI WEB selected-machine config"));
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { WebSocket } from "ws";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
|
||||
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
|
||||
import { bridgeSockets } from "../webSocketBridge.js";
|
||||
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
|
||||
import { RemoteMachineRequestError, type MachineClient, type MachineJsonResponse, type MachineRequestOptions } from "./machineClient.js";
|
||||
import { MachineService } from "./machineService.js";
|
||||
|
||||
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
|
||||
@@ -23,7 +24,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
||||
app.route<{ Params: { machineId: string }; Body: unknown }>({
|
||||
method: spec.method,
|
||||
url: `/api/machines/:machineId${spec.path}`,
|
||||
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
|
||||
handler: (request, reply) => proxyHttpRequest(machines, spec, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
|
||||
async function proxyHttpRequest(machines: MachineService, spec: FederatedHttpRouteSpec, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
|
||||
if (machineId === "local") {
|
||||
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
|
||||
}
|
||||
@@ -45,19 +46,62 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met
|
||||
}
|
||||
|
||||
try {
|
||||
const requestOptions = proxyRequestOptions(body, contentType);
|
||||
const remotePath = remoteApiPath(machineId, requestUrl);
|
||||
if (spec.path === "/config") return await proxySelectedMachineConfigRequest(client, machineId, method, remotePath, body, reply);
|
||||
|
||||
const requestOptions = proxyRequestOptions(spec, body, contentType);
|
||||
const upstream = requestOptions === undefined
|
||||
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
|
||||
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
|
||||
? await client.request(method, remotePath, body)
|
||||
: await client.request(method, remotePath, body, requestOptions);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
if (upstream.body === undefined) return await reply.send();
|
||||
return await reply.send(upstream.body);
|
||||
} catch (error) {
|
||||
if (isSelectedMachineConfigRequestError(error)) return reply.code(400).send({ error: errorMessage(error) });
|
||||
return sendGatewayError(reply, machineId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function proxySelectedMachineConfigRequest(client: MachineClient, machineId: string, method: string, remotePath: string, body: unknown, reply: FastifyReply): Promise<FastifyReply> {
|
||||
if (method === "GET") {
|
||||
return sendSelectedMachineConfigResponse(reply, await client.requestJson("GET", remotePath), machineId);
|
||||
}
|
||||
|
||||
if (method === "PUT") {
|
||||
const patch = parseSelectedMachineConfigRequest(configPayload(body));
|
||||
const currentResponse = await client.requestJson("GET", remotePath);
|
||||
if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId);
|
||||
|
||||
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
|
||||
const merged = mergeSelectedMachineConfig(current.config, patch);
|
||||
return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId);
|
||||
}
|
||||
|
||||
return reply.code(405).send({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
function configPayload(body: unknown): unknown {
|
||||
return isRecord(body) ? body["config"] : undefined;
|
||||
}
|
||||
|
||||
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
|
||||
}
|
||||
|
||||
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
return reply.send(upstream.body ?? { error: "Remote machine config request failed", machineId, statusCode: upstream.statusCode });
|
||||
}
|
||||
|
||||
function isSuccessfulStatus(statusCode: number): boolean {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
async function proxyWebSocket(machines: MachineService, machineId: string, requestUrl: string, socket: WebSocket): Promise<void> {
|
||||
if (machineId === "local") {
|
||||
socket.close(1011, "Local machine route is not registered for this endpoint");
|
||||
@@ -84,10 +128,14 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
|
||||
return `/api${compatPath}`;
|
||||
}
|
||||
|
||||
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
|
||||
if (!isRawProxyBody(body)) return undefined;
|
||||
const value = firstHeaderValue(contentType);
|
||||
return value === undefined || value === "" ? undefined : { contentType: value };
|
||||
function proxyRequestOptions(spec: Pick<FederatedHttpRouteSpec, "timeoutMs">, body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
|
||||
const options: MachineRequestOptions = {};
|
||||
if (spec.timeoutMs !== undefined) options.timeoutMs = spec.timeoutMs;
|
||||
if (isRawProxyBody(body)) {
|
||||
const value = firstHeaderValue(contentType);
|
||||
if (value !== undefined && value !== "") options.contentType = value;
|
||||
}
|
||||
return Object.keys(options).length === 0 ? undefined : options;
|
||||
}
|
||||
|
||||
function isRawProxyBody(body: unknown): boolean {
|
||||
@@ -106,6 +154,18 @@ function applySafeHeaders(reply: FastifyReply, headers: Record<string, string |
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectedMachineConfigRequestError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith("PI WEB selected-machine config");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown): FastifyReply {
|
||||
const statusCode = error instanceof RemoteMachineRequestError ? error.statusCode : 502;
|
||||
const label = statusCode === 504 ? "Remote machine timeout" : "Remote machine unavailable";
|
||||
@@ -113,6 +173,6 @@ function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown
|
||||
error: label,
|
||||
machineId,
|
||||
statusCode,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
detail: errorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
|
||||
import { isPiWebCapability } from "../../shared/capabilities.js";
|
||||
import { parsePiWebRuntimeResponse } from "../../shared/piWebStatusParsing.js";
|
||||
import { getPiWebRuntime } from "../piWebStatus.js";
|
||||
import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js";
|
||||
import { MachineStore, type StoredMachine } from "./machineStore.js";
|
||||
@@ -151,7 +151,8 @@ export class MachineService {
|
||||
const checkedAt = this.now().toISOString();
|
||||
try {
|
||||
const response = await this.clientFor(machine).requestJson("GET", "/api/pi-web/runtime", undefined, { timeoutMs: DEFAULT_REMOTE_HEALTH_TIMEOUT_MS });
|
||||
if (response.statusCode >= 200 && response.statusCode < 300 && isPiWebRuntimeResponse(response.body)) return machineRuntime(id, checkedAt, response.body);
|
||||
const runtime = parsePiWebRuntimeResponse(response.body);
|
||||
if (response.statusCode >= 200 && response.statusCode < 300 && runtime !== undefined) return machineRuntime(id, checkedAt, runtime);
|
||||
return { machineId: id, ok: false, checkedAt, error: `Remote runtime returned HTTP ${String(response.statusCode)}` };
|
||||
} catch (error) {
|
||||
return { machineId: id, ok: false, checkedAt, error: errorMessage(error) };
|
||||
@@ -241,16 +242,6 @@ function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
|
||||
return isPiWebComponentStatus(components["web"]) && isPiWebComponentStatus(components["sessiond"]);
|
||||
}
|
||||
|
||||
function isPiWebRuntimeResponse(value: unknown): value is PiWebRuntimeResponse {
|
||||
if (!isRecord(value)) return false;
|
||||
const packageName = value["packageName"];
|
||||
const generatedAt = value["generatedAt"];
|
||||
const components = value["components"];
|
||||
const capabilities = value["capabilities"];
|
||||
if (typeof packageName !== "string" || typeof generatedAt !== "string" || !isRecord(components) || !isPiWebCapabilityArray(capabilities)) return false;
|
||||
return isPiWebRuntimeComponent(components["web"]) && isPiWebRuntimeComponent(components["sessiond"]);
|
||||
}
|
||||
|
||||
function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
|
||||
if (!isRecord(value)) return false;
|
||||
const component = value["component"];
|
||||
@@ -260,19 +251,6 @@ function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
|
||||
&& typeof value["available"] === "boolean";
|
||||
}
|
||||
|
||||
function isPiWebRuntimeComponent(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
const component = value["component"];
|
||||
return (component === "web" || component === "sessiond")
|
||||
&& typeof value["label"] === "string"
|
||||
&& typeof value["available"] === "boolean"
|
||||
&& isPiWebCapabilityArray(value["capabilities"]);
|
||||
}
|
||||
|
||||
function isPiWebCapabilityArray(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.every(isPiWebCapability);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PiPackageInfo } from "../shared/apiTypes.js";
|
||||
import type { PiPackageService } from "./piPackageService.js";
|
||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let service: PiPackageService;
|
||||
let serviceMocks: ReturnType<typeof fakePiPackageService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
serviceMocks = fakePiPackageService();
|
||||
service = serviceMocks.service;
|
||||
app = Fastify({ logger: false });
|
||||
registerPiPackageRoutes(app, service);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("registerPiPackageRoutes", () => {
|
||||
it("lists configured Pi packages", async () => {
|
||||
const response = await app.inject({ method: "GET", url: "/api/pi-packages" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }] });
|
||||
expect(serviceMocks.list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("registers package routes under a custom API prefix", async () => {
|
||||
const prefixedApp = Fastify({ logger: false });
|
||||
const prefixedMocks = fakePiPackageService();
|
||||
registerPiPackageRoutes(prefixedApp, prefixedMocks.service, "/api/machines/local");
|
||||
await prefixedApp.ready();
|
||||
|
||||
try {
|
||||
const response = await prefixedApp.inject({ method: "GET", url: "/api/machines/local/pi-packages" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }] });
|
||||
expect(prefixedMocks.list).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await prefixedApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("installs a trimmed Pi package source without accepting a scope", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: " npm:@acme/new-tools " } });
|
||||
const scopedResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools", scope: "project" } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
|
||||
expect(scopedResponse.statusCode).toBe(400);
|
||||
expect(scopedResponse.json()).toEqual({ error: "Pi package install scope is not supported; installs use Pi's default package location" });
|
||||
expect(serviceMocks.install).toHaveBeenCalledOnce();
|
||||
expect(serviceMocks.install).toHaveBeenCalledWith("npm:@acme/new-tools");
|
||||
});
|
||||
|
||||
it("removes from an explicitly listed package scope", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "../project-tools", scope: "project" } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ action: "remove", source: "../project-tools", scope: "project", removed: true });
|
||||
expect(serviceMocks.remove).toHaveBeenCalledWith("../project-tools", "project");
|
||||
});
|
||||
|
||||
it("updates all packages when source is omitted and one package when source is provided", async () => {
|
||||
const allResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update" });
|
||||
const oneResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: " npm:@acme/tools " } });
|
||||
|
||||
expect(allResponse.statusCode).toBe(200);
|
||||
expect(oneResponse.statusCode).toBe(200);
|
||||
expect(serviceMocks.update).toHaveBeenNthCalledWith(1);
|
||||
expect(serviceMocks.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
|
||||
});
|
||||
|
||||
it("returns stable 400 errors for invalid requests before calling the service", async () => {
|
||||
const missingSource = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: {} });
|
||||
const blankSource = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: " " } });
|
||||
const invalidScope = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "temporary" } });
|
||||
const invalidUpdate = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: "" } });
|
||||
|
||||
expect(missingSource.statusCode).toBe(400);
|
||||
expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
|
||||
expect(blankSource.statusCode).toBe(400);
|
||||
expect(invalidScope.statusCode).toBe(400);
|
||||
expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" });
|
||||
expect(invalidUpdate.statusCode).toBe(400);
|
||||
expect(serviceMocks.install).not.toHaveBeenCalled();
|
||||
expect(serviceMocks.remove).not.toHaveBeenCalled();
|
||||
expect(serviceMocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns stable 500 errors for package-manager failures", async () => {
|
||||
serviceMocks.install.mockRejectedValueOnce(new Error("install failed"));
|
||||
|
||||
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/fails" } });
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json()).toEqual({ error: "install failed" });
|
||||
});
|
||||
});
|
||||
|
||||
function fakePiPackageService() {
|
||||
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
|
||||
const list = vi.fn<PiPackageService["list"]>(() => Promise.resolve({ packages: [...packages] }));
|
||||
const install = vi.fn<PiPackageService["install"]>((source) => Promise.resolve({ action: "install", source, packages: [...packages] }));
|
||||
const remove = vi.fn<PiPackageService["remove"]>((source, scope = "user") => Promise.resolve({ action: "remove", source, scope, removed: true, packages: [...packages] }));
|
||||
const update = vi.fn<PiPackageService["update"]>((source) => Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages: [...packages] }));
|
||||
const service: PiPackageService = { list, install, remove, update };
|
||||
return { service, list, install, remove, update };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { PiPackageScope } from "../shared/apiTypes.js";
|
||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||
|
||||
class PiPackageRequestValidationError extends Error {}
|
||||
|
||||
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void {
|
||||
const routePrefix = normalizeRoutePrefix(prefix);
|
||||
|
||||
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
|
||||
try {
|
||||
return await service.list();
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/install`, async (request, reply) => {
|
||||
try {
|
||||
return await service.install(parseRequiredSourceRequest(request.body));
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/remove`, async (request, reply) => {
|
||||
try {
|
||||
const body = requireRequestObject(request.body);
|
||||
return await service.remove(parseRequiredSource(body["source"]), parseOptionalScope(body["scope"]));
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/update`, async (request, reply) => {
|
||||
try {
|
||||
const source = parseOptionalUpdateSource(request.body);
|
||||
return source === undefined ? await service.update() : await service.update(source);
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoutePrefix(prefix: string): string {
|
||||
const normalized = prefix.replace(/\/+$/u, "");
|
||||
return normalized === "" ? "/api" : normalized;
|
||||
}
|
||||
|
||||
function parseRequiredSourceRequest(body: unknown): string {
|
||||
const request = requireRequestObject(body);
|
||||
if (request["scope"] !== undefined || request["local"] !== undefined) {
|
||||
throw new PiPackageRequestValidationError("Pi package install scope is not supported; installs use Pi's default package location");
|
||||
}
|
||||
return parseRequiredSource(request["source"]);
|
||||
}
|
||||
|
||||
function parseRequiredSource(value: unknown): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new PiPackageRequestValidationError("Pi package source must be a non-empty string");
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parseOptionalUpdateSource(body: unknown): string | undefined {
|
||||
if (body === undefined) return undefined;
|
||||
const source = requireRequestObject(body)["source"];
|
||||
if (source === undefined) return undefined;
|
||||
return parseRequiredSource(source);
|
||||
}
|
||||
|
||||
function parseOptionalScope(value: unknown): PiPackageScope | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value !== "user" && value !== "project") throw new PiPackageRequestValidationError("Pi package scope must be \"user\" or \"project\"");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireRequestObject(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new PiPackageRequestValidationError("Pi package request body must be an object");
|
||||
return value;
|
||||
}
|
||||
|
||||
function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply {
|
||||
const status = error instanceof PiPackageRequestValidationError ? 400 : 500;
|
||||
return reply.code(status).send({ error: errorMessage(error) });
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PiPackageInfo } from "../shared/apiTypes.js";
|
||||
import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js";
|
||||
|
||||
function fakeManager(packages: PiPackageInfo[] = []) {
|
||||
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
|
||||
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(() => Promise.resolve());
|
||||
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
|
||||
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
|
||||
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update };
|
||||
return { manager, listConfiguredPackages, installAndPersist, removeAndPersist, update };
|
||||
}
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("DefaultPiPackageService", () => {
|
||||
it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => {
|
||||
const fake = fakeManager([
|
||||
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
|
||||
{ source: "../project-tools", scope: "project", filtered: true },
|
||||
]);
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
await expect(service.list()).resolves.toEqual({
|
||||
packages: [
|
||||
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
|
||||
{ source: "../project-tools", scope: "project", filtered: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("installs through the default Pi package-manager behavior without a local option", async () => {
|
||||
const fake = fakeManager([{ source: "npm:@acme/tools", scope: "user", filtered: false }]);
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
const response = await service.install("npm:@acme/tools");
|
||||
|
||||
expect(fake.installAndPersist).toHaveBeenCalledWith("npm:@acme/tools");
|
||||
expect(response).toEqual({ action: "install", source: "npm:@acme/tools", packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] });
|
||||
});
|
||||
|
||||
it("removes user packages by default and project packages only when the known scope is supplied", async () => {
|
||||
const fake = fakeManager();
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
await service.remove("npm:@acme/user-tools");
|
||||
await service.remove("../project-tools", "project");
|
||||
|
||||
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(1, "npm:@acme/user-tools");
|
||||
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(2, "../project-tools", { local: true });
|
||||
});
|
||||
|
||||
it("updates all configured packages or a single source", async () => {
|
||||
const fake = fakeManager();
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
await service.update();
|
||||
await service.update("npm:@acme/tools");
|
||||
|
||||
expect(fake.update).toHaveBeenNthCalledWith(1);
|
||||
expect(fake.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
|
||||
});
|
||||
|
||||
it("serializes package mutations in call order and lists after each mutation before starting the next", async () => {
|
||||
const firstMutation = deferred();
|
||||
const events: string[] = [];
|
||||
let packages: PiPackageInfo[] = [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }];
|
||||
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
|
||||
events.push(`list:${packages.map((configuredPackage) => configuredPackage.source).join(",")}`);
|
||||
return packages;
|
||||
});
|
||||
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
|
||||
events.push(`install:start:${source}`);
|
||||
await firstMutation.promise;
|
||||
packages = [{ source, scope: "user", filtered: false }];
|
||||
events.push(`install:finish:${source}`);
|
||||
});
|
||||
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>((source) => {
|
||||
events.push(`remove:start:${source}`);
|
||||
packages = [];
|
||||
events.push(`remove:finish:${source}`);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
|
||||
const flush = vi.fn<NonNullable<PiPackageManagerPort["flush"]>>(() => {
|
||||
events.push("flush");
|
||||
return Promise.resolve();
|
||||
});
|
||||
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update, flush };
|
||||
const service = new DefaultPiPackageService(manager);
|
||||
|
||||
const installPromise = service.install("npm:@acme/new-tools");
|
||||
const removePromise = service.remove("npm:@acme/new-tools");
|
||||
|
||||
await Promise.resolve();
|
||||
expect(installAndPersist).toHaveBeenCalledOnce();
|
||||
expect(removeAndPersist).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["install:start:npm:@acme/new-tools"]);
|
||||
|
||||
firstMutation.resolve();
|
||||
await expect(Promise.all([installPromise, removePromise])).resolves.toEqual([
|
||||
{ action: "install", source: "npm:@acme/new-tools", packages: [{ source: "npm:@acme/new-tools", scope: "user", filtered: false }] },
|
||||
{ action: "remove", source: "npm:@acme/new-tools", scope: "user", removed: true, packages: [] },
|
||||
]);
|
||||
expect(events).toEqual([
|
||||
"install:start:npm:@acme/new-tools",
|
||||
"install:finish:npm:@acme/new-tools",
|
||||
"flush",
|
||||
"list:npm:@acme/new-tools",
|
||||
"remove:start:npm:@acme/new-tools",
|
||||
"remove:finish:npm:@acme/new-tools",
|
||||
"flush",
|
||||
"list:",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not queue list requests behind an in-flight mutation", async () => {
|
||||
const mutation = deferred();
|
||||
const events: string[] = [];
|
||||
let packages: PiPackageInfo[] = [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }];
|
||||
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
|
||||
events.push("list");
|
||||
return packages;
|
||||
});
|
||||
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
|
||||
events.push(`install:start:${source}`);
|
||||
await mutation.promise;
|
||||
packages = [{ source, scope: "user", filtered: false }];
|
||||
events.push(`install:finish:${source}`);
|
||||
});
|
||||
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
|
||||
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
|
||||
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update };
|
||||
const service = new DefaultPiPackageService(manager);
|
||||
|
||||
const installPromise = service.install("npm:@acme/new-tools");
|
||||
await Promise.resolve();
|
||||
|
||||
await expect(service.list()).resolves.toEqual({ packages: [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }] });
|
||||
expect(events).toEqual(["install:start:npm:@acme/new-tools", "list"]);
|
||||
|
||||
mutation.resolve();
|
||||
await expect(installPromise).resolves.toEqual({
|
||||
action: "install",
|
||||
source: "npm:@acme/new-tools",
|
||||
packages: [{ source: "npm:@acme/new-tools", scope: "user", filtered: false }],
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the mutation queue after a mutation fails", async () => {
|
||||
const failingMutation = deferred();
|
||||
const events: string[] = [];
|
||||
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false }];
|
||||
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
|
||||
events.push("list");
|
||||
return packages;
|
||||
});
|
||||
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
|
||||
events.push(`install:start:${source}`);
|
||||
await failingMutation.promise;
|
||||
});
|
||||
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
|
||||
const update = vi.fn<PiPackageManagerPort["update"]>((source) => {
|
||||
events.push(`update:start:${source ?? "all"}`);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const flush = vi.fn<NonNullable<PiPackageManagerPort["flush"]>>(() => {
|
||||
events.push("flush");
|
||||
return Promise.resolve();
|
||||
});
|
||||
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update, flush };
|
||||
const service = new DefaultPiPackageService(manager);
|
||||
|
||||
const installPromise = service.install("npm:@acme/fails");
|
||||
const updatePromise = service.update("npm:@acme/tools");
|
||||
|
||||
await Promise.resolve();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["install:start:npm:@acme/fails"]);
|
||||
|
||||
failingMutation.reject(new Error("install failed"));
|
||||
await expect(installPromise).rejects.toThrow("install failed");
|
||||
await expect(updatePromise).resolves.toEqual({
|
||||
action: "update",
|
||||
source: "npm:@acme/tools",
|
||||
packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }],
|
||||
});
|
||||
expect(events).toEqual([
|
||||
"install:start:npm:@acme/fails",
|
||||
"update:start:npm:@acme/tools",
|
||||
"flush",
|
||||
"list",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js";
|
||||
|
||||
export interface PiPackageManagerPort {
|
||||
listConfiguredPackages(): PiPackageInfo[];
|
||||
installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;
|
||||
removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;
|
||||
update(source?: string): Promise<void>;
|
||||
flush?(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PiPackageService {
|
||||
list(): Promise<PiPackagesResponse>;
|
||||
install(source: string): Promise<PiPackageMutationResponse>;
|
||||
remove(source: string, scope?: PiPackageScope): Promise<PiPackageMutationResponse>;
|
||||
update(source?: string): Promise<PiPackageMutationResponse>;
|
||||
}
|
||||
|
||||
export class DefaultPiPackageService implements PiPackageService {
|
||||
private mutationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(private readonly manager: PiPackageManagerPort) {}
|
||||
|
||||
list(): Promise<PiPackagesResponse> {
|
||||
return Promise.resolve({ packages: this.listPackages() });
|
||||
}
|
||||
|
||||
install(source: string): Promise<PiPackageMutationResponse> {
|
||||
return this.enqueueMutation(async () => {
|
||||
await this.manager.installAndPersist(source);
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("install", { source });
|
||||
});
|
||||
}
|
||||
|
||||
remove(source: string, scope: PiPackageScope = "user"): Promise<PiPackageMutationResponse> {
|
||||
return this.enqueueMutation(async () => {
|
||||
const removed = scope === "project"
|
||||
? await this.manager.removeAndPersist(source, { local: true })
|
||||
: await this.manager.removeAndPersist(source);
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("remove", { source, scope, removed });
|
||||
});
|
||||
}
|
||||
|
||||
update(source?: string): Promise<PiPackageMutationResponse> {
|
||||
return this.enqueueMutation(async () => {
|
||||
if (source === undefined) {
|
||||
await this.manager.update();
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("update", {});
|
||||
}
|
||||
|
||||
await this.manager.update(source);
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("update", { source });
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const queuedMutation = this.mutationQueue.then(operation);
|
||||
this.mutationQueue = queuedMutation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return queuedMutation;
|
||||
}
|
||||
|
||||
private mutationResponse(action: PiPackageMutationAction, metadata: Omit<PiPackageMutationResponse, "action" | "packages">): PiPackageMutationResponse {
|
||||
return { action, ...metadata, packages: this.listPackages() };
|
||||
}
|
||||
|
||||
private async flushSettings(): Promise<void> {
|
||||
await this.manager.flush?.();
|
||||
}
|
||||
|
||||
private listPackages(): PiPackageInfo[] {
|
||||
return this.manager.listConfiguredPackages().map((configuredPackage) => ({
|
||||
source: configuredPackage.source,
|
||||
scope: configuredPackage.scope,
|
||||
filtered: configuredPackage.filtered,
|
||||
...(configuredPackage.installedPath === undefined ? {} : { installedPath: configuredPackage.installedPath }),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultPiPackageService(cwd = process.cwd(), agentDir = getAgentDir()): PiPackageService {
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
return new DefaultPiPackageService({
|
||||
listConfiguredPackages: () => manager.listConfiguredPackages(),
|
||||
installAndPersist: (source, options) => manager.installAndPersist(source, options),
|
||||
removeAndPersist: (source, options) => manager.removeAndPersist(source, options),
|
||||
update: (source) => manager.update(source),
|
||||
flush: () => settingsManager.flush(),
|
||||
});
|
||||
}
|
||||
@@ -89,6 +89,29 @@ describe("PiWebPluginService", () => {
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
|
||||
});
|
||||
|
||||
it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
|
||||
const agentDir = join(tempDir, "agent");
|
||||
const firstPackageDir = join(tempDir, "first-package");
|
||||
const secondPackageDir = join(tempDir, "second-package");
|
||||
await writePlugin(firstPackageDir, {
|
||||
packageJson: { piWeb: { plugins: [{ id: "first", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
await writePlugin(secondPackageDir, {
|
||||
packageJson: { piWeb: { plugins: [{ id: "second", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
await writePiPackageSettings(agentDir, [firstPackageDir]);
|
||||
const service = new PiWebPluginService({ roots: [], cwd: tempDir, agentDir });
|
||||
|
||||
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "first" }] });
|
||||
|
||||
await writePiPackageSettings(agentDir, [secondPackageDir]);
|
||||
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("discovers source checkout plugin packages without symlinks", async () => {
|
||||
await mkdir(join(tempDir, "src", "server"), { recursive: true });
|
||||
await writeFile(join(tempDir, "src", "server", "index.ts"), "export {};\n");
|
||||
@@ -212,6 +235,11 @@ describe("PiWebPluginService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function writePiPackageSettings(agentDir: string, packages: string[]): Promise<void> {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writePlugin(root: string, options: { packageJson: unknown; files: Record<string, string> }): Promise<void> {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(join(root, "package.json"), `${JSON.stringify(options.packageJson, null, 2)}\n`);
|
||||
|
||||
@@ -69,22 +69,22 @@ interface PiWebPluginEntry {
|
||||
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
||||
|
||||
export class DefaultPiPackageProvider implements PiPackageProvider {
|
||||
private readonly packageManager: DefaultPackageManager;
|
||||
|
||||
constructor(cwd = process.cwd(), agentDir = getAgentDir()) {
|
||||
this.packageManager = new DefaultPackageManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager: SettingsManager.create(cwd, agentDir),
|
||||
});
|
||||
}
|
||||
constructor(private readonly cwd = process.cwd(), private readonly agentDir = getAgentDir()) {}
|
||||
|
||||
listPackages(): ConfiguredPiPackage[] {
|
||||
return this.packageManager.listConfiguredPackages();
|
||||
return this.createPackageManager().listConfiguredPackages();
|
||||
}
|
||||
|
||||
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
|
||||
return this.packageManager.getInstalledPath(source, scope);
|
||||
return this.createPackageManager().getInstalledPath(source, scope);
|
||||
}
|
||||
|
||||
private createPackageManager(): DefaultPackageManager {
|
||||
return new DefaultPackageManager({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
settingsManager: SettingsManager.create(this.cwd, this.agentDir),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
import type { PiWebComponentStatus } from "../shared/apiTypes.js";
|
||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||
|
||||
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
|
||||
const originalHome = process.env["HOME"];
|
||||
@@ -50,6 +51,24 @@ describe("PI WEB status", () => {
|
||||
expect(status).not.toHaveProperty("release");
|
||||
});
|
||||
|
||||
it("reports web-only capabilities from the web runtime", async () => {
|
||||
const daemon = daemonWithComponent({
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
runtimeVersion: "1.202605.7",
|
||||
installedVersion: "1.202605.8",
|
||||
stale: true,
|
||||
available: true,
|
||||
});
|
||||
|
||||
const runtime = await getPiWebRuntime(daemon);
|
||||
|
||||
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
});
|
||||
|
||||
it("reports stale session daemon versions as messages", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
|
||||
let workspace: string;
|
||||
let externalDirectories: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-"));
|
||||
externalDirectories = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await Promise.all([
|
||||
rm(workspace, { recursive: true, force: true }),
|
||||
...externalDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
]);
|
||||
});
|
||||
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
@@ -43,6 +48,59 @@ describe("saveAttachmentsToWorkspace", () => {
|
||||
expect(written.equals(pngBytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("saves generic files with sanitized original filenames", async () => {
|
||||
const pdfBytes = Buffer.from("PDF bytes");
|
||||
const saved = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[
|
||||
{ kind: "file", mimeType: "application/pdf", data: pdfBytes.toString("base64"), name: "../Quarterly Report (final).pdf" },
|
||||
{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" },
|
||||
],
|
||||
{ now: () => new Date("2026-06-13T12:05:01.123Z") },
|
||||
);
|
||||
|
||||
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true);
|
||||
expect(saved[0]?.path.endsWith("-1-Quarterly-Report-final.pdf")).toBe(true);
|
||||
expect(saved[0]).toMatchObject({ mimeType: "application/pdf", size: pdfBytes.byteLength });
|
||||
expect(saved[1]?.path.endsWith("-2-empty.txt")).toBe(true);
|
||||
expect(saved[1]).toMatchObject({ mimeType: "text/plain", size: 0 });
|
||||
|
||||
expect((await readFile(join(workspace, saved[0]?.path ?? ""))).equals(pdfBytes)).toBe(true);
|
||||
expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not overwrite an existing attachment name", async () => {
|
||||
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
|
||||
const first = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
|
||||
{ now: fixedNow },
|
||||
);
|
||||
const second = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "REVG", name: "note.txt" }],
|
||||
{ now: fixedNow },
|
||||
);
|
||||
|
||||
expect(second[0]?.path).not.toBe(first[0]?.path);
|
||||
expect(second[0]?.path.endsWith("-1-note-2.txt")).toBe(true);
|
||||
expect((await readFile(join(workspace, first[0]?.path ?? ""))).toString()).toBe("ABC");
|
||||
expect((await readFile(join(workspace, second[0]?.path ?? ""))).toString()).toBe("DEF");
|
||||
});
|
||||
|
||||
it("rejects unsafe custom folders", async () => {
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
|
||||
{ folder: "/tmp/uploads" },
|
||||
)).rejects.toThrow(/Absolute paths/);
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
|
||||
{ folder: "../uploads" },
|
||||
)).rejects.toThrow(/Path traversal/);
|
||||
});
|
||||
|
||||
it("honors a custom folder", async () => {
|
||||
const saved = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
@@ -52,6 +110,19 @@ describe("saveAttachmentsToWorkspace", () => {
|
||||
expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects attachment folders that resolve outside the workspace", async () => {
|
||||
const outside = await mkdtemp(join(tmpdir(), "pi-web-attachments-outside-"));
|
||||
externalDirectories.push(outside);
|
||||
await mkdir(join(workspace, ".pi-web"));
|
||||
await symlink(outside, join(workspace, ".pi-web", "attachments"), "dir");
|
||||
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
|
||||
)).rejects.toThrow(/Path escapes workspace/);
|
||||
await expect(readdir(outside)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty for no attachments", async () => {
|
||||
expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, realpath, writeFile } from "node:fs/promises";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import { formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent";
|
||||
import type { PromptAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
import type { PromptAttachment, PromptImageAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
import { extensionForImageMimeType } from "../../shared/promptAttachments.js";
|
||||
import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
||||
import { ensureInside, isNodeErrorWithCode, resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
||||
|
||||
/**
|
||||
* Default workspace-relative folder used when saving pasted/dropped
|
||||
@@ -26,7 +26,7 @@ export interface InlineImage {
|
||||
* (2000x2000, ~4.5MB base64). Images that cannot be resized below the limit
|
||||
* are dropped, matching pi's `[Image omitted]` behaviour.
|
||||
*/
|
||||
export async function attachmentsToInlineImages(attachments: PromptAttachment[]): Promise<InlineImage[]> {
|
||||
export async function attachmentsToInlineImages(attachments: PromptImageAttachment[]): Promise<InlineImage[]> {
|
||||
const results: InlineImage[] = [];
|
||||
for (const attachment of attachments) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
@@ -57,25 +57,83 @@ export async function saveAttachmentsToWorkspace(
|
||||
attachments: PromptAttachment[],
|
||||
options: SaveAttachmentsOptions = {},
|
||||
): Promise<SavedPromptAttachment[]> {
|
||||
const folder = normalizeFolder(options.folder ?? DEFAULT_ATTACHMENT_FOLDER);
|
||||
const folder = options.folder ?? DEFAULT_ATTACHMENT_FOLDER;
|
||||
const now = options.now ?? (() => new Date());
|
||||
const { target: folderTarget } = await resolveParentInsideWorkspace(cwd, folder);
|
||||
await mkdir(folderTarget, { recursive: true });
|
||||
const { root, target: requestedFolderTarget, relativePath: normalizedFolder } = await resolveParentInsideWorkspace(cwd, folder);
|
||||
await mkdir(requestedFolderTarget, { recursive: true });
|
||||
const folderTarget = await realpath(requestedFolderTarget);
|
||||
ensureInside(root, folderTarget);
|
||||
|
||||
const stamp = timestamp(now());
|
||||
const saved: SavedPromptAttachment[] = [];
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
const filename = `attachment-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
const relativePath = `${folder}/${filename}`;
|
||||
await writeFile(join(folderTarget, filename), bytes);
|
||||
const filename = await writeUniqueAttachmentFile(folderTarget, attachmentFilename(attachment, stamp, index), bytes);
|
||||
const relativePath = normalizedFolder === "" ? filename : `${normalizedFolder}/${filename}`;
|
||||
saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength });
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
function normalizeFolder(folder: string): string {
|
||||
return folder.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").join("/");
|
||||
async function writeUniqueAttachmentFile(folderTarget: string, filename: string, bytes: Buffer): Promise<string> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const candidate = attempt === 0 ? filename : addCollisionSuffix(filename, attempt + 1);
|
||||
try {
|
||||
await writeFile(join(folderTarget, candidate), bytes, { flag: "wx" });
|
||||
return candidate;
|
||||
} catch (error: unknown) {
|
||||
if (!isNodeErrorWithCode(error, "EEXIST")) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Unable to choose a unique attachment filename");
|
||||
}
|
||||
|
||||
function addCollisionSuffix(filename: string, suffix: number): string {
|
||||
const extension = extname(filename);
|
||||
const stem = filename.slice(0, filename.length - extension.length);
|
||||
return `${stem}-${String(suffix)}${extension}`;
|
||||
}
|
||||
|
||||
function attachmentFilename(attachment: PromptAttachment, stamp: string, index: number): string {
|
||||
const originalName = sanitizeOriginalFilename(attachment.name) ?? fallbackAttachmentFilename(attachment);
|
||||
return `attachment-${stamp}-${String(index + 1)}-${originalName}`;
|
||||
}
|
||||
|
||||
function fallbackAttachmentFilename(attachment: PromptAttachment): string {
|
||||
if (attachment.kind === "image") return `image.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
return "file.bin";
|
||||
}
|
||||
|
||||
const MAX_ORIGINAL_FILENAME_LENGTH = 96;
|
||||
|
||||
function sanitizeOriginalFilename(name: string | undefined): string | undefined {
|
||||
const trimmed = name?.trim();
|
||||
if (trimmed === undefined || trimmed === "") return undefined;
|
||||
const leaf = basename(trimmed.replace(/\\/g, "/"));
|
||||
const sanitized = stripControlCharacters(leaf)
|
||||
.normalize("NFKC")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/-+\./g, ".")
|
||||
.replace(/^\.+/, "")
|
||||
.replace(/[.-]+$/, "");
|
||||
if (sanitized === "") return undefined;
|
||||
return truncateFilename(sanitized, MAX_ORIGINAL_FILENAME_LENGTH);
|
||||
}
|
||||
|
||||
function stripControlCharacters(value: string): string {
|
||||
return Array.from(value).filter((character) => {
|
||||
const codePoint = character.codePointAt(0);
|
||||
return codePoint !== undefined && codePoint > 0x1f && codePoint !== 0x7f;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function truncateFilename(filename: string, maxLength: number): string {
|
||||
if (filename.length <= maxLength) return filename;
|
||||
const extension = extname(filename);
|
||||
if (extension.length >= maxLength) return filename.slice(0, maxLength);
|
||||
const stem = filename.slice(0, filename.length - extension.length);
|
||||
return `${stem.slice(0, maxLength - extension.length)}${extension}`;
|
||||
}
|
||||
|
||||
function timestamp(date: Date): string {
|
||||
|
||||
@@ -20,7 +20,7 @@ export const BUILTIN_COMMANDS: ClientCommand[] = [
|
||||
{ name: "new", description: "Start a new session", source: "builtin" },
|
||||
{ name: "compact", description: "Manually compact session context", source: "builtin" },
|
||||
{ name: "resume", description: "Resume a different session", source: "builtin" },
|
||||
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes", source: "builtin" },
|
||||
{ name: "reload", description: "Reload Pi runtime resources for this session", source: "builtin" },
|
||||
{ name: "quit", description: "Quit pi", source: "builtin" },
|
||||
];
|
||||
|
||||
|
||||
@@ -72,6 +72,16 @@ describe("Pi session manager gateway", () => {
|
||||
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })]));
|
||||
});
|
||||
|
||||
it("includes an absolute env-configured session directory in global listing", async () => {
|
||||
const envSessionDir = join(tempDir, "env-sessions");
|
||||
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd);
|
||||
await writeSessionFile(envSessionDir, "env-session", cwd);
|
||||
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } });
|
||||
|
||||
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
|
||||
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })]));
|
||||
});
|
||||
|
||||
it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => {
|
||||
const sharedSessionDir = join(tempDir, "shared-sessions");
|
||||
const otherCwd = join(tempDir, "other-workspace");
|
||||
|
||||
@@ -34,6 +34,13 @@ export class SessionDirResolver {
|
||||
return defaultPiSessionsRoot(this.agentDir);
|
||||
}
|
||||
|
||||
globalEnvSessionDir(): string | undefined {
|
||||
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir === undefined || envSessionDir === "") return undefined;
|
||||
const expanded = expandTildePath(envSessionDir);
|
||||
return isAbsolute(expanded) ? expanded : undefined;
|
||||
}
|
||||
|
||||
resolve(cwd: string): SessionDirResolution {
|
||||
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir !== undefined && envSessionDir !== "") {
|
||||
@@ -68,8 +75,13 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
|
||||
return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession });
|
||||
}
|
||||
|
||||
listAll(): Promise<PiSessionListEntry[]> {
|
||||
return listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot());
|
||||
async listAll(): Promise<PiSessionListEntry[]> {
|
||||
const envSessionDir = this.resolver.globalEnvSessionDir();
|
||||
const [defaultSessions, envSessions] = await Promise.all([
|
||||
listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot()),
|
||||
envSessionDir === undefined ? Promise.resolve([]) : listSessionsInDir(envSessionDir),
|
||||
]);
|
||||
return uniqueSessionsByPath([...defaultSessions, ...envSessions]);
|
||||
}
|
||||
|
||||
open(path: string): PiSessionManager {
|
||||
@@ -106,6 +118,12 @@ export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cw
|
||||
return sessions.filter((session) => session.cwd !== "" && cwdPathsEqual(session.cwd, cwd));
|
||||
}
|
||||
|
||||
function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] {
|
||||
const byPath = new Map<string, PiSessionListEntry>();
|
||||
for (const session of sessions) byPath.set(session.path, session);
|
||||
return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
}
|
||||
|
||||
export function defaultPiSessionsRoot(agentDir = getAgentDir()): string {
|
||||
return join(agentDir, "sessions");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||
@@ -52,12 +54,18 @@ function sessionRef(id: string, cwd = "/workspace") {
|
||||
return { id, cwd };
|
||||
}
|
||||
|
||||
function testModel(): NonNullable<PiAgentSession["model"]> {
|
||||
const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find("anthropic", "claude-3-5-sonnet-20241022");
|
||||
if (model === undefined) throw new Error("test model not found");
|
||||
return model;
|
||||
}
|
||||
|
||||
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
|
||||
const promptCalls: { text: string; options: unknown }[] = [];
|
||||
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
|
||||
const bindExtensionCalls: unknown[] = [];
|
||||
const listeners: ((event: unknown) => void)[] = [];
|
||||
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, sendCustomMessage: customMessageCalls };
|
||||
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls };
|
||||
const session: TestSession = {
|
||||
sessionId,
|
||||
sessionFile: `/tmp/${sessionId}.jsonl`,
|
||||
@@ -88,6 +96,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
|
||||
},
|
||||
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
|
||||
getContextUsage: () => undefined,
|
||||
reload: () => {
|
||||
calls.reload += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
prompt: (text: string, options: unknown) => {
|
||||
calls.prompt.push({ text, options });
|
||||
return Promise.resolve();
|
||||
@@ -115,6 +127,7 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
|
||||
setSessionName: (name: string) => { session.sessionName = name; },
|
||||
compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }),
|
||||
getUserMessagesForForking: () => [],
|
||||
agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } },
|
||||
...patch,
|
||||
};
|
||||
const runtime: PiSessionRuntime = {
|
||||
@@ -156,6 +169,23 @@ function emptyArchiveStore(): NonNullable<PiSessionServiceDependencies["archiveS
|
||||
}
|
||||
|
||||
describe("PiSessionService", () => {
|
||||
it("exposes the session's agent.streamFn for one-off model calls", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const streamFn = vi.fn();
|
||||
const fake = fakeRuntime("stream-session", { agent: { streamFn } });
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
expect(fake.session.agent.streamFn).toBe(streamFn);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("starts sessions through an injected runtime creator", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime();
|
||||
@@ -185,6 +215,35 @@ describe("PiSessionService", () => {
|
||||
expect(fake.calls.dispose).toBe(1);
|
||||
});
|
||||
|
||||
it("reports persistence from actual session-file existence for fresh active sessions", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-web-persisted-"));
|
||||
const sessionFile = join(dir, "new-session.jsonl");
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("new-session", { sessionFile });
|
||||
let service: PiSessionService | undefined;
|
||||
try {
|
||||
service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const session = await service.start("/workspace");
|
||||
const createdEvent = hub.globalEvents.find((event) => event.type === "session.created");
|
||||
|
||||
expect(session).toMatchObject({ id: "new-session", path: sessionFile, persisted: false });
|
||||
expect(createdEvent).toMatchObject({ type: "session.created", session: { id: "new-session", persisted: false } });
|
||||
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: false });
|
||||
|
||||
await writeFile(sessionFile, '{"type":"session","id":"new-session"}\n', "utf8");
|
||||
|
||||
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: true });
|
||||
} finally {
|
||||
await service?.dispose();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("opens legacy id-only lookups from the default session store gateway", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("legacy-session");
|
||||
@@ -346,7 +405,7 @@ describe("PiSessionService", () => {
|
||||
|
||||
const sessions = await service.list("/workspace");
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0]).toMatchObject({ id: "active" });
|
||||
expect(sessions[0]).toMatchObject({ id: "active", persisted: true });
|
||||
expect(sessions[0]?.archived).toBeUndefined();
|
||||
expect(sessions[1]).toMatchObject({ id: "archived", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" });
|
||||
|
||||
@@ -446,6 +505,315 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("bulk archives inactive sessions by cwd without opening runtimes", async () => {
|
||||
const recordsByCwd = new Map([
|
||||
["/one", [sessionRecord("a", "/one"), sessionRecord("b", "/one")]],
|
||||
["/two", [sessionRecord("c", "/two")]],
|
||||
]);
|
||||
const listCalls: string[] = [];
|
||||
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
|
||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
|
||||
archiveMany,
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd) => {
|
||||
listCalls.push(cwd);
|
||||
return Promise.resolve(recordsByCwd.get(cwd) ?? []);
|
||||
},
|
||||
open,
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const result = await service.archiveMany([{ id: "a", cwd: "/one" }, { id: "b", cwd: "/one" }, { id: "c", cwd: "/two" }]);
|
||||
|
||||
expect(result).toMatchObject({ archived: true, archivedSessionIds: ["a", "b", "c"], failures: [] });
|
||||
expect(listCalls).toEqual(["/one", "/two"]);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(archiveMany).toHaveBeenCalledTimes(1);
|
||||
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("bulk archive reports per-session failures without aborting other archives", async () => {
|
||||
const busy = fakeRuntime("busy", { isStreaming: true });
|
||||
let createCalls = 0;
|
||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: () => {
|
||||
createCalls += 1;
|
||||
return Promise.resolve(busy.runtime);
|
||||
},
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
|
||||
archiveMany,
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([sessionRecord("busy"), sessionRecord("ok")]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("busy"));
|
||||
const result = await service.archiveMany([{ id: "busy", cwd: "/workspace" }, { id: "ok", cwd: "/workspace" }, { id: "missing", cwd: "/workspace" }]);
|
||||
|
||||
expect(createCalls).toBe(1);
|
||||
expect(busy.calls.abort).toBe(0);
|
||||
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["ok"]);
|
||||
expect(result.archivedSessionIds).toEqual(["ok"]);
|
||||
expect(result.failures).toEqual([
|
||||
{ sessionId: "busy", error: "Stop current session activity before archiving" },
|
||||
{ sessionId: "missing", error: "Session not found" },
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("bulk deletes only archived sessions and skips busy active archived runtimes", async () => {
|
||||
const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" };
|
||||
const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" };
|
||||
const busy = fakeRuntime("busy-archived", { isStreaming: true });
|
||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(busy.runtime),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([busyRecord, idleRecord]),
|
||||
get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined),
|
||||
archive: () => { throw new Error("archive should not be called for records that already have archive files"); },
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: () => Promise.resolve(),
|
||||
deleteArchivedMany,
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([sessionRecord("unarchived")]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("busy-archived"));
|
||||
const result = await service.deleteArchivedMany([{ id: "busy-archived", cwd: "/workspace" }, { id: "idle-archived", cwd: "/workspace" }, { id: "unarchived", cwd: "/workspace" }]);
|
||||
|
||||
expect(busy.calls.abort).toBe(0);
|
||||
expect(deleteArchivedMany).toHaveBeenCalledWith(["idle-archived"]);
|
||||
expect(result.deletedSessionIds).toEqual(["idle-archived"]);
|
||||
expect(result.failures).toEqual([
|
||||
{ sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" },
|
||||
{ sessionId: "unarchived", error: "Archived session not found" },
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("bulk delete moves legacy archived records with one workspace scan before deleting", async () => {
|
||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
|
||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||
const listCalls: string[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([
|
||||
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
|
||||
{ sessionId: "legacy-b", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
|
||||
{ sessionId: "moved", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/moved.jsonl" },
|
||||
]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
|
||||
archiveMany,
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: () => Promise.resolve(),
|
||||
deleteArchivedMany,
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd) => {
|
||||
listCalls.push(cwd);
|
||||
return Promise.resolve([sessionRecord("legacy-a"), sessionRecord("legacy-b"), sessionRecord("unarchived")]);
|
||||
},
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const result = await service.deleteArchivedMany([{ id: "legacy-a", cwd: "/workspace" }, { id: "legacy-b", cwd: "/workspace" }, { id: "moved", cwd: "/workspace" }]);
|
||||
|
||||
expect(listCalls).toEqual(["/workspace"]);
|
||||
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]);
|
||||
expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b", "moved"]);
|
||||
expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b", "moved"]);
|
||||
expect(result.failures).toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("previews session cleanup without mutating and executes a recomputed plan", async () => {
|
||||
const archivedInputs: string[] = [];
|
||||
const deletedSessionIds: string[] = [];
|
||||
let listAllCalls = 0;
|
||||
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
|
||||
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([archived, otherArchived]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
|
||||
archiveMany: (inputs) => {
|
||||
archivedInputs.push(...inputs.map((input) => input.sessionId));
|
||||
return Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" })));
|
||||
},
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
|
||||
deleteArchivedMany: (sessionIds) => {
|
||||
deletedSessionIds.push(...sessionIds);
|
||||
return Promise.resolve([...sessionIds]);
|
||||
},
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([]),
|
||||
listAll: () => {
|
||||
listAllCalls += 1;
|
||||
return Promise.resolve([
|
||||
listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"),
|
||||
listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"),
|
||||
]);
|
||||
},
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
|
||||
expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
|
||||
expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]);
|
||||
expect(archivedInputs).toEqual([]);
|
||||
expect(deletedSessionIds).toEqual([]);
|
||||
|
||||
const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
|
||||
expect(result.archivedSessionIds).toEqual(["execute-only"]);
|
||||
expect(result.deletedSessionIds).toEqual(["archived-old"]);
|
||||
expect(archivedInputs).toEqual(["execute-only"]);
|
||||
expect(deletedSessionIds).toEqual(["archived-old"]);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("moves legacy cleanup delete records with one workspace scan before batch deleting", async () => {
|
||||
const listCalls: string[] = [];
|
||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
|
||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([
|
||||
{ sessionId: "legacy-a", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" },
|
||||
{ sessionId: "legacy-b", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" },
|
||||
]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
|
||||
archiveMany,
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
|
||||
deleteArchivedMany,
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd) => {
|
||||
listCalls.push(cwd);
|
||||
return Promise.resolve([sessionRecord("legacy-a", cwd), sessionRecord("legacy-b", cwd)]);
|
||||
},
|
||||
listAll: () => Promise.resolve([]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const result = await service.cleanup({ thresholds: { deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
|
||||
|
||||
expect(listCalls).toEqual(["/old-project"]);
|
||||
expect(archiveMany).toHaveBeenCalledTimes(1);
|
||||
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]);
|
||||
expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b"]);
|
||||
expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b"]);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("skips busy active sessions during cleanup execution", async () => {
|
||||
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
|
||||
const archivedInputs: string[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: (input) => {
|
||||
archivedInputs.push(input.sessionId);
|
||||
return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" });
|
||||
},
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager("/old-project"),
|
||||
list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
|
||||
listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
|
||||
open: () => fakeSessionManager("/old-project"),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("busy-open");
|
||||
const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } });
|
||||
|
||||
expect(result.archivedSessionIds).toEqual([]);
|
||||
expect(result.skippedBusySessionIds).toEqual(["busy-open"]);
|
||||
expect(archivedInputs).toEqual([]);
|
||||
expect(fake.calls.abort).toBe(0);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("runs /reload by refreshing the active runtime resources in place", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("runtime-reload-session");
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({
|
||||
type: "done",
|
||||
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
|
||||
});
|
||||
|
||||
expect(fake.calls.reload).toBe(1);
|
||||
expect(fake.calls.abort).toBe(0);
|
||||
expect(fake.calls.dispose).toBe(0);
|
||||
expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true);
|
||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reloads a session by closing the active runtime and re-opening it from disk", async () => {
|
||||
const first = fakeRuntime("reload-session");
|
||||
const second = fakeRuntime("reload-session");
|
||||
@@ -601,6 +969,42 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("generates a session name for the first prompt via the session's agent.streamFn", async () => {
|
||||
const model = testModel();
|
||||
const streamCalls: unknown[] = [];
|
||||
const streamFn: StreamFn = (streamModel, context, options) => {
|
||||
streamCalls.push({ streamModel, context, options });
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Fix login bug" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: model.id,
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
stream.end(message);
|
||||
return stream;
|
||||
};
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("name-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt(sessionRef("name-session"), "Please fix the login bug");
|
||||
await vi.waitFor(() => { expect(fake.session.sessionName).toBe("Fix login bug"); });
|
||||
|
||||
expect(streamCalls).toHaveLength(1);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "session.name" && event.name === "Fix login bug")).toBe(true);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("includes queued message details in session status", async () => {
|
||||
const fake = fakeRuntime("status-session", {
|
||||
messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }],
|
||||
@@ -820,6 +1224,28 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("uses the dispatching session's model as the spawned session's initial model", async () => {
|
||||
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
|
||||
const model = testModel();
|
||||
let initialModel: PiAgentSession["model"];
|
||||
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||
await Promise.resolve();
|
||||
initialModel = options.initialModel;
|
||||
return fake.runtime;
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model });
|
||||
|
||||
expect(initialModel).toBe(model);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects an out-of-project target without starting a session", async () => {
|
||||
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
|
||||
|
||||
@@ -901,6 +1327,35 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("uses the parent session's model as the tracked child's initial model", async () => {
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
|
||||
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
|
||||
const model = testModel();
|
||||
const initialModels: PiAgentSession["model"][] = [];
|
||||
const runtimes = [parent.runtime, child.runtime];
|
||||
let index = 0;
|
||||
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||
await Promise.resolve();
|
||||
initialModels.push(options.initialModel);
|
||||
const runtime = runtimes[index] ?? child.runtime;
|
||||
index += 1;
|
||||
return runtime;
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
archiveStore: emptyArchiveStore(),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model });
|
||||
|
||||
expect(initialModels).toEqual([undefined, model]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("persists tracked child links in the parent and child sessions", async () => {
|
||||
const parentPersisted: { customType: string; data?: unknown }[] = [];
|
||||
const childPersisted: { customType: string; data?: unknown }[] = [];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { open, readFile, writeFile } from "node:fs/promises";
|
||||
import type { Api, ImageContent, Model } from "@earendil-works/pi-ai";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSessionFromServices,
|
||||
@@ -13,7 +15,7 @@ import {
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
type EditToolDetails,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||
@@ -27,13 +29,14 @@ import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
|
||||
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
||||
import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js";
|
||||
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
||||
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||
import { buildTranscriptView } from "./subsessionTranscript.js";
|
||||
import { planSessionCleanup, summarizeSessionCleanupExecution, type NormalizedSessionCleanupRequest, type SessionCleanupPlan } from "./sessionCleanup.js";
|
||||
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
|
||||
|
||||
/**
|
||||
@@ -101,6 +104,11 @@ interface PersistedChildSubsessionLink {
|
||||
spawnedSessionId: string;
|
||||
}
|
||||
|
||||
interface StartSessionOptions {
|
||||
parentSession?: string;
|
||||
initialModel?: AgentModel;
|
||||
}
|
||||
|
||||
function requirePromptText(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("Prompt text is required");
|
||||
return value;
|
||||
@@ -112,7 +120,11 @@ function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefi
|
||||
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & { deleteArchived?: (sessionId: string) => Promise<void> };
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & {
|
||||
archiveMany?: (sessions: readonly ArchiveSessionInput[]) => Promise<ArchivedSessionRecord[]>;
|
||||
deleteArchived?: (sessionId: string) => Promise<void>;
|
||||
deleteArchivedMany?: (sessionIds: readonly string[]) => Promise<string[]>;
|
||||
};
|
||||
|
||||
export type PiSessionRef = ClientSessionRef;
|
||||
|
||||
@@ -137,7 +149,20 @@ interface WorkspaceArchiveCandidate extends SessionArchiveTreeCandidate {
|
||||
activeSession?: PiAgentSession;
|
||||
}
|
||||
|
||||
type AgentModel = Model<Api>;
|
||||
interface BulkSessionLookupContext {
|
||||
sessionsByCwd: Map<string, PiSessionListEntry[]>;
|
||||
allSessions?: readonly PiSessionListEntry[];
|
||||
}
|
||||
|
||||
interface BulkArchivePlanItem {
|
||||
input: ArchiveSessionInput;
|
||||
}
|
||||
|
||||
interface BulkDeletePlanItem {
|
||||
record: ArchivedSessionRecord;
|
||||
}
|
||||
|
||||
type AgentModel = NonNullable<SpawnSessionInvocation["model"]>;
|
||||
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
||||
|
||||
export interface PiSessionManager {
|
||||
@@ -194,6 +219,7 @@ export interface PiAgentSession {
|
||||
compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>;
|
||||
getUserMessagesForForking(): readonly { entryId: string; text: string }[];
|
||||
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
|
||||
reload(): Promise<void>;
|
||||
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
|
||||
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
|
||||
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
|
||||
@@ -208,6 +234,15 @@ export interface PiAgentSession {
|
||||
setThinkingLevel(level: ClientThinkingLevel): void;
|
||||
cycleThinkingLevel(): ClientThinkingLevel | undefined;
|
||||
setSessionName(name: string): void;
|
||||
/**
|
||||
* Narrow re-expression of `AgentSession.agent` (an `@earendil-works/pi-agent-core`
|
||||
* `Agent`), exposing only `streamFn` — the resolved-auth/headers/retry "call this
|
||||
* model" function pi's own compaction/branch-summarization code uses internally.
|
||||
* Lets callers (e.g. session title generation) issue one-off model calls without
|
||||
* depending on pi-ai's deprecated `/compat` provider registry or leaking the full
|
||||
* `Agent`/`AgentSession` surface.
|
||||
*/
|
||||
agent: { streamFn: StreamFn };
|
||||
}
|
||||
|
||||
export interface PiSessionRuntime {
|
||||
@@ -222,29 +257,56 @@ interface CreateAgentRuntimeOptions {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
sessionManager: PiSessionManager;
|
||||
initialModel?: AgentModel;
|
||||
}
|
||||
|
||||
type CreateAgentRuntime = (createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions) => Promise<PiSessionRuntime>;
|
||||
type PiWebCreateAgentSessionRuntimeFactory = (
|
||||
options: Parameters<CreateAgentSessionRuntimeFactory>[0] & { initialModel?: AgentModel }
|
||||
) => ReturnType<CreateAgentSessionRuntimeFactory>;
|
||||
|
||||
function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
|
||||
type CreateAgentRuntime = (createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions) => Promise<PiSessionRuntime>;
|
||||
|
||||
function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
|
||||
if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager");
|
||||
return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager });
|
||||
const runtimeFactory = createRuntimeWithOneShotInitialModel(createRuntime, options.initialModel);
|
||||
return createAgentSessionRuntime(runtimeFactory, {
|
||||
cwd: options.cwd,
|
||||
agentDir: options.agentDir,
|
||||
sessionManager: options.sessionManager,
|
||||
});
|
||||
}
|
||||
|
||||
function createRuntimeWithOneShotInitialModel(createRuntime: PiWebCreateAgentSessionRuntimeFactory, initialModel: AgentModel | undefined): CreateAgentSessionRuntimeFactory {
|
||||
// The inherited model belongs only to the session being spawned. Do not keep
|
||||
// reapplying it if that runtime later creates/forks/switches sessions itself.
|
||||
let pendingInitialModel = initialModel;
|
||||
return async (options) => {
|
||||
const model = pendingInitialModel;
|
||||
pendingInitialModel = undefined;
|
||||
return createRuntime({
|
||||
...options,
|
||||
...(model === undefined ? {} : { initialModel: model }),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
type SpawnSessionFn = (input: SpawnSessionInvocation) => Promise<SpawnSessionResult>;
|
||||
|
||||
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): CreateAgentSessionRuntimeFactory {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
||||
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): PiWebCreateAgentSessionRuntimeFactory {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel }) => {
|
||||
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
|
||||
const customTools = [
|
||||
createPiWebEditToolDefinition(cwd),
|
||||
...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]),
|
||||
...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)),
|
||||
];
|
||||
const options = sessionStartEvent === undefined
|
||||
? { services, sessionManager, customTools }
|
||||
: { services, sessionManager, sessionStartEvent, customTools };
|
||||
const result = await createAgentSessionFromServices(options);
|
||||
const result = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
customTools,
|
||||
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
|
||||
...(initialModel === undefined ? {} : { model: initialModel }),
|
||||
});
|
||||
return { ...result, services, diagnostics: services.diagnostics };
|
||||
};
|
||||
}
|
||||
@@ -277,7 +339,7 @@ export interface PiSessionServiceDependencies {
|
||||
archiveStore?: SessionArchiveRepository;
|
||||
agentDir?: string;
|
||||
sessionManager?: PiSessionManagerGateway;
|
||||
createRuntime?: CreateAgentSessionRuntimeFactory;
|
||||
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
|
||||
createAgentRuntime?: CreateAgentRuntime;
|
||||
modelRegistry?: ModelRegistryInstance;
|
||||
heartbeatIntervalMs?: number;
|
||||
@@ -298,6 +360,8 @@ export interface PiSessionServiceDependencies {
|
||||
subsessionsEnabled?: boolean;
|
||||
/** Structured logger for notable runtime events (e.g. spawns). */
|
||||
logger?: PiSessionLogger;
|
||||
/** Clock seam for cleanup planning tests. */
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export class PiSessionService {
|
||||
@@ -325,12 +389,13 @@ export class PiSessionService {
|
||||
private readonly archiveStore: SessionArchiveRepository;
|
||||
private readonly agentDir: string;
|
||||
private readonly sessionManager: PiSessionManagerGateway;
|
||||
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
|
||||
private readonly createRuntime: PiWebCreateAgentSessionRuntimeFactory;
|
||||
private readonly createAgentRuntime: CreateAgentRuntime;
|
||||
private readonly modelRegistry: ModelRegistryInstance;
|
||||
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
|
||||
private readonly spawnTargets: SpawnTargetResolver | undefined;
|
||||
private readonly logger: PiSessionLogger;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
|
||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||
@@ -339,6 +404,7 @@ export class PiSessionService {
|
||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
||||
this.spawnTargets = deps.spawnTargets;
|
||||
this.logger = deps.logger ?? noopLogger;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
// Subsessions are a beta capability gated behind their own flag, and they
|
||||
// also require the spawn capability (they share its project-scope resolver).
|
||||
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
|
||||
@@ -369,6 +435,7 @@ export class PiSessionService {
|
||||
this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
|
||||
this.publishStatus(session);
|
||||
},
|
||||
reloadSession: (session) => this.reloadSessionRuntime(session),
|
||||
},
|
||||
{ listSessionNames: (cwd) => this.listSessionNames(cwd) },
|
||||
);
|
||||
@@ -378,6 +445,52 @@ export class PiSessionService {
|
||||
return this.active.size;
|
||||
}
|
||||
|
||||
async cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupPreviewResponse> {
|
||||
return previewResponseFromPlan(await this.cleanupPlan(request));
|
||||
}
|
||||
|
||||
async cleanup(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupExecuteResponse> {
|
||||
const plan = await this.cleanupPlan(request);
|
||||
if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined) throw new Error("Archive store does not support deletion");
|
||||
|
||||
const archiveInputs: ArchiveSessionInput[] = [];
|
||||
const readyArchiveInputs: ArchiveSessionInput[] = [];
|
||||
const deleteRecords: ArchivedSessionRecord[] = [];
|
||||
const readyDeleteRecords: ArchivedSessionRecord[] = [];
|
||||
const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
|
||||
|
||||
for (const input of plan.archiveInputs) {
|
||||
if (this.activeSessionHasWork(input.sessionId)) {
|
||||
skippedBusySessionIds.add(input.sessionId);
|
||||
continue;
|
||||
}
|
||||
await this.closeActive(input.sessionId);
|
||||
readyArchiveInputs.push(input);
|
||||
}
|
||||
await this.archiveStoreArchiveMany(readyArchiveInputs);
|
||||
archiveInputs.push(...readyArchiveInputs);
|
||||
|
||||
for (const record of plan.deleteRecords) {
|
||||
if (this.activeSessionHasWork(record.sessionId)) {
|
||||
skippedBusySessionIds.add(record.sessionId);
|
||||
continue;
|
||||
}
|
||||
await this.closeActive(record.sessionId);
|
||||
readyDeleteRecords.push(record);
|
||||
}
|
||||
await this.ensureArchivedRecordsMoved(readyDeleteRecords);
|
||||
const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
|
||||
deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
|
||||
|
||||
return summarizeSessionCleanupExecution({
|
||||
archiveInputs,
|
||||
deleteRecords,
|
||||
thresholds: plan.thresholds,
|
||||
generatedAt: plan.generatedAt,
|
||||
skippedBusySessionIds: [...skippedBusySessionIds],
|
||||
});
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
clearInterval(this.heartbeat);
|
||||
this.clearCompactionDrainTimers();
|
||||
@@ -417,20 +530,25 @@ export class PiSessionService {
|
||||
return [...unarchivedSessions, ...archivedSessions];
|
||||
}
|
||||
|
||||
async start(cwd: string, parentSession?: string): Promise<ClientSession> {
|
||||
const active = await this.create(this.sessionManager.create(cwd, parentSession === undefined ? undefined : { parentSession }), cwd);
|
||||
async start(cwd: string, options: StartSessionOptions = {}): Promise<ClientSession> {
|
||||
const active = await this.create(
|
||||
this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }),
|
||||
cwd,
|
||||
options.initialModel === undefined ? {} : { initialModel: options.initialModel },
|
||||
);
|
||||
const { session } = active.runtime;
|
||||
const created: ClientSession = {
|
||||
id: session.sessionId,
|
||||
path: session.sessionFile ?? "",
|
||||
cwd,
|
||||
persisted: sessionFileExists(session.sessionFile),
|
||||
created: new Date().toISOString(),
|
||||
modified: new Date().toISOString(),
|
||||
messageCount: session.messages.length,
|
||||
firstMessage: "",
|
||||
// Include the parent so listeners can nest the new session in the tree
|
||||
// immediately, instead of showing it flat until the next reload.
|
||||
...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
|
||||
...(options.parentSession === undefined ? {} : { parentSessionPath: options.parentSession }),
|
||||
};
|
||||
// Broadcast so other clients (and the spawning agent's UI) can add the new
|
||||
// session to their list without a manual reload.
|
||||
@@ -447,7 +565,7 @@ export class PiSessionService {
|
||||
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||
if (!decision.allowed) throw spawnTargetError(decision);
|
||||
const created = await this.start(decision.cwd);
|
||||
const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
|
||||
await this.prompt(created.id, input.prompt);
|
||||
this.logger.info(
|
||||
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
||||
@@ -466,7 +584,10 @@ export class PiSessionService {
|
||||
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||
if (!decision.allowed) throw spawnTargetError(decision);
|
||||
const created = await this.start(decision.cwd, input.parentSessionFile);
|
||||
const created = await this.start(decision.cwd, {
|
||||
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
|
||||
...(input.model === undefined ? {} : { initialModel: input.model }),
|
||||
});
|
||||
const parentSessionFile = nonEmptyString(input.parentSessionFile);
|
||||
const link: TrackedSubsessionLink = {
|
||||
parentSessionId: input.parentSessionId,
|
||||
@@ -957,7 +1078,7 @@ export class PiSessionService {
|
||||
}
|
||||
|
||||
async saveAttachments(ref: PiSessionLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]> {
|
||||
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
|
||||
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
|
||||
if (parsed.length === 0) return [];
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
@@ -1011,6 +1132,22 @@ export class PiSessionService {
|
||||
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
|
||||
}
|
||||
|
||||
private async reloadSessionRuntime(session: PiAgentSession): Promise<void> {
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
|
||||
this.publishActivity(session, "reloading resources", "active");
|
||||
try {
|
||||
await session.reload();
|
||||
this.publishActivity(session, "resources reloaded", "idle");
|
||||
this.publishStatus(session);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.publishActivity(session, "reload failed", "error", message);
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
this.publishStatus(session);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async archive(ref: PiSessionLookup): Promise<void> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
|
||||
@@ -1019,6 +1156,70 @@ export class PiSessionService {
|
||||
await this.archiveStore.archive(archiveInput);
|
||||
}
|
||||
|
||||
async archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
|
||||
const uniqueRefs = uniqueBulkSessionRefs(refs);
|
||||
const [archivedRecords, sessionContext] = await Promise.all([
|
||||
this.archiveStore.list(),
|
||||
this.bulkSessionLookupContext(uniqueRefs),
|
||||
]);
|
||||
const failures: SessionBulkFailure[] = [];
|
||||
const alreadyArchivedSessionIds: string[] = [];
|
||||
const planItems: BulkArchivePlanItem[] = [];
|
||||
|
||||
for (const ref of uniqueRefs) {
|
||||
const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
|
||||
if (archived !== undefined) {
|
||||
alreadyArchivedSessionIds.push(archived.sessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const active = this.activeForLookup(bulkRefToLookup(ref));
|
||||
const listed = findListedSessionForBulkRef(sessionContext, ref);
|
||||
const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
|
||||
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
|
||||
failures.push({ sessionId: resolvedSessionId, error: "Stop current session activity before archiving" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (listed !== undefined) {
|
||||
planItems.push({ input: archiveInputFromListEntry(listed) });
|
||||
} else if (active !== undefined) {
|
||||
planItems.push({ input: archiveInputFromActiveSession(active.runtime.session) });
|
||||
} else {
|
||||
failures.push({ sessionId: ref.id, error: "Session not found" });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
const readyInputs: ArchiveSessionInput[] = [];
|
||||
for (const item of planItems) {
|
||||
try {
|
||||
await this.closeActive(item.input.sessionId);
|
||||
readyInputs.push(item.input);
|
||||
} catch (error: unknown) {
|
||||
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
const archivedSessionIds = [...alreadyArchivedSessionIds];
|
||||
try {
|
||||
const archived = await this.archiveStoreArchiveMany(readyInputs);
|
||||
archivedSessionIds.push(...archived.map((record) => record.sessionId));
|
||||
} catch (error: unknown) {
|
||||
for (const input of readyInputs) failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
|
||||
}
|
||||
|
||||
return {
|
||||
archived: true,
|
||||
archivedSessionIds: uniqueStrings(archivedSessionIds),
|
||||
failures,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async archiveTree(ref: PiSessionLookup): Promise<ClientArchiveSessionsResponse> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
|
||||
@@ -1029,7 +1230,7 @@ export class PiSessionService {
|
||||
|
||||
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
|
||||
for (const input of archiveInputs) await this.closeActive(input.sessionId);
|
||||
for (const input of archiveInputs) await this.archiveStore.archive(input);
|
||||
await this.archiveStoreArchiveMany(archiveInputs);
|
||||
|
||||
return {
|
||||
archived: true,
|
||||
@@ -1056,6 +1257,61 @@ export class PiSessionService {
|
||||
await this.archiveStore.deleteArchived(record.sessionId);
|
||||
}
|
||||
|
||||
async deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
|
||||
if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined) throw new Error("Archive store does not support deletion");
|
||||
|
||||
const uniqueRefs = uniqueBulkSessionRefs(refs);
|
||||
const archivedRecords = await this.archiveStore.list();
|
||||
const failures: SessionBulkFailure[] = [];
|
||||
const planItems: BulkDeletePlanItem[] = [];
|
||||
|
||||
for (const ref of uniqueRefs) {
|
||||
const record = findArchivedRecordForBulkRef(archivedRecords, ref);
|
||||
if (record === undefined) {
|
||||
failures.push({ sessionId: ref.id, error: "Archived session not found" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const active = this.activeForLookup({ id: record.sessionId, cwd: record.cwd });
|
||||
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
|
||||
failures.push({ sessionId: record.sessionId, error: "Stop current session activity before deleting archived session" });
|
||||
continue;
|
||||
}
|
||||
planItems.push({ record });
|
||||
}
|
||||
|
||||
const readyRecords: ArchivedSessionRecord[] = [];
|
||||
for (const item of planItems) {
|
||||
try {
|
||||
await this.closeActive(item.record.sessionId);
|
||||
readyRecords.push(item.record);
|
||||
} catch (error: unknown) {
|
||||
failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
const moveFailures = await this.moveLegacyArchivedRecordsForDelete(readyRecords);
|
||||
failures.push(...moveFailures);
|
||||
const moveFailureIds = new Set(moveFailures.map((failure) => failure.sessionId));
|
||||
const deleteIds = readyRecords
|
||||
.map((record) => record.sessionId)
|
||||
.filter((sessionId) => !moveFailureIds.has(sessionId));
|
||||
|
||||
let deletedSessionIds: string[] = [];
|
||||
try {
|
||||
deletedSessionIds = await this.archiveStoreDeleteArchivedMany(deleteIds);
|
||||
} catch (error: unknown) {
|
||||
for (const sessionId of deleteIds) failures.push({ sessionId, error: errorMessage(error) });
|
||||
}
|
||||
|
||||
return {
|
||||
deleted: true,
|
||||
deletedSessionIds,
|
||||
failures,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async reload(ref: PiSessionLookup): Promise<void> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
@@ -1093,6 +1349,95 @@ export class PiSessionService {
|
||||
});
|
||||
}
|
||||
|
||||
private async bulkSessionLookupContext(refs: readonly SessionBulkMutationRef[]): Promise<BulkSessionLookupContext> {
|
||||
const cwdSet = new Set<string>();
|
||||
let needsAllSessions = false;
|
||||
for (const ref of refs) {
|
||||
if (ref.cwd === undefined) needsAllSessions = true;
|
||||
else cwdSet.add(ref.cwd);
|
||||
}
|
||||
|
||||
const [sessionsByCwd, allSessions] = await Promise.all([
|
||||
this.listSessionsByCwd([...cwdSet]),
|
||||
needsAllSessions ? this.sessionManager.listAll?.() ?? Promise.resolve([]) : Promise.resolve(undefined),
|
||||
]);
|
||||
return allSessions === undefined ? { sessionsByCwd } : { sessionsByCwd, allSessions };
|
||||
}
|
||||
|
||||
private async listSessionsByCwd(cwds: readonly string[]): Promise<Map<string, PiSessionListEntry[]>> {
|
||||
const uniqueCwds = uniqueStrings(cwds);
|
||||
const entries = await Promise.all(uniqueCwds.map(async (cwd) => [cwd, await this.sessionManager.list(cwd)] as const));
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
private async archiveStoreArchiveMany(inputs: readonly ArchiveSessionInput[]): Promise<ArchivedSessionRecord[]> {
|
||||
if (inputs.length === 0) return [];
|
||||
if (this.archiveStore.archiveMany !== undefined) return this.archiveStore.archiveMany(inputs);
|
||||
const records: ArchivedSessionRecord[] = [];
|
||||
for (const input of inputs) records.push(await this.archiveStore.archive(input));
|
||||
return records;
|
||||
}
|
||||
|
||||
private async archiveStoreDeleteArchivedMany(sessionIds: readonly string[]): Promise<string[]> {
|
||||
if (sessionIds.length === 0) return [];
|
||||
if (this.archiveStore.deleteArchivedMany !== undefined) return this.archiveStore.deleteArchivedMany(sessionIds);
|
||||
if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
|
||||
for (const sessionId of sessionIds) await this.archiveStore.deleteArchived(sessionId);
|
||||
return [...sessionIds];
|
||||
}
|
||||
|
||||
private async moveLegacyArchivedRecordsForDelete(records: readonly ArchivedSessionRecord[]): Promise<SessionBulkFailure[]> {
|
||||
const legacyRecords = records.filter((record) => record.archivePath === undefined);
|
||||
if (legacyRecords.length === 0) return [];
|
||||
|
||||
let sessionsByCwd: Map<string, PiSessionListEntry[]>;
|
||||
try {
|
||||
sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
|
||||
} catch (error: unknown) {
|
||||
return legacyRecords.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
|
||||
}
|
||||
|
||||
const moveInputs = legacyRecords
|
||||
.map((record) => findSessionByIdOrPrefix(sessionsByCwd.get(record.cwd) ?? [], record.sessionId))
|
||||
.filter(isDefined)
|
||||
.map(archiveInputFromListEntry);
|
||||
if (moveInputs.length === 0) return [];
|
||||
|
||||
try {
|
||||
await this.archiveStoreArchiveMany(moveInputs);
|
||||
return [];
|
||||
} catch (error: unknown) {
|
||||
const failedIds = new Set(moveInputs.map((input) => input.sessionId));
|
||||
return legacyRecords
|
||||
.filter((record) => failedIds.has(record.sessionId))
|
||||
.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupPlan(request: NormalizedSessionCleanupRequest) {
|
||||
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
|
||||
return planSessionCleanup({
|
||||
sessions,
|
||||
archivedRecords,
|
||||
activeSessions: this.cleanupActiveSessionStatuses(),
|
||||
thresholds: request.thresholds,
|
||||
...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }),
|
||||
now: this.now(),
|
||||
});
|
||||
}
|
||||
|
||||
private cleanupActiveSessionStatuses(): { sessionId: string; hasActiveWork: boolean }[] {
|
||||
return [...new Set(this.active.values())].map((active) => ({
|
||||
sessionId: active.runtime.session.sessionId,
|
||||
hasActiveWork: this.hasActiveWork(active.runtime.session),
|
||||
}));
|
||||
}
|
||||
|
||||
private activeSessionHasWork(sessionId: string): boolean {
|
||||
const active = this.active.get(sessionId);
|
||||
return active !== undefined && this.hasActiveWork(active.runtime.session);
|
||||
}
|
||||
|
||||
private reconcilableSessionIds(cwd: string, listedSessionIds: string[], archivedById: Map<string, ArchivedSessionRecord>): string[] {
|
||||
const sessionIds = new Set(listedSessionIds);
|
||||
for (const active of new Set(this.active.values())) {
|
||||
@@ -1114,7 +1459,20 @@ export class PiSessionService {
|
||||
private async ensureArchivedRecordMoved(record: ArchivedSessionRecord): Promise<ArchivedSessionRecord> {
|
||||
const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
|
||||
if (session === undefined) return record;
|
||||
return this.archiveStore.archive(archiveInputFromListEntry(session));
|
||||
const [moved] = await this.archiveStoreArchiveMany([archiveInputFromListEntry(session)]);
|
||||
return moved ?? record;
|
||||
}
|
||||
|
||||
private async ensureArchivedRecordsMoved(records: readonly ArchivedSessionRecord[]): Promise<void> {
|
||||
const legacyRecords = records.filter((record) => record.archivePath === undefined);
|
||||
if (legacyRecords.length === 0) return;
|
||||
|
||||
const sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
|
||||
const moveInputs = legacyRecords
|
||||
.map((record) => sessionsByCwd.get(record.cwd)?.find((candidate) => candidate.id === record.sessionId))
|
||||
.filter(isDefined)
|
||||
.map(archiveInputFromListEntry);
|
||||
await this.archiveStoreArchiveMany(moveInputs);
|
||||
}
|
||||
|
||||
private async archiveInputForSession(session: PiAgentSession): Promise<ArchiveSessionInput> {
|
||||
@@ -1234,8 +1592,13 @@ export class PiSessionService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async create(sessionManager: PiSessionManager, cwd: string): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
|
||||
private async create(sessionManager: PiSessionManager, cwd: string, options: Pick<StartSessionOptions, "initialModel"> = {}): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const runtime = await this.createAgentRuntime(this.createRuntime, {
|
||||
cwd,
|
||||
agentDir: this.agentDir,
|
||||
sessionManager,
|
||||
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
||||
});
|
||||
await this.bindSessionExtensions(runtime.session);
|
||||
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
|
||||
this.bindRuntime(active);
|
||||
@@ -1346,7 +1709,7 @@ export class PiSessionService {
|
||||
const model = session.model;
|
||||
if (model === undefined) return;
|
||||
|
||||
void generateShortSessionName(this.modelRegistry, model, firstMessage).then((name) => {
|
||||
void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => {
|
||||
this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
|
||||
}).catch(() => {
|
||||
this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
|
||||
@@ -1496,6 +1859,7 @@ export class PiSessionService {
|
||||
const contextUsage = session.getContextUsage();
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
persisted: sessionFileExists(session.sessionFile),
|
||||
...(model === undefined ? {} : { model }),
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
isStreaming: session.isStreaming,
|
||||
@@ -1523,6 +1887,53 @@ export class PiSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
function previewResponseFromPlan(plan: SessionCleanupPlan): ClientSessionCleanupPreviewResponse {
|
||||
return {
|
||||
generatedAt: plan.generatedAt,
|
||||
thresholds: plan.thresholds,
|
||||
projects: plan.projects,
|
||||
totals: plan.totals,
|
||||
...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueBulkSessionRefs(refs: readonly SessionBulkMutationRef[]): SessionBulkMutationRef[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: SessionBulkMutationRef[] = [];
|
||||
for (const ref of refs) {
|
||||
const key = `${ref.cwd ?? ""}\0${ref.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
unique.push(ref);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function bulkRefToLookup(ref: SessionBulkMutationRef): PiSessionLookup {
|
||||
return ref.cwd === undefined ? ref.id : { id: ref.id, cwd: ref.cwd };
|
||||
}
|
||||
|
||||
function findArchivedRecordForBulkRef(records: readonly ArchivedSessionRecord[], ref: SessionBulkMutationRef): ArchivedSessionRecord | undefined {
|
||||
return records.find((record) => (ref.cwd === undefined || record.cwd === ref.cwd) && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
|
||||
}
|
||||
|
||||
function findListedSessionForBulkRef(context: BulkSessionLookupContext, ref: SessionBulkMutationRef): PiSessionListEntry | undefined {
|
||||
if (ref.cwd !== undefined) return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
|
||||
return context.allSessions === undefined ? undefined : findSessionByIdOrPrefix(context.allSessions, ref.id);
|
||||
}
|
||||
|
||||
function findSessionByIdOrPrefix(sessions: readonly PiSessionListEntry[], sessionId: string): PiSessionListEntry | undefined {
|
||||
return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
|
||||
}
|
||||
|
||||
function uniqueStrings(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel {
|
||||
if (model === undefined) return {};
|
||||
const name = getString(model, "name");
|
||||
@@ -1541,6 +1952,7 @@ function clientSessionFromListEntry(session: PiSessionListEntry): ClientSession
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
cwd: session.cwd,
|
||||
persisted: true,
|
||||
...(session.name === undefined ? {} : { name: session.name }),
|
||||
created: session.created.toISOString(),
|
||||
modified: session.modified.toISOString(),
|
||||
@@ -1743,6 +2155,15 @@ function sessionPathsEqual(a: string, b: string): boolean {
|
||||
return cwdPathsEqual(a, b);
|
||||
}
|
||||
|
||||
function sessionFileExists(sessionFile: string | undefined): sessionFile is string {
|
||||
if (sessionFile === undefined || sessionFile === "") return false;
|
||||
try {
|
||||
return statSync(sessionFile).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionFileMatches(session: PiAgentSession, expectedSessionFile: string | undefined): boolean {
|
||||
const sessionFile = nonEmptyString(session.sessionFile);
|
||||
return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
|
||||
|
||||
@@ -71,6 +71,53 @@ describe("SessionArchiveStore", () => {
|
||||
expect(await exists(record.archivePath)).toBe(false);
|
||||
await expect(store.list()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("archives and permanently deletes sessions in batches", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-batch-"));
|
||||
tempRoots.push(root);
|
||||
const activeDir = join(root, "active");
|
||||
await mkdir(activeDir, { recursive: true });
|
||||
const sourceA = join(activeDir, "2026-01-01_a.jsonl");
|
||||
const sourceB = join(activeDir, "2026-01-01_b.jsonl");
|
||||
await writeFile(sourceA, "a\n", "utf8");
|
||||
await writeFile(sourceB, "b\n", "utf8");
|
||||
|
||||
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
|
||||
const records = await store.archiveMany([
|
||||
{
|
||||
sessionId: "a",
|
||||
cwd: "/workspace",
|
||||
path: sourceA,
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:01:00.000Z",
|
||||
messageCount: 1,
|
||||
firstMessage: "a",
|
||||
},
|
||||
{
|
||||
sessionId: "b",
|
||||
cwd: "/workspace",
|
||||
path: sourceB,
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:02:00.000Z",
|
||||
messageCount: 2,
|
||||
firstMessage: "b",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(records.map((record) => record.sessionId)).toEqual(["a", "b"]);
|
||||
expect(await exists(sourceA)).toBe(false);
|
||||
expect(await exists(sourceB)).toBe(false);
|
||||
await expect(store.list()).resolves.toMatchObject([{ sessionId: "a" }, { sessionId: "b" }]);
|
||||
|
||||
const archivePaths = records.map((record) => record.archivePath);
|
||||
if (archivePaths.some((path) => path === undefined)) throw new Error("Expected archive paths");
|
||||
await expect(store.deleteArchivedMany(["a", "b", "missing"])).resolves.toEqual(["a", "b"]);
|
||||
for (const archivePath of archivePaths) {
|
||||
if (archivePath === undefined) throw new Error("Expected archive path");
|
||||
expect(await exists(archivePath)).toBe(false);
|
||||
}
|
||||
await expect(store.list()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
|
||||
@@ -53,24 +53,39 @@ export class SessionArchiveStore {
|
||||
}
|
||||
|
||||
async archive(session: ArchiveSessionInput): Promise<ArchivedSessionRecord> {
|
||||
const [record] = await this.archiveMany([session]);
|
||||
if (record === undefined) throw new Error("Archive operation did not produce a record");
|
||||
return record;
|
||||
}
|
||||
|
||||
async archiveMany(sessions: readonly ArchiveSessionInput[]): Promise<ArchivedSessionRecord[]> {
|
||||
if (sessions.length === 0) return [];
|
||||
return this.exclusive(async () => {
|
||||
const data = await this.read();
|
||||
const existingIndex = data.sessions.findIndex((record) => record.sessionId === session.sessionId);
|
||||
const existing = existingIndex === -1 ? undefined : data.sessions[existingIndex];
|
||||
const archivePath = existing?.archivePath ?? this.archivePathFor(session);
|
||||
const record = archiveRecordFromInput(session, {
|
||||
archivedAt: existing?.archivedAt ?? new Date().toISOString(),
|
||||
originalPath: existing?.originalPath ?? session.path,
|
||||
archivePath,
|
||||
});
|
||||
const records: ArchivedSessionRecord[] = [];
|
||||
const filesToRemove: { source: string; archivePath: string }[] = [];
|
||||
|
||||
await copySessionFileToArchive(session.path, archivePath);
|
||||
for (const session of sessions) {
|
||||
const existingIndex = data.sessions.findIndex((record) => record.sessionId === session.sessionId);
|
||||
const existing = existingIndex === -1 ? undefined : data.sessions[existingIndex];
|
||||
const archivePath = existing?.archivePath ?? this.archivePathFor(session);
|
||||
const record = archiveRecordFromInput(session, {
|
||||
archivedAt: existing?.archivedAt ?? new Date().toISOString(),
|
||||
originalPath: existing?.originalPath ?? session.path,
|
||||
archivePath,
|
||||
});
|
||||
|
||||
await copySessionFileToArchive(session.path, archivePath);
|
||||
|
||||
if (existingIndex === -1) data.sessions.push(record);
|
||||
else data.sessions[existingIndex] = record;
|
||||
records.push(record);
|
||||
filesToRemove.push({ source: session.path, archivePath });
|
||||
}
|
||||
|
||||
if (existingIndex === -1) data.sessions.push(record);
|
||||
else data.sessions[existingIndex] = record;
|
||||
await this.write(data);
|
||||
await removeActiveSessionFile(session.path, archivePath);
|
||||
return record;
|
||||
for (const file of filesToRemove) await removeActiveSessionFile(file.source, file.archivePath);
|
||||
return records;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,14 +105,25 @@ export class SessionArchiveStore {
|
||||
}
|
||||
|
||||
async deleteArchived(sessionId: string): Promise<void> {
|
||||
await this.exclusive(async () => {
|
||||
const data = await this.read();
|
||||
const record = data.sessions.find((session) => session.sessionId === sessionId);
|
||||
if (record === undefined) return;
|
||||
await this.deleteArchivedMany([sessionId]);
|
||||
}
|
||||
|
||||
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
|
||||
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
|
||||
async deleteArchivedMany(sessionIds: readonly string[]): Promise<string[]> {
|
||||
const targetIds = uniqueStrings(sessionIds);
|
||||
if (targetIds.length === 0) return [];
|
||||
return this.exclusive(async () => {
|
||||
const data = await this.read();
|
||||
const targetIdSet = new Set(targetIds);
|
||||
const records = data.sessions.filter((session) => targetIdSet.has(session.sessionId));
|
||||
if (records.length === 0) return [];
|
||||
|
||||
for (const record of records) {
|
||||
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
|
||||
}
|
||||
const sessions = data.sessions.filter((session) => !targetIdSet.has(session.sessionId));
|
||||
await this.write({ sessions });
|
||||
const deletedIds = new Set(records.map((record) => record.sessionId));
|
||||
return targetIds.filter((sessionId) => deletedIds.has(sessionId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -254,6 +280,10 @@ function safeFileName(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "session";
|
||||
}
|
||||
|
||||
function uniqueStrings(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSessionCleanupRequest, normalizeSessionCleanupThresholds, planSessionCleanup } from "./sessionCleanup.js";
|
||||
import type { PiSessionListEntry } from "./piSessionService.js";
|
||||
import type { ArchivedSessionRecord } from "./sessionArchiveStore.js";
|
||||
|
||||
describe("session cleanup planning", () => {
|
||||
it("plans cleanup by strict cutoffs and groups counts by stored cwd", () => {
|
||||
const now = new Date("2026-06-25T00:00:00.000Z");
|
||||
const archivedRecords: ArchivedSessionRecord[] = [
|
||||
archivedRecord("already-archived", "/unregistered", "2026-06-20T00:00:00.000Z"),
|
||||
archivedRecord("delete-old", "/other", "2026-06-14T23:59:59.999Z"),
|
||||
archivedRecord("keep-exact", "/other", "2026-06-15T00:00:00.000Z"),
|
||||
];
|
||||
|
||||
const plan = planSessionCleanup({
|
||||
now,
|
||||
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 10 },
|
||||
archivedRecords,
|
||||
sessions: [
|
||||
sessionEntry("archive-old", "/unregistered", "2026-05-25T23:59:59.999Z"),
|
||||
sessionEntry("keep-exact", "/unregistered", "2026-05-26T00:00:00.000Z"),
|
||||
sessionEntry("keep-new", "/unregistered", "2026-05-26T00:00:00.001Z"),
|
||||
sessionEntry("already-archived", "/unregistered", "2026-05-01T00:00:00.000Z"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-old"]);
|
||||
expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-old"]);
|
||||
expect(plan.projects).toEqual([
|
||||
{ cwd: "/other", archiveCount: 0, deleteCount: 1 },
|
||||
{ cwd: "/unregistered", archiveCount: 1, deleteCount: 0 },
|
||||
]);
|
||||
expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
|
||||
});
|
||||
|
||||
it("filters cleanup candidates to selected project cwd paths", () => {
|
||||
const plan = planSessionCleanup({
|
||||
now: new Date("2026-06-25T00:00:00.000Z"),
|
||||
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 },
|
||||
projectCwds: ["/repo-a"],
|
||||
sessions: [
|
||||
sessionEntry("archive-a", "/repo-a", "2026-05-01T00:00:00.000Z"),
|
||||
sessionEntry("archive-b", "/repo-b", "2026-05-01T00:00:00.000Z"),
|
||||
],
|
||||
archivedRecords: [
|
||||
archivedRecord("delete-a", "/repo-a", "2026-05-01T00:00:00.000Z"),
|
||||
archivedRecord("delete-b", "/repo-b", "2026-05-01T00:00:00.000Z"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-a"]);
|
||||
expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-a"]);
|
||||
expect(plan.projects).toEqual([{ cwd: "/repo-a", archiveCount: 1, deleteCount: 1 }]);
|
||||
expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
|
||||
});
|
||||
|
||||
it("skips archive and delete candidates that are busy in memory", () => {
|
||||
const plan = planSessionCleanup({
|
||||
now: new Date("2026-06-25T00:00:00.000Z"),
|
||||
thresholds: { archiveIdleDays: 1, deleteArchivedDays: 1 },
|
||||
sessions: [sessionEntry("busy-open", "/repo", "2026-06-01T00:00:00.000Z")],
|
||||
archivedRecords: [archivedRecord("busy-archived", "/repo", "2026-06-01T00:00:00.000Z")],
|
||||
activeSessions: [
|
||||
{ sessionId: "busy-open", hasActiveWork: true },
|
||||
{ sessionId: "busy-archived", hasActiveWork: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(plan.archiveInputs).toHaveLength(0);
|
||||
expect(plan.deleteRecords).toHaveLength(0);
|
||||
expect(plan.skippedBusySessionIds).toEqual(["busy-archived", "busy-open"]);
|
||||
expect(plan.totals).toEqual({ archiveCount: 0, deleteCount: 0 });
|
||||
});
|
||||
|
||||
it("validates optional runtime thresholds", () => {
|
||||
expect(normalizeSessionCleanupThresholds({ archiveIdleDays: 30, deleteArchivedDays: null })).toEqual({ archiveIdleDays: 30 });
|
||||
expect(normalizeSessionCleanupThresholds({})).toEqual({});
|
||||
expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: -1 })).toThrow("archiveIdleDays field must be a non-negative integer");
|
||||
expect(() => normalizeSessionCleanupThresholds({ deleteArchivedDays: 1.5 })).toThrow("deleteArchivedDays field must be a non-negative integer");
|
||||
expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: "30" })).toThrow("archiveIdleDays field must be a non-negative integer");
|
||||
});
|
||||
|
||||
it("validates optional selected project cwd paths", () => {
|
||||
expect(normalizeSessionCleanupRequest({ archiveIdleDays: 30, projectCwds: ["/repo", "/repo"] })).toEqual({
|
||||
thresholds: { archiveIdleDays: 30 },
|
||||
projectCwds: ["/repo"],
|
||||
});
|
||||
expect(normalizeSessionCleanupRequest({ projectCwds: null })).toEqual({ thresholds: {} });
|
||||
expect(() => normalizeSessionCleanupRequest({ projectCwds: ["/repo", 1] })).toThrow("projectCwds field must be an array of strings");
|
||||
});
|
||||
});
|
||||
|
||||
function sessionEntry(id: string, cwd: string, modified: string): PiSessionListEntry {
|
||||
return {
|
||||
id,
|
||||
cwd,
|
||||
path: `/sessions/${id}.jsonl`,
|
||||
created: new Date("2026-01-01T00:00:00.000Z"),
|
||||
modified: new Date(modified),
|
||||
messageCount: 1,
|
||||
firstMessage: "hello",
|
||||
allMessagesText: "hello",
|
||||
};
|
||||
}
|
||||
|
||||
function archivedRecord(sessionId: string, cwd: string, archivedAt: string): ArchivedSessionRecord {
|
||||
return { sessionId, cwd, archivedAt };
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds } from "../../shared/apiTypes.js";
|
||||
import type { PiSessionListEntry } from "./piSessionService.js";
|
||||
import type { ArchivedSessionRecord, ArchiveSessionInput } from "./sessionArchiveStore.js";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface CleanupActiveSessionStatus {
|
||||
sessionId: string;
|
||||
hasActiveWork: boolean;
|
||||
}
|
||||
|
||||
export interface PlanSessionCleanupInput {
|
||||
sessions: readonly PiSessionListEntry[];
|
||||
archivedRecords: readonly ArchivedSessionRecord[];
|
||||
activeSessions?: readonly CleanupActiveSessionStatus[];
|
||||
thresholds: SessionCleanupThresholds;
|
||||
projectCwds?: readonly string[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface SessionCleanupPlan extends SessionCleanupPreviewResponse {
|
||||
archiveInputs: ArchiveSessionInput[];
|
||||
deleteRecords: ArchivedSessionRecord[];
|
||||
skippedBusySessionIds: string[];
|
||||
}
|
||||
|
||||
export interface NormalizedSessionCleanupRequest {
|
||||
thresholds: SessionCleanupThresholds;
|
||||
/** Stored cwd paths to include. Undefined means all discovered projects/workspaces. */
|
||||
projectCwds?: string[];
|
||||
}
|
||||
|
||||
export function normalizeSessionCleanupRequest(record: Record<string, unknown>): NormalizedSessionCleanupRequest {
|
||||
const projectCwds = optionalProjectCwds(record);
|
||||
return {
|
||||
thresholds: normalizeSessionCleanupThresholds(record),
|
||||
...(projectCwds === undefined ? {} : { projectCwds }),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSessionCleanupThresholds(record: Record<string, unknown>): SessionCleanupThresholds {
|
||||
const thresholds: SessionCleanupThresholds = {};
|
||||
const archiveIdleDays = optionalDayThreshold(record, "archiveIdleDays");
|
||||
const deleteArchivedDays = optionalDayThreshold(record, "deleteArchivedDays");
|
||||
if (archiveIdleDays !== undefined) thresholds.archiveIdleDays = archiveIdleDays;
|
||||
if (deleteArchivedDays !== undefined) thresholds.deleteArchivedDays = deleteArchivedDays;
|
||||
return thresholds;
|
||||
}
|
||||
|
||||
export function planSessionCleanup(input: PlanSessionCleanupInput): SessionCleanupPlan {
|
||||
const thresholds = copyThresholds(input.thresholds);
|
||||
const archiveCutoff = cutoffTime(input.now, thresholds.archiveIdleDays);
|
||||
const deleteCutoff = cutoffTime(input.now, thresholds.deleteArchivedDays);
|
||||
const archivedIds = new Set(input.archivedRecords.map((record) => record.sessionId));
|
||||
const includedCwds = input.projectCwds === undefined ? undefined : new Set(input.projectCwds);
|
||||
const busySessionIds = new Set((input.activeSessions ?? []).filter((session) => session.hasActiveWork).map((session) => session.sessionId));
|
||||
const skippedBusy = new Set<string>();
|
||||
const archiveInputs: ArchiveSessionInput[] = [];
|
||||
const deleteRecords: ArchivedSessionRecord[] = [];
|
||||
|
||||
if (archiveCutoff !== undefined) {
|
||||
for (const session of uniqueSessionsById(input.sessions)) {
|
||||
if (archivedIds.has(session.id)) continue;
|
||||
if (includedCwds !== undefined && !includedCwds.has(session.cwd)) continue;
|
||||
if (!isBefore(session.modified, archiveCutoff)) continue;
|
||||
if (busySessionIds.has(session.id)) {
|
||||
skippedBusy.add(session.id);
|
||||
continue;
|
||||
}
|
||||
archiveInputs.push(archiveInputFromListEntry(session));
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteCutoff !== undefined) {
|
||||
for (const record of input.archivedRecords) {
|
||||
if (includedCwds !== undefined && !includedCwds.has(record.cwd)) continue;
|
||||
if (!isTimestampBefore(record.archivedAt, deleteCutoff)) continue;
|
||||
if (busySessionIds.has(record.sessionId)) {
|
||||
skippedBusy.add(record.sessionId);
|
||||
continue;
|
||||
}
|
||||
deleteRecords.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...summarizeSessionCleanupTargets({ archiveInputs, deleteRecords, thresholds, generatedAt: input.now.toISOString(), skippedBusySessionIds: [...skippedBusy] }),
|
||||
archiveInputs,
|
||||
deleteRecords,
|
||||
skippedBusySessionIds: [...skippedBusy].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSessionCleanupTargets(input: {
|
||||
archiveInputs: readonly ArchiveSessionInput[];
|
||||
deleteRecords: readonly ArchivedSessionRecord[];
|
||||
thresholds: SessionCleanupThresholds;
|
||||
generatedAt: string;
|
||||
skippedBusySessionIds?: readonly string[];
|
||||
}): SessionCleanupPreviewResponse {
|
||||
const projectsByCwd = new Map<string, SessionCleanupProjectSummary>();
|
||||
let archiveCount = 0;
|
||||
let deleteCount = 0;
|
||||
|
||||
for (const session of input.archiveInputs) {
|
||||
archiveCount += 1;
|
||||
projectSummary(projectsByCwd, session.cwd).archiveCount += 1;
|
||||
}
|
||||
|
||||
for (const record of input.deleteRecords) {
|
||||
deleteCount += 1;
|
||||
projectSummary(projectsByCwd, record.cwd).deleteCount += 1;
|
||||
}
|
||||
|
||||
const skippedBusySessionIds = [...new Set(input.skippedBusySessionIds ?? [])].sort();
|
||||
return {
|
||||
generatedAt: input.generatedAt,
|
||||
thresholds: copyThresholds(input.thresholds),
|
||||
projects: [...projectsByCwd.values()].sort((a, b) => a.cwd.localeCompare(b.cwd)),
|
||||
totals: { archiveCount, deleteCount },
|
||||
...(skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds }),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSessionCleanupExecution(input: {
|
||||
archiveInputs: readonly ArchiveSessionInput[];
|
||||
deleteRecords: readonly ArchivedSessionRecord[];
|
||||
thresholds: SessionCleanupThresholds;
|
||||
generatedAt: string;
|
||||
skippedBusySessionIds?: readonly string[];
|
||||
}): SessionCleanupExecuteResponse {
|
||||
return {
|
||||
...summarizeSessionCleanupTargets(input),
|
||||
archivedSessionIds: input.archiveInputs.map((session) => session.sessionId),
|
||||
deletedSessionIds: input.deleteRecords.map((record) => record.sessionId),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalDayThreshold(record: Record<string, unknown>, field: keyof SessionCleanupThresholds): number | undefined {
|
||||
const value = record[field];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`${field} field must be a non-negative integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalProjectCwds(record: Record<string, unknown>): string[] | undefined {
|
||||
const value = record["projectCwds"];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error("projectCwds field must be an array of strings");
|
||||
return [...new Set(value)];
|
||||
}
|
||||
|
||||
function cutoffTime(now: Date, days: number | undefined): number | undefined {
|
||||
return days === undefined ? undefined : now.getTime() - days * DAY_MS;
|
||||
}
|
||||
|
||||
function isBefore(value: Date, cutoff: number): boolean {
|
||||
const time = value.getTime();
|
||||
return Number.isFinite(time) && time < cutoff;
|
||||
}
|
||||
|
||||
function isTimestampBefore(value: string, cutoff: number): boolean {
|
||||
const time = Date.parse(value);
|
||||
return Number.isFinite(time) && time < cutoff;
|
||||
}
|
||||
|
||||
function uniqueSessionsById(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] {
|
||||
const sessionsById = new Map<string, PiSessionListEntry>();
|
||||
for (const session of sessions) {
|
||||
const existing = sessionsById.get(session.id);
|
||||
if (existing === undefined || session.modified.getTime() > existing.modified.getTime()) sessionsById.set(session.id, session);
|
||||
}
|
||||
return [...sessionsById.values()];
|
||||
}
|
||||
|
||||
function archiveInputFromListEntry(session: PiSessionListEntry): ArchiveSessionInput {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
cwd: session.cwd,
|
||||
path: session.path,
|
||||
created: session.created.toISOString(),
|
||||
modified: session.modified.toISOString(),
|
||||
messageCount: session.messageCount,
|
||||
firstMessage: session.firstMessage,
|
||||
...(session.name === undefined ? {} : { name: session.name }),
|
||||
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
|
||||
};
|
||||
}
|
||||
|
||||
function projectSummary(projectsByCwd: Map<string, SessionCleanupProjectSummary>, cwd: string): SessionCleanupProjectSummary {
|
||||
const existing = projectsByCwd.get(cwd);
|
||||
if (existing !== undefined) return existing;
|
||||
const created = { cwd, archiveCount: 0, deleteCount: 0 };
|
||||
projectsByCwd.set(cwd, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function copyThresholds(thresholds: SessionCleanupThresholds): SessionCleanupThresholds {
|
||||
const copy: SessionCleanupThresholds = {};
|
||||
if (thresholds.archiveIdleDays !== undefined) copy.archiveIdleDays = thresholds.archiveIdleDays;
|
||||
if (thresholds.deleteArchivedDays !== undefined) copy.deleteArchivedDays = thresholds.deleteArchivedDays;
|
||||
return copy;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user