Archived
Merge remote-tracking branch 'origin/main' into pr-36-generic-agent-config
# Conflicts: # docs/config.html # docs/config.md # src/cli.test.ts # src/cli.ts # src/client/src/components/settings/SettingsSessiondPanel.ts # src/client/src/components/settings/settingsConfigDraft.test.ts # src/client/src/components/settings/settingsConfigDraft.ts # src/server/app.test.ts # src/server/app.ts # src/server/configRoutes.test.ts # src/server/configRoutes.ts # src/server/piWebPluginService.test.ts # src/server/piWebPluginService.ts # src/server/piWebStatus.test.ts # src/server/piWebStatus.ts # src/server/piWebStatusCache.ts # src/server/sessions/authService.test.ts # src/server/sessions/piSessionService.ts # src/server/sessions/sessionRoutes.test.ts
This commit is contained in:
@@ -5,12 +5,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>PI WEB</title>
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png" />
|
||||
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
|
||||
<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;
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
"name": "PI WEB",
|
||||
"short_name": "PI WEB",
|
||||
"description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1117",
|
||||
"theme_color": "#0d1117",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-icon-192.png",
|
||||
"src": "./pwa-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-icon-512.png",
|
||||
"src": "./pwa-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
|
||||
@@ -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, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, 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";
|
||||
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 { afterEach, beforeEach, 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",
|
||||
@@ -13,6 +13,20 @@ const workspace: Workspace = {
|
||||
isGitWorktree: true,
|
||||
};
|
||||
|
||||
function piWebStatusResponse() {
|
||||
return {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", available: true, stale: false },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: {},
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
const commandRun: TerminalCommandRun = {
|
||||
id: "run1",
|
||||
origin: "core",
|
||||
@@ -26,28 +40,42 @@ const commandRun: TerminalCommandRun = {
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("machine-scoped runtime API", () => {
|
||||
it("reads machine PI WEB status through the gateway route", async () => {
|
||||
const fetchMock = stubJsonFetch({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", available: true, stale: false },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: {},
|
||||
messages: [],
|
||||
});
|
||||
const fetchMock = stubJsonFetch(piWebStatusResponse());
|
||||
|
||||
await piWebApi.piWebStatus("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/pi-web/status");
|
||||
});
|
||||
|
||||
it("requests an uncached update check through the local status route", async () => {
|
||||
const fetchMock = stubJsonFetch(piWebStatusResponse());
|
||||
|
||||
await piWebApi.checkForUpdates();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/pi-web/status?refresh=1");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
it("requests an uncached update check through the selected machine route", async () => {
|
||||
const fetchMock = stubJsonFetch(piWebStatusResponse());
|
||||
|
||||
await piWebApi.checkForUpdates("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/pi-web/status?refresh=1");
|
||||
expect(fetchCall(fetchMock, 0)[1]?.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
it("reads machine runtime through the gateway route", async () => {
|
||||
@@ -56,7 +84,109 @@ describe("machine-scoped runtime API", () => {
|
||||
await machinesApi.runtime("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/runtime");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime");
|
||||
});
|
||||
});
|
||||
|
||||
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([
|
||||
"https://pi.example.test/api/config",
|
||||
"https://pi.example.test/api/config",
|
||||
"https://pi.example.test/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([
|
||||
"https://pi.example.test/api/machines/remote%20a/config",
|
||||
"https://pi.example.test/api/machines/remote%20a/config",
|
||||
"https://pi.example.test/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([
|
||||
"https://pi.example.test/api/pi-packages",
|
||||
"https://pi.example.test/api/pi-packages/install",
|
||||
"https://pi.example.test/api/pi-packages/remove",
|
||||
"https://pi.example.test/api/pi-packages/update",
|
||||
"https://pi.example.test/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([
|
||||
"https://pi.example.test/api/machines/local/pi-packages",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages/install",
|
||||
"https://pi.example.test/api/machines/remote%20a/pi-packages/remove",
|
||||
"https://pi.example.test/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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,14 +200,31 @@ describe("session API compatibility", () => {
|
||||
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)[0]).toBe("https://pi.example.test/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)[0]).toBe("https://pi.example.test/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("https://pi.example.test/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("https://pi.example.test/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 });
|
||||
|
||||
@@ -85,7 +232,7 @@ describe("session API compatibility", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ text: "hello", streamingBehavior: "followUp" });
|
||||
});
|
||||
|
||||
@@ -96,7 +243,7 @@ describe("session API compatibility", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" });
|
||||
});
|
||||
});
|
||||
@@ -108,7 +255,7 @@ describe("machine-scoped file suggestion API", () => {
|
||||
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
|
||||
});
|
||||
|
||||
it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => {
|
||||
@@ -117,7 +264,18 @@ describe("machine-scoped file suggestion API", () => {
|
||||
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("machine-scoped workspace API", () => {
|
||||
it("keeps project ids in one encoded route segment when listing workspaces", async () => {
|
||||
const fetchMock = stubJsonFetch([]);
|
||||
|
||||
await workspacesApi.workspaces("../p /?", "remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/projects/..%2Fp%20%2F%3F/workspaces");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,7 +287,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1");
|
||||
expect(init?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
@@ -140,7 +298,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} });
|
||||
});
|
||||
@@ -152,7 +310,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminals");
|
||||
expect(init?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
@@ -168,9 +326,9 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
await terminalsApi.cancelCommandRun("run 1", "remote a");
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D",
|
||||
"/api/machines/remote%20a/terminal-command-runs/run%201",
|
||||
"/api/machines/remote%20a/terminal-command-runs/run%201/cancel",
|
||||
"https://pi.example.test/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D",
|
||||
"https://pi.example.test/api/machines/remote%20a/terminal-command-runs/run%201",
|
||||
"https://pi.example.test/api/machines/remote%20a/terminal-command-runs/run%201/cancel",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST");
|
||||
});
|
||||
@@ -180,7 +338,7 @@ describe("machine-scoped terminal command-run API", () => {
|
||||
|
||||
await expect(terminalsApi.getCommandRun("missing", "remote-a")).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote-a/terminal-command-runs/missing");
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote-a/terminal-command-runs/missing");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,7 +350,7 @@ describe("workspace file write API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt");
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(new Headers(init?.headers).get("content-type")).toBe("text/plain");
|
||||
});
|
||||
@@ -205,7 +363,7 @@ describe("workspace file write API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchCall(fetchMock, 0);
|
||||
expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
|
||||
expect(url).toBe("https://pi.example.test/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png");
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream");
|
||||
});
|
||||
@@ -243,7 +401,7 @@ describe("workspace file write API", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url] = fetchCall(fetchMock, 0);
|
||||
expect(url).toContain("/api/machines/remote%20a/");
|
||||
expect(url).toContain("api/machines/remote%20a/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,6 +439,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, agentCommand: false, agentDir: false, agentSessionDir: 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,5 @@
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionCleanupRequest, 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 { resolveAppUrl } from "../appUrl";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
arrayOf,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
parseModelSelectionResponse,
|
||||
parseMoveWorkspaceFileResponse,
|
||||
parseOAuthFlowState,
|
||||
parsePiPackageMutationResponse,
|
||||
parsePiPackagesResponse,
|
||||
parsePiWebConfigResponse,
|
||||
parsePiWebPluginsResponse,
|
||||
parsePiWebRuntimeResponse,
|
||||
@@ -32,6 +35,8 @@ import {
|
||||
parseReloaded,
|
||||
parseRestored,
|
||||
parseSavedAttachments,
|
||||
parseSessionBulkArchiveResponse,
|
||||
parseSessionBulkDeleteArchivedResponse,
|
||||
parseSessionCleanupExecuteResponse,
|
||||
parseSessionCleanupPreviewResponse,
|
||||
parseSessionInfo,
|
||||
@@ -45,9 +50,9 @@ import {
|
||||
parseWorkspace,
|
||||
parseWorkspaceActivityResponse,
|
||||
} from "./parsers";
|
||||
import { machineGitDiffUrl, messageUrl } from "./urls";
|
||||
import { machineGitDiffPath, messagePath } from "./urls";
|
||||
|
||||
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
|
||||
const machinePrefix = (machineId = "local") => `api/machines/${encodeURIComponent(machineId)}`;
|
||||
|
||||
type SessionLookup = SessionRef | string;
|
||||
|
||||
@@ -59,20 +64,20 @@ function sessionCwd(session: SessionLookup): string | undefined {
|
||||
return typeof session === "string" ? undefined : session.cwd;
|
||||
}
|
||||
|
||||
function sessionBaseUrl(session: SessionLookup, machineId = "local"): string {
|
||||
function sessionBasePath(session: SessionLookup, machineId = "local"): string {
|
||||
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId(session))}`;
|
||||
}
|
||||
|
||||
function sessionUrl(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionBaseUrl(session, machineId)}/${endpoint}`;
|
||||
function sessionPath(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionBasePath(session, machineId)}/${endpoint}`;
|
||||
}
|
||||
|
||||
function sessionQueryUrl(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionUrl(session, endpoint, machineId)}${sessionQuery(session)}`;
|
||||
function sessionQueryPath(session: SessionLookup, endpoint: string, machineId = "local"): string {
|
||||
return `${sessionPath(session, endpoint, machineId)}${sessionQuery(session)}`;
|
||||
}
|
||||
|
||||
function sessionBaseQueryUrl(session: SessionLookup, machineId = "local"): string {
|
||||
return `${sessionBaseUrl(session, machineId)}${sessionQuery(session)}`;
|
||||
function sessionBaseQueryPath(session: SessionLookup, machineId = "local"): string {
|
||||
return `${sessionBasePath(session, machineId)}${sessionQuery(session)}`;
|
||||
}
|
||||
|
||||
function sessionQuery(session: SessionLookup): string {
|
||||
@@ -85,26 +90,70 @@ 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 };
|
||||
}
|
||||
|
||||
function piWebStatusPath(machineId: string): string {
|
||||
return machineId === "local" ? "api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`;
|
||||
}
|
||||
|
||||
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),
|
||||
piWebStatus: (machineId = "local") => request(piWebStatusPath(machineId), parsePiWebStatusResponse),
|
||||
checkForUpdates: (machineId = "local") => request(`${piWebStatusPath(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }),
|
||||
piWebRuntime: () => request("api/pi-web/runtime", parsePiWebRuntimeResponse),
|
||||
};
|
||||
|
||||
export const machinesApi = {
|
||||
machines: () => request("/api/machines", parseMachinesResponse),
|
||||
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
||||
health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
|
||||
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
|
||||
machines: () => request("api/machines", parseMachinesResponse),
|
||||
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
||||
health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
|
||||
runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
|
||||
};
|
||||
|
||||
function configPath(machineId?: string): string {
|
||||
return machineId === undefined ? "api/config" : `${machinePrefix(machineId)}/config`;
|
||||
}
|
||||
|
||||
function pluginsPath(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(configPath(machineId), parsePiWebConfigResponse),
|
||||
saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
||||
};
|
||||
|
||||
export const pluginsApi = {
|
||||
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
|
||||
plugins: (machineId?: string) => request(pluginsPath(machineId), parsePiWebPluginsResponse),
|
||||
};
|
||||
|
||||
function piPackagePath(endpoint = "", machineId?: string): string {
|
||||
const basePath = machineId === undefined ? "api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`;
|
||||
return endpoint === "" ? basePath : `${basePath}/${endpoint}`;
|
||||
}
|
||||
|
||||
export const piPackagesApi = {
|
||||
packages: (machineId?: string) => request(piPackagePath("", machineId), parsePiPackagesResponse),
|
||||
install: (source: string, machineId?: string) => {
|
||||
const body: PiPackageInstallRequest = { source };
|
||||
return request(piPackagePath("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(piPackagePath("remove", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
|
||||
},
|
||||
update: (source?: string, machineId?: string) => {
|
||||
const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source };
|
||||
return request(piPackagePath("update", machineId), parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
},
|
||||
};
|
||||
|
||||
export const activityApi = {
|
||||
@@ -119,7 +168,7 @@ export const projectsApi = {
|
||||
};
|
||||
|
||||
export const workspacesApi = {
|
||||
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
||||
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces`, arrayOf(parseWorkspace)),
|
||||
deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }),
|
||||
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
||||
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
||||
@@ -156,28 +205,30 @@ export const sessionsApi = {
|
||||
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) }),
|
||||
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),
|
||||
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionUrl(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
|
||||
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionUrl(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
|
||||
thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
|
||||
setThinkingLevel: (session: SessionLookup, level: string, machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }),
|
||||
cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
|
||||
commands: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)),
|
||||
prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }),
|
||||
saveAttachments: (session: SessionLookup, attachments: PromptAttachment[], machineId = "local", folder?: string) => request(sessionUrl(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }),
|
||||
shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }),
|
||||
abort: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }),
|
||||
stop: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "stop", machineId), parseStopped, { method: "POST", body: sessionBody(session) }),
|
||||
archive: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "archive", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
archiveWithDescendants: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "archive-tree", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
restore: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }),
|
||||
deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryUrl(session, machineId), parseDeleted, { method: "DELETE" }),
|
||||
detachParent: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }),
|
||||
reloadSession: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }),
|
||||
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(messagePath(session, options, machineId), parseMessagePage),
|
||||
status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus),
|
||||
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
|
||||
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
|
||||
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
|
||||
thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
|
||||
setThinkingLevel: (session: SessionLookup, level: string, machineId = "local") => request(sessionPath(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }),
|
||||
cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
|
||||
commands: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "commands", machineId), arrayOf(parseSlashCommand)),
|
||||
prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionPath(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }),
|
||||
saveAttachments: (session: SessionLookup, attachments: PromptAttachment[], machineId = "local", folder?: string) => request(sessionPath(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }),
|
||||
shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionPath(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionPath(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }),
|
||||
respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionPath(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }),
|
||||
abort: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }),
|
||||
stop: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "stop", machineId), parseStopped, { method: "POST", body: sessionBody(session) }),
|
||||
archive: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "archive", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
archiveWithDescendants: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "archive-tree", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
|
||||
restore: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }),
|
||||
deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryPath(session, machineId), parseDeleted, { method: "DELETE" }),
|
||||
detachParent: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }),
|
||||
reloadSession: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }),
|
||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.mode !== undefined) params.set("mode", options.mode);
|
||||
@@ -206,7 +257,7 @@ export const terminalsApi = {
|
||||
};
|
||||
|
||||
async function getOptionalTerminalCommandRun(runId: string, machineId: string): Promise<TerminalCommandRun | undefined> {
|
||||
const response = await fetch(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`);
|
||||
const response = await fetch(resolveAppUrl(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`));
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
@@ -263,7 +314,7 @@ export const filesApi = {
|
||||
|
||||
export const gitApi = {
|
||||
gitStatus: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffUrl(machineId, projectId, workspaceId, options), parseGitDiffResponse),
|
||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffPath(machineId, projectId, workspaceId, options), parseGitDiffResponse),
|
||||
};
|
||||
|
||||
export const api = {
|
||||
@@ -271,6 +322,7 @@ export const api = {
|
||||
...machinesApi,
|
||||
...configApi,
|
||||
...pluginsApi,
|
||||
...piPackagesApi,
|
||||
...activityApi,
|
||||
...projectsApi,
|
||||
...workspacesApi,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, 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";
|
||||
|
||||
@@ -17,6 +17,10 @@ const workspace: Workspace = {
|
||||
};
|
||||
const session = { id: "s 1", cwd: workspace.path };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -28,6 +32,14 @@ describe("federated route contract", () => {
|
||||
|
||||
await Promise.all([
|
||||
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
|
||||
ignoreParseFailure(piWebApi.checkForUpdates(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)),
|
||||
@@ -48,6 +60,8 @@ describe("federated route contract", () => {
|
||||
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)),
|
||||
@@ -58,6 +72,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(sessionsApi.cycleThinkingLevel(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.commands(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.prompt(session, "hello", "followUp", machineId)),
|
||||
ignoreParseFailure(sessionsApi.saveAttachments(session, [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }], machineId, "uploads")),
|
||||
ignoreParseFailure(sessionsApi.shell(session, "ls", machineId)),
|
||||
ignoreParseFailure(sessionsApi.runCommand(session, "/help", machineId)),
|
||||
ignoreParseFailure(sessionsApi.respondToCommand(session, "req 1", "yes", machineId)),
|
||||
@@ -102,7 +117,6 @@ describe("federated route contract", () => {
|
||||
webSocketUrls.push(url);
|
||||
}
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||
|
||||
sessionEvents(session, machineId);
|
||||
globalSessionEvents(machineId);
|
||||
@@ -135,8 +149,9 @@ function fetchCallToRoute(call: Parameters<FetchLike>, scopedMachineId: string):
|
||||
function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute {
|
||||
const url = toUrl(input);
|
||||
const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`;
|
||||
if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
|
||||
return { method, path: url.pathname.slice(prefix.length) || "/" };
|
||||
const prefixIndex = url.pathname.lastIndexOf(prefix);
|
||||
if (prefixIndex === -1) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
|
||||
return { method, path: url.pathname.slice(prefixIndex + prefix.length) || "/" };
|
||||
}
|
||||
|
||||
function toUrl(input: string | URL | Request): URL {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
|
||||
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
const response = await fetch(resolveAppUrl(url), { ...init, headers });
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
throw new Error(errorMessage(body) ?? response.statusText);
|
||||
|
||||
@@ -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, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, 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,60 @@ 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", () => {
|
||||
const response = {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, stale: false, installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, stale: false, installation: { kind: "docker", dockerMode: "dev" } },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: { restart: "pi-web-docker restart", status: "pi-web-docker status" },
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const parsed = parsePiWebStatusResponse(response);
|
||||
|
||||
expect(parsed.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
|
||||
expect(parsed.components.sessiond.installation).toEqual({ kind: "docker", dockerMode: "dev" });
|
||||
expect(parsed.commands).toEqual({ restart: "pi-web-docker restart", status: "pi-web-docker status" });
|
||||
expect(() => parsePiWebStatusResponse({
|
||||
...response,
|
||||
components: {
|
||||
...response.components,
|
||||
web: { ...response.components.web, installation: { kind: "docker", dockerMode: "hidden" } },
|
||||
},
|
||||
})).toThrow("Invalid PI WEB Docker mode");
|
||||
});
|
||||
|
||||
it("parses PI WEB plugin status responses", () => {
|
||||
@@ -69,9 +118,56 @@ describe("API parsers", () => {
|
||||
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,
|
||||
@@ -85,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, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, 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"),
|
||||
@@ -212,6 +216,33 @@ export function parseSessionCleanupExecuteResponse(value: unknown): SessionClean
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -620,6 +651,45 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
|
||||
};
|
||||
}
|
||||
|
||||
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"]) };
|
||||
@@ -710,15 +780,18 @@ function optionalPiWebInstallationInfo(value: unknown): PiWebInstallationInfo |
|
||||
if (value === undefined) return undefined;
|
||||
const record = requireRecord(value);
|
||||
const kind = requireString(record, "kind");
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") throw new Error("Invalid PI WEB installation kind");
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "docker" && kind !== "unknown") throw new Error("Invalid PI WEB installation kind");
|
||||
const scope = record["scope"];
|
||||
if (scope !== undefined && scope !== "user" && scope !== "project") throw new Error("Invalid PI WEB installation scope");
|
||||
const dockerMode = record["dockerMode"];
|
||||
if (dockerMode !== undefined && dockerMode !== "runtime" && dockerMode !== "dev") throw new Error("Invalid PI WEB Docker mode");
|
||||
return {
|
||||
kind,
|
||||
...optionalField("path", optionalString(record, "path")),
|
||||
...optionalField("source", optionalString(record, "source")),
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
...optionalField("npmRoot", optionalString(record, "npmRoot")),
|
||||
...(dockerMode === undefined ? {} : { dockerMode }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -762,8 +835,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 {
|
||||
|
||||
@@ -10,7 +10,7 @@ function FakeWebSocket(url: string): void {
|
||||
beforeEach(() => {
|
||||
webSocketUrls.length = 0;
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SessionRef } from "../../../shared/apiTypes";
|
||||
import { resolveAppWebSocketUrl } from "../appUrl";
|
||||
|
||||
type SessionLookup = SessionRef | string;
|
||||
|
||||
@@ -6,27 +7,22 @@ export function sessionEvents(session: SessionLookup, machineId = "local"): WebS
|
||||
const cwd = typeof session === "string" ? undefined : session.cwd;
|
||||
const query = cwd === undefined || cwd === "" ? "" : `?${new URLSearchParams({ cwd }).toString()}`;
|
||||
const sessionId = typeof session === "string" ? session : session.id;
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`);
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`));
|
||||
}
|
||||
|
||||
export function globalSessionEvents(machineId = "local"): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/events`);
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/sessions/events`));
|
||||
}
|
||||
|
||||
export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }, machineId = "local"): WebSocket {
|
||||
const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`;
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`);
|
||||
const sizeQuery = initialSize === undefined ? "" : `?${new URLSearchParams({ cols: String(initialSize.cols), rows: String(initialSize.rows) }).toString()}`;
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`));
|
||||
}
|
||||
|
||||
export function realtimeEvents(machineId = "local"): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/events`);
|
||||
return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/events`));
|
||||
}
|
||||
|
||||
function machinePrefix(machineId: string): string {
|
||||
return `/api/machines/${encodeURIComponent(machineId)}`;
|
||||
}
|
||||
|
||||
function webSocketBaseUrl(): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${location.host}`;
|
||||
return `api/machines/${encodeURIComponent(machineId)}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SessionRef } from "../../../shared/apiTypes";
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
|
||||
type SessionLookup = SessionRef | string;
|
||||
|
||||
@@ -10,36 +11,36 @@ function sessionCwd(session: SessionLookup): string | undefined {
|
||||
return typeof session === "string" ? undefined : session.cwd;
|
||||
}
|
||||
|
||||
export function machineGitDiffUrl(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||
export function machineGitDiffPath(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.path !== undefined) params.set("path", options.path);
|
||||
if (options?.staged === true) params.set("staged", "true");
|
||||
const query = params.toString();
|
||||
return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||
return `api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function messageUrl(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string {
|
||||
export function messagePath(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string {
|
||||
const params = new URLSearchParams();
|
||||
const cwd = sessionCwd(session);
|
||||
if (cwd !== undefined && cwd !== "") params.set("cwd", cwd);
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.before !== undefined) params.set("before", String(options.before));
|
||||
const query = params.toString();
|
||||
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`;
|
||||
return `api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`;
|
||||
}
|
||||
|
||||
export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string {
|
||||
const params = new URLSearchParams({ path });
|
||||
if (options?.createDirs === false) params.set("createDirs", "false");
|
||||
if (options?.overwrite === false) params.set("overwrite", "false");
|
||||
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`;
|
||||
const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return resolveAppUrl(`${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("path", path);
|
||||
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
|
||||
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
|
||||
const prefix = `api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||
return resolveAppUrl(`${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
effectiveWorkspaceUploadFolder,
|
||||
uploadWorkspaceFile,
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
type WorkspaceUploadXhr,
|
||||
} from "./workspaceUploads";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("workspace upload helpers", () => {
|
||||
it("resolves effective upload defaults and workspace-relative paths", () => {
|
||||
expect(effectiveWorkspaceUploadFolder(undefined)).toBe(".pi-web/uploads");
|
||||
@@ -40,7 +48,7 @@ describe("workspace upload helpers", () => {
|
||||
|
||||
const xhr = xhrs.only();
|
||||
expect(xhr.method).toBe("PUT");
|
||||
expect(xhr.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false");
|
||||
expect(xhr.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=manual%2Fhello.txt&overwrite=false");
|
||||
expect(xhr.headers.get("content-type")).toBe("text/plain");
|
||||
expect(xhr.body).toBe(file);
|
||||
|
||||
@@ -78,13 +86,13 @@ describe("workspace upload helpers", () => {
|
||||
});
|
||||
|
||||
const first = xhrs.at(0);
|
||||
expect(first.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt");
|
||||
expect(first.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fa.txt");
|
||||
first.emitUploadProgress(1, 2);
|
||||
first.respondJson(200, { path: "uploads/manual/a.txt", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
|
||||
await Promise.resolve();
|
||||
|
||||
const second = xhrs.at(1);
|
||||
expect(second.url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt");
|
||||
expect(second.url).toBe("https://pi.example.test/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/file?path=uploads%2Fmanual%2Fb.txt");
|
||||
second.emitUploadProgress(3, 3);
|
||||
second.respondJson(200, { path: "uploads/manual/b.txt", size: 3, modifiedAt: "2026-06-25T00:00:01.000Z", created: true });
|
||||
|
||||
@@ -100,6 +108,43 @@ describe("workspace upload helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards createDirs through batch upload requests", async () => {
|
||||
const xhrs = new FakeXhrQueue();
|
||||
const file = new File(["hello"], "nested.txt", { type: "text/plain" });
|
||||
|
||||
const task = uploadWorkspaceFiles("p1", "w1", [file], {
|
||||
destinationFolder: "uploads",
|
||||
createDirs: false,
|
||||
xhrFactory: xhrs.factory,
|
||||
});
|
||||
|
||||
const xhr = xhrs.only();
|
||||
expect(xhr.url).toBe("https://pi.example.test/api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false");
|
||||
xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
|
||||
|
||||
await expect(task.promise).resolves.toEqual([
|
||||
{ path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels an in-flight batch upload without starting remaining files", async () => {
|
||||
const xhrs = new FakeXhrQueue();
|
||||
const files = [new File(["ab"], "a.txt"), new File(["cde"], "b.txt")];
|
||||
|
||||
const task = uploadWorkspaceFiles("p1", "w1", files, {
|
||||
destinationFolder: "uploads",
|
||||
xhrFactory: xhrs.factory,
|
||||
});
|
||||
const first = xhrs.only();
|
||||
const cancellation = expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadCancelledError);
|
||||
|
||||
task.cancel();
|
||||
|
||||
await cancellation;
|
||||
expect(first.aborted).toBe(true);
|
||||
expect(xhrs.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("continues batch uploads after per-file failures and reports the failed file only", async () => {
|
||||
const xhrs = new FakeXhrQueue();
|
||||
const progress: WorkspaceUploadBatchProgress[] = [];
|
||||
@@ -146,6 +191,10 @@ class FakeXhrQueue {
|
||||
at(index: number): FakeXMLHttpRequest {
|
||||
return this.instances[index] ?? failTest(`missing XHR instance ${String(index)}`);
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.instances.length;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeXMLHttpRequest implements WorkspaceUploadXhr {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BrowserResumeController } from "./browserResumeController";
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve) => { resolveDeferred = resolve; });
|
||||
if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred };
|
||||
}
|
||||
|
||||
function frameHarness() {
|
||||
const frames: { callback: () => void; canceled: boolean }[] = [];
|
||||
return {
|
||||
scheduleFrame: (callback: () => void) => {
|
||||
const frame = { callback, canceled: false };
|
||||
frames.push(frame);
|
||||
return { cancel: () => { frame.canceled = true; } };
|
||||
},
|
||||
pendingCount: () => frames.filter((frame) => !frame.canceled).length,
|
||||
runNext: () => {
|
||||
const frame = frames.shift();
|
||||
if (frame === undefined) throw new Error("No scheduled frame");
|
||||
if (!frame.canceled) frame.callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("BrowserResumeController", () => {
|
||||
it("batches overlapping focus and visible signals into one app refresh", async () => {
|
||||
const windowTarget = new EventTarget();
|
||||
const documentTarget = new EventTarget();
|
||||
const frames = frameHarness();
|
||||
const refreshGate = deferred<undefined>();
|
||||
const refreshStarted = deferred<undefined>();
|
||||
const refreshCompleted = deferred<undefined>();
|
||||
const onResumeSignal = vi.fn();
|
||||
let visible = true;
|
||||
let refreshCalls = 0;
|
||||
const controller = new BrowserResumeController({
|
||||
onResumeSignal,
|
||||
refreshAfterResume: async () => {
|
||||
refreshCalls += 1;
|
||||
refreshStarted.resolve(undefined);
|
||||
await refreshGate.promise;
|
||||
refreshCompleted.resolve(undefined);
|
||||
},
|
||||
onRefreshError: (error) => { throw error; },
|
||||
}, {
|
||||
windowTarget,
|
||||
documentTarget,
|
||||
isDocumentVisible: () => visible,
|
||||
scheduleFrame: frames.scheduleFrame,
|
||||
});
|
||||
controller.connect();
|
||||
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
|
||||
expect(onResumeSignal).toHaveBeenCalledTimes(3);
|
||||
expect(frames.pendingCount()).toBe(1);
|
||||
expect(refreshCalls).toBe(0);
|
||||
|
||||
frames.runNext();
|
||||
await refreshStarted.promise;
|
||||
expect(refreshCalls).toBe(1);
|
||||
|
||||
visible = false;
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(onResumeSignal).toHaveBeenCalledTimes(3);
|
||||
expect(frames.pendingCount()).toBe(0);
|
||||
|
||||
refreshGate.resolve(undefined);
|
||||
await refreshCompleted.promise;
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
expect(frames.pendingCount()).toBe(1);
|
||||
controller.disconnect();
|
||||
frames.runNext();
|
||||
await Promise.resolve();
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
expect(onResumeSignal).toHaveBeenCalledTimes(4);
|
||||
expect(refreshCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("runs one trailing refresh when another resume arrives during active work", async () => {
|
||||
const windowTarget = new EventTarget();
|
||||
const documentTarget = new EventTarget();
|
||||
const frames = frameHarness();
|
||||
const firstGate = deferred<undefined>();
|
||||
const secondGate = deferred<undefined>();
|
||||
const firstStarted = deferred<undefined>();
|
||||
const secondStarted = deferred<undefined>();
|
||||
const secondCompleted = deferred<undefined>();
|
||||
let refreshCalls = 0;
|
||||
const controller = new BrowserResumeController({
|
||||
onResumeSignal: () => undefined,
|
||||
refreshAfterResume: async () => {
|
||||
refreshCalls += 1;
|
||||
if (refreshCalls === 1) {
|
||||
firstStarted.resolve(undefined);
|
||||
await firstGate.promise;
|
||||
return;
|
||||
}
|
||||
secondStarted.resolve(undefined);
|
||||
await secondGate.promise;
|
||||
secondCompleted.resolve(undefined);
|
||||
},
|
||||
onRefreshError: (error) => { throw error; },
|
||||
}, {
|
||||
windowTarget,
|
||||
documentTarget,
|
||||
isDocumentVisible: () => true,
|
||||
scheduleFrame: frames.scheduleFrame,
|
||||
});
|
||||
controller.connect();
|
||||
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
frames.runNext();
|
||||
await firstStarted.promise;
|
||||
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
expect(frames.pendingCount()).toBe(1);
|
||||
frames.runNext();
|
||||
expect(refreshCalls).toBe(1);
|
||||
|
||||
firstGate.resolve(undefined);
|
||||
await secondStarted.promise;
|
||||
expect(refreshCalls).toBe(2);
|
||||
|
||||
secondGate.resolve(undefined);
|
||||
await secondCompleted.promise;
|
||||
controller.disconnect();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TrailingRefreshCoordinator } from "../controllers/trailingRefreshCoordinator";
|
||||
|
||||
interface BrowserEventTarget {
|
||||
addEventListener(type: string, listener: EventListener): void;
|
||||
removeEventListener(type: string, listener: EventListener): void;
|
||||
}
|
||||
|
||||
interface ScheduledFrame {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export interface BrowserResumeCallbacks {
|
||||
onResumeSignal(): void;
|
||||
refreshAfterResume(): void | Promise<void>;
|
||||
onRefreshError(error: unknown): void;
|
||||
}
|
||||
|
||||
export interface BrowserResumeControllerOptions {
|
||||
windowTarget?: BrowserEventTarget | undefined;
|
||||
documentTarget?: BrowserEventTarget | undefined;
|
||||
isDocumentVisible?: (() => boolean) | undefined;
|
||||
scheduleFrame?: ((callback: () => void) => ScheduledFrame) | undefined;
|
||||
}
|
||||
|
||||
/** Owns browser resume listeners and batches focus/visibility refreshes per frame. */
|
||||
export class BrowserResumeController {
|
||||
private readonly windowTarget: BrowserEventTarget | undefined;
|
||||
private readonly documentTarget: BrowserEventTarget | undefined;
|
||||
private readonly isDocumentVisible: () => boolean;
|
||||
private readonly scheduleFrame: (callback: () => void) => ScheduledFrame;
|
||||
private readonly refreshes = new TrailingRefreshCoordinator<"browser-resume">();
|
||||
private scheduledRefresh: ScheduledFrame | undefined;
|
||||
private connected = false;
|
||||
|
||||
constructor(private readonly callbacks: BrowserResumeCallbacks, options: BrowserResumeControllerOptions = {}) {
|
||||
this.windowTarget = options.windowTarget ?? browserWindowTarget();
|
||||
this.documentTarget = options.documentTarget ?? browserDocumentTarget();
|
||||
this.isDocumentVisible = options.isDocumentVisible ?? documentIsVisible;
|
||||
this.scheduleFrame = options.scheduleFrame ?? scheduleBrowserFrame;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.connected) return;
|
||||
this.connected = true;
|
||||
this.windowTarget?.addEventListener("focus", this.onFocus);
|
||||
this.documentTarget?.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (!this.connected) return;
|
||||
this.connected = false;
|
||||
this.windowTarget?.removeEventListener("focus", this.onFocus);
|
||||
this.documentTarget?.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
this.scheduledRefresh?.cancel();
|
||||
this.scheduledRefresh = undefined;
|
||||
}
|
||||
|
||||
private readonly onFocus: EventListener = () => {
|
||||
this.handleResumeSignal();
|
||||
};
|
||||
|
||||
private readonly onVisibilityChange: EventListener = () => {
|
||||
if (this.isDocumentVisible()) this.handleResumeSignal();
|
||||
};
|
||||
|
||||
private handleResumeSignal(): void {
|
||||
this.callbacks.onResumeSignal();
|
||||
if (this.scheduledRefresh !== undefined) return;
|
||||
this.scheduledRefresh = this.scheduleFrame(() => {
|
||||
this.scheduledRefresh = undefined;
|
||||
if (!this.connected) return;
|
||||
void this.refreshes.request("browser-resume", async () => {
|
||||
if (this.connected) await this.callbacks.refreshAfterResume();
|
||||
}).catch((error: unknown) => { this.callbacks.onRefreshError(error); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function browserWindowTarget(): BrowserEventTarget | undefined {
|
||||
return typeof window === "undefined" ? undefined : window;
|
||||
}
|
||||
|
||||
function browserDocumentTarget(): BrowserEventTarget | undefined {
|
||||
return typeof document === "undefined" ? undefined : document;
|
||||
}
|
||||
|
||||
function documentIsVisible(): boolean {
|
||||
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||
}
|
||||
|
||||
function scheduleBrowserFrame(callback: () => void): ScheduledFrame {
|
||||
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
||||
const frame = window.requestAnimationFrame(() => { callback(); });
|
||||
return { cancel: () => { window.cancelAnimationFrame(frame); } };
|
||||
}
|
||||
const timer = globalThis.setTimeout(callback, 0);
|
||||
return { cancel: () => { globalThis.clearTimeout(timer); } };
|
||||
}
|
||||
@@ -80,15 +80,27 @@ describe("ViewportPositionRepairer", () => {
|
||||
const timer = firstMapEntry(scheduler.timers);
|
||||
expect(timer[1].delayMs).toBe(VIEWPORT_POSITION_REPAIR_DELAY_MS);
|
||||
|
||||
scheduler.documentElement.scrollTop = 56;
|
||||
scheduler.body.scrollTop = 78;
|
||||
scheduler.runAnimationFrame(firstFrame);
|
||||
expect(scheduler.scrollCalls).toHaveLength(2);
|
||||
expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0]]);
|
||||
expect(scheduler.documentElement.scrollTop).toBe(0);
|
||||
expect(scheduler.body.scrollTop).toBe(0);
|
||||
const secondFrame = firstMapKey(scheduler.animationFrames);
|
||||
|
||||
scheduler.documentElement.scrollTop = 90;
|
||||
scheduler.body.scrollTop = 123;
|
||||
scheduler.runAnimationFrame(secondFrame);
|
||||
expect(scheduler.scrollCalls).toHaveLength(3);
|
||||
expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0], [0, 0]]);
|
||||
expect(scheduler.documentElement.scrollTop).toBe(0);
|
||||
expect(scheduler.body.scrollTop).toBe(0);
|
||||
|
||||
scheduler.documentElement.scrollTop = 34;
|
||||
scheduler.body.scrollTop = 12;
|
||||
scheduler.runTimer(timer[0]);
|
||||
expect(scheduler.scrollCalls).toHaveLength(4);
|
||||
expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0], [0, 0], [0, 0]]);
|
||||
expect(scheduler.documentElement.scrollTop).toBe(0);
|
||||
expect(scheduler.body.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("replaces pending scheduled repairs", () => {
|
||||
@@ -102,6 +114,10 @@ describe("ViewportPositionRepairer", () => {
|
||||
|
||||
expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]);
|
||||
expect(scheduler.clearedTimers).toEqual([firstTimer]);
|
||||
expect(scheduler.animationFrames.has(firstFrame)).toBe(false);
|
||||
expect(scheduler.timers.has(firstTimer)).toBe(false);
|
||||
expect(scheduler.animationFrames.size).toBe(1);
|
||||
expect(scheduler.timers.size).toBe(1);
|
||||
});
|
||||
|
||||
it("clears pending work when repair is no longer needed", () => {
|
||||
@@ -115,5 +131,7 @@ describe("ViewportPositionRepairer", () => {
|
||||
|
||||
expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]);
|
||||
expect(scheduler.clearedTimers).toEqual([firstTimer]);
|
||||
expect(scheduler.animationFrames.size).toBe(0);
|
||||
expect(scheduler.timers.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAppUrl, resolveAppWebSocketUrl, type AppUrlContext } from "./appUrl";
|
||||
|
||||
const rootHttpContext: AppUrlContext = {
|
||||
viteBaseUrl: "/",
|
||||
documentBaseUrl: "http://pi.example.test/",
|
||||
};
|
||||
|
||||
const nestedHttpsContext: AppUrlContext = {
|
||||
viteBaseUrl: "./",
|
||||
documentBaseUrl: "https://pi.example.test/test/ai/",
|
||||
};
|
||||
|
||||
describe("application URLs", () => {
|
||||
it("resolves app-owned paths at an HTTP root deployment", () => {
|
||||
expect(resolveAppUrl("api/pi-web/status", rootHttpContext)).toBe("http://pi.example.test/api/pi-web/status");
|
||||
expect(resolveAppUrl("/pi-web-plugins/manifest.json", rootHttpContext)).toBe("http://pi.example.test/pi-web-plugins/manifest.json");
|
||||
});
|
||||
|
||||
it("resolves paths within a canonical nested HTTPS deployment", () => {
|
||||
expect(resolveAppUrl("api/pi-web/status", nestedHttpsContext)).toBe("https://pi.example.test/test/ai/api/pi-web/status");
|
||||
expect(resolveAppUrl("/pi-web-plugins/manifest.json", nestedHttpsContext)).toBe("https://pi.example.test/test/ai/pi-web-plugins/manifest.json");
|
||||
});
|
||||
|
||||
it("preserves encoded path segments and query parameters", () => {
|
||||
expect(resolveAppUrl("api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one&before=10", nestedHttpsContext))
|
||||
.toBe("https://pi.example.test/test/ai/api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one&before=10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("application WebSocket URLs", () => {
|
||||
it("maps root HTTP URLs to absolute ws URLs", () => {
|
||||
expect(resolveAppWebSocketUrl("api/machines/local/events", rootHttpContext)).toBe("ws://pi.example.test/api/machines/local/events");
|
||||
});
|
||||
|
||||
it("maps nested HTTPS URLs to absolute wss URLs without losing path or query data", () => {
|
||||
expect(resolveAppWebSocketUrl("api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one", nestedHttpsContext))
|
||||
.toBe("wss://pi.example.test/test/ai/api/machines/remote%20a/sessions/s%2F1/events?cwd=%2Frepo+one");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface AppUrlContext {
|
||||
viteBaseUrl: string;
|
||||
documentBaseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PI WEB-owned reference at a browser boundary.
|
||||
*
|
||||
* Core callers keep paths application-relative (no leading slash), encode every dynamic path segment,
|
||||
* and resolve exactly once. Leading slashes are accepted only for existing plugin-manifest compatibility
|
||||
* and mean the application root rather than the origin root.
|
||||
*/
|
||||
export function resolveAppUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string {
|
||||
const applicationBaseUrl = new URL(context.viteBaseUrl, context.documentBaseUrl);
|
||||
return new URL(appRelativePath(path), applicationBaseUrl).toString();
|
||||
}
|
||||
|
||||
export function resolveAppWebSocketUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string {
|
||||
const url = new URL(resolveAppUrl(path, context));
|
||||
if (url.protocol === "http:") {
|
||||
url.protocol = "ws:";
|
||||
} else if (url.protocol === "https:") {
|
||||
url.protocol = "wss:";
|
||||
} else {
|
||||
throw new Error(`Cannot create a WebSocket URL from ${url.protocol}`);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function browserAppUrlContext(): AppUrlContext {
|
||||
return {
|
||||
viteBaseUrl: import.meta.env.BASE_URL,
|
||||
documentBaseUrl: document.baseURI,
|
||||
};
|
||||
}
|
||||
|
||||
function appRelativePath(path: string): string {
|
||||
// A leading slash means the application root, not the origin root, so it must stay within nested deployments.
|
||||
return path.startsWith("/") ? `.${path}` : path;
|
||||
}
|
||||
@@ -57,9 +57,12 @@ describe("cached new sessions", () => {
|
||||
rememberCachedNewSession(baseSession, "local", storage);
|
||||
rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, "local", storage);
|
||||
|
||||
expect(mergeCachedNewSessions("/repo", [], "local", storage).map((session) => session.id)).toEqual(["session-1"]);
|
||||
expect(mergeCachedNewSessions("/repo", [baseSession], "local", storage).map((session) => session.id)).toEqual(["session-1"]);
|
||||
expect(isCachedNewSessionInfo(mergeCachedNewSessions("/repo", [baseSession], "local", storage)[0])).toBe(false);
|
||||
const cachedOnly = mergeCachedNewSessions("/repo", [], "local", storage);
|
||||
const mergedWithServerSession = mergeCachedNewSessions("/repo", [baseSession], "local", storage);
|
||||
|
||||
expect(cachedOnly.map((session) => session.id)).toEqual(["session-1"]);
|
||||
expect(mergedWithServerSession.map((session) => session.id)).toEqual(["session-1"]);
|
||||
expect(isCachedNewSessionInfo(mergedWithServerSession[0])).toBe(false);
|
||||
expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("ChatScrollController", () => {
|
||||
expect(JSON.parse(storage.getItem(key) ?? "{}")).toEqual({ mode: "bottom" });
|
||||
});
|
||||
|
||||
it("captures the session id when scheduling a delayed save", () => {
|
||||
it("cancels the previous delayed save and passes the latest session id", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const controller = new ChatScrollController(new MemoryScrollStorage(), scheduler);
|
||||
const saved: string[] = [];
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { writeClipboardText } from "./clipboard";
|
||||
|
||||
describe("writeClipboardText", () => {
|
||||
it("uses the synchronous fallback directly in insecure contexts", async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: false, writeText, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("uses the async Clipboard API in secure contexts", async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: true, writeText, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
expect(fallbackWriteText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when the async Clipboard API is unavailable", async () => {
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: true, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("falls back when the async Clipboard API rejects", async () => {
|
||||
const writeText = vi.fn(() => Promise.reject(new Error("denied")));
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: true, writeText, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
export interface ClipboardTextWriteHost {
|
||||
readonly isSecureContext: boolean;
|
||||
readonly writeText?: (text: string) => Promise<void>;
|
||||
readonly fallbackWriteText: (text: string) => boolean;
|
||||
}
|
||||
|
||||
export async function writeClipboardText(text: string, host: ClipboardTextWriteHost = browserClipboardTextWriteHost()): Promise<boolean> {
|
||||
if (!host.isSecureContext) return host.fallbackWriteText(text);
|
||||
|
||||
if (host.writeText !== undefined) {
|
||||
try {
|
||||
await host.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return host.fallbackWriteText(text);
|
||||
}
|
||||
}
|
||||
|
||||
return host.fallbackWriteText(text);
|
||||
}
|
||||
|
||||
function browserClipboardTextWriteHost(): ClipboardTextWriteHost {
|
||||
const fallbackWriteText = (text: string) => writeClipboardTextWithSelectionFallback(text);
|
||||
const writeText = browserClipboardWriteText();
|
||||
return writeText === undefined
|
||||
? { isSecureContext: browserIsSecureContext(), fallbackWriteText }
|
||||
: { isSecureContext: browserIsSecureContext(), writeText, fallbackWriteText };
|
||||
}
|
||||
|
||||
function browserIsSecureContext(): boolean {
|
||||
return typeof window !== "undefined" && window.isSecureContext;
|
||||
}
|
||||
|
||||
function browserClipboardWriteText(): ((text: string) => Promise<void>) | undefined {
|
||||
if (typeof navigator === "undefined" || !("clipboard" in navigator)) return undefined;
|
||||
return navigator.clipboard.writeText.bind(navigator.clipboard);
|
||||
}
|
||||
|
||||
function writeClipboardTextWithSelectionFallback(text: string): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
const selection = document.getSelection();
|
||||
const selectedRanges = selection === null ? [] : selectionRanges(selection);
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.readOnly = true;
|
||||
textarea.setAttribute("aria-hidden", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.width = "1px";
|
||||
textarea.style.height = "1px";
|
||||
textarea.style.padding = "0";
|
||||
textarea.style.border = "0";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.style.pointerEvents = "none";
|
||||
|
||||
document.body.append(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- Required for HTTP/private-network pages where navigator.clipboard is unavailable.
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
textarea.remove();
|
||||
restoreSelection(selection, selectedRanges);
|
||||
restoreFocus(activeElement);
|
||||
}
|
||||
}
|
||||
|
||||
function selectionRanges(selection: Selection): Range[] {
|
||||
const ranges: Range[] = [];
|
||||
for (let index = 0; index < selection.rangeCount; index += 1) {
|
||||
ranges.push(selection.getRangeAt(index));
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function restoreSelection(selection: Selection | null, ranges: readonly Range[]): void {
|
||||
if (selection === null) return;
|
||||
try {
|
||||
selection.removeAllRanges();
|
||||
for (const range of ranges) selection.addRange(range);
|
||||
} catch {
|
||||
// Restoring the prior selection is best-effort; the copy result should remain authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFocus(element: Element | null): void {
|
||||
if (typeof HTMLElement === "undefined" || !(element instanceof HTMLElement)) return;
|
||||
try {
|
||||
element.focus({ preventScroll: true });
|
||||
} catch {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatLine } from "./shared";
|
||||
import { ChatView, chatMessageMetadataLabel, 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" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chatMessageMetadataLabel", () => {
|
||||
it("uses one full date and model label without a model prefix", () => {
|
||||
const timestamp = "2026-07-10T19:15:30.000Z";
|
||||
const formattedTimestamp = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(new Date(timestamp));
|
||||
|
||||
expect(chatMessageMetadataLabel({
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
meta: { timestamp, model: { provider: "provider", id: "model" } },
|
||||
})).toBe(`${formattedTimestamp} · provider/model`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView technical-event groups", () => {
|
||||
const messages: ChatLine[] = [
|
||||
{ role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "inspect a file" }] },
|
||||
{ role: "tool", parts: [{ type: "toolExecution", toolName: "read", summary: "inspect a file", status: "success", resultText: "large result" }] },
|
||||
];
|
||||
|
||||
it("defers a closed body while retaining native disclosure and group scroll anchors", () => {
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
const bodyCalls = observeGroupBodyRenders(view);
|
||||
|
||||
const closed = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
expect(bodyCalls).toEqual([]);
|
||||
expect(templateStaticMarkup(closed)).toContain("<details");
|
||||
expect(templateStaticMarkup(closed)).toContain("<summary>");
|
||||
expect(templateStaticMarkup(closed)).toContain('aria-hidden="true"');
|
||||
expect(templateValuesAfterMarker(closed, "?open=")).toEqual([false]);
|
||||
expect(templateValuesAfterMarker(closed, "data-scroll-anchor-id=")).toEqual(["g:40"]);
|
||||
expect(templateValuesAfterMarker(closed, "data-marker-id=")).toEqual(["g:41"]);
|
||||
});
|
||||
|
||||
// Direct handler extraction keeps this node-environment test focused on the
|
||||
// native details toggle wiring without introducing a component-wide DOM shim.
|
||||
it("renders an opened body with event anchors and removes it when closed again", () => {
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
const bodyCalls = observeGroupBodyRenders(view);
|
||||
const initiallyClosed = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
dispatchDetailsToggle(templateEventHandler(initiallyClosed, "@toggle="), true);
|
||||
const opened = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]);
|
||||
expect(templateValuesAfterMarker(opened, "?open=")).toEqual([true]);
|
||||
expect(templateValuesAfterMarker(opened, "data-scroll-anchor-id=")).toEqual(["g:40", "e:40", "e:41"]);
|
||||
|
||||
bodyCalls.length = 0;
|
||||
dispatchDetailsToggle(templateEventHandler(opened, "@toggle="), false);
|
||||
const closedAgain = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
expect(bodyCalls).toEqual([]);
|
||||
expect(templateValuesAfterMarker(closedAgain, "?open=")).toEqual([false]);
|
||||
expect(templateValuesAfterMarker(closedAgain, "data-scroll-anchor-id=")).toEqual(["g:40"]);
|
||||
});
|
||||
|
||||
it("renders a live tail body by default", () => {
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
const bodyCalls = observeGroupBodyRenders(view);
|
||||
|
||||
const live = renderMessageGroup(view, messages, 40, 41, true);
|
||||
|
||||
expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]);
|
||||
expect(templateValuesAfterMarker(live, "?open=")).toEqual([true]);
|
||||
expect(templateValues(live)).toContain("msg event-group live");
|
||||
expect(templateValues(live)).toContain("live events");
|
||||
});
|
||||
});
|
||||
|
||||
interface GroupBodyRenderCall {
|
||||
messages: ChatLine[];
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
|
||||
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
|
||||
type TemplateEventHandler = (event: Event) => void;
|
||||
|
||||
function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult {
|
||||
const method: unknown = Reflect.get(view, "renderMessageGroup");
|
||||
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
|
||||
return method.call(view, messages, startIndex, endIndex, defaultOpen);
|
||||
}
|
||||
|
||||
function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
|
||||
const method: unknown = Reflect.get(view, "renderMessageGroupBody");
|
||||
if (!isRenderMessageGroupBody(method)) throw new Error("ChatView.renderMessageGroupBody is not callable");
|
||||
const calls: GroupBodyRenderCall[] = [];
|
||||
const observed: RenderMessageGroupBody = function (messages, startIndex) {
|
||||
calls.push({ messages, startIndex });
|
||||
return method.call(this, messages, startIndex);
|
||||
};
|
||||
if (!Reflect.set(view, "renderMessageGroupBody", observed)) throw new Error("Could not observe ChatView.renderMessageGroupBody");
|
||||
return calls;
|
||||
}
|
||||
|
||||
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isRenderMessageGroupBody(value: unknown): value is RenderMessageGroupBody {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler {
|
||||
const strings = templateStrings(template);
|
||||
const values = templateValues(template);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index];
|
||||
if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value;
|
||||
}
|
||||
throw new Error(`Expected template event handler after ${marker}`);
|
||||
}
|
||||
|
||||
function isTemplateEventHandler(value: unknown): value is TemplateEventHandler {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): void {
|
||||
const hadDetailsElement = Reflect.has(globalThis, "HTMLDetailsElement");
|
||||
const previousDetailsElement = Reflect.get(globalThis, "HTMLDetailsElement");
|
||||
class StubDetailsElement extends EventTarget {
|
||||
constructor(readonly open: boolean) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
Reflect.set(globalThis, "HTMLDetailsElement", StubDetailsElement);
|
||||
try {
|
||||
const details = new StubDetailsElement(open);
|
||||
details.addEventListener("toggle", (event) => { handler(event); });
|
||||
details.dispatchEvent(new Event("toggle"));
|
||||
} finally {
|
||||
if (hadDetailsElement) Reflect.set(globalThis, "HTMLDetailsElement", previousDetailsElement);
|
||||
else Reflect.deleteProperty(globalThis, "HTMLDetailsElement");
|
||||
}
|
||||
}
|
||||
|
||||
function templateStaticMarkup(template: TemplateResult): string {
|
||||
const chunks: string[] = [];
|
||||
visit(template);
|
||||
return chunks.join("");
|
||||
|
||||
function visit(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isTemplateResult(value)) return;
|
||||
chunks.push(...templateStrings(value));
|
||||
for (const child of templateValues(value)) visit(child);
|
||||
}
|
||||
}
|
||||
|
||||
function templateValuesAfterMarker(template: TemplateResult, marker: string): unknown[] {
|
||||
const matches: unknown[] = [];
|
||||
visit(template);
|
||||
return matches;
|
||||
|
||||
function visit(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isTemplateResult(value)) return;
|
||||
const strings = templateStrings(value);
|
||||
const values = templateValues(value);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (strings[index]?.includes(marker) === true) matches.push(values[index]);
|
||||
visit(values[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -3,18 +3,18 @@ import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { ChatDisclosureController } from "../chatDisclosure";
|
||||
import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
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";
|
||||
import "./FormattedText";
|
||||
import "./ToolExecutionView";
|
||||
|
||||
const shortTimestampFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
const fullTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
||||
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
||||
|
||||
const partialStreamNoticeBodies = [
|
||||
"You opened this chat while the assistant was already replying. The complete answer will appear shortly.",
|
||||
@@ -38,6 +38,41 @@ 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);
|
||||
}
|
||||
|
||||
export function chatMessageMetadataLabel(message: ChatLine): string {
|
||||
const timestamp = message.meta?.timestamp;
|
||||
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
|
||||
const model = chatMessageModelLabel(message);
|
||||
const parts = [time, model].filter((part): part is string => part !== undefined && part !== "");
|
||||
return parts.length === 0 ? "No Pi message metadata available" : parts.join(" · ");
|
||||
}
|
||||
|
||||
function formatMessageTimestamp(timestamp: string): string | undefined {
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isFinite(date.getTime())) return undefined;
|
||||
return messageTimestampFormatter.format(date);
|
||||
}
|
||||
|
||||
function chatMessageModelLabel(message: ChatLine): string | undefined {
|
||||
const model = message.meta?.model;
|
||||
if (model === undefined) return undefined;
|
||||
const id = model.responseId ?? model.id;
|
||||
if (id === undefined || id === "") return model.provider;
|
||||
return model.provider !== undefined && model.provider !== "" ? `${model.provider}/${id}` : id;
|
||||
}
|
||||
|
||||
@customElement("chat-view")
|
||||
export class ChatView extends LitElement {
|
||||
@property({ attribute: false }) messages: ChatLine[] = [];
|
||||
@@ -51,6 +86,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;
|
||||
@@ -69,7 +105,7 @@ export class ChatView extends LitElement {
|
||||
private groupedMessagesInput?: ChatLine[];
|
||||
private groupedMessagesStart = 0;
|
||||
private groupedMessagesCache: ChatGroup[] = [];
|
||||
private readonly messageMetaCache = new WeakMap<ChatLine, { short: string; full: string }>();
|
||||
private readonly messageMetaCache = new WeakMap<ChatLine, string>();
|
||||
private readonly messageCopyTextCache = new WeakMap<ChatLine, string>();
|
||||
private partialStreamNoticeBody: string | undefined;
|
||||
private lastScrollTop = 0;
|
||||
@@ -220,15 +256,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>
|
||||
@@ -352,21 +391,27 @@ export class ChatView extends LitElement {
|
||||
<b class="label">${defaultOpen ? "live events" : "events"}</b>
|
||||
<span>${summarizeChatGroup(messages)}</span>
|
||||
</summary>
|
||||
<div class="group-body">
|
||||
${messages.map((message, offset) => {
|
||||
const toolOnly = this.isToolExecutionOnlyMessage(message);
|
||||
return html`
|
||||
<section class=${toolOnly ? "group-msg tool-execution-shell" : `group-msg ${message.role}`} data-index=${startIndex + offset} data-scroll-anchor-id=${this.eventAnchorKey(startIndex + offset)}>
|
||||
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
|
||||
${message.parts.map((part) => this.renderPart(part, message))}
|
||||
</section>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
${open ? this.renderMessageGroupBody(messages, startIndex) : null}
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessageGroupBody(messages: ChatLine[], startIndex: number) {
|
||||
return html`
|
||||
<div class="group-body">
|
||||
${messages.map((message, offset) => {
|
||||
const toolOnly = this.isToolExecutionOnlyMessage(message);
|
||||
return html`
|
||||
<section class=${toolOnly ? "group-msg tool-execution-shell" : `group-msg ${message.role}`} data-index=${startIndex + offset} data-scroll-anchor-id=${this.eventAnchorKey(startIndex + offset)}>
|
||||
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
|
||||
${message.parts.map((part) => this.renderPart(part, message))}
|
||||
</section>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderScrollMarker(markerId: string) {
|
||||
return html`<span class="scroll-marker" data-marker-id=${markerId} aria-hidden="true"></span>`;
|
||||
}
|
||||
@@ -379,7 +424,7 @@ export class ChatView extends LitElement {
|
||||
<b class="label">${message.role}</b>
|
||||
<div class="msg-header-trailing">
|
||||
${this.renderMessageActions(message, key)}
|
||||
<span class=${expanded ? "msg-meta expanded" : "msg-meta"} role="button" tabindex="0" title=${meta.full} aria-label=${meta.full} aria-expanded=${String(expanded)} @click=${() => { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta.short}</span>
|
||||
<span class=${expanded ? "msg-meta expanded" : "msg-meta"} role="button" tabindex="0" title=${meta} aria-label=${meta} aria-expanded=${String(expanded)} @click=${() => { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -421,55 +466,23 @@ export class ChatView extends LitElement {
|
||||
|
||||
private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise<void> {
|
||||
event.stopPropagation();
|
||||
const ok = await this.writeClipboard(this.messageCopyText(message));
|
||||
if (!ok) return;
|
||||
const copied = await writeClipboardText(this.messageCopyText(message));
|
||||
if (!copied) return;
|
||||
this.copiedMessageKey = key;
|
||||
window.setTimeout(() => {
|
||||
if (this.copiedMessageKey === key) this.copiedMessageKey = undefined;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
private async writeClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private messageMetaLabel(message: ChatLine): { short: string; full: string } {
|
||||
private messageMetaLabel(message: ChatLine): string {
|
||||
const cached = this.messageMetaCache.get(message);
|
||||
if (cached !== undefined) return cached;
|
||||
const timestamp = message.meta?.timestamp;
|
||||
const model = this.modelLabel(message);
|
||||
if (timestamp === undefined && model === undefined) {
|
||||
const empty = { short: "no info", full: "No Pi message metadata available" };
|
||||
this.messageMetaCache.set(message, empty);
|
||||
return empty;
|
||||
}
|
||||
const time = timestamp === undefined ? undefined : this.formatTimestamp(timestamp);
|
||||
const parts = [time?.short, model].filter((part): part is string => part !== undefined && part !== "");
|
||||
const fullParts = [time?.full, model === undefined ? undefined : `Model: ${model}`].filter((part): part is string => part !== undefined && part !== "");
|
||||
const label = { short: parts.join(" · "), full: fullParts.join(" · ") };
|
||||
const label = chatMessageMetadataLabel(message);
|
||||
this.messageMetaCache.set(message, label);
|
||||
return label;
|
||||
}
|
||||
|
||||
private formatTimestamp(timestamp: string): { short: string; full: string } | undefined {
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isFinite(date.getTime())) return undefined;
|
||||
return { short: shortTimestampFormatter.format(date), full: fullTimestampFormatter.format(date) };
|
||||
}
|
||||
|
||||
private modelLabel(message: ChatLine): string | undefined {
|
||||
const model = message.meta?.model;
|
||||
if (model === undefined) return undefined;
|
||||
const id = model.responseId ?? model.id;
|
||||
if (id === undefined || id === "") return model.provider;
|
||||
return model.provider !== undefined && model.provider !== "" ? `${model.provider}/${id}` : id;
|
||||
}
|
||||
|
||||
private renderPart(part: ChatPart, message?: ChatLine) {
|
||||
if (part.type === "text" && message?.role === "bash") return html`<pre class="part shell-output">${part.text}</pre>`;
|
||||
if (part.type === "text") return html`<formatted-text class="part" .text=${part.text}></formatted-text>`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
import { toSafeMarkdownHtml } from "../formatting/markdown";
|
||||
import { formattedTextStyles } from "./shared";
|
||||
|
||||
@@ -49,8 +50,8 @@ export class FormattedText extends LitElement {
|
||||
};
|
||||
|
||||
private async copyCode(text: string, button: HTMLButtonElement): Promise<void> {
|
||||
const ok = await writeClipboard(text);
|
||||
this.setCopyButtonState(button, ok ? "copied" : "failed");
|
||||
const copied = await writeClipboardText(text);
|
||||
this.setCopyButtonState(button, copied ? "copied" : "failed");
|
||||
window.setTimeout(() => {
|
||||
this.setCopyButtonState(button, "idle");
|
||||
}, 1200);
|
||||
@@ -66,12 +67,3 @@ export class FormattedText extends LitElement {
|
||||
|
||||
static override styles = formattedTextStyles;
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, 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 { configApi, effectiveWorkspaceUploadFolder, 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";
|
||||
@@ -11,6 +11,7 @@ import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||
import { GitController } from "../controllers/gitController";
|
||||
import { MachineController } from "../controllers/machineController";
|
||||
import { ProjectController } from "../controllers/projectController";
|
||||
import { PiWebStatusController } from "../controllers/piWebStatusController";
|
||||
import { SessionController } from "../controllers/sessionController";
|
||||
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
||||
import { emptyMachineNavigationSnapshot, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, SessionStorageMachineNavigationMemory, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory";
|
||||
@@ -20,6 +21,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
|
||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||
import { selectedMachineId } from "../controllers/types";
|
||||
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
|
||||
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
|
||||
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";
|
||||
@@ -29,6 +31,7 @@ import { loadExternalPlugins } from "../plugins/external";
|
||||
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
|
||||
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||
import { AppShellController } from "../appShell/appShellController";
|
||||
import { BrowserResumeController } from "../appShell/browserResumeController";
|
||||
import { NavigationSectionsController, type NavigationSection } from "../appShell/navigationState";
|
||||
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
|
||||
import { PanelResizeController, type PanelResizeConstraints, type ResizablePanelSide } from "../appShell/panelResizeController";
|
||||
@@ -130,6 +133,11 @@ export class PiWebApp extends LitElement {
|
||||
() => { this.updateUrl(); },
|
||||
this.projects,
|
||||
);
|
||||
private readonly piWebStatusController = new PiWebStatusController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
{ onRefreshError: (machineId, error) => { console.warn(`Failed to refresh PI WEB status for ${machineId}`, error); } },
|
||||
);
|
||||
private readonly files = new FileExplorerController(
|
||||
() => this.state,
|
||||
(patch) => { this.setState(patch); },
|
||||
@@ -147,6 +155,11 @@ export class PiWebApp extends LitElement {
|
||||
private readonly machineNavigation = new SessionStorageMachineNavigationMemory();
|
||||
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
|
||||
private readonly appShell = new AppShellController(this);
|
||||
private readonly browserResume = new BrowserResumeController({
|
||||
onResumeSignal: () => { this.handleBrowserResumeSignal(); },
|
||||
refreshAfterResume: () => this.refreshAfterBrowserResume(),
|
||||
onRefreshError: (error) => { console.warn("Failed to refresh after browser resume", error); },
|
||||
});
|
||||
private readonly panelCollapse = new PanelCollapseController(this);
|
||||
private readonly panelResize = new PanelResizeController(this);
|
||||
private readonly navigationSections = new NavigationSectionsController(
|
||||
@@ -190,24 +203,6 @@ export class PiWebApp extends LitElement {
|
||||
this.appShell.repairViewportPosition();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
};
|
||||
private readonly onFocus = () => {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
void this.refreshMachineActivities();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
};
|
||||
private readonly onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
void this.refreshMachineActivities();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
}
|
||||
};
|
||||
private readonly onSystemLightThemeChange = () => {
|
||||
if (this.themePreference.auto) this.applyPreferredTheme(false);
|
||||
};
|
||||
@@ -231,8 +226,7 @@ export class PiWebApp extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("popstate", this.onPopState);
|
||||
window.addEventListener("pageshow", this.onPageShow);
|
||||
window.addEventListener("focus", this.onFocus);
|
||||
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
this.browserResume.connect();
|
||||
window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
|
||||
this.applyPreferredTheme(false);
|
||||
@@ -247,8 +241,7 @@ export class PiWebApp extends LitElement {
|
||||
override disconnectedCallback(): void {
|
||||
window.removeEventListener("popstate", this.onPopState);
|
||||
window.removeEventListener("pageshow", this.onPageShow);
|
||||
window.removeEventListener("focus", this.onFocus);
|
||||
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
this.browserResume.disconnect();
|
||||
window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange);
|
||||
this.keyboard.reset();
|
||||
@@ -293,11 +286,25 @@ export class PiWebApp extends LitElement {
|
||||
await this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
|
||||
private handleBrowserResumeSignal(): void {
|
||||
this.appShell.repairViewportPosition();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
}
|
||||
|
||||
private async refreshAfterBrowserResume(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.sessions.refreshSelectedSession(),
|
||||
this.refreshMachineActivities(),
|
||||
this.refreshWorkspaceDeletionRuns(),
|
||||
]);
|
||||
}
|
||||
|
||||
private schedulePiWebStatusRefresh(delayMs = PI_WEB_STATUS_DEFER_MS): void {
|
||||
this.clearScheduledPiWebStatusRefresh();
|
||||
this.piWebStatusDeferredTimer = window.setTimeout(() => {
|
||||
this.piWebStatusDeferredTimer = undefined;
|
||||
void this.refreshPiWebStatus();
|
||||
void this.piWebStatusController.refresh();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
@@ -307,17 +314,6 @@ export class PiWebApp extends LitElement {
|
||||
this.piWebStatusDeferredTimer = undefined;
|
||||
}
|
||||
|
||||
private async refreshPiWebStatus(): Promise<void> {
|
||||
const machineId = selectedMachineId(this.state);
|
||||
try {
|
||||
const piWebStatus = await piWebApi.piWebStatus(machineId);
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus });
|
||||
} catch (error) {
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined });
|
||||
console.warn(`Failed to refresh PI WEB status for ${machineId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshWorkspaceActivity(machineId = selectedMachineId(this.state)): Promise<void> {
|
||||
try {
|
||||
await this.activity.refresh(machineId);
|
||||
@@ -1016,7 +1012,10 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private canDeleteArchivedSessions(): boolean {
|
||||
const runtime = this.selectedMachineRuntime();
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
|
||||
// COMPAT-CAP sessions.deleteArchived: older federated machines may support
|
||||
// the legacy DELETE route without advertising runtime capabilities. Only
|
||||
// block when capability discovery succeeds and reports no support.
|
||||
return runtime?.ok !== true || supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
|
||||
}
|
||||
|
||||
private canReloadSessions(): boolean {
|
||||
@@ -1029,6 +1028,10 @@ export class PiWebApp extends LitElement {
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
|
||||
}
|
||||
|
||||
private hasAuthoritativeSessionPersistence(): boolean {
|
||||
return runtimeHasAuthoritativeSessionPersistence(this.selectedMachineRuntime());
|
||||
}
|
||||
|
||||
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
|
||||
if (machineId === "local") return true;
|
||||
// COMPAT-CAP workspace.fileSuggestions: remote machines without this
|
||||
@@ -1119,10 +1122,12 @@ 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()}
|
||||
.authoritativeSessionPersistence=${this.hasAuthoritativeSessionPersistence()}
|
||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
|
||||
.cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()}
|
||||
.collapsible=${true}
|
||||
@@ -1141,7 +1146,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)}
|
||||
@@ -1176,6 +1181,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();
|
||||
@@ -1465,7 +1488,7 @@ export class PiWebApp extends LitElement {
|
||||
const existing = this.machinePluginLoadPromises.get(machine.id);
|
||||
if (existing !== undefined) return existing;
|
||||
|
||||
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, {
|
||||
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, {
|
||||
machineId: machine.id,
|
||||
shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific),
|
||||
}))
|
||||
@@ -1545,9 +1568,10 @@ export class PiWebApp extends LitElement {
|
||||
refreshFiles: () => this.files.refreshFiles(),
|
||||
refreshGit: () => this.git.refreshGit(),
|
||||
refreshAppData: () => this.refreshAppData(),
|
||||
checkForPiWebUpdates: () => this.piWebStatusController.checkForUpdates(),
|
||||
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(),
|
||||
@@ -1830,6 +1854,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`
|
||||
@@ -1888,8 +1931,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}
|
||||
@@ -1904,7 +1947,7 @@ export class PiWebApp extends LitElement {
|
||||
${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; }
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
|
||||
import { inputModeForDraft } from "../inputModes";
|
||||
import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
|
||||
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
@@ -42,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[] = [];
|
||||
@@ -64,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 {
|
||||
@@ -84,8 +103,8 @@ 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`
|
||||
@@ -94,7 +113,7 @@ export class PromptEditor extends LitElement {
|
||||
<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" 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${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
|
||||
${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>
|
||||
@@ -288,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();
|
||||
}
|
||||
|
||||
@@ -432,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;
|
||||
|
||||
@@ -233,7 +233,7 @@ export class SessionCleanupDialog extends LitElement {
|
||||
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: inherit; }
|
||||
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); }
|
||||
|
||||
@@ -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,40 @@ describe("sessionRowActivityKind", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session action eligibility", () => {
|
||||
it("requires a persisted server signal before archiving when persistence is authoritative", () => {
|
||||
const authoritative = { authoritative: true };
|
||||
expect(isArchivableSessionInfo(session("persisted", { persisted: true }), undefined, authoritative)).toBe(true);
|
||||
expect(isArchivableSessionInfo(session("unknown"), undefined, authoritative)).toBe(false);
|
||||
expect(isArchivableSessionInfo(session("transient", { persisted: false }), undefined, authoritative)).toBe(false);
|
||||
expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }, undefined, authoritative)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves legacy archiving when persistence support is not advertised", () => {
|
||||
expect(isArchivableSessionInfo(session("legacy"))).toBe(true);
|
||||
expect(isTransientNewSessionInfo(session("legacy"))).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 +93,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,12 @@ 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: Boolean }) authoritativeSessionPersistence = 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;
|
||||
@@ -109,6 +113,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))}
|
||||
@@ -130,7 +135,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
Sessions
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
${this.renderCleanupButton()}
|
||||
<button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button>
|
||||
${this.renderStartButton()}
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
@@ -142,7 +147,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
<small class="section-count">${sessionCount}</small>
|
||||
${this.renderCleanupButton()}
|
||||
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
|
||||
${this.renderStartButton()}
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
@@ -157,6 +162,23 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
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`
|
||||
@@ -172,7 +194,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], this.sessionPersistenceOptions()));
|
||||
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`
|
||||
@@ -211,6 +233,12 @@ 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 persistenceOptions = this.sessionPersistenceOptions();
|
||||
const canArchive = isArchivableSessionInfo(session, status, persistenceOptions);
|
||||
const canDeleteTransient = isTransientNewSessionInfo(session, status, persistenceOptions);
|
||||
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" : ""}"
|
||||
@@ -222,25 +250,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" 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)}${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}
|
||||
@@ -287,7 +317,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.sessionPersistenceOptions()));
|
||||
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
|
||||
void this.onArchiveMany?.(sessions);
|
||||
}
|
||||
@@ -366,12 +396,20 @@ 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, this.sessionPersistenceOptions())) {
|
||||
if (activity?.phase === "active") return "creating · ";
|
||||
if (activity?.phase === "error") return "error · ";
|
||||
return "new · ";
|
||||
}
|
||||
if (session.archived === true) return "read-only · ";
|
||||
return "";
|
||||
}
|
||||
|
||||
private sessionPersistenceOptions() {
|
||||
return { authoritative: this.authoritativeSessionPersistence };
|
||||
}
|
||||
|
||||
private renderActivity(session: SessionInfo) {
|
||||
const kind = sessionRowActivityKind(session, this.statuses[session.id], this.activities[session.id], this.sending[session.id] === true);
|
||||
return renderActionActivityIndicator(kind, kind === "sending" ? "Sending message" : "Session active");
|
||||
@@ -381,6 +419,7 @@ 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; }
|
||||
@@ -391,6 +430,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
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,182 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configApi, type PiWebConfigResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, collectTemplateStrings, configResponse, deferred, getDialogProperty, remoteMachine, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { piPackagesApi, pluginsApi, type PiPackageMutationResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, deferred, getDialogProperty, packageInfo, packageMutationResponse, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement, secondRemoteMachine } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog Pi package orchestration", () => {
|
||||
it("loads package data from the selected machine and ignores stale target responses", async () => {
|
||||
const remotePackages = { packages: [packageInfo("npm:@acme/tools")] };
|
||||
const staleLoad = deferred<typeof remotePackages>();
|
||||
const packagesSpy = vi.spyOn(piPackagesApi, "packages").mockReturnValue(staleLoad.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithPackageManagement;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadPackagesForTarget");
|
||||
expect(packagesSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "packageLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
staleLoad.resolve(remotePackages);
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("runs remote package mutations against the selected machine without refreshing gateway plugins", async () => {
|
||||
const installedPackages = [packageInfo("npm:@acme/new-tools")];
|
||||
const install = deferred<PiPackageMutationResponse>();
|
||||
const installSpy = vi.spyOn(piPackagesApi, "install").mockReturnValue(install.promise);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("gateway", true)]));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithPackageManagement;
|
||||
|
||||
const installPromise = callDialogPromise(dialog, "installPiPackage", "npm:@acme/new-tools");
|
||||
|
||||
expect(installSpy.mock.calls).toEqual([["npm:@acme/new-tools", "remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
expect(getDialogProperty(dialog, "packageOperation")).toEqual({ kind: "install", source: "npm:@acme/new-tools" });
|
||||
|
||||
install.resolve(packageMutationResponse("install", installedPackages, "npm:@acme/new-tools"));
|
||||
await installPromise;
|
||||
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: installedPackages });
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("Pi package installed on Lab Mac");
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("each idle PI WEB session on Lab Mac");
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageOperation")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshes gateway plugins after a local package mutation", async () => {
|
||||
const updatedPackages = [packageInfo("npm:@acme/tools")];
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("browser-helper", true)]);
|
||||
const updateSpy = vi.spyOn(piPackagesApi, "update").mockResolvedValue(packageMutationResponse("update", updatedPackages));
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const dialog = new SettingsDialog();
|
||||
|
||||
await callDialogPromise(dialog, "updatePiPackage");
|
||||
|
||||
expect(updateSpy.mock.calls).toEqual([[undefined, "local"]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([[]]);
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: updatedPackages });
|
||||
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("Reload the browser page separately for PI WEB browser plugin changes");
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { Machine, MachineRuntime, PiPackageInfo, PiPackageMutationResponse, PiWebConfigResponse, PiWebConfigValues, PiWebPluginInfo, PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
|
||||
export 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",
|
||||
};
|
||||
|
||||
export 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",
|
||||
};
|
||||
|
||||
export const runtimeWithPackageManagement: MachineRuntime = {
|
||||
machineId: "remote-a",
|
||||
ok: true,
|
||||
checkedAt: "2026-07-01T00:00:00.000Z",
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
|
||||
};
|
||||
|
||||
export function getDialogProperty(dialog: SettingsDialog, property: string): unknown {
|
||||
return Reflect.get(dialog, property);
|
||||
}
|
||||
|
||||
export function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void {
|
||||
if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`);
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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";
|
||||
}
|
||||
|
||||
export 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");
|
||||
}
|
||||
|
||||
export 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, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse {
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
export function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
|
||||
return {
|
||||
id,
|
||||
module: `/pi-web-plugins/${id}/plugin.js`,
|
||||
source: "test",
|
||||
scope: "local",
|
||||
machineSpecific: false,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function packageInfo(source: string): PiPackageInfo {
|
||||
return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` };
|
||||
}
|
||||
|
||||
export function packageMutationResponse(action: PiPackageMutationResponse["action"], packages: PiPackageInfo[], source?: string): PiPackageMutationResponse {
|
||||
return source === undefined ? { action, packages } : { action, source, packages };
|
||||
}
|
||||
|
||||
export interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export 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);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { css, html, LitElement, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { styleMap, type StyleInfo } from "lit/directives/style-map.js";
|
||||
import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm";
|
||||
import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection";
|
||||
import { createTerminalCopySnapshot, DEFAULT_TERMINAL_ANSI_THEME, type TerminalCopyRunStyle, type TerminalCopySnapshot } from "../terminalCopySnapshot";
|
||||
import { createTerminalSoftKeysDefaultEnvironmentMedia, hasTerminalSoftKeysPreference, initialTerminalSoftKeysEnabled, isTerminalSoftKeysDefaultEnvironment, writeTerminalSoftKeysPreference } from "../terminalSoftKeysPreference";
|
||||
import "./TerminalSoftKeys";
|
||||
import type { TerminalSoftKeyInputOptions } from "./TerminalSoftKeys";
|
||||
@@ -27,6 +30,8 @@ export class TerminalPanel extends LitElement {
|
||||
@property({ type: Boolean }) autoStart = false;
|
||||
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
|
||||
@query(".terminal-host") private terminalHost?: HTMLDivElement | null;
|
||||
@query(".terminal-copy-content") private terminalCopyContent?: HTMLPreElement | null;
|
||||
@query(".terminal-copy-selector") private terminalCopySelector?: HTMLTextAreaElement | null;
|
||||
@state() private terminals: TerminalInfo[] = [];
|
||||
@state() private commandRuns: TerminalCommandRun[] = [];
|
||||
@state() private selectedId: string | undefined;
|
||||
@@ -37,6 +42,8 @@ export class TerminalPanel extends LitElement {
|
||||
@state() private continuingTerminalIds: string[] = [];
|
||||
@state() private defaultSoftKeysEnvironment = false;
|
||||
@state() private softKeysEnabled = initialTerminalSoftKeysEnabled();
|
||||
@state() private copySnapshot: TerminalCopySnapshot | undefined;
|
||||
@state() private copyStatus: string | undefined;
|
||||
|
||||
private terminal: Terminal | undefined;
|
||||
private fitAddon: FitAddon | undefined;
|
||||
@@ -311,7 +318,7 @@ export class TerminalPanel extends LitElement {
|
||||
this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); });
|
||||
this.resizeObserver.observe(terminalHost);
|
||||
terminal.onData((data) => {
|
||||
if (this.suppressTerminalInput) return;
|
||||
if (this.suppressTerminalInput || this.copySnapshot !== undefined) return;
|
||||
this.sendTerminalInput(data);
|
||||
});
|
||||
const initialSize = this.fitTerminal();
|
||||
@@ -406,6 +413,7 @@ export class TerminalPanel extends LitElement {
|
||||
}
|
||||
|
||||
private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void {
|
||||
if (this.copySnapshot !== undefined) return;
|
||||
this.sendTerminalInput(data);
|
||||
if (options.refocus) this.focusTerminal();
|
||||
}
|
||||
@@ -429,6 +437,8 @@ export class TerminalPanel extends LitElement {
|
||||
this.terminal?.dispose();
|
||||
this.terminal = undefined;
|
||||
this.fitAddon = undefined;
|
||||
this.copySnapshot = undefined;
|
||||
this.copyStatus = undefined;
|
||||
}
|
||||
|
||||
private renderCommandRunNotice() {
|
||||
@@ -464,20 +474,147 @@ export class TerminalPanel extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
private enterCopyMode(): void {
|
||||
if (this.copySnapshot !== undefined) return;
|
||||
this.captureCopySnapshot();
|
||||
}
|
||||
|
||||
private refreshCopyMode(): void {
|
||||
if (this.copySnapshot === undefined) return;
|
||||
this.captureCopySnapshot();
|
||||
}
|
||||
|
||||
private captureCopySnapshot(): void {
|
||||
const terminal = this.terminal;
|
||||
if (terminal === undefined) return;
|
||||
const snapshot = createTerminalCopySnapshot(terminal.buffer.active, terminal.cols, {
|
||||
theme: terminal.options.theme,
|
||||
drawBoldTextInBrightColors: terminal.options.drawBoldTextInBrightColors,
|
||||
});
|
||||
this.copySnapshot = snapshot;
|
||||
this.copyStatus = undefined;
|
||||
terminal.blur();
|
||||
void this.updateComplete.then(() => {
|
||||
const selector = this.terminalCopySelector;
|
||||
if (selector === null || selector === undefined) return;
|
||||
const sourceScrollRange = Math.max(0, snapshot.physicalLineCount - terminal.rows);
|
||||
const sourceScrollTop = Math.min(sourceScrollRange, snapshot.viewportLine);
|
||||
const scrollRatio = sourceScrollRange === 0 ? 0 : sourceScrollTop / sourceScrollRange;
|
||||
selector.scrollTop = scrollRatio * Math.max(0, selector.scrollHeight - selector.clientHeight);
|
||||
this.syncCopySnapshotScroll();
|
||||
});
|
||||
}
|
||||
|
||||
private exitCopyMode(): void {
|
||||
if (this.copySnapshot === undefined) return;
|
||||
this.copySnapshot = undefined;
|
||||
this.copyStatus = undefined;
|
||||
}
|
||||
|
||||
// iOS WebKit offsets native selection hit-testing in a scrolled generic
|
||||
// overflow container. A textarea owns selection and scrolling while the
|
||||
// synchronized, noninteractive pre preserves the terminal's ANSI styling.
|
||||
// Keep its caret visible: iOS hides native selection handles with the caret.
|
||||
private syncCopySnapshotScroll(): void {
|
||||
const selector = this.terminalCopySelector;
|
||||
const content = this.terminalCopyContent;
|
||||
if (selector === null || selector === undefined || content === null || content === undefined) return;
|
||||
const selectorVerticalRange = Math.max(0, selector.scrollHeight - selector.clientHeight);
|
||||
const contentVerticalRange = Math.max(0, content.scrollHeight - content.clientHeight);
|
||||
const selectorHorizontalRange = Math.max(0, selector.scrollWidth - selector.clientWidth);
|
||||
const contentHorizontalRange = Math.max(0, content.scrollWidth - content.clientWidth);
|
||||
content.scrollTop = normalizedScrollOffset(selector.scrollTop, selectorVerticalRange, contentVerticalRange);
|
||||
content.scrollLeft = normalizedScrollOffset(selector.scrollLeft, selectorHorizontalRange, contentHorizontalRange);
|
||||
}
|
||||
|
||||
private async copyAllSnapshotText(): Promise<void> {
|
||||
const text = this.copySnapshot?.text ?? "";
|
||||
if (text === "") {
|
||||
this.copyStatus = "No terminal output to copy.";
|
||||
return;
|
||||
}
|
||||
this.copyStatus = await writeClipboardText(text) ? "Copied all terminal output." : "Unable to copy terminal output.";
|
||||
}
|
||||
|
||||
private renderCopyModeToggle() {
|
||||
if (this.selectedId === undefined) return null;
|
||||
const active = this.copySnapshot !== undefined;
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class=${active ? "copy-mode-toggle selected" : "copy-mode-toggle"}
|
||||
title=${active ? "Return to the interactive terminal" : "Select and copy terminal output"}
|
||||
aria-label=${active ? "Close terminal copy mode" : "Open terminal copy mode"}
|
||||
aria-pressed=${String(active)}
|
||||
@click=${() => { if (active) this.exitCopyMode(); else this.enterCopyMode(); }}
|
||||
>
|
||||
<span>${active ? "Done" : "Select"}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCopyModeToolbar() {
|
||||
const snapshot = this.copySnapshot;
|
||||
if (snapshot === undefined) return null;
|
||||
return html`
|
||||
<div class="terminal-copy-toolbar" role="toolbar" aria-label="Terminal copy controls">
|
||||
<span aria-live="polite">${this.copyStatus ?? "Snapshot · long-press and select text"}</span>
|
||||
<small>${snapshot.physicalLineCount} ${snapshot.physicalLineCount === 1 ? "row" : "rows"}</small>
|
||||
<button type="button" @click=${() => { this.refreshCopyMode(); }}>Refresh</button>
|
||||
<button type="button" @click=${() => { void this.copyAllSnapshotText(); }}>Copy all</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCopyMode() {
|
||||
const snapshot = this.copySnapshot;
|
||||
if (snapshot === undefined) return null;
|
||||
return html`
|
||||
<section class="terminal-copy-view" aria-label="Terminal copy mode">
|
||||
${this.copyToolbarReplacesSoftKeys() ? null : this.renderCopyModeToolbar()}
|
||||
<div class="terminal-copy-layers">
|
||||
<pre class="terminal-copy-content" aria-hidden="true">${snapshot.lines.map((line, index) => html`${index === 0 ? null : "\n"}${line.runs.map((run) => html`<span style=${styleMap(terminalCopyRunStyle(run.style))}>${run.text}</span>`)}`)}</pre>
|
||||
<textarea
|
||||
class="terminal-copy-selector"
|
||||
readonly
|
||||
inputmode="none"
|
||||
wrap="soft"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
aria-label="Selectable terminal output"
|
||||
.value=${snapshot.text}
|
||||
@scroll=${() => { this.syncCopySnapshotScroll(); }}
|
||||
></textarea>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private selectedTerminalAcceptsInput(): boolean {
|
||||
const terminal = this.selectedTerminalInfo();
|
||||
return terminal !== undefined && !terminal.exited;
|
||||
}
|
||||
|
||||
private copyToolbarReplacesSoftKeys(): boolean {
|
||||
return this.copySnapshot !== undefined && this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
|
||||
}
|
||||
|
||||
private renderTerminalAccessoryBar() {
|
||||
if (this.copySnapshot !== undefined) return this.copyToolbarReplacesSoftKeys() ? this.renderCopyModeToolbar() : null;
|
||||
return this.shouldShowSoftKeys() ? this.renderSoftKeys() : null;
|
||||
}
|
||||
|
||||
private shouldShowSoftKeys(): boolean {
|
||||
return this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
|
||||
}
|
||||
|
||||
private shouldShowSoftKeysToggle(): boolean {
|
||||
return this.selectedTerminalAcceptsInput();
|
||||
return this.copySnapshot === undefined && this.selectedTerminalAcceptsInput();
|
||||
}
|
||||
|
||||
private toggleSoftKeys(): void {
|
||||
if (this.copySnapshot !== undefined) return;
|
||||
this.softKeysEnabled = !this.softKeysEnabled;
|
||||
this.softKeysPreferenceStored = true;
|
||||
writeTerminalSoftKeysPreference(this.softKeysEnabled);
|
||||
@@ -518,6 +655,7 @@ export class TerminalPanel extends LitElement {
|
||||
return html`
|
||||
<section class="terminal-shell">
|
||||
<div class="terminal-tabs">
|
||||
${this.renderCopyModeToggle()}
|
||||
${this.renderSoftKeysToggle()}
|
||||
${this.terminals.map((terminal) => html`
|
||||
<button class=${this.selectedId === terminal.id ? "selected" : ""} @click=${() => { this.selectTerminal(terminal.id); }}>
|
||||
@@ -529,9 +667,12 @@ export class TerminalPanel extends LitElement {
|
||||
</div>
|
||||
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
|
||||
${this.renderCommandRunNotice()}
|
||||
${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null}
|
||||
${this.renderTerminalAccessoryBar()}
|
||||
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
|
||||
<div class="terminal-host"></div>
|
||||
<div class="terminal-stage">
|
||||
<div class=${this.copySnapshot === undefined ? "terminal-host" : "terminal-host copying"} ?inert=${this.copySnapshot !== undefined}></div>
|
||||
${this.renderCopyMode()}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -540,11 +681,19 @@ export class TerminalPanel extends LitElement {
|
||||
:host { flex: 1 1 auto; min-height: 0; display: flex; }
|
||||
.terminal-shell { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--pi-terminal-bg); }
|
||||
.terminal-tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow: auto; }
|
||||
.terminal-tabs > button { box-sizing: border-box; height: 30px; line-height: 16px; }
|
||||
/* Desktop xterm already has mouse selection and hardware keys; keep touch controls to touch/narrow layouts. */
|
||||
.copy-mode-toggle, .soft-keys-toggle, terminal-soft-keys { display: none; }
|
||||
.copy-mode-toggle.selected { display: inline-flex; }
|
||||
@media (pointer: coarse), (max-width: 760px) {
|
||||
.copy-mode-toggle, .soft-keys-toggle { display: inline-flex; }
|
||||
terminal-soft-keys { display: block; }
|
||||
}
|
||||
button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
|
||||
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
button.new { flex: 0 0 auto; color: var(--pi-muted); }
|
||||
.soft-keys-toggle { flex: 0 0 auto; }
|
||||
.soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
.soft-keys-toggle .keyboard-icon { display: block; flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
button small { color: var(--pi-muted); font-size: 14px; line-height: 1; }
|
||||
button small:hover { color: var(--pi-danger); }
|
||||
@@ -558,7 +707,20 @@ export class TerminalPanel extends LitElement {
|
||||
.command-run-notice code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-text-secondary); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.command-run-notice kbd { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 0 4px; font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.command-run-notice button { justify-self: end; max-width: none; }
|
||||
.terminal-host { flex: 1 1 auto; min-height: 0; padding: 6px; box-sizing: border-box; overflow: hidden; }
|
||||
.terminal-stage { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; background: var(--pi-terminal-bg); }
|
||||
.terminal-host { position: absolute; inset: 0; padding: 6px; box-sizing: border-box; overflow: hidden; }
|
||||
.terminal-host.copying { visibility: hidden; pointer-events: none; }
|
||||
.terminal-copy-view { position: absolute; inset: 0; display: flex; flex-direction: column; min-height: 0; background: var(--pi-terminal-bg); color: var(--pi-terminal-text); }
|
||||
.terminal-copy-toolbar { box-sizing: border-box; flex: 0 0 auto; display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 47px; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); color: var(--pi-muted); font: 12px system-ui, sans-serif; }
|
||||
.terminal-copy-toolbar > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.terminal-copy-toolbar small { margin-left: auto; white-space: nowrap; color: var(--pi-dim); }
|
||||
.terminal-copy-toolbar button { flex: 0 0 auto; width: auto; min-height: 34px; padding: 6px 9px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.terminal-copy-layers { flex: 1 1 auto; min-height: 0; display: grid; overflow: hidden; background: var(--pi-terminal-bg); }
|
||||
/* xterm renders the configured 13px terminal font in 17px-high cells. */
|
||||
.terminal-copy-content, .terminal-copy-selector { grid-area: 1 / 1; box-sizing: border-box; min-width: 0; min-height: 0; width: 100%; height: 100%; margin: 0; padding: 6px; border: 0; border-radius: 0; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 17px; letter-spacing: normal; font-variant-ligatures: none; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; }
|
||||
.terminal-copy-content { overflow: auto; pointer-events: none; background: var(--pi-terminal-bg); color: var(--pi-terminal-text); -webkit-user-select: none; user-select: none; }
|
||||
.terminal-copy-selector { z-index: 1; overflow: auto; resize: none; outline: none; appearance: none; background: transparent; color: transparent; caret-color: var(--pi-accent); -webkit-text-fill-color: transparent; cursor: text; -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; touch-action: auto; }
|
||||
.terminal-copy-selector::selection { background: var(--pi-terminal-selection); color: transparent; -webkit-text-fill-color: transparent; }
|
||||
.terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; }
|
||||
.terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; }
|
||||
.terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; }
|
||||
@@ -582,6 +744,30 @@ export class TerminalPanel extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizedScrollOffset(sourceOffset: number, sourceRange: number, targetRange: number): number {
|
||||
if (sourceRange <= 0 || targetRange <= 0) return 0;
|
||||
return Math.min(1, Math.max(0, sourceOffset / sourceRange)) * targetRange;
|
||||
}
|
||||
|
||||
function dimTerminalCopyColor(color: string): string {
|
||||
return /^#[\da-f]{6}$/i.test(color) ? `${color}80` : `color-mix(in srgb, ${color} 50%, transparent)`;
|
||||
}
|
||||
|
||||
function terminalCopyRunStyle(style: TerminalCopyRunStyle): StyleInfo {
|
||||
const decorations = [
|
||||
style.underline ? "underline" : undefined,
|
||||
style.strikethrough ? "line-through" : undefined,
|
||||
style.overline ? "overline" : undefined,
|
||||
].filter((decoration): decoration is string => decoration !== undefined).join(" ");
|
||||
return {
|
||||
color: style.invisible ? "transparent" : style.dim ? dimTerminalCopyColor(style.foreground) : style.foreground,
|
||||
backgroundColor: style.background,
|
||||
fontWeight: style.bold ? "700" : undefined,
|
||||
fontStyle: style.italic ? "italic" : undefined,
|
||||
textDecorationLine: decorations === "" ? undefined : decorations,
|
||||
};
|
||||
}
|
||||
|
||||
interface TerminalSize {
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -631,6 +817,7 @@ function terminalOptions(element: HTMLElement): ITerminalOptions {
|
||||
|
||||
function terminalTheme(element: HTMLElement): ITheme {
|
||||
return {
|
||||
...DEFAULT_TERMINAL_ANSI_THEME,
|
||||
background: themeColor(element, "--pi-terminal-bg", "#05070a"),
|
||||
foreground: themeColor(element, "--pi-terminal-text", "#e6edf3"),
|
||||
cursor: themeColor(element, "--pi-accent", "#58a6ff"),
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
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 +21,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 +29,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 +37,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 +48,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 +95,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>
|
||||
@@ -88,13 +116,13 @@ export class ToolExecutionView extends LitElement {
|
||||
}
|
||||
|
||||
private async copyDiff(diff: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(diff);
|
||||
this.copied = true;
|
||||
window.setTimeout(() => { this.copied = false; }, 1200);
|
||||
} catch {
|
||||
const copied = await writeClipboardText(diff);
|
||||
if (!copied) {
|
||||
this.copied = false;
|
||||
return;
|
||||
}
|
||||
this.copied = true;
|
||||
window.setTimeout(() => { this.copied = false; }, 1200);
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
@@ -104,10 +132,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 +146,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 +172,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");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,76 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FileContentResponse, FileTreeEntry } from "../api";
|
||||
import { initialAppState } from "../appState";
|
||||
import type { WorkspacePanelContext } from "../plugins/types";
|
||||
import type { WorkspaceUploadBatchState } from "../workspaceUploadState";
|
||||
import { startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel";
|
||||
import { WorkspaceFilesPanel, startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("workspace-files-panel upload review", () => {
|
||||
it("opens review from the hidden file input and submits selected files with defaults", () => {
|
||||
vi.stubGlobal("HTMLInputElement", FakeHTMLInputElement);
|
||||
const files = [new File(["a"], "a.txt"), new File(["b"], "b.txt")];
|
||||
const onStartWorkspaceUpload = vi.fn<WorkspacePanelContext["onStartWorkspaceUpload"]>(() => ({ batchId: "batch-1", done: Promise.resolve() }));
|
||||
const panel = new WorkspaceFilesPanel();
|
||||
panel.context = workspacePanelContext({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload });
|
||||
|
||||
const inputChange = findTemplateEventHandler<Event>(panel.render(), `id="workspace-upload-input"`);
|
||||
const input = new FakeHTMLInputElement(files);
|
||||
inputChange(new EventWithCurrentTarget("change", input));
|
||||
|
||||
expect(input.value).toBe("");
|
||||
expect(onStartWorkspaceUpload).not.toHaveBeenCalled();
|
||||
|
||||
const submit = findTemplateEventHandler<SubmitEvent>(panel.render(), "<form @submit=");
|
||||
const submitEvent = new FakeSubmitEvent("submit", { cancelable: true });
|
||||
submit(submitEvent);
|
||||
|
||||
expect(submitEvent.defaultPrevented).toBe(true);
|
||||
expect(onStartWorkspaceUpload).toHaveBeenCalledWith(files, {
|
||||
destinationFolder: "project/uploads",
|
||||
createDirs: true,
|
||||
overwrite: false,
|
||||
selectUploadedFile: true,
|
||||
});
|
||||
expect(findOptionalTemplateEventHandler<SubmitEvent>(panel.render(), "<form @submit=")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-files-panel file tree boundary", () => {
|
||||
it("renders expanded tree and selected-file state while wiring row clicks", () => {
|
||||
const onExpandDir = vi.fn<WorkspacePanelContext["onExpandDir"]>();
|
||||
const onSelectFile = vi.fn<WorkspacePanelContext["onSelectFile"]>();
|
||||
const panel = new WorkspaceFilesPanel();
|
||||
panel.context = workspacePanelContext({
|
||||
fileTree: [directoryEntry("src"), fileEntry("README.md", 4096)],
|
||||
expandedDirs: { src: [fileEntry("src/main.ts")] },
|
||||
selectedFilePath: "README.md",
|
||||
selectedFileContent: binaryFileContent("README.md", 4096),
|
||||
onExpandDir,
|
||||
onSelectFile,
|
||||
});
|
||||
|
||||
const rendered = panel.render();
|
||||
const text = collectTemplateText(rendered);
|
||||
|
||||
expect(text).toContain("▾");
|
||||
expect(text).toContain("src");
|
||||
expect(text).toContain("main.ts");
|
||||
expect(text).toContain("README.md");
|
||||
expect(text).toContain("Binary file: README.md · 4.0 KB");
|
||||
expect(text).not.toContain("Select a file.");
|
||||
|
||||
findTemplateClickHandlerForText<Event>(rendered, "src")(new Event("click"));
|
||||
findTemplateClickHandlerForText<Event>(rendered, "README.md")(new Event("click"));
|
||||
|
||||
expect(onExpandDir).toHaveBeenCalledWith("src");
|
||||
expect(onSelectFile).toHaveBeenCalledWith("README.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceUploadBatchesForScope", () => {
|
||||
it("filters upload batches to the selected project, workspace, and machine", () => {
|
||||
@@ -80,6 +150,216 @@ describe("workspaceUploadReviewError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
type TemplateEventHandler<E extends Event> = (event: E) => void;
|
||||
|
||||
function findTemplateEventHandler<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
|
||||
const handler = findOptionalTemplateEventHandler<E>(template, marker);
|
||||
if (handler === undefined) throw new Error(`Expected template event handler after ${marker}`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function findOptionalTemplateEventHandler<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> | undefined {
|
||||
return findInTemplate(template);
|
||||
|
||||
function findInTemplate(current: TemplateResult): TemplateEventHandler<E> | undefined {
|
||||
const strings = templateStrings(current);
|
||||
const values = templateValues(current);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const staticChunk = strings[index];
|
||||
const value = values[index];
|
||||
if (staticChunk !== undefined && staticChunk.includes(marker) && isTemplateEventHandler<E>(value)) return value;
|
||||
const nestedHandler = findInValue(value);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findInValue(value: unknown): TemplateEventHandler<E> | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nestedHandler = findInValue(item);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (isTemplateResult(value)) return findInTemplate(value);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Node-based Lit tests cannot click shadow DOM here; keep direct handler extraction
|
||||
// anchored to rendered file labels and assert the observable context callbacks.
|
||||
function findTemplateClickHandlerForText<E extends Event>(template: TemplateResult, text: string): TemplateEventHandler<E> {
|
||||
const handler = findOptionalTemplateClickHandlerForText<E>(template, text);
|
||||
if (handler === undefined) throw new Error(`Expected click handler near ${text}`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function findOptionalTemplateClickHandlerForText<E extends Event>(value: unknown, text: string): TemplateEventHandler<E> | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nestedHandler = findOptionalTemplateClickHandlerForText<E>(item, text);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!isTemplateResult(value)) return undefined;
|
||||
|
||||
for (const item of templateValues(value)) {
|
||||
const nestedHandler = findOptionalTemplateClickHandlerForText<E>(item, text);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
if (!collectTemplateText(value).includes(text)) return undefined;
|
||||
|
||||
const strings = templateStrings(value);
|
||||
const values = templateValues(value);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const staticChunk = strings[index];
|
||||
const candidate = values[index];
|
||||
if (staticChunk !== undefined && staticChunk.includes("@click") && isTemplateEventHandler<E>(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectTemplateText(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.map((item) => collectTemplateText(item)).join("");
|
||||
if (isTemplateResult(value)) {
|
||||
const strings = templateStrings(value);
|
||||
const values = templateValues(value);
|
||||
return strings.map((part, index) => `${part}${index < values.length ? collectTemplateText(values[index]) : ""}`).join("");
|
||||
}
|
||||
return typeof value === "string" || typeof value === "number" ? String(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 isTemplateEventHandler<E extends Event>(value: unknown): value is TemplateEventHandler<E> {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
|
||||
class FakeFileList implements FileList {
|
||||
readonly length: number;
|
||||
[index: number]: File;
|
||||
|
||||
constructor(private readonly files: readonly File[]) {
|
||||
this.length = files.length;
|
||||
files.forEach((file, index) => {
|
||||
this[index] = file;
|
||||
});
|
||||
}
|
||||
|
||||
item(index: number): File | null {
|
||||
return this.files[index] ?? null;
|
||||
}
|
||||
|
||||
[Symbol.iterator](): ArrayIterator<File> {
|
||||
return this.files[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeHTMLInputElement extends EventTarget {
|
||||
readonly files: FileList;
|
||||
value = "selected-files";
|
||||
|
||||
constructor(files: readonly File[]) {
|
||||
super();
|
||||
this.files = new FakeFileList(files);
|
||||
}
|
||||
}
|
||||
|
||||
class EventWithCurrentTarget extends Event {
|
||||
constructor(type: string, private readonly eventCurrentTarget: EventTarget) {
|
||||
super(type);
|
||||
}
|
||||
|
||||
override get currentTarget(): EventTarget {
|
||||
return this.eventCurrentTarget;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSubmitEvent extends Event implements SubmitEvent {
|
||||
readonly submitter: HTMLElement | null = null;
|
||||
}
|
||||
|
||||
function fileEntry(path: string, size = 2): FileTreeEntry {
|
||||
return { name: path.split("/").at(-1) ?? path, path, type: "file", size };
|
||||
}
|
||||
|
||||
function directoryEntry(path: string): FileTreeEntry {
|
||||
return { name: path.split("/").at(-1) ?? path, path, type: "directory" };
|
||||
}
|
||||
|
||||
function binaryFileContent(path: string, size: number): FileContentResponse {
|
||||
return {
|
||||
path,
|
||||
encoding: "utf8",
|
||||
size,
|
||||
modifiedAt: "2026-06-25T00:00:00.000Z",
|
||||
content: "",
|
||||
truncated: false,
|
||||
binary: true,
|
||||
};
|
||||
}
|
||||
|
||||
function workspacePanelContext(patch: Partial<WorkspacePanelContext> = {}): WorkspacePanelContext {
|
||||
const workspace = patch.workspace ?? { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
|
||||
return {
|
||||
machine: patch.machine ?? { id: "local", name: "Local", kind: "local" },
|
||||
workspace,
|
||||
state: patch.state ?? { ...initialAppState(), workspaceUploadBatches: {} },
|
||||
files: patch.files ?? {
|
||||
readFile: vi.fn<WorkspacePanelContext["files"]["readFile"]>(() => Promise.reject(new Error("not implemented"))),
|
||||
writeFile: vi.fn<WorkspacePanelContext["files"]["writeFile"]>(() => Promise.reject(new Error("not implemented"))),
|
||||
deleteFile: vi.fn<WorkspacePanelContext["files"]["deleteFile"]>(() => Promise.reject(new Error("not implemented"))),
|
||||
moveFile: vi.fn<WorkspacePanelContext["files"]["moveFile"]>(() => Promise.reject(new Error("not implemented"))),
|
||||
},
|
||||
prompt: patch.prompt ?? { insertText: vi.fn<WorkspacePanelContext["prompt"]["insertText"]>(), getText: vi.fn<WorkspacePanelContext["prompt"]["getText"]>(() => ""), getSelection: vi.fn<WorkspacePanelContext["prompt"]["getSelection"]>(() => null) },
|
||||
terminal: patch.terminal ?? { open: vi.fn<WorkspacePanelContext["terminal"]["open"]>(), runCommand: vi.fn<WorkspacePanelContext["terminal"]["runCommand"]>(() => Promise.reject(new Error("not implemented"))) },
|
||||
host: patch.host ?? { requestRender: vi.fn<WorkspacePanelContext["host"]["requestRender"]>() },
|
||||
fileTree: patch.fileTree ?? [],
|
||||
expandedDirs: patch.expandedDirs ?? {},
|
||||
selectedFilePath: patch.selectedFilePath,
|
||||
selectedFileContent: patch.selectedFileContent,
|
||||
fileTreeStale: patch.fileTreeStale ?? false,
|
||||
gitStatus: patch.gitStatus,
|
||||
selectedDiffPath: patch.selectedDiffPath,
|
||||
selectedDiff: patch.selectedDiff,
|
||||
selectedStagedDiff: patch.selectedStagedDiff,
|
||||
gitStale: patch.gitStale ?? false,
|
||||
activeTerminalCount: patch.activeTerminalCount ?? 0,
|
||||
selectedTerminalId: patch.selectedTerminalId,
|
||||
terminalAutoStart: patch.terminalAutoStart ?? false,
|
||||
workspaceUploadDefaultFolder: patch.workspaceUploadDefaultFolder ?? ".pi-web/uploads",
|
||||
onRefreshFiles: patch.onRefreshFiles ?? vi.fn<WorkspacePanelContext["onRefreshFiles"]>(),
|
||||
onExpandDir: patch.onExpandDir ?? vi.fn<WorkspacePanelContext["onExpandDir"]>(),
|
||||
onSelectFile: patch.onSelectFile ?? vi.fn<WorkspacePanelContext["onSelectFile"]>(),
|
||||
onStartWorkspaceUpload: patch.onStartWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onStartWorkspaceUpload"]>(() => undefined),
|
||||
onCancelWorkspaceUpload: patch.onCancelWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onCancelWorkspaceUpload"]>(),
|
||||
onClearWorkspaceUpload: patch.onClearWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onClearWorkspaceUpload"]>(),
|
||||
onRefreshGit: patch.onRefreshGit ?? vi.fn<WorkspacePanelContext["onRefreshGit"]>(),
|
||||
onSelectDiff: patch.onSelectDiff ?? vi.fn<WorkspacePanelContext["onSelectDiff"]>(),
|
||||
onSelectTerminal: patch.onSelectTerminal ?? vi.fn<WorkspacePanelContext["onSelectTerminal"]>(),
|
||||
};
|
||||
}
|
||||
|
||||
function uploadBatch(patch: Partial<WorkspaceUploadBatchState> = {}): WorkspaceUploadBatchState {
|
||||
return {
|
||||
id: patch.id ?? "batch-1",
|
||||
|
||||
@@ -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,12 @@ 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: Boolean }) authoritativeSessionPersistence = 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;
|
||||
@@ -159,10 +161,12 @@ 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}
|
||||
.authoritativeSessionPersistence=${this.authoritativeSessionPersistence}
|
||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
|
||||
.cleanupUnavailableMessage=${this.cleanupUnavailableMessage}
|
||||
.collapsible=${this.collapsible}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
import { activateSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
|
||||
describe("selectable row activation", () => {
|
||||
it("activates rows from non-interactive click targets", () => {
|
||||
@@ -8,7 +8,7 @@ describe("selectable row activation", () => {
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves contributed links and other interactive elements", () => {
|
||||
it("preserves contributed links inside rows", () => {
|
||||
const action = vi.fn();
|
||||
activateSelectableRow(eventWithPath(matchTarget((selector: string) => selector.includes("a[href]"))), action);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
@@ -20,8 +20,8 @@ describe("selectable row activation", () => {
|
||||
const enter = keyboardEventWithPath("Enter", matchTarget(() => false));
|
||||
const space = keyboardEventWithPath(" ", matchTarget(() => false));
|
||||
|
||||
activateSelectableRowFromKeyboard(enter, enterAction);
|
||||
activateSelectableRowFromKeyboard(space, spaceAction);
|
||||
expect(handleSelectableRowKeyboard(enter, { activate: enterAction })).toBe(true);
|
||||
expect(handleSelectableRowKeyboard(space, { activate: spaceAction })).toBe(true);
|
||||
|
||||
expect(enterAction).toHaveBeenCalledOnce();
|
||||
expect(spaceAction).toHaveBeenCalledOnce();
|
||||
@@ -33,7 +33,7 @@ describe("selectable row activation", () => {
|
||||
const action = vi.fn();
|
||||
const event = keyboardEventWithPath("Enter", matchTarget((selector: string) => selector.includes("button")));
|
||||
|
||||
activateSelectableRowFromKeyboard(event, action);
|
||||
expect(handleSelectableRowKeyboard(event, { activate: action })).toBe(false);
|
||||
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
@@ -57,14 +57,17 @@ describe("selectable row activation", () => {
|
||||
expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), cancel })).toBe(true);
|
||||
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(event.stopPropagation).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
type EventWithPath = Pick<Event, "composedPath">;
|
||||
type KeyboardEventWithPath = EventWithPath & Pick<KeyboardEvent, "key" | "preventDefault" | "stopPropagation">;
|
||||
type MatchTarget = EventTarget & Pick<Element, "matches">;
|
||||
type MatchTarget = EventTarget & { matches: (selector: string) => boolean };
|
||||
type MatchPredicate = (selector: string) => boolean;
|
||||
|
||||
function matchTarget(matches: Element["matches"]): MatchTarget {
|
||||
function matchTarget(matches: MatchPredicate): MatchTarget {
|
||||
return Object.assign(new EventTarget(), { matches });
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ const interactiveSelector = [
|
||||
].join(",");
|
||||
|
||||
type ComposedPathEvent = Pick<Event, "composedPath">;
|
||||
type SelectableKeyboardEvent = ComposedPathEvent & Pick<KeyboardEvent, "key" | "preventDefault">;
|
||||
type SelectableNavigationKeyboardEvent = SelectableKeyboardEvent & Partial<Pick<KeyboardEvent, "currentTarget" | "stopPropagation">>;
|
||||
type SelectableNavigationKeyboardEvent = ComposedPathEvent
|
||||
& Pick<KeyboardEvent, "key" | "preventDefault">
|
||||
& Partial<Pick<KeyboardEvent, "currentTarget" | "stopPropagation">>;
|
||||
|
||||
export interface SelectableRowKeyboardOptions {
|
||||
activate: () => void;
|
||||
@@ -37,13 +38,6 @@ export function activateSelectableRow(event: ComposedPathEvent, action: () => vo
|
||||
action();
|
||||
}
|
||||
|
||||
export function activateSelectableRowFromKeyboard(event: SelectableKeyboardEvent, action: () => void): void {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (isFromInteractiveElement(event)) return;
|
||||
event.preventDefault();
|
||||
action();
|
||||
}
|
||||
|
||||
export function handleSelectableRowKeyboard(event: SelectableNavigationKeyboardEvent, options: SelectableRowKeyboardOptions): boolean {
|
||||
if (isFromInteractiveElement(event)) return false;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
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("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, agentCommand: false, agentDir: false, agentSessionDir: 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 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,157 @@
|
||||
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 representative 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.filter(isNoticeClassOrRole)).toEqual([
|
||||
"notice error", "alert",
|
||||
"notice success", "status",
|
||||
"notice warning", "note",
|
||||
"notice info", "note",
|
||||
]);
|
||||
});
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
function isNoticeClassOrRole(value: unknown): value is string {
|
||||
return typeof value === "string"
|
||||
&& (value.startsWith("notice ") || value === "alert" || value === "status" || value === "note");
|
||||
}
|
||||
@@ -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, agentCommand: false, agentDir: false, agentSessionDir: 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,151 @@
|
||||
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({
|
||||
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
|
||||
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",
|
||||
"Agent command for diagnostics",
|
||||
"agent-lab",
|
||||
"Agent state directory",
|
||||
"/srv/agent-lab",
|
||||
"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, agentCommand: false, agentDir: false, agentSessionDir: 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 { agentFieldConfigPatch, 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>;
|
||||
|
||||
@@ -25,168 +29,163 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
const agentDirOverridden = config?.envOverrides.agentDir === true;
|
||||
const effectiveAgent = config?.effectiveConfig.agent;
|
||||
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>Agent command for diagnostics</span>
|
||||
${agentCommandOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<input
|
||||
class="text-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
.value=${config?.config.agent?.command ?? ""}
|
||||
placeholder="pi"
|
||||
?disabled=${this.loading || this.saving || agentCommandOverridden}
|
||||
@change=${(event: Event) => { void this.saveAgentField("command", event); }}
|
||||
>
|
||||
<small>Set an alternate Pi-compatible CLI when doctor/update checks should target a different command. The embedded session runtime remains PI WEB's SDK path, so this does not dynamically load a different agent implementation.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Agent state directory</span>
|
||||
${agentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<input
|
||||
class="text-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
.value=${config?.config.agent?.dir ?? ""}
|
||||
placeholder="~/.pi/agent or ~/agent-profiles/work"
|
||||
?disabled=${this.loading || this.saving || agentDirOverridden}
|
||||
@change=${(event: Event) => { void this.saveAgentField("dir", event); }}
|
||||
>
|
||||
<small>Choose which compatible auth, models, settings, and sessions PI WEB reads. Non-<code>pi</code> commands require an explicit state directory, then a session daemon restart.</small>
|
||||
</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">
|
||||
<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>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Agent command for diagnostics</span>
|
||||
${agentCommandOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${effectiveSpawn}
|
||||
?disabled=${this.loading || this.saving || spawnOverridden}
|
||||
@change=${(event: Event) => { void this.toggleSpawnSessions(event); }}
|
||||
class="text-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
.value=${config.config.agent?.command ?? ""}
|
||||
placeholder="pi"
|
||||
?disabled=${this.loading || this.saving || agentCommandOverridden}
|
||||
@change=${(event: Event) => { void this.saveAgentField("command", event); }}
|
||||
>
|
||||
<span>Enable the <code>spawn_session</code> tool</span>
|
||||
<small>Set an alternate Pi-compatible CLI when doctor/update checks should target a different command. The embedded session runtime remains PI WEB's SDK path, so this does not dynamically load a different agent implementation.</small>
|
||||
</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">
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Agent state directory</span>
|
||||
${agentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${effectiveSubsessions}
|
||||
?disabled=${this.loading || this.saving || subsessionsOverridden || !effectiveSpawn}
|
||||
@change=${(event: Event) => { void this.toggleSubsessions(event); }}
|
||||
class="text-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
.value=${config.config.agent?.dir ?? ""}
|
||||
placeholder="~/.pi/agent or ~/agent-profiles/work"
|
||||
?disabled=${this.loading || this.saving || agentDirOverridden}
|
||||
@change=${(event: Event) => { void this.saveAgentField("dir", event); }}
|
||||
>
|
||||
<span>Enable the <code>spawn_subsession</code> tools</span>
|
||||
<small>Choose which compatible auth, models, settings, and sessions PI WEB reads. Non-<code>pi</code> commands require an explicit state directory, then a session daemon restart.</small>
|
||||
</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>Agent command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">pi default</span>`}</dd></div>
|
||||
<div><dt>Agent state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">~/.pi/agent default</span>`}</dd></div>
|
||||
<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>
|
||||
`}
|
||||
<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>Agent command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">pi default</span>`}</dd></div>
|
||||
<div><dt>Agent state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">~/.pi/agent default</span>`}</dd></div>
|
||||
<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 saveAgentField(field: "command" | "dir", event: Event): Promise<void> {
|
||||
if (!(event.target instanceof HTMLInputElement)) return;
|
||||
const value = event.target.value.trim();
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
const nextConfig: PiWebConfigValues = { ...baseConfig };
|
||||
const nextAgent: NonNullable<PiWebConfigValues["agent"]> = { ...(baseConfig.agent ?? {}) };
|
||||
if (field === "command") {
|
||||
if (value === "") delete nextAgent.command;
|
||||
else nextAgent.command = value;
|
||||
} else if (value === "") {
|
||||
delete nextAgent.dir;
|
||||
} else {
|
||||
nextAgent.dir = value;
|
||||
}
|
||||
if (nextAgent.command === undefined && nextAgent.dir === undefined) {
|
||||
delete nextConfig.agent;
|
||||
} else {
|
||||
nextConfig.agent = nextAgent;
|
||||
}
|
||||
await this.onSave?.(nextConfig);
|
||||
await this.onSave?.(agentFieldConfigPatch(this.configResponse?.config ?? {}, field, event.target.value));
|
||||
}
|
||||
|
||||
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; }
|
||||
.toggle input { width: 16px; height: 16px; }
|
||||
.text-input { width: 100%; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.text-input:disabled { opacity: .65; cursor: not-allowed; }
|
||||
.text-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
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);
|
||||
}
|
||||
.text-input:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
|
||||
.text-input:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.toggle input:disabled { cursor: not-allowed; }
|
||||
.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; }
|
||||
.beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
||||
@@ -197,9 +196,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,263 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { AppAction } from "../../actions";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { SettingsShortcutsPanel } from "./SettingsShortcutsPanel";
|
||||
import type { SettingsNotice } from "./SettingsPanelFrame";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-shortcuts-panel shortcut row actions", () => {
|
||||
it("saves edited shortcuts, disables them with None, and resets overrides", () => {
|
||||
vi.stubGlobal("HTMLInputElement", FakeHTMLInputElement);
|
||||
const onSave = vi.fn<SaveHandler>();
|
||||
const savePanel = panelWithShortcuts({ shortcuts: { "core:other": "mod+o" } }, onSave);
|
||||
|
||||
findTemplateEventHandler<Event>(savePanel.render(), "@input=")(
|
||||
new EventWithTarget("input", new FakeHTMLInputElement(" control + shift + p ")),
|
||||
);
|
||||
|
||||
expectTextOrder(flattenTemplateContent(savePanel.render()), ["Open palette", "Ctrl+Shift+P", "Custom · Unsaved"]);
|
||||
|
||||
findTemplateEventHandler<Event>(savePanel.render(), ">Save</button>")(new Event("click"));
|
||||
|
||||
const nonePanel = panelWithShortcuts({ shortcuts: { "core:open-palette": "mod+shift+p", "core:other": "mod+o" } }, onSave);
|
||||
findTemplateEventHandler<Event>(nonePanel.render(), ">None</button>")(new Event("click"));
|
||||
|
||||
const resetPanel = panelWithShortcuts({ shortcuts: { "core:open-palette": null, "core:other": "mod+o" } }, onSave);
|
||||
findTemplateEventHandler<Event>(resetPanel.render(), ">Reset</button>")(new Event("click"));
|
||||
|
||||
expect(onSave.mock.calls).toEqual([
|
||||
[{ shortcuts: { "core:other": "mod+o", "core:open-palette": "mod+shift+p" } }],
|
||||
[{ shortcuts: { "core:open-palette": null, "core:other": "mod+o" } }],
|
||||
[{ shortcuts: { "core:other": "mod+o" } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
type SaveHandler = (config: PiWebConfigValues) => void | Promise<void>;
|
||||
type TemplateEventHandler<E extends Event> = (event: E) => void;
|
||||
|
||||
function panelWithShortcuts(config: PiWebConfigValues, onSave: SaveHandler): SettingsShortcutsPanel {
|
||||
const panel = new SettingsShortcutsPanel();
|
||||
panel.actions = [shortcutAction()];
|
||||
panel.configResponse = configResponse(config);
|
||||
panel.onSave = onSave;
|
||||
return panel;
|
||||
}
|
||||
|
||||
function shortcutAction(): AppAction {
|
||||
return {
|
||||
id: "core:open-palette",
|
||||
title: "Open palette",
|
||||
description: "Open the command palette.",
|
||||
shortcut: "mod+k",
|
||||
group: "Navigation",
|
||||
run: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function findTemplateEventHandler<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
|
||||
const handler = findOptionalTemplateEventHandler<E>(template, marker);
|
||||
if (handler === undefined) throw new Error(`Expected template event handler near ${marker}`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function findOptionalTemplateEventHandler<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> | undefined {
|
||||
return findInTemplate(template);
|
||||
|
||||
function findInTemplate(current: TemplateResult): TemplateEventHandler<E> | undefined {
|
||||
const strings = templateStrings(current);
|
||||
const values = templateValues(current);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index];
|
||||
if (isTemplateEventHandler<E>(value) && templateEventHandlerMatches(strings, index, marker)) return value;
|
||||
const nestedHandler = findInValue(value);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findInValue(value: unknown): TemplateEventHandler<E> | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nestedHandler = findInValue(item);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (isTemplateResult(value)) return findInTemplate(value);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function templateEventHandlerMatches(strings: readonly string[], valueIndex: number, marker: string): boolean {
|
||||
return (strings[valueIndex] ?? "").includes(marker) || (strings[valueIndex + 1] ?? "").includes(marker);
|
||||
}
|
||||
|
||||
function isTemplateEventHandler<E extends Event>(value: unknown): value is TemplateEventHandler<E> {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
class FakeHTMLInputElement extends EventTarget {
|
||||
constructor(readonly value: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
class EventWithTarget extends Event {
|
||||
constructor(type: string, private readonly eventTarget: EventTarget) {
|
||||
super(type);
|
||||
}
|
||||
|
||||
override get target(): EventTarget {
|
||||
return this.eventTarget;
|
||||
}
|
||||
}
|
||||
|
||||
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, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { AppAction } from "../../actions";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api";
|
||||
import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts";
|
||||
import { readPromptEnterPreference, writePromptEnterPreference, type PromptEnterPreference } from "../../promptEnterBehavior";
|
||||
import "./SettingsPanelFrame";
|
||||
import type { SettingsNotice } from "./SettingsPanelFrame";
|
||||
|
||||
const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
||||
|
||||
@@ -25,6 +27,10 @@ const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: stri
|
||||
},
|
||||
];
|
||||
|
||||
function renderShortcutsDescription(): TemplateResult {
|
||||
return html`Edit app shortcuts by action. Type a shortcut such as <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[] = [];
|
||||
@@ -87,38 +93,40 @@ 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.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
|
||||
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 {
|
||||
@@ -335,25 +343,18 @@ export class SettingsShortcutsPanel extends LitElement {
|
||||
|
||||
static override styles = css`
|
||||
:host { display: block; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
|
||||
h2, h3, p { margin: 0; }
|
||||
h2 { font-size: 17px; line-height: 1.25; }
|
||||
h3, p { margin: 0; }
|
||||
h3 { font-size: 13px; line-height: 1.3; }
|
||||
p { color: var(--pi-muted); line-height: 1.45; }
|
||||
button, input { font: inherit; }
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
button:disabled, input:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
|
||||
.secondary { flex: 0 0 auto; }
|
||||
.message, .loading-card, .config-path-card, .prompt-enter-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.message { margin-bottom: 12px; }
|
||||
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
|
||||
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
|
||||
.loading-card, .config-path-card, .prompt-enter-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.loading-card, .config-path-card { color: var(--pi-muted); }
|
||||
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
|
||||
.config-path-card { display: grid; gap: 5px; }
|
||||
.config-path-card span, .card-eyebrow { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||
.prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; margin-bottom: 14px; }
|
||||
.prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; }
|
||||
.prompt-enter-copy { display: grid; gap: 5px; min-width: 0; }
|
||||
.prompt-enter-copy p, .prompt-enter-option small { font-size: 12px; }
|
||||
.prompt-enter-options { display: grid; gap: 7px; }
|
||||
@@ -363,7 +364,7 @@ export class SettingsShortcutsPanel extends LitElement {
|
||||
.prompt-enter-option span { display: grid; gap: 2px; }
|
||||
.prompt-enter-option small { color: var(--pi-muted); line-height: 1.35; }
|
||||
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
||||
.shortcut-group { margin: 0 0 16px; }
|
||||
.shortcut-group { margin: 0; }
|
||||
.shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
||||
.shortcut-list { border: 1px solid var(--pi-border); border-radius: 10px; overflow: hidden; }
|
||||
.shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 48%); gap: 14px; align-items: start; padding: 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); }
|
||||
@@ -384,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; }
|
||||
@@ -392,8 +393,6 @@ export class SettingsShortcutsPanel extends LitElement {
|
||||
.recording-hint { color: var(--pi-accent); font-size: 12px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.section-heading { display: grid; gap: 12px; }
|
||||
.section-heading .secondary { justify-self: start; }
|
||||
.prompt-enter-card { grid-template-columns: minmax(0, 1fr); }
|
||||
.shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; }
|
||||
.shortcut-status, .shortcut-actions { justify-content: flex-start; }
|
||||
|
||||
@@ -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,66 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
|
||||
import {
|
||||
gatewayServerConfigFromDraft,
|
||||
gatewayServerDraftFromConfig,
|
||||
machineAccessConfigPatchFromDraft,
|
||||
machineAccessDraftFromConfig,
|
||||
} from "./settingsConfigDraft";
|
||||
|
||||
describe("settings config drafts", () => {
|
||||
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({
|
||||
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",
|
||||
allowedPathsText: "/tmp\n~/SDKs",
|
||||
});
|
||||
expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
|
||||
expect(machineAccessDraftFromConfig(config)).toEqual({
|
||||
allowedPathsText: "/tmp\n~/SDKs",
|
||||
uploadDefaultFolder: "manual/uploads",
|
||||
});
|
||||
expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
|
||||
});
|
||||
|
||||
it("converts drafts back to config while preserving non-general preferences", () => {
|
||||
expect(configFromDraft({
|
||||
host: " 127.0.0.1 ",
|
||||
it("builds gateway server saves without dropping preserved config values", () => {
|
||||
expect(gatewayServerConfigFromDraft({
|
||||
host: " gateway.local ",
|
||||
port: "9000",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "example.local, 192.168.1.20\n",
|
||||
allowedPathsText: "/tmp\n~/SDKs\n",
|
||||
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } })).toEqual({
|
||||
host: "127.0.0.1",
|
||||
port: 9000,
|
||||
allowedHosts: ["example.local", "192.168.1.20"],
|
||||
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
|
||||
allowedHostsMode: "all",
|
||||
allowedHostsText: "ignored.local",
|
||||
}, {
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
agent: { command: "agent-lab", dir: "~/agent-profiles/lab" },
|
||||
})).toEqual({
|
||||
host: "gateway.local",
|
||||
port: 9000,
|
||||
allowedHosts: true,
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
agent: { command: "agent-lab", dir: "~/agent-profiles/lab" },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes global path access when the allowed paths field is cleared", () => {
|
||||
expect(configFromDraft({
|
||||
expect(gatewayServerConfigFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "",
|
||||
}, { pathAccess: { allowedPaths: ["/old"] } })).not.toHaveProperty("pathAccess");
|
||||
allowedHostsText: "example.local, 192.168.1.20\n",
|
||||
})).toEqual({ allowedHosts: ["example.local", "192.168.1.20"] });
|
||||
});
|
||||
|
||||
it("rejects relative external paths before saving", () => {
|
||||
expect(() => configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
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("rejects relative external paths before saving selected-machine access", () => {
|
||||
expect(() => machineAccessConfigPatchFromDraft({
|
||||
allowedPathsText: "relative/path",
|
||||
uploadDefaultFolder: "",
|
||||
})).toThrow("Allowed external paths must be absolute paths or start with ~");
|
||||
});
|
||||
|
||||
it("preserves the spawnSessions flag when saving general settings", () => {
|
||||
const result = configFromDraft({
|
||||
host: "",
|
||||
port: "",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "",
|
||||
allowedPathsText: "",
|
||||
}, { spawnSessions: true });
|
||||
expect(result.spawnSessions).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
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 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 }),
|
||||
...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }),
|
||||
export function machineAccessDraftFromConfig(config: PiWebConfigValues): MachineAccessConfigDraft {
|
||||
return {
|
||||
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
|
||||
uploadDefaultFolder: config.uploads?.defaultFolder ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -41,11 +47,31 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
|
||||
config.port = parsed;
|
||||
}
|
||||
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
|
||||
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
|
||||
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
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 }),
|
||||
...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowedHostsText(value: string): string[] {
|
||||
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
|
||||
}
|
||||
@@ -57,6 +83,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, agentCommand: false, agentDir: false, agentSessionDir: 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, agentCommand: false, agentDir: false, agentSessionDir: 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, agentCommand: false, agentDir: false, agentSessionDir: 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,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { agentFieldConfigPatch, 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(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
|
||||
});
|
||||
|
||||
it("builds agent-only patches while preserving sibling agent fields", () => {
|
||||
const base = {
|
||||
host: "127.0.0.1",
|
||||
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
|
||||
};
|
||||
|
||||
expect(agentFieldConfigPatch(base, "command", " alternate-agent ")).toEqual({
|
||||
agent: { command: "alternate-agent", dir: "/srv/agent-lab" },
|
||||
});
|
||||
expect(agentFieldConfigPatch(base, "dir", " ")).toEqual({
|
||||
agent: { command: "agent-lab" },
|
||||
});
|
||||
expect(agentFieldConfigPatch({ agent: { dir: "/srv/agent-lab" } }, "dir", "")).toEqual({ agent: {} });
|
||||
expect(agentFieldConfigPatch(base, "command", "agent-lab")).not.toHaveProperty("host");
|
||||
});
|
||||
|
||||
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,
|
||||
agent: { command: "gateway-agent", dir: "/srv/gateway-agent" },
|
||||
});
|
||||
const selectedMachine = configResponse(
|
||||
{ spawnSessions: true, subsessions: true, agent: { command: "machine-agent", dir: "/srv/machine-agent" } },
|
||||
{ spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentSessionDir: true },
|
||||
{ spawnSessions: true, subsessions: true, agent: { command: "env-agent", dir: "/srv/machine-agent" } },
|
||||
);
|
||||
|
||||
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,
|
||||
agent: { command: "machine-agent", dir: "/srv/machine-agent" },
|
||||
},
|
||||
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,
|
||||
agent: { command: "env-agent", dir: "/srv/machine-agent" },
|
||||
},
|
||||
envOverrides: {
|
||||
host: false,
|
||||
port: false,
|
||||
allowedHosts: false,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
agentCommand: true,
|
||||
agentDir: false,
|
||||
agentSessionDir: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function configResponse(
|
||||
config: PiWebConfigValues,
|
||||
overrides: Partial<PiWebConfigResponse["envOverrides"]> = {},
|
||||
effectiveConfig: PiWebConfigValues = config,
|
||||
): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig,
|
||||
envOverrides: {
|
||||
host: false,
|
||||
port: false,
|
||||
allowedHosts: false,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
agentCommand: false,
|
||||
agentDir: false,
|
||||
agentSessionDir: false,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 agentFieldConfigPatch(
|
||||
baseConfig: PiWebConfigValues,
|
||||
field: "command" | "dir",
|
||||
rawValue: string,
|
||||
): PiWebConfigValues {
|
||||
const value = rawValue.trim();
|
||||
const agent: NonNullable<PiWebConfigValues["agent"]> = { ...(baseConfig.agent ?? {}) };
|
||||
if (field === "command") {
|
||||
if (value === "") delete agent.command;
|
||||
else agent.command = value;
|
||||
} else if (value === "") {
|
||||
delete agent.dir;
|
||||
} else {
|
||||
agent.dir = value;
|
||||
}
|
||||
return { agent };
|
||||
}
|
||||
|
||||
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,
|
||||
agentCommand: selectedMachine.envOverrides.agentCommand,
|
||||
agentDir: selectedMachine.envOverrides.agentDir,
|
||||
agentSessionDir: selectedMachine.envOverrides.agentSessionDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -327,22 +327,24 @@ export const chatStyles = css`
|
||||
.msg.bash > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-success) 35%, transparent); background: var(--pi-success-bg); }
|
||||
.msg.skill > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-purple-border) 35%, transparent); background: var(--pi-purple-surface); }
|
||||
.group-msg > .msg-header { position: sticky; top: -26px; z-index: 4; margin: -10px 0 8px; padding: 7px 0 6px; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-bg); }
|
||||
.msg-header-trailing { min-width: 0; display: inline-flex; align-items: baseline; justify-content: flex-end; gap: 8px; }
|
||||
.msg-actions { display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; }
|
||||
.msg-header-trailing { min-width: 0; flex: 1 1 auto; display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.msg-actions { flex: 0 0 auto; display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; }
|
||||
.msg-action { 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; }
|
||||
.msg-action:hover, .msg-action:focus { color: var(--pi-text); border-color: var(--pi-accent); }
|
||||
.msg:hover > .msg-header .msg-actions, .msg:focus-within > .msg-header .msg-actions, .group-msg:hover > .msg-header .msg-actions, .group-msg:focus-within > .msg-header .msg-actions { opacity: 1; }
|
||||
.label { display: block; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
||||
.msg-header .label { margin: 0; }
|
||||
.msg-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: var(--pi-dim); padding: 0; font: 11px system-ui, sans-serif; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .12s ease, max-width .12s ease; cursor: pointer; user-select: text; -webkit-user-select: text; }
|
||||
.msg-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: var(--pi-dim); padding: 0; font: 11px system-ui, sans-serif; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .12s ease; cursor: pointer; user-select: text; -webkit-user-select: text; }
|
||||
.msg:hover > .msg-header .msg-meta, .msg:focus-within > .msg-header .msg-meta, .group-msg:hover > .msg-header .msg-meta, .group-msg:focus-within > .msg-header .msg-meta, .msg-meta:focus, .msg-meta.expanded { opacity: 1; }
|
||||
.msg-meta.expanded { flex: 1 1 auto; max-width: 100%; white-space: normal; overflow: visible; overflow-wrap: anywhere; text-overflow: clip; }
|
||||
.msg-meta:focus { outline: 1px solid var(--pi-border); outline-offset: 3px; border-radius: 4px; }
|
||||
@media (hover: none) {
|
||||
.msg-actions { opacity: 1; }
|
||||
.msg-meta { opacity: .75; max-width: 26px; }
|
||||
.msg-meta:not(.expanded) { display: inline-grid; width: 26px; height: 22px; place-items: center; font-size: 0; text-overflow: clip; }
|
||||
.msg-meta::before { content: "ⓘ"; font-size: 13px; }
|
||||
.msg-meta:focus, .msg-meta.expanded { opacity: 1; max-width: 75%; }
|
||||
.msg-meta:focus::before, .msg-meta.expanded::before { content: ""; }
|
||||
.msg-meta.expanded { opacity: 1; max-width: 100%; }
|
||||
.msg-meta.expanded::before { content: ""; }
|
||||
}
|
||||
formatted-text.part { display: block; }
|
||||
formatted-text.part, .queued-message formatted-text { text-align: start; unicode-bidi: plaintext; }
|
||||
@@ -418,7 +420,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); }
|
||||
@@ -431,7 +433,7 @@ 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; }
|
||||
@@ -467,9 +469,9 @@ 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-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); }
|
||||
@@ -483,7 +485,7 @@ export const promptEditorStyles = css`
|
||||
.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; }
|
||||
|
||||
@@ -12,6 +12,13 @@ function snapshot(...workspaces: WorkspaceActivity[]): WorkspaceActivityResponse
|
||||
return { workspaces, generatedAt: "now" };
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve) => { resolveDeferred = resolve; });
|
||||
if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred };
|
||||
}
|
||||
|
||||
describe("ActivityController", () => {
|
||||
it("stores workspace activity under the requested machine", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedMachine: { id: "remote", name: "Remote", kind: "remote", createdAt: "now", updatedAt: "now" } };
|
||||
@@ -29,6 +36,42 @@ describe("ActivityController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shares duplicate requests and runs one trailing refresh requested during the active fetch", async () => {
|
||||
const firstSnapshot = deferred<WorkspaceActivityResponse>();
|
||||
const trailingSnapshot = deferred<WorkspaceActivityResponse>();
|
||||
const trailingStarted = deferred<undefined>();
|
||||
let calls = 0;
|
||||
let state: AppState = { ...initialAppState(), selectedMachine: { id: "local", name: "Local", kind: "local", createdAt: "now", updatedAt: "now" } };
|
||||
const controller = new ActivityController(() => state, (patch) => { state = { ...state, ...patch }; }, {
|
||||
api: {
|
||||
workspaceActivity: () => {
|
||||
calls += 1;
|
||||
if (calls === 2) trailingStarted.resolve(undefined);
|
||||
return calls === 1 ? firstSnapshot.promise : trailingSnapshot.promise;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const first = controller.refresh("local");
|
||||
const duplicate = controller.refresh("local");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
|
||||
const later = controller.refresh("local");
|
||||
const laterDuplicate = controller.refresh("local");
|
||||
firstSnapshot.resolve(snapshot(activity("/stale")));
|
||||
await trailingStarted.promise;
|
||||
|
||||
expect(calls).toBe(2);
|
||||
|
||||
trailingSnapshot.resolve(snapshot(activity("/fresh")));
|
||||
await Promise.all([first, duplicate, later, laterDuplicate]);
|
||||
|
||||
expect(calls).toBe(2);
|
||||
expect(state.workspaceActivities).toEqual({ "/fresh": activity("/fresh") });
|
||||
});
|
||||
|
||||
it("applies live activity updates to the owning machine only", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedMachine: { id: "local", name: "Local", kind: "local", createdAt: "now", updatedAt: "now" } };
|
||||
const controller = new ActivityController(() => state, (patch) => { state = { ...state, ...patch }; });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api";
|
||||
import { isWorkspaceActivityActive } from "../../../shared/activity";
|
||||
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||
import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
|
||||
|
||||
export interface ActivityControllerDependencies {
|
||||
api?: Pick<typeof defaultApi, "workspaceActivity">;
|
||||
@@ -8,13 +9,16 @@ export interface ActivityControllerDependencies {
|
||||
|
||||
export class ActivityController {
|
||||
private readonly api: Pick<typeof defaultApi, "workspaceActivity">;
|
||||
private readonly refreshes = new TrailingRefreshCoordinator<string>();
|
||||
|
||||
constructor(private readonly getState: GetState, private readonly setState: SetState, deps: ActivityControllerDependencies = {}) {
|
||||
this.api = deps.api ?? defaultApi;
|
||||
}
|
||||
|
||||
async refresh(machineId = selectedMachineId(this.getState())): Promise<void> {
|
||||
this.applyMachineActivitySnapshot(machineId, indexWorkspaceActivities(await this.api.workspaceActivity(machineId)));
|
||||
refresh(machineId = selectedMachineId(this.getState())): Promise<void> {
|
||||
return this.refreshes.request(machineId, async () => {
|
||||
this.applyMachineActivitySnapshot(machineId, indexWorkspaceActivities(await this.api.workspaceActivity(machineId)));
|
||||
});
|
||||
}
|
||||
|
||||
applyWorkspaceActivity(activity: WorkspaceActivity, machineId = selectedMachineId(this.getState())): void {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { api as defaultApi, type AuthProviderOption, type OAuthFlowState } from "../api";
|
||||
import { api as defaultApi, type AuthProviderOption, type OAuthFlowState, type SessionInfo, type SessionStatus } from "../api";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { AuthController, parseAuthSlashCommand } from "./authController";
|
||||
|
||||
@@ -42,20 +42,226 @@ describe("AuthController", () => {
|
||||
|
||||
expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true });
|
||||
});
|
||||
|
||||
it("resets OAuth prompt input and submit state when the request id changes", async () => {
|
||||
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
|
||||
const { controller, getState } = createController(
|
||||
{ authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } },
|
||||
{
|
||||
respondOAuthFlow: () => Promise.resolve(oauthFlow({
|
||||
select: { requestId: "request-2", message: "Choose an account", options: [{ value: "acct-1", label: "Account 1" }] },
|
||||
progress: ["Need account selection"],
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
await controller.respondOAuth();
|
||||
|
||||
expect(getState().authDialog).toMatchObject({
|
||||
step: "oauth",
|
||||
flow: { select: { requestId: "request-2" } },
|
||||
inputValue: "",
|
||||
responding: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the OAuth dialog and refreshes selected session status when the flow completes", async () => {
|
||||
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
|
||||
const session = sessionInfo("session-1");
|
||||
const refreshedStatus = sessionStatus(session.id);
|
||||
const respondCalls: { flowId: string; requestId: string; value: string; machineId: string | undefined }[] = [];
|
||||
const statusCalls: { session: Parameters<typeof defaultApi.status>[0]; machineId: string | undefined }[] = [];
|
||||
const appliedStatuses: SessionStatus[] = [];
|
||||
const { controller, getState } = createController(
|
||||
{ selectedSession: session, authDialog: { step: "oauth", flow, inputValue: "https://callback" } },
|
||||
{
|
||||
respondOAuthFlow: (flowId, requestId, value, machineId) => {
|
||||
respondCalls.push({ flowId, requestId, value, machineId });
|
||||
return Promise.resolve(oauthFlow({ status: "complete" }));
|
||||
},
|
||||
status: (sessionArg, machineId) => {
|
||||
statusCalls.push({ session: sessionArg, machineId });
|
||||
return Promise.resolve(refreshedStatus);
|
||||
},
|
||||
},
|
||||
(status) => { appliedStatuses.push(status); },
|
||||
);
|
||||
|
||||
await controller.respondOAuth();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(respondCalls).toEqual([{ flowId: "flow-1", requestId: "request-1", value: "https://callback", machineId: "local" }]);
|
||||
expect(getState().authDialog).toBeUndefined();
|
||||
expect(statusCalls).toEqual([{ session, machineId: "local" }]);
|
||||
expect(appliedStatuses).toEqual([refreshedStatus]);
|
||||
});
|
||||
|
||||
it("leaves the OAuth dialog ready to retry if responding fails", async () => {
|
||||
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
|
||||
const { controller, getState } = createController(
|
||||
{ authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } },
|
||||
{ respondOAuthFlow: () => Promise.reject(new Error("Invalid callback")) },
|
||||
);
|
||||
|
||||
await controller.respondOAuth();
|
||||
|
||||
expect(getState().authDialog).toMatchObject({
|
||||
step: "oauth",
|
||||
flow,
|
||||
inputValue: "https://callback",
|
||||
responding: false,
|
||||
error: "Error: Invalid callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels the active OAuth flow and closes the dialog even when cancellation fails", async () => {
|
||||
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
|
||||
const cancelCalls: { flowId: string; machineId: string | undefined }[] = [];
|
||||
const { controller, getState } = createController(
|
||||
{ authDialog: { step: "oauth", flow } },
|
||||
{
|
||||
cancelOAuthFlow: (flowId, machineId) => {
|
||||
cancelCalls.push({ flowId, machineId });
|
||||
return Promise.reject(new Error("Cancel unavailable"));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await controller.cancelOAuth();
|
||||
|
||||
expect(cancelCalls).toEqual([{ flowId: "flow-1", machineId: "local" }]);
|
||||
expect(getState().authDialog).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates API key input before saving and clears the validation error when edited", async () => {
|
||||
const saveCalls: { providerId: string; key: string; machineId: string | undefined }[] = [];
|
||||
const provider = authProvider("openai", "api_key");
|
||||
const { controller, getState } = createController(
|
||||
{ authDialog: { step: "apiKey", provider, value: " " } },
|
||||
{
|
||||
saveApiKey: (providerId, key, machineId) => {
|
||||
saveCalls.push({ providerId, key, machineId });
|
||||
return Promise.resolve({ accepted: true });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await controller.saveApiKey();
|
||||
|
||||
expect(saveCalls).toEqual([]);
|
||||
expect(getState().authDialog).toMatchObject({ step: "apiKey", error: "API key is required" });
|
||||
|
||||
controller.updateApiKey("sk-live");
|
||||
|
||||
expect(getState().authDialog).toMatchObject({ step: "apiKey", value: "sk-live" });
|
||||
expect(getState().authDialog).not.toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("saves a trimmed API key on the selected machine and refreshes selected session status", async () => {
|
||||
const saveCalls: { providerId: string; key: string; machineId: string | undefined }[] = [];
|
||||
const statusCalls: { session: Parameters<typeof defaultApi.status>[0]; machineId: string | undefined }[] = [];
|
||||
const appliedStatuses: SessionStatus[] = [];
|
||||
const provider = authProvider("openai", "api_key");
|
||||
const session = sessionInfo("session-1");
|
||||
const refreshedStatus = sessionStatus(session.id);
|
||||
const { controller, getState } = createController(
|
||||
{
|
||||
selectedMachine: remoteMachine("remote-1"),
|
||||
selectedSession: session,
|
||||
authDialog: { step: "apiKey", provider, value: " sk-live " },
|
||||
},
|
||||
{
|
||||
saveApiKey: (providerId, key, machineId) => {
|
||||
saveCalls.push({ providerId, key, machineId });
|
||||
return Promise.resolve({ accepted: true });
|
||||
},
|
||||
status: (sessionArg, machineId) => {
|
||||
statusCalls.push({ session: sessionArg, machineId });
|
||||
return Promise.resolve(refreshedStatus);
|
||||
},
|
||||
},
|
||||
(status) => { appliedStatuses.push(status); },
|
||||
);
|
||||
|
||||
await controller.saveApiKey();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(saveCalls).toEqual([{ providerId: "openai", key: "sk-live", machineId: "remote-1" }]);
|
||||
expect(getState().authDialog).toBeUndefined();
|
||||
expect(statusCalls).toEqual([{ session, machineId: "remote-1" }]);
|
||||
expect(appliedStatuses).toEqual([refreshedStatus]);
|
||||
});
|
||||
|
||||
it("keeps the API key dialog open with an error if saving fails", async () => {
|
||||
const provider = authProvider("openai", "api_key");
|
||||
const { controller, getState } = createController(
|
||||
{ authDialog: { step: "apiKey", provider, value: "sk-live" } },
|
||||
{ saveApiKey: () => Promise.reject(new Error("Denied")) },
|
||||
);
|
||||
|
||||
await controller.saveApiKey();
|
||||
|
||||
expect(getState().authDialog).toMatchObject({ step: "apiKey", value: "sk-live", saving: false, error: "Error: Denied" });
|
||||
});
|
||||
});
|
||||
|
||||
function createController(statePatch: Partial<AppState>, apiPatch: Partial<typeof defaultApi> = {}) {
|
||||
function createController(
|
||||
statePatch: Partial<AppState>,
|
||||
apiPatch: Partial<typeof defaultApi> = {},
|
||||
applyStatus: (status: SessionStatus) => void = () => undefined,
|
||||
) {
|
||||
let state: AppState = { ...initialAppState(), ...statePatch };
|
||||
const api = { ...defaultApi, ...apiPatch };
|
||||
const controller = new AuthController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
applyStatus,
|
||||
{ api },
|
||||
);
|
||||
return { controller, getState: () => state };
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function remoteMachine(id: string): NonNullable<AppState["selectedMachine"]> {
|
||||
return {
|
||||
id,
|
||||
name: "Remote",
|
||||
kind: "remote",
|
||||
baseUrl: "https://remote.example",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function sessionInfo(id: string): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
cwd: "/repo",
|
||||
path: `/tmp/${id}.jsonl`,
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:00:00.000Z",
|
||||
messageCount: 0,
|
||||
firstMessage: "",
|
||||
};
|
||||
}
|
||||
|
||||
function sessionStatus(sessionId: string): 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,
|
||||
};
|
||||
}
|
||||
|
||||
function authProvider(id: string, authType: "oauth" | "api_key"): AuthProviderOption {
|
||||
return { id, authType, name: `${id} ${authType}`, status: { configured: false } };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
WorkspaceUploadBatchError,
|
||||
WorkspaceUploadCancelledError,
|
||||
type FileContentResponse,
|
||||
type FileTreeEntry,
|
||||
type FileTreeResponse,
|
||||
type Machine,
|
||||
type Project,
|
||||
@@ -13,6 +14,9 @@ import {
|
||||
} from "../api";
|
||||
import { FileExplorerController, type FileExplorerControllerDependencies } from "./fileExplorerController";
|
||||
|
||||
type FileExplorerApi = NonNullable<FileExplorerControllerDependencies["api"]>;
|
||||
type WorkspaceTree = FileExplorerApi["workspaceTree"];
|
||||
type WorkspaceFile = FileExplorerApi["workspaceFile"];
|
||||
type UploadWorkspaceFiles = NonNullable<FileExplorerControllerDependencies["uploadWorkspaceFiles"]>;
|
||||
type UploadWorkspaceFilesOptions = NonNullable<Parameters<UploadWorkspaceFiles>[3]>;
|
||||
|
||||
@@ -48,6 +52,56 @@ const workspace: Workspace = {
|
||||
isGitWorktree: false,
|
||||
};
|
||||
|
||||
describe("FileExplorerController file tree workflows", () => {
|
||||
it("refreshes the root and already-expanded directories for the selected machine", async () => {
|
||||
const rootEntries = [directoryEntry("src"), fileEntry("README.md")];
|
||||
const refreshedSrcEntries = [fileEntry("src/index.ts")];
|
||||
const refreshedDocsEntries = [fileEntry("docs/guide.md")];
|
||||
const workspaceTree = vi.fn<WorkspaceTree>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path, {
|
||||
"": rootEntries,
|
||||
src: refreshedSrcEntries,
|
||||
docs: refreshedDocsEntries,
|
||||
}[path] ?? [])));
|
||||
const harness = createHarness({ api: createApi({ workspaceTree }) }, {
|
||||
expandedDirs: {
|
||||
src: [fileEntry("src/stale.ts")],
|
||||
docs: [fileEntry("docs/stale.md")],
|
||||
},
|
||||
fileTreeStale: true,
|
||||
error: "stale failure",
|
||||
});
|
||||
|
||||
await harness.controller.refreshFiles();
|
||||
|
||||
expect(workspaceTree).toHaveBeenCalledTimes(3);
|
||||
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "", "remote-1");
|
||||
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "src", "remote-1");
|
||||
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "docs", "remote-1");
|
||||
expect(harness.state.fileTree).toEqual(rootEntries);
|
||||
expect(harness.state.expandedDirs).toEqual({ src: refreshedSrcEntries, docs: refreshedDocsEntries });
|
||||
expect(harness.state.fileTreeStale).toBe(false);
|
||||
expect(harness.state.error).toBe("");
|
||||
});
|
||||
|
||||
it("expands a directory then collapses it locally without refetching", async () => {
|
||||
const srcEntries = [fileEntry("src/index.ts")];
|
||||
const workspaceTree = vi.fn<WorkspaceTree>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path, srcEntries)));
|
||||
const harness = createHarness({ api: createApi({ workspaceTree }) });
|
||||
|
||||
await harness.controller.expandDir("src");
|
||||
|
||||
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "src", "remote-1");
|
||||
expect(harness.state.expandedDirs).toEqual({ src: srcEntries });
|
||||
expect(harness.state.error).toBe("");
|
||||
|
||||
workspaceTree.mockClear();
|
||||
await harness.controller.expandDir("src");
|
||||
|
||||
expect(workspaceTree).not.toHaveBeenCalled();
|
||||
expect(harness.state.expandedDirs).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileExplorerController workspace uploads", () => {
|
||||
it("tracks upload progress, completes from final responses, refreshes files, and selects the first uploaded file", async () => {
|
||||
const upload = controllableUpload();
|
||||
@@ -158,6 +212,22 @@ describe("FileExplorerController workspace uploads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears an in-flight upload by cancelling the request and removing the batch", async () => {
|
||||
const upload = controllableUpload({ rejectOnCancel: true });
|
||||
const harness = createHarness({ uploadWorkspaceFiles: upload.fn });
|
||||
const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt")], { destinationFolder: "uploads" });
|
||||
|
||||
expect(run?.batchId).toBe("batch-1");
|
||||
expect(harness.state.workspaceUploadBatches["batch-1"]?.status).toBe("uploading");
|
||||
|
||||
harness.controller.clearWorkspaceUpload(run?.batchId ?? "missing");
|
||||
await run?.done;
|
||||
|
||||
expect(upload.cancel).toHaveBeenCalledTimes(1);
|
||||
expect(harness.state.workspaceUploadBatches).toEqual({});
|
||||
expect(harness.state.error).toBe("");
|
||||
});
|
||||
|
||||
it("keeps per-file errors accurate and refreshes after partial batch success", async () => {
|
||||
const upload = controllableUpload();
|
||||
const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "fail") });
|
||||
@@ -211,18 +281,16 @@ describe("FileExplorerController workspace uploads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createHarness(deps: FileExplorerControllerDependencies = {}) {
|
||||
function createHarness(deps: FileExplorerControllerDependencies = {}, statePatch: Partial<AppState> = {}) {
|
||||
installWindow("http://localhost/app");
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedMachine: machine,
|
||||
selectedProject: project,
|
||||
selectedWorkspace: workspace,
|
||||
...statePatch,
|
||||
};
|
||||
const api: NonNullable<FileExplorerControllerDependencies["api"]> = deps.api ?? {
|
||||
workspaceTree: vi.fn<NonNullable<FileExplorerControllerDependencies["api"]>["workspaceTree"]>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))),
|
||||
workspaceFile: vi.fn<NonNullable<FileExplorerControllerDependencies["api"]>["workspaceFile"]>((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))),
|
||||
};
|
||||
const api = deps.api ?? createApi();
|
||||
const updateUrl = vi.fn();
|
||||
let batchSequence = 0;
|
||||
const controller = new FileExplorerController(
|
||||
@@ -292,8 +360,24 @@ function sequenceNow(...values: string[]): () => string {
|
||||
return () => values[index++] ?? values.at(-1) ?? "now";
|
||||
}
|
||||
|
||||
function treeResponse(path: string): FileTreeResponse {
|
||||
return { path, entries: [], scannedAt: "2026-06-25T00:00:00.000Z", truncated: false };
|
||||
function createApi(overrides: Partial<FileExplorerApi> = {}): FileExplorerApi {
|
||||
return {
|
||||
workspaceTree: vi.fn<WorkspaceTree>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))),
|
||||
workspaceFile: vi.fn<WorkspaceFile>((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function treeResponse(path: string, entries: FileTreeEntry[] = []): FileTreeResponse {
|
||||
return { path, entries, scannedAt: "2026-06-25T00:00:00.000Z", truncated: false };
|
||||
}
|
||||
|
||||
function directoryEntry(path: string): FileTreeEntry {
|
||||
return { name: path.split("/").at(-1) ?? path, path, type: "directory" };
|
||||
}
|
||||
|
||||
function fileEntry(path: string): FileTreeEntry {
|
||||
return { name: path.split("/").at(-1) ?? path, path, type: "file", size: 2 };
|
||||
}
|
||||
|
||||
function fileResponse(path: string): FileContentResponse {
|
||||
|
||||
@@ -20,6 +20,15 @@ const remoteMachine: Machine = {
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const addedMachine: Machine = {
|
||||
id: "remote-2",
|
||||
name: "New Remote",
|
||||
kind: "remote",
|
||||
baseUrl: "https://new-remote.example.test",
|
||||
createdAt: "2026-05-27T00:00:00.000Z",
|
||||
updatedAt: "2026-05-27T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const offlineHealth: MachineHealth = {
|
||||
machineId: remoteMachine.id,
|
||||
ok: false,
|
||||
@@ -33,6 +42,85 @@ describe("MachineController", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("selects a newly added machine and clears stale workspace state", async () => {
|
||||
const project = { id: "p1", name: "Project", path: "/repo", createdAt: "now" };
|
||||
const workspace = { id: "w1", projectId: project.id, path: "/repo", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
|
||||
const session = { id: "s1", cwd: "/repo", path: "/repo/.pi/sessions/s1.json", created: "now", modified: "now", messageCount: 1, firstMessage: "hello" };
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
machines: [localMachine, remoteMachine],
|
||||
selectedMachine: localMachine,
|
||||
projects: [project],
|
||||
workspaces: [workspace],
|
||||
sessions: [session],
|
||||
selectedProject: project,
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: session,
|
||||
fileTree: [{ name: "index.ts", path: "src/index.ts", type: "file" }],
|
||||
selectedFilePath: "src/index.ts",
|
||||
gitStatus: { isGitRepo: true, hash: "abc123", branch: "main", files: [{ path: "src/index.ts", index: "modified", workingTree: "modified" }] },
|
||||
activeTerminalCount: 2,
|
||||
error: "stale error",
|
||||
};
|
||||
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
||||
const updateUrl = vi.fn();
|
||||
const projects = { loadProjects: vi.fn() };
|
||||
const input = { name: "New Remote", baseUrl: "https://new-remote.example.test", token: "secret-token" };
|
||||
|
||||
const addMachine = vi.spyOn(api, "addMachine").mockResolvedValue(addedMachine);
|
||||
const health = vi.spyOn(api, "health").mockResolvedValue({ machineId: addedMachine.id, ok: true, checkedAt: "2026-05-27T00:00:01.000Z", status: "online" });
|
||||
const runtime = vi.spyOn(api, "runtime").mockResolvedValue({ machineId: addedMachine.id, ok: true, checkedAt: "2026-05-27T00:00:02.000Z" });
|
||||
|
||||
const controller = new MachineController(() => state, setState, updateUrl, projects);
|
||||
|
||||
const machine = await controller.addMachine(input);
|
||||
|
||||
expect(machine).toEqual(addedMachine);
|
||||
expect(addMachine).toHaveBeenCalledWith(input);
|
||||
expect(state.machines).toEqual([localMachine, remoteMachine, addedMachine]);
|
||||
expect(state.selectedMachine).toEqual(addedMachine);
|
||||
expect(state.projects).toEqual([]);
|
||||
expect(state.workspaces).toEqual([]);
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.selectedProject).toBeUndefined();
|
||||
expect(state.selectedWorkspace).toBeUndefined();
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
expect(state.fileTree).toEqual([]);
|
||||
expect(state.selectedFilePath).toBeUndefined();
|
||||
expect(state.gitStatus).toBeUndefined();
|
||||
expect(state.activeTerminalCount).toBe(0);
|
||||
expect(state.error).toBe("");
|
||||
expect(projects.loadProjects).toHaveBeenCalledOnce();
|
||||
expect(updateUrl).toHaveBeenCalledOnce();
|
||||
expect(health).toHaveBeenCalledWith(addedMachine.id);
|
||||
expect(runtime).toHaveBeenCalledWith(addedMachine.id);
|
||||
});
|
||||
|
||||
it("preserves the current machine state when adding a machine fails", async () => {
|
||||
let state: AppState = { ...initialAppState(), machines: [localMachine], selectedMachine: localMachine };
|
||||
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
||||
const updateUrl = vi.fn();
|
||||
const projects = { loadProjects: vi.fn() };
|
||||
const input = { name: "New Remote", baseUrl: "https://new-remote.example.test" };
|
||||
|
||||
vi.spyOn(api, "addMachine").mockRejectedValue(new Error("Remote rejected"));
|
||||
const health = vi.spyOn(api, "health");
|
||||
const runtime = vi.spyOn(api, "runtime");
|
||||
|
||||
const controller = new MachineController(() => state, setState, updateUrl, projects);
|
||||
|
||||
const machine = await controller.addMachine(input);
|
||||
|
||||
expect(machine).toBeUndefined();
|
||||
expect(state.machines).toEqual([localMachine]);
|
||||
expect(state.selectedMachine).toEqual(localMachine);
|
||||
expect(state.error).toBe("Error: Remote rejected");
|
||||
expect(projects.loadProjects).not.toHaveBeenCalled();
|
||||
expect(updateUrl).not.toHaveBeenCalled();
|
||||
expect(health).not.toHaveBeenCalled();
|
||||
expect(runtime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the routed remote machine selected while its health is offline", async () => {
|
||||
let state: AppState = initialAppState();
|
||||
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Machine, PiWebReleaseStatus, PiWebStatusResponse } from "../api";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { PiWebStatusController, type PiWebStatusControllerDependencies } from "./piWebStatusController";
|
||||
|
||||
type StatusApi = NonNullable<PiWebStatusControllerDependencies["api"]>;
|
||||
|
||||
describe("PiWebStatusController", () => {
|
||||
it("targets the selected machine and applies refreshed status", async () => {
|
||||
const harness = createHarness("remote-a");
|
||||
harness.piWebStatus.mockResolvedValue(status("remote"));
|
||||
|
||||
await harness.controller.refresh();
|
||||
|
||||
expect(harness.piWebStatus).toHaveBeenCalledWith("remote-a");
|
||||
expect(harness.state().piWebStatus?.generatedAt).toBe("remote");
|
||||
});
|
||||
|
||||
it("does not let an older periodic response overwrite a forced response", async () => {
|
||||
const harness = createHarness();
|
||||
const regular = createDeferred<PiWebStatusResponse>();
|
||||
const forced = createDeferred<PiWebStatusResponse>();
|
||||
harness.piWebStatus.mockReturnValue(regular.promise);
|
||||
harness.checkForUpdates.mockReturnValue(forced.promise);
|
||||
|
||||
const regularRequest = harness.controller.refresh();
|
||||
const forcedRequest = harness.controller.checkForUpdates();
|
||||
forced.resolve(status("forced"));
|
||||
await forcedRequest;
|
||||
regular.resolve(status("regular"));
|
||||
await regularRequest;
|
||||
|
||||
expect(harness.state().piWebStatus?.generatedAt).toBe("forced");
|
||||
});
|
||||
|
||||
it("deduplicates forced checks and suppresses periodic refresh while one is pending", async () => {
|
||||
const harness = createHarness();
|
||||
const forced = createDeferred<PiWebStatusResponse>();
|
||||
harness.checkForUpdates.mockReturnValue(forced.promise);
|
||||
|
||||
const first = harness.controller.checkForUpdates();
|
||||
const second = harness.controller.checkForUpdates();
|
||||
await harness.controller.refresh();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(harness.checkForUpdates).toHaveBeenCalledOnce();
|
||||
expect(harness.piWebStatus).not.toHaveBeenCalled();
|
||||
|
||||
forced.resolve(status("forced"));
|
||||
await first;
|
||||
});
|
||||
|
||||
it("does not apply a response or error after the selected machine changes", async () => {
|
||||
const harness = createHarness("remote-a");
|
||||
const forced = createDeferred<PiWebStatusResponse>();
|
||||
harness.checkForUpdates.mockReturnValue(forced.promise);
|
||||
|
||||
const request = harness.controller.checkForUpdates();
|
||||
harness.selectMachine("remote-b");
|
||||
forced.resolve(status("remote-a", { error: "registry unavailable" }));
|
||||
await expect(request).resolves.toBeUndefined();
|
||||
|
||||
expect(harness.state().piWebStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ error: "registry unavailable" }, "PI WEB update check failed: registry unavailable"],
|
||||
[{ skipped: true }, "PI WEB update check was skipped"],
|
||||
] as const)("applies status and rejects an unsuccessful manual check", async (release, message) => {
|
||||
const harness = createHarness();
|
||||
harness.checkForUpdates.mockResolvedValue(status("checked", release));
|
||||
|
||||
await expect(harness.controller.checkForUpdates()).rejects.toThrow(message);
|
||||
|
||||
expect(harness.state().piWebStatus?.generatedAt).toBe("checked");
|
||||
});
|
||||
|
||||
it("clears current status and reports periodic refresh failures", async () => {
|
||||
const harness = createHarness();
|
||||
const error = new Error("offline");
|
||||
harness.setStatus(status("old"));
|
||||
harness.piWebStatus.mockRejectedValue(error);
|
||||
|
||||
await harness.controller.refresh();
|
||||
|
||||
expect(harness.state().piWebStatus).toBeUndefined();
|
||||
expect(harness.onRefreshError).toHaveBeenCalledWith("local", error);
|
||||
});
|
||||
});
|
||||
|
||||
function createHarness(machineId = "local") {
|
||||
let state: AppState = { ...initialAppState(), selectedMachine: machine(machineId) };
|
||||
const piWebStatus = vi.fn<StatusApi["piWebStatus"]>();
|
||||
const checkForUpdates = vi.fn<StatusApi["checkForUpdates"]>();
|
||||
const onRefreshError = vi.fn<(machineId: string, error: unknown) => void>();
|
||||
const controller = new PiWebStatusController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
{ api: { piWebStatus, checkForUpdates }, onRefreshError },
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
piWebStatus,
|
||||
checkForUpdates,
|
||||
onRefreshError,
|
||||
state: () => state,
|
||||
setStatus: (piWebStatusValue: PiWebStatusResponse) => { state = { ...state, piWebStatus: piWebStatusValue }; },
|
||||
selectMachine: (id: string) => { state = { ...state, selectedMachine: machine(id) }; },
|
||||
};
|
||||
}
|
||||
|
||||
function machine(id: string): Machine {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
kind: id === "local" ? "local" : "remote",
|
||||
...(id === "local" ? {} : { baseUrl: `https://${id}.example.test` }),
|
||||
createdAt: "now",
|
||||
updatedAt: "now",
|
||||
};
|
||||
}
|
||||
|
||||
function status(generatedAt: string, release: Partial<PiWebReleaseStatus> = {}): PiWebStatusResponse {
|
||||
return {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt,
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false, ...release },
|
||||
commands: {},
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { piWebApi, type PiWebStatusResponse } from "../api";
|
||||
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||
|
||||
export interface PiWebStatusControllerDependencies {
|
||||
api?: Pick<typeof piWebApi, "piWebStatus" | "checkForUpdates">;
|
||||
onRefreshError?: (machineId: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
export class PiWebStatusController {
|
||||
private readonly api: Pick<typeof piWebApi, "piWebStatus" | "checkForUpdates">;
|
||||
private readonly onRefreshError: (machineId: string, error: unknown) => void;
|
||||
private requestSequence = 0;
|
||||
private pendingUpdateCheck: { machineId: string; requestSequence: number; promise: Promise<void> } | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly getState: GetState,
|
||||
private readonly setState: SetState,
|
||||
dependencies: PiWebStatusControllerDependencies = {},
|
||||
) {
|
||||
this.api = dependencies.api ?? piWebApi;
|
||||
this.onRefreshError = dependencies.onRefreshError ?? (() => undefined);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
if (this.pendingUpdateCheck?.machineId === machineId) return;
|
||||
const requestSequence = ++this.requestSequence;
|
||||
try {
|
||||
const piWebStatus = await this.api.piWebStatus(machineId);
|
||||
if (this.isCurrent(machineId, requestSequence)) this.setState({ piWebStatus });
|
||||
} catch (error) {
|
||||
if (!this.isCurrent(machineId, requestSequence)) return;
|
||||
this.setState({ piWebStatus: undefined });
|
||||
this.onRefreshError(machineId, error);
|
||||
}
|
||||
}
|
||||
|
||||
checkForUpdates(): Promise<void> {
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const existing = this.pendingUpdateCheck;
|
||||
if (existing?.machineId === machineId) return existing.promise;
|
||||
|
||||
const requestSequence = ++this.requestSequence;
|
||||
const promise = this.api.checkForUpdates(machineId)
|
||||
.then((piWebStatus) => {
|
||||
if (!this.isCurrent(machineId, requestSequence)) return;
|
||||
this.setState({ piWebStatus });
|
||||
throwForUnsuccessfulReleaseCheck(piWebStatus);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (this.isCurrent(machineId, requestSequence)) throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pendingUpdateCheck?.requestSequence === requestSequence) this.pendingUpdateCheck = undefined;
|
||||
});
|
||||
this.pendingUpdateCheck = { machineId, requestSequence, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
private isCurrent(machineId: string, requestSequence: number): boolean {
|
||||
return selectedMachineId(this.getState()) === machineId && requestSequence === this.requestSequence;
|
||||
}
|
||||
}
|
||||
|
||||
function throwForUnsuccessfulReleaseCheck(status: PiWebStatusResponse): void {
|
||||
if (status.release.error !== undefined) throw new Error(`PI WEB update check failed: ${status.release.error}`);
|
||||
if (status.release.skipped === true) throw new Error("PI WEB update check was skipped because remote version checks are disabled by offline/version-check settings");
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { InMemorySessionSelectionMemory } from "./sessionSelection";
|
||||
import { defaultApi, emptyPage, FakeSocket, oldSession, sessionLookupId, status, workspace, type AppState } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController archive and cleanup", () => {
|
||||
it("forgets the selected active session when archiving leaves only archived sessions", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession] };
|
||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: () => Promise.resolve({ archived: true }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(persistedSession, { updateUrl: false });
|
||||
await controller.archiveSession();
|
||||
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
expect(state.sessions).toHaveLength(1);
|
||||
expect(state.sessions[0]).toMatchObject({ ...oldSession, archived: true });
|
||||
expect(typeof state.sessions[0]?.archivedAt).toBe("string");
|
||||
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined();
|
||||
expect(urlUpdates).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("archives legacy sessions when persistence support is not advertised", async () => {
|
||||
const legacySession = { ...oldSession };
|
||||
const archivedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: legacySession, sessions: [legacySession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: (session) => {
|
||||
archivedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ archived: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.archiveSession(legacySession);
|
||||
|
||||
expect(archivedIds).toEqual([legacySession.id]);
|
||||
expect(state.sessions[0]).toMatchObject({ id: legacySession.id, archived: true });
|
||||
});
|
||||
|
||||
it("archives selected session descendants and selects the next active session", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: persistedSession.path, persisted: true };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, childSession, nextSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [persistedSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(persistedSession, { updateUrl: false });
|
||||
await controller.archiveSessionWithDescendants(persistedSession);
|
||||
|
||||
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.sessions.find((session) => session.id === childSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("archives selected sessions in bulk", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
const secondSession = { ...oldSession, id: "second-session", path: "/tmp/second-session.jsonl", persisted: true };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
|
||||
const archivedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, secondSession, nextSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: (session) => {
|
||||
archivedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ archived: true });
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(persistedSession, { updateUrl: false });
|
||||
await controller.archiveSessions([persistedSession, secondSession]);
|
||||
|
||||
expect(archivedIds).toEqual([oldSession.id, secondSession.id]);
|
||||
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.sessions.find((session) => session.id === secondSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("uses true bulk archive when the selected runtime supports it and applies partial failures", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
const failedSession = { ...oldSession, id: "failed-session", path: "/tmp/failed-session.jsonl", persisted: true };
|
||||
const archiveCalls: { ids: string[]; machineId: string }[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
sessions: [persistedSession, failedSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsBulkMutations] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archiveMany: (sessions, machineId) => {
|
||||
archiveCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" });
|
||||
return Promise.resolve({ archived: true, archivedSessionIds: [persistedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" });
|
||||
},
|
||||
archive: () => { throw new Error("single archive should not be used"); },
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(persistedSession, { updateUrl: false });
|
||||
await controller.archiveSessions([persistedSession, failedSession]);
|
||||
|
||||
expect(archiveCalls).toEqual([{ ids: [oldSession.id, failedSession.id], machineId: "local" }]);
|
||||
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.sessions.find((session) => session.id === failedSession.id)?.archived).toBeUndefined();
|
||||
expect(state.selectedSession?.id).toBe(failedSession.id);
|
||||
expect(state.error).toBe("Archive failed for 1 session: failed-session: busy");
|
||||
});
|
||||
|
||||
it("throttles per-session archive fallback when bulk mutations are unsupported", async () => {
|
||||
const sessions = Array.from({ length: 6 }, (_value, index) => ({ ...oldSession, id: `session-${String(index)}`, path: `/tmp/session-${String(index)}.jsonl`, persisted: true }));
|
||||
const resolvers: (() => void)[] = [];
|
||||
const startedIds: string[] = [];
|
||||
let activeCount = 0;
|
||||
let maxActiveCount = 0;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: (session) => new Promise((resolve) => {
|
||||
activeCount += 1;
|
||||
maxActiveCount = Math.max(maxActiveCount, activeCount);
|
||||
startedIds.push(sessionLookupId(session));
|
||||
resolvers.push(() => {
|
||||
activeCount -= 1;
|
||||
resolve({ archived: true });
|
||||
});
|
||||
}),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const archive = controller.archiveSessions(sessions);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(startedIds).toHaveLength(4);
|
||||
resolvers.shift()?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(startedIds).toHaveLength(5);
|
||||
for (const resolve of resolvers.splice(0)) resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
for (const resolve of resolvers.splice(0)) resolve();
|
||||
await archive;
|
||||
|
||||
expect(maxActiveCount).toBe(4);
|
||||
expect(state.sessions.every((session) => session.archived === true)).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes selected archived sessions in bulk and selects the next current session", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
|
||||
const deletedIds: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: archivedSession,
|
||||
sessions: [archivedSession, nextSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchived: (session) => {
|
||||
deletedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([archivedSession]);
|
||||
|
||||
expect(deletedIds).toEqual([archivedSession.id]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]);
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("uses true bulk delete when supported and keeps partial failures visible", async () => {
|
||||
const deletedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const failedSession = { ...oldSession, id: "failed-archived", path: "/tmp/failed-archived.jsonl", archived: true, archivedAt: "later" };
|
||||
const deleteCalls: { ids: string[]; machineId: string }[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: deletedSession,
|
||||
sessions: [deletedSession, failedSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsBulkMutations] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchivedMany: (sessions, machineId) => {
|
||||
deleteCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" });
|
||||
return Promise.resolve({ deleted: true, deletedSessionIds: [deletedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" });
|
||||
},
|
||||
deleteArchived: () => { throw new Error("single delete should not be used"); },
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([deletedSession, failedSession]);
|
||||
|
||||
expect(deleteCalls).toEqual([{ ids: [deletedSession.id, failedSession.id], machineId: "local" }]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([failedSession.id]);
|
||||
expect(state.selectedSession?.id).toBe(failedSession.id);
|
||||
expect(state.error).toBe("Delete failed for 1 session: failed-archived: busy");
|
||||
});
|
||||
|
||||
it("applies cleanup execution results and refreshes the current workspace sessions", async () => {
|
||||
const archivedAt = "2026-06-25T12:00:00.000Z";
|
||||
const deletedArchived = { ...oldSession, id: "deleted-archived", path: "/tmp/deleted-archived.jsonl", archived: true, archivedAt: "2026-05-01T00:00:00.000Z" };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
|
||||
const refreshedArchived = { ...oldSession, archived: true, archivedAt };
|
||||
const sessionsCalls: { cwd: string; machineId: string }[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession, deletedArchived, nextSession],
|
||||
sessionStatuses: { [oldSession.id]: status(oldSession.id), [deletedArchived.id]: status(deletedArchived.id), [nextSession.id]: status(nextSession.id) },
|
||||
sessionActivities: { [oldSession.id]: { sessionId: oldSession.id, phase: "idle", label: "idle", at: archivedAt } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
sessions: (cwd, machineId) => {
|
||||
sessionsCalls.push({ cwd, machineId: machineId ?? "local" });
|
||||
return Promise.resolve([refreshedArchived, nextSession]);
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.applySessionCleanupResult({
|
||||
generatedAt: archivedAt,
|
||||
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 60 },
|
||||
projects: [{ cwd: workspace.path, archiveCount: 1, deleteCount: 1 }],
|
||||
totals: { archiveCount: 1, deleteCount: 1 },
|
||||
archivedSessionIds: [oldSession.id],
|
||||
deletedSessionIds: [deletedArchived.id],
|
||||
});
|
||||
|
||||
expect(sessionsCalls).toEqual([{ cwd: workspace.path, machineId: "local" }]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([oldSession.id, nextSession.id]);
|
||||
expect(state.sessions[0]).toMatchObject({ id: oldSession.id, archived: true, archivedAt });
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
expect(state.sessionStatuses[oldSession.id]).toBeUndefined();
|
||||
expect(state.sessionStatuses[deletedArchived.id]).toBeUndefined();
|
||||
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not delete archived sessions when the selected machine runtime reports no support", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const deletedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession], machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchived: (session) => {
|
||||
deletedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([archivedSession]);
|
||||
|
||||
expect(deletedIds).toEqual([]);
|
||||
expect(state.sessions).toEqual([archivedSession]);
|
||||
expect(state.error).toContain("requires an updated Pi-Web runtime");
|
||||
});
|
||||
|
||||
it("allows legacy archived-session deletion when runtime support is unknown", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const deletedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: archivedSession, sessions: [archivedSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchived: (session) => {
|
||||
deletedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([archivedSession]);
|
||||
|
||||
expect(deletedIds).toEqual([archivedSession.id]);
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.error).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
|
||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, emptyPage, FakeSocket, MemoryStorage, oldSession, replacementSession, sessionKey, sessionLookupId, status, workspace, type AppState } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController cached-new sessions", () => {
|
||||
it("keeps live message count updates when a cached new session becomes persisted", async () => {
|
||||
const cachedSession = markCachedNewSessionInfo(oldSession);
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: cachedSession, sessions: [cachedSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const send = controller.send("hello");
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 1 } });
|
||||
controller.flushPendingUpdates();
|
||||
resolvePrompt?.();
|
||||
await send;
|
||||
|
||||
expect(state.sessions[0]?.messageCount).toBe(1);
|
||||
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(false);
|
||||
expect(state.selectedSession?.messageCount).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes transient server-reported new sessions and clears local state", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
const transientSession = { ...oldSession, persisted: false };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
|
||||
const stoppedIds: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: transientSession,
|
||||
sessions: [transientSession, nextSession],
|
||||
sessionStatuses: { [transientSession.id]: { ...status(transientSession.id), persisted: false } },
|
||||
sessionActivities: { [transientSession.id]: { sessionId: transientSession.id, phase: "active", label: "Starting", at: "2026-05-20T00:00:00.000Z" } },
|
||||
sendingPrompts: { [transientSession.id]: true },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); },
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
saveDraft(sessionKey(transientSession.id), "discard me");
|
||||
|
||||
await controller.deleteCachedNewSession(transientSession);
|
||||
|
||||
expect(stoppedIds).toEqual([transientSession.id]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]);
|
||||
expect(state.sessionStatuses[transientSession.id]).toBeUndefined();
|
||||
expect(state.sessionActivities[transientSession.id]).toBeUndefined();
|
||||
expect(state.sendingPrompts[transientSession.id]).toBeUndefined();
|
||||
expect(loadDraft(sessionKey(transientSession.id))).toBe("");
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("recreates missing browser-cached new sessions and moves their draft", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
rememberCachedNewSession(oldSession);
|
||||
saveDraft(sessionKey(oldSession.id), "draft text");
|
||||
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] };
|
||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||
const socket = new FakeSocket();
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => Promise.resolve(replacementSession),
|
||||
messages: (session) => {
|
||||
if (sessionLookupId(session) === oldSession.id) return Promise.reject(new Error("Session not found"));
|
||||
return Promise.resolve(emptyPage);
|
||||
},
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
undefined,
|
||||
{ api, socket },
|
||||
);
|
||||
|
||||
await controller.selectSession(markCachedNewSessionInfo(oldSession), { updateUrl: false });
|
||||
|
||||
expect(state.selectedSession?.id).toBe(replacementSession.id);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]);
|
||||
expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]);
|
||||
expect(loadDraft(sessionKey(oldSession.id))).toBe("");
|
||||
expect(loadDraft(sessionKey(replacementSession.id))).toBe("draft text");
|
||||
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]);
|
||||
expect(urlUpdates).toEqual([{ replace: true }]);
|
||||
});
|
||||
|
||||
it("stores command prompt drafts for replacement sessions before selecting them", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
commandDialog: { type: "select", requestId: "r1", title: "Fork from message", options: [{ value: "m1", label: "fork me" }] },
|
||||
};
|
||||
const urlUpdates: unknown[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
respondToCommand: () => Promise.resolve({ type: "done", message: "Session forked", session: replacementSession, promptDraft: "fork me" }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.respondToCommand("r1", "m1");
|
||||
|
||||
expect(state.commandDialog).toBeUndefined();
|
||||
expect(loadDraft(sessionKey(replacementSession.id))).toBe("fork me");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, EmitSocket, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController live events", () => {
|
||||
it("coalesces rapid status updates into a single state write per frame", () => {
|
||||
const setStateCalls: Partial<AppState>[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 1 } });
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 2 } });
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 3 } });
|
||||
|
||||
// Nothing applies until the frame is flushed; last-write-wins per session.
|
||||
expect(setStateCalls).toHaveLength(0);
|
||||
expect(state.sessionStatuses[oldSession.id]).toBeUndefined();
|
||||
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(setStateCalls).toHaveLength(1);
|
||||
expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, messageCount: 3 });
|
||||
expect(state.status?.messageCount).toBe(3);
|
||||
});
|
||||
|
||||
it("applies the latest activity per session on flush", () => {
|
||||
const setStateCalls: Partial<AppState>[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "active", label: "running tool", at: "t1" } });
|
||||
controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "idle", label: "idle", at: "t2" } });
|
||||
|
||||
expect(setStateCalls).toHaveLength(0);
|
||||
|
||||
controller.flushPendingUpdates();
|
||||
|
||||
expect(state.sessionActivities[oldSession.id]).toMatchObject({ phase: "idle", label: "idle" });
|
||||
expect(state.activity?.phase).toBe("idle");
|
||||
});
|
||||
|
||||
it("coalesces status updates delivered over the per-session socket until the frame is flushed", async () => {
|
||||
const socket = new EmitSocket();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: () => Promise.resolve(status(oldSession.id)),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket },
|
||||
);
|
||||
await controller.selectSession(oldSession, { updateUrl: false });
|
||||
|
||||
socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 7 } });
|
||||
socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 8 } });
|
||||
|
||||
// Buffered, not applied synchronously.
|
||||
expect(state.sessionStatuses[oldSession.id]?.messageCount).toBeUndefined();
|
||||
|
||||
controller.flushPendingUpdates();
|
||||
|
||||
expect(state.sessionStatuses[oldSession.id]?.messageCount).toBe(8);
|
||||
expect(state.status?.messageCount).toBe(8);
|
||||
});
|
||||
|
||||
it("clears stale active activity when an idle status arrives", () => {
|
||||
const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" };
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
activity: activeActivity,
|
||||
sessionActivities: { [oldSession.id]: activeActivity },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) });
|
||||
controller.flushPendingUpdates();
|
||||
|
||||
expect(state.activity).toBeUndefined();
|
||||
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
|
||||
expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, isStreaming: false });
|
||||
});
|
||||
|
||||
it("updates visible session message counts from live status events", () => {
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 3 } });
|
||||
controller.flushPendingUpdates();
|
||||
|
||||
expect(state.sessions[0]?.messageCount).toBe(3);
|
||||
expect(state.selectedSession?.messageCount).toBe(3);
|
||||
});
|
||||
|
||||
it("adds a newly created session to the list when it belongs to the selected workspace", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
const spawned: SessionInfo = { ...oldSession, id: "spawned-session", path: "/tmp/spawned-session.jsonl" };
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.created", session: spawned });
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["spawned-session", "old-session"]);
|
||||
});
|
||||
|
||||
it("ignores a created session for a different workspace or a duplicate id", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession, id: "other", cwd: "/other-repo" } });
|
||||
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession } });
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { isCachedNewSessionInfo, loadCachedNewSessions } from "../cachedNewSessions";
|
||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, deferred, emptyPage, FakeSocket, MemoryStorage, oldSession, sessionKey, sessionLookupId, status, workspace, type AppState, type SessionInfo } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController pending starts", () => {
|
||||
it("creates and selects a temporary editable session before backend start resolves", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const messageCalls: string[] = [];
|
||||
const statusCalls: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: (session) => { messageCalls.push(sessionLookupId(session)); return Promise.resolve(emptyPage); },
|
||||
status: (session) => { statusCalls.push(sessionLookupId(session)); return Promise.resolve(status(sessionLookupId(session))); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporarySession = state.selectedSession;
|
||||
|
||||
expect(temporarySession?.id).toMatch(/^pending-session-/);
|
||||
expect(temporarySession?.persisted).toBe(false);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([temporarySession?.id]);
|
||||
expect(state.activity).toMatchObject({ sessionId: temporarySession?.id, phase: "active", label: "Creating session" });
|
||||
expect(messageCalls).toEqual([]);
|
||||
expect(statusCalls).toEqual([]);
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
|
||||
expect(state.selectedSession?.id).toBe("started-session");
|
||||
expect(messageCalls).toEqual(["started-session"]);
|
||||
expect(statusCalls).toEqual(["started-session"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const socket = new FakeSocket();
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => {
|
||||
// Simulate the broadcast arriving before the HTTP response resolves.
|
||||
controller.applyGlobalEvent({ type: "session.created", session: started });
|
||||
return startRequest.promise;
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
|
||||
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("releases unrelated created-session broadcasts after pending starts settle", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const otherClientSession: SessionInfo = { ...oldSession, id: "other-client-session", path: "/tmp/other-client-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
controller.applyGlobalEvent({ type: "session.created", session: started });
|
||||
controller.applyGlobalEvent({ type: "session.created", session: otherClientSession });
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
const sessionIds = state.sessions.map((session) => session.id);
|
||||
expect(sessionIds).not.toContain(temporaryId);
|
||||
expect(sessionIds.filter((id) => id === started.id)).toHaveLength(1);
|
||||
expect(sessionIds.filter((id) => id === otherClientSession.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves temporary start rows across session-list refreshes before backend resolution", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
sessions: () => Promise.resolve([oldSession]),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
await controller.refreshCurrentWorkspaceSessions();
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId, oldSession.id]);
|
||||
expect(state.selectedSession?.id).toBe(temporaryId);
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([started.id, oldSession.id]);
|
||||
expect(state.selectedSession?.id).toBe(started.id);
|
||||
});
|
||||
|
||||
it("tracks multiple pending session starts without blocking another start", async () => {
|
||||
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
|
||||
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
|
||||
const startResolvers: ((session: SessionInfo) => void)[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => new Promise<SessionInfo>((resolve) => { startResolvers.push(resolve); }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const firstStart = controller.startSession();
|
||||
const firstTemporaryId = state.selectedSession?.id;
|
||||
const secondStart = controller.startSession();
|
||||
const secondTemporaryId = state.selectedSession?.id;
|
||||
|
||||
expect(startResolvers).toHaveLength(2);
|
||||
expect(state.startingSessionCount).toBe(0);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, firstTemporaryId]);
|
||||
expect(state.selectedSession?.id).toBe(secondTemporaryId);
|
||||
expect(state.sessions.every((session) => session.persisted === false)).toBe(true);
|
||||
|
||||
startResolvers[0]?.(firstStarted);
|
||||
await firstStart;
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, "started-session-1"]);
|
||||
expect(state.selectedSession?.id).toBe(secondTemporaryId);
|
||||
|
||||
startResolvers[1]?.(secondStarted);
|
||||
await secondStart;
|
||||
|
||||
expect(state.startingSessionCount).toBe(0);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["started-session-2", "started-session-1"]);
|
||||
expect(state.selectedSession?.id).toBe("started-session-2");
|
||||
});
|
||||
|
||||
it("moves a temporary session draft and cached-new marker to the resolved session", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
saveDraft(sessionKey(temporaryId), "draft text");
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(loadDraft(sessionKey(temporaryId))).toBe("");
|
||||
expect(loadDraft(sessionKey(started.id))).toBe("draft text");
|
||||
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([started.id]);
|
||||
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a failed temporary start selected with a discardable transient row", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => Promise.reject(new Error("backend unavailable")),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
|
||||
expect(temporaryId).toMatch(/^pending-session-/);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
|
||||
expect(state.sessions[0]?.persisted).toBe(false);
|
||||
expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" });
|
||||
expect(state.error).toContain("backend unavailable");
|
||||
|
||||
await controller.deleteCachedNewSession(state.sessions[0]);
|
||||
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stops the backend session if a discarded pending start resolves later", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const stoppedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
await controller.send("queued before discard");
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "queued before discard" }]);
|
||||
|
||||
await controller.deleteCachedNewSession(state.selectedSession);
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(stoppedIds).toEqual([started.id]);
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, deferred, FakeSocket, oldSession, replacementSession, sessionLookupId, status, workspace, type AppState, type MessagePage, type SessionStatus } from "./sessionController.testSupport";
|
||||
|
||||
function page(text: string, total: number): MessagePage {
|
||||
return { messages: [{ role: "assistant", content: text }], start: 0, total };
|
||||
}
|
||||
|
||||
describe("SessionController selected-session refresh", () => {
|
||||
it("shares same-turn requests and runs one trailing refresh requested during the active fetch", async () => {
|
||||
const firstPage = deferred<MessagePage>();
|
||||
const firstStatus = deferred<SessionStatus>();
|
||||
const trailingPage = deferred<MessagePage>();
|
||||
const trailingStatus = deferred<SessionStatus>();
|
||||
const trailingStarted = deferred<undefined>();
|
||||
let messageCalls = 0;
|
||||
let statusCalls = 0;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
messages: () => {
|
||||
messageCalls += 1;
|
||||
if (messageCalls === 2) trailingStarted.resolve(undefined);
|
||||
return messageCalls === 1 ? firstPage.promise : trailingPage.promise;
|
||||
},
|
||||
status: () => {
|
||||
statusCalls += 1;
|
||||
return statusCalls === 1 ? firstStatus.promise : trailingStatus.promise;
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const first = controller.refreshSelectedSession();
|
||||
const duplicate = controller.refreshSelectedSession();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(messageCalls).toBe(1);
|
||||
expect(statusCalls).toBe(1);
|
||||
|
||||
const later = controller.refreshSelectedSession();
|
||||
const laterDuplicate = controller.refreshSelectedSession();
|
||||
firstPage.resolve(page("stale", 1));
|
||||
firstStatus.resolve({ ...status(oldSession.id), messageCount: 1 });
|
||||
await trailingStarted.promise;
|
||||
|
||||
expect(messageCalls).toBe(2);
|
||||
expect(statusCalls).toBe(2);
|
||||
|
||||
trailingPage.resolve(page("fresh", 2));
|
||||
trailingStatus.resolve({ ...status(oldSession.id), messageCount: 2 });
|
||||
await Promise.all([first, duplicate, later, laterDuplicate]);
|
||||
|
||||
expect(messageCalls).toBe(2);
|
||||
expect(statusCalls).toBe(2);
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh" }] }]);
|
||||
expect(state.status?.messageCount).toBe(2);
|
||||
});
|
||||
|
||||
it("does not apply an older refresh after the user selects another session", async () => {
|
||||
const stalePage = deferred<MessagePage>();
|
||||
const staleStatus = deferred<SessionStatus>();
|
||||
const replacementPage = page("replacement", 1);
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
messages: (session) => sessionLookupId(session) === oldSession.id ? stalePage.promise : Promise.resolve(replacementPage),
|
||||
status: (session) => sessionLookupId(session) === oldSession.id ? staleStatus.promise : Promise.resolve(status(replacementSession.id)),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const staleRefresh = controller.refreshSelectedSession();
|
||||
await Promise.resolve();
|
||||
await controller.selectSession(replacementSession, { updateUrl: false });
|
||||
stalePage.resolve(page("old response", 1));
|
||||
staleStatus.resolve({ ...status(oldSession.id), messageCount: 1 });
|
||||
await staleRefresh;
|
||||
|
||||
expect(state.selectedSession?.id).toBe(replacementSession.id);
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "replacement" }] }]);
|
||||
expect(state.status?.sessionId).toBe(replacementSession.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { initialAppState } from "../appState";
|
||||
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { InMemorySessionSelectionMemory } from "./sessionSelection";
|
||||
import { defaultApi, emptyPage, FakeSocket, oldSession, sessionKey, sessionLookupId, status, workspace, type AppState, type MessagePage } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController reload and selection", () => {
|
||||
it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
const cacheKey = sessionKey(oldSession.id);
|
||||
const freshPage: MessagePage = { messages: [{ role: "assistant", content: "fresh from disk" }], start: 1, total: 2 };
|
||||
const cachedPages = new Map<string, MessagePage>([[cacheKey, { messages: [{ role: "user", content: "stale cached transcript" }], start: 0, total: 2 }]]);
|
||||
const reloadCalls: string[] = [];
|
||||
const messageCalls: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: persistedSession,
|
||||
sessions: [persistedSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
reloadSession: (session) => {
|
||||
reloadCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve({ reloaded: true });
|
||||
},
|
||||
messages: (session) => {
|
||||
messageCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve(freshPage);
|
||||
},
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{
|
||||
api,
|
||||
socket: new FakeSocket(),
|
||||
transcripts: new ChatTranscriptStore({
|
||||
read: (sessionId) => cachedPages.get(sessionId),
|
||||
write: (sessionId, page) => { cachedPages.set(sessionId, page); },
|
||||
remove: (sessionId) => { cachedPages.delete(sessionId); },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await controller.reloadSession(persistedSession);
|
||||
|
||||
expect(reloadCalls).toEqual([oldSession.id]);
|
||||
expect(messageCalls).toEqual([oldSession.id]);
|
||||
expect(cachedPages.get(cacheKey)).toEqual(freshPage);
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh from disk" }] }]);
|
||||
expect(state.messagePageStart).toBe(1);
|
||||
expect(state.error).toBe("");
|
||||
});
|
||||
|
||||
it("does not reload sessions from disk when the selected machine runtime does not support it", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
const reloadCalls: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: persistedSession,
|
||||
sessions: [persistedSession],
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
reloadSession: (session) => {
|
||||
reloadCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve({ reloaded: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.reloadSession(persistedSession);
|
||||
|
||||
expect(reloadCalls).toEqual([]);
|
||||
expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime");
|
||||
});
|
||||
|
||||
it("does not reload sessions from disk without a persisted server signal when persistence is authoritative", async () => {
|
||||
const reloadCalls: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
reloadSession: (session) => {
|
||||
reloadCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve({ reloaded: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.reloadSession(oldSession);
|
||||
await controller.reloadSession({ ...oldSession, persisted: false });
|
||||
|
||||
expect(reloadCalls).toEqual([]);
|
||||
expect(state.error).toBe("");
|
||||
});
|
||||
|
||||
it("forgets archived selections when the archived section collapse clears selection", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
|
||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(archivedSession, { updateUrl: false });
|
||||
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBe(archivedSession);
|
||||
|
||||
controller.clearSelectionAfterArchivedCollapse();
|
||||
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined();
|
||||
expect(urlUpdates).toEqual([undefined]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, replacementSession, sessionLookupId, status, workspace, type AppState, type Deferred, type PromptAttachment, type SessionInfo } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController send queue", () => {
|
||||
it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => {
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
let promptArgs: { attachments?: PromptAttachment[] } | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: (_session, _text, _behavior, _machineId, sentAttachments) => new Promise<{ accepted: true }>((resolve) => {
|
||||
promptArgs = { ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) };
|
||||
resolvePrompt = () => { resolve({ accepted: true }); };
|
||||
}),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const send = controller.send("look", undefined, attachments, "inline");
|
||||
const sendingDuringPrompt = state.sendingPrompts;
|
||||
resolvePrompt?.();
|
||||
await send;
|
||||
|
||||
expect(sendingDuringPrompt).toEqual({ [oldSession.id]: true });
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
expect(promptArgs).toEqual({ attachments });
|
||||
});
|
||||
|
||||
it("keeps the sending state scoped to the originating session when the user switches away", async () => {
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] };
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const send = controller.send("look", undefined, attachments, "inline");
|
||||
// While the upload is in flight, deselecting must not clear the originating
|
||||
// session's sending entry, and it must stay keyed to that session only.
|
||||
controller.deselectSession();
|
||||
expect(state.sendingPrompts).toEqual({ [oldSession.id]: true });
|
||||
expect(state.sendingPrompts[replacementSession.id]).toBeUndefined();
|
||||
resolvePrompt?.();
|
||||
await send;
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("uploads to the workspace folder and rewrites the prompt for folder delivery", async () => {
|
||||
let savedCalledWith: PromptAttachment[] | undefined;
|
||||
let promptText: string | undefined;
|
||||
let promptAttachments: PromptAttachment[] | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); },
|
||||
prompt: (_session, text, _behavior, _machineId, sentAttachments) => { promptText = text; promptAttachments = sentAttachments; return Promise.resolve({ accepted: true }); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.send("check this", undefined, attachments, "folder");
|
||||
|
||||
expect(savedCalledWith).toEqual(attachments);
|
||||
expect(promptText).toBe("check this\n\[email protected]/attachments/shot.png");
|
||||
expect(promptAttachments).toBeUndefined();
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("does not set the sending state for plain text messages", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const seen: Record<string, true>[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: () => { seen.push({ ...state.sendingPrompts }); return Promise.resolve({ accepted: true }); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.send("hello");
|
||||
expect(seen).toEqual([{}]);
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("sends slash commands without inserting an optimistic transcript line and toggles the sending state", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
let resolveCommand: (() => void) | undefined;
|
||||
const seenDuringCommand: Record<string, true>[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
runCommand: (_session, text) => new Promise((resolve) => {
|
||||
seenDuringCommand.push({ ...state.sendingPrompts });
|
||||
resolveCommand = () => { resolve(text.startsWith("/skill") ? { type: "done" } : { type: "done", message: "stats" }); };
|
||||
}),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const run = controller.send("/skill:skill-creator");
|
||||
expect(seenDuringCommand).toEqual([{ [oldSession.id]: true }]);
|
||||
// No raw command text is added to the transcript; the agent streams the
|
||||
// canonical expanded message back instead.
|
||||
expect(state.messages).toEqual([]);
|
||||
resolveCommand?.();
|
||||
await run;
|
||||
expect(state.messages).toEqual([]);
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("queues prompt sends for a pending session start and flushes them after resolution", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const promptCalls: { sessionId: string; text: string; behavior?: "steer" | "followUp" }[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
prompt: (session, text, behavior) => {
|
||||
promptCalls.push({ sessionId: sessionLookupId(session), text, ...(behavior === undefined ? {} : { behavior }) });
|
||||
return Promise.resolve({ accepted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
|
||||
await controller.send("first");
|
||||
await controller.send("second", "steer");
|
||||
|
||||
expect(promptCalls).toEqual([]);
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([
|
||||
{ kind: "followUp", text: "first" },
|
||||
{ kind: "steer", text: "second" },
|
||||
]);
|
||||
expect(state.activity?.detail).toContain("2 queued messages");
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(promptCalls).toEqual([
|
||||
{ sessionId: started.id, text: "first" },
|
||||
{ sessionId: started.id, text: "second", behavior: "steer" },
|
||||
]);
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
|
||||
expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined();
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
expect(state.selectedSession?.id).toBe(started.id);
|
||||
});
|
||||
|
||||
it("queues slash commands, shell input, and attachments for a pending session start", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const calls: string[] = [];
|
||||
const promptCalls: { text: string; attachments?: PromptAttachment[] }[] = [];
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
runCommand: (session, text) => {
|
||||
calls.push(`command:${sessionLookupId(session)}:${text}`);
|
||||
return Promise.resolve({ type: "done" });
|
||||
},
|
||||
shell: (session, text) => {
|
||||
calls.push(`shell:${sessionLookupId(session)}:${text}`);
|
||||
return Promise.resolve({ accepted: true });
|
||||
},
|
||||
saveAttachments: (session, sentAttachments) => {
|
||||
calls.push(`save:${sessionLookupId(session)}:${sentAttachments[0]?.name ?? ""}`);
|
||||
return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]);
|
||||
},
|
||||
prompt: (session, text, _behavior, _machineId, sentAttachments) => {
|
||||
calls.push(`prompt:${sessionLookupId(session)}:${text}`);
|
||||
promptCalls.push({ text, ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) });
|
||||
return Promise.resolve({ accepted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
|
||||
await controller.send("/help");
|
||||
await controller.send("!pwd");
|
||||
await controller.send("look", undefined, attachments, "inline");
|
||||
await controller.send("save", undefined, attachments, "folder");
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([
|
||||
{ kind: "followUp", text: "/help" },
|
||||
{ kind: "followUp", text: "!pwd" },
|
||||
{ kind: "followUp", text: "look\n\n[1 attachment queued: shot.png]" },
|
||||
{ kind: "followUp", text: "save\n\n[1 attachment queued: shot.png]" },
|
||||
]);
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(calls).toEqual([
|
||||
`command:${started.id}:/help`,
|
||||
`shell:${started.id}:!pwd`,
|
||||
`prompt:${started.id}:look`,
|
||||
`save:${started.id}:shot.png`,
|
||||
`prompt:${started.id}:save\n\[email protected]/attachments/shot.png`,
|
||||
]);
|
||||
expect(promptCalls).toEqual([
|
||||
{ text: "look", attachments },
|
||||
{ text: "save\n\[email protected]/attachments/shot.png" },
|
||||
]);
|
||||
expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps queued sends visible when backend session creation fails", async () => {
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
await controller.send("recover me");
|
||||
|
||||
startRequest.reject(new Error("backend unavailable"));
|
||||
await start;
|
||||
|
||||
expect(state.selectedSession?.id).toBe(temporaryId);
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "recover me" }]);
|
||||
expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" });
|
||||
expect(state.activity?.detail).toContain("1 queued message kept below");
|
||||
|
||||
await controller.deleteCachedNewSession(state.selectedSession);
|
||||
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps queued sends scoped to their originating pending start", async () => {
|
||||
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
|
||||
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
|
||||
const startRequests: Deferred<SessionInfo>[] = [];
|
||||
const promptCalls: { sessionId: string; text: string }[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => {
|
||||
const request = deferred<SessionInfo>();
|
||||
startRequests.push(request);
|
||||
return request.promise;
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
prompt: (session, text) => {
|
||||
promptCalls.push({ sessionId: sessionLookupId(session), text });
|
||||
return Promise.resolve({ accepted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const firstStart = controller.startSession();
|
||||
const firstTemporary = state.selectedSession;
|
||||
if (firstTemporary === undefined) throw new Error("Expected first temporary session");
|
||||
const secondStart = controller.startSession();
|
||||
const secondTemporary = state.selectedSession;
|
||||
if (secondTemporary === undefined) throw new Error("Expected second temporary session");
|
||||
|
||||
await controller.send("second prompt");
|
||||
await controller.selectSession(firstTemporary, { updateUrl: false });
|
||||
await controller.send("first prompt");
|
||||
|
||||
startRequests[1]?.resolve(secondStarted);
|
||||
await secondStart;
|
||||
|
||||
expect(promptCalls).toEqual([{ sessionId: secondStarted.id, text: "second prompt" }]);
|
||||
expect(state.selectedSession?.id).toBe(firstTemporary.id);
|
||||
expect(state.clientQueuedSessionMessages[secondStarted.id]).toBeUndefined();
|
||||
expect(state.clientQueuedSessionMessages[firstTemporary.id]).toEqual([{ kind: "followUp", text: "first prompt" }]);
|
||||
|
||||
startRequests[0]?.resolve(firstStarted);
|
||||
await firstStart;
|
||||
|
||||
expect(promptCalls).toEqual([
|
||||
{ sessionId: secondStarted.id, text: "second prompt" },
|
||||
{ sessionId: firstStarted.id, text: "first prompt" },
|
||||
]);
|
||||
expect(state.selectedSession?.id).toBe(firstStarted.id);
|
||||
expect(state.clientQueuedSessionMessages[firstStarted.id]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,731 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { api as defaultApi, type MessagePage, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api";
|
||||
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { SessionController, type SessionEventSocket } from "./sessionController";
|
||||
import { InMemorySessionSelectionMemory } from "./sessionSelection";
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
get length(): number {
|
||||
return this.values.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.values.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSocket implements SessionEventSocket {
|
||||
readonly connectedSessionIds: string[] = [];
|
||||
|
||||
connect(session: SessionRef): void {
|
||||
this.connectedSessionIds.push(session.id);
|
||||
}
|
||||
|
||||
setHandler(): void {
|
||||
// Test socket does not emit events.
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// No-op.
|
||||
}
|
||||
}
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
path: "/repo",
|
||||
label: "repo",
|
||||
isMain: true,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: false,
|
||||
};
|
||||
|
||||
const oldSession: SessionInfo = {
|
||||
id: "old-session",
|
||||
path: "/tmp/old-session.jsonl",
|
||||
cwd: "/repo",
|
||||
created: "2026-05-15T00:00:00.000Z",
|
||||
modified: "2026-05-15T00:00:00.000Z",
|
||||
messageCount: 0,
|
||||
firstMessage: "",
|
||||
};
|
||||
|
||||
const replacementSession: SessionInfo = {
|
||||
...oldSession,
|
||||
id: "new-session",
|
||||
path: "/tmp/new-session.jsonl",
|
||||
};
|
||||
|
||||
const emptyPage: MessagePage = { messages: [], start: 0, total: 0 };
|
||||
|
||||
function status(sessionId: string): 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,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionController", () => {
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
|
||||
});
|
||||
|
||||
it("clears stale active activity when an idle status arrives", () => {
|
||||
const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" };
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
activity: activeActivity,
|
||||
sessionActivities: { [oldSession.id]: activeActivity },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) });
|
||||
|
||||
expect(state.activity).toBeUndefined();
|
||||
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
|
||||
expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, isStreaming: false });
|
||||
});
|
||||
|
||||
it("updates visible session message counts from live status events", () => {
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 3 } });
|
||||
|
||||
expect(state.sessions[0]?.messageCount).toBe(3);
|
||||
expect(state.selectedSession?.messageCount).toBe(3);
|
||||
});
|
||||
|
||||
it("adds a newly created session to the list when it belongs to the selected workspace", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
const spawned: SessionInfo = { ...oldSession, id: "spawned-session", path: "/tmp/spawned-session.jsonl" };
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.created", session: spawned });
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["spawned-session", "old-session"]);
|
||||
});
|
||||
|
||||
it("ignores a created session for a different workspace or a duplicate id", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession, id: "other", cwd: "/other-repo" } });
|
||||
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession } });
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const socket = new FakeSocket();
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => {
|
||||
// Simulate the broadcast arriving before the HTTP response resolves.
|
||||
controller.applyGlobalEvent({ type: "session.created", session: started });
|
||||
return Promise.resolve(started);
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket },
|
||||
);
|
||||
|
||||
await controller.startSession();
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
|
||||
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => {
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
let promptArgs: { attachments?: PromptAttachment[] } | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: (_session, _text, _behavior, _machineId, sentAttachments) => new Promise<{ accepted: true }>((resolve) => {
|
||||
promptArgs = { ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) };
|
||||
resolvePrompt = () => { resolve({ accepted: true }); };
|
||||
}),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const send = controller.send("look", undefined, attachments, "inline");
|
||||
const sendingDuringPrompt = state.sendingPrompts;
|
||||
resolvePrompt?.();
|
||||
await send;
|
||||
|
||||
expect(sendingDuringPrompt).toEqual({ [oldSession.id]: true });
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
expect(promptArgs).toEqual({ attachments });
|
||||
});
|
||||
|
||||
it("keeps the sending state scoped to the originating session when the user switches away", async () => {
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] };
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const send = controller.send("look", undefined, attachments, "inline");
|
||||
// While the upload is in flight, deselecting must not clear the originating
|
||||
// session's sending entry, and it must stay keyed to that session only.
|
||||
controller.deselectSession();
|
||||
expect(state.sendingPrompts).toEqual({ [oldSession.id]: true });
|
||||
expect(state.sendingPrompts[replacementSession.id]).toBeUndefined();
|
||||
resolvePrompt?.();
|
||||
await send;
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("uploads to the workspace folder and rewrites the prompt for folder delivery", async () => {
|
||||
let savedCalledWith: PromptAttachment[] | undefined;
|
||||
let promptText: string | undefined;
|
||||
let promptAttachments: PromptAttachment[] | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); },
|
||||
prompt: (_session, text, _behavior, _machineId, sentAttachments) => { promptText = text; promptAttachments = sentAttachments; return Promise.resolve({ accepted: true }); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.send("check this", undefined, attachments, "folder");
|
||||
|
||||
expect(savedCalledWith).toEqual(attachments);
|
||||
expect(promptText).toBe("check this\n\[email protected]/attachments/shot.png");
|
||||
expect(promptAttachments).toBeUndefined();
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("does not set the sending state for plain text messages", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const seen: Record<string, true>[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: () => { seen.push({ ...state.sendingPrompts }); return Promise.resolve({ accepted: true }); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.send("hello");
|
||||
expect(seen).toEqual([{}]);
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("sends slash commands without inserting an optimistic transcript line and toggles the sending state", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
let resolveCommand: (() => void) | undefined;
|
||||
const seenDuringCommand: Record<string, true>[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
runCommand: (_session, text) => new Promise((resolve) => {
|
||||
seenDuringCommand.push({ ...state.sendingPrompts });
|
||||
resolveCommand = () => { resolve(text.startsWith("/skill") ? { type: "done" } : { type: "done", message: "stats" }); };
|
||||
}),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const run = controller.send("/skill:skill-creator");
|
||||
expect(seenDuringCommand).toEqual([{ [oldSession.id]: true }]);
|
||||
// No raw command text is added to the transcript; the agent streams the
|
||||
// canonical expanded message back instead.
|
||||
expect(state.messages).toEqual([]);
|
||||
resolveCommand?.();
|
||||
await run;
|
||||
expect(state.messages).toEqual([]);
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps live message count updates when a cached new session becomes persisted", async () => {
|
||||
const cachedSession = markCachedNewSessionInfo(oldSession);
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: cachedSession, sessions: [cachedSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const send = controller.send("hello");
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 1 } });
|
||||
resolvePrompt?.();
|
||||
await send;
|
||||
|
||||
expect(state.sessions[0]?.messageCount).toBe(1);
|
||||
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(false);
|
||||
expect(state.selectedSession?.messageCount).toBe(1);
|
||||
});
|
||||
|
||||
it("recreates missing browser-cached new sessions and moves their draft", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
rememberCachedNewSession(oldSession);
|
||||
saveDraft(sessionKey(oldSession.id), "draft text");
|
||||
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] };
|
||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||
const socket = new FakeSocket();
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => Promise.resolve(replacementSession),
|
||||
messages: (session) => {
|
||||
if (sessionLookupId(session) === oldSession.id) return Promise.reject(new Error("Session not found"));
|
||||
return Promise.resolve(emptyPage);
|
||||
},
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
undefined,
|
||||
{ api, socket },
|
||||
);
|
||||
|
||||
await controller.selectSession(markCachedNewSessionInfo(oldSession), { updateUrl: false });
|
||||
|
||||
expect(state.selectedSession?.id).toBe(replacementSession.id);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]);
|
||||
expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]);
|
||||
expect(loadDraft(sessionKey(oldSession.id))).toBe("");
|
||||
expect(loadDraft(sessionKey(replacementSession.id))).toBe("draft text");
|
||||
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]);
|
||||
expect(urlUpdates).toEqual([{ replace: true }]);
|
||||
});
|
||||
|
||||
it("stores command prompt drafts for replacement sessions before selecting them", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
commandDialog: { type: "select", requestId: "r1", title: "Fork from message", options: [{ value: "m1", label: "fork me" }] },
|
||||
};
|
||||
const urlUpdates: unknown[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
respondToCommand: () => Promise.resolve({ type: "done", message: "Session forked", session: replacementSession, promptDraft: "fork me" }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.respondToCommand("r1", "m1");
|
||||
|
||||
expect(state.commandDialog).toBeUndefined();
|
||||
expect(loadDraft(sessionKey(replacementSession.id))).toBe("fork me");
|
||||
});
|
||||
|
||||
it("forgets the selected active session when archiving leaves only archived sessions", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
|
||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: () => Promise.resolve({ archived: true }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(oldSession, { updateUrl: false });
|
||||
await controller.archiveSession();
|
||||
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
expect(state.sessions).toHaveLength(1);
|
||||
expect(state.sessions[0]).toMatchObject({ ...oldSession, archived: true });
|
||||
expect(typeof state.sessions[0]?.archivedAt).toBe("string");
|
||||
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined();
|
||||
expect(urlUpdates).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("archives selected session descendants and selects the next active session", async () => {
|
||||
const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: oldSession.path };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession, childSession, nextSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [oldSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(oldSession, { updateUrl: false });
|
||||
await controller.archiveSessionWithDescendants(oldSession);
|
||||
|
||||
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.sessions.find((session) => session.id === childSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("archives selected sessions in bulk", async () => {
|
||||
const secondSession = { ...oldSession, id: "second-session", path: "/tmp/second-session.jsonl" };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
|
||||
const archivedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession, secondSession, nextSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: (session) => {
|
||||
archivedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ archived: true });
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(oldSession, { updateUrl: false });
|
||||
await controller.archiveSessions([oldSession, secondSession]);
|
||||
|
||||
expect(archivedIds).toEqual([oldSession.id, secondSession.id]);
|
||||
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.sessions.find((session) => session.id === secondSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("deletes selected archived sessions in bulk and selects the next current session", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
|
||||
const deletedIds: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: archivedSession,
|
||||
sessions: [archivedSession, nextSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchived: (session) => {
|
||||
deletedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([archivedSession]);
|
||||
|
||||
expect(deletedIds).toEqual([archivedSession.id]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]);
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("applies cleanup execution results and refreshes the current workspace sessions", async () => {
|
||||
const archivedAt = "2026-06-25T12:00:00.000Z";
|
||||
const deletedArchived = { ...oldSession, id: "deleted-archived", path: "/tmp/deleted-archived.jsonl", archived: true, archivedAt: "2026-05-01T00:00:00.000Z" };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
|
||||
const refreshedArchived = { ...oldSession, archived: true, archivedAt };
|
||||
const sessionsCalls: { cwd: string; machineId: string }[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession, deletedArchived, nextSession],
|
||||
sessionStatuses: { [oldSession.id]: status(oldSession.id), [deletedArchived.id]: status(deletedArchived.id), [nextSession.id]: status(nextSession.id) },
|
||||
sessionActivities: { [oldSession.id]: { sessionId: oldSession.id, phase: "idle", label: "idle", at: archivedAt } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
sessions: (cwd, machineId) => {
|
||||
sessionsCalls.push({ cwd, machineId: machineId ?? "local" });
|
||||
return Promise.resolve([refreshedArchived, nextSession]);
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.applySessionCleanupResult({
|
||||
generatedAt: archivedAt,
|
||||
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 60 },
|
||||
projects: [{ cwd: workspace.path, archiveCount: 1, deleteCount: 1 }],
|
||||
totals: { archiveCount: 1, deleteCount: 1 },
|
||||
archivedSessionIds: [oldSession.id],
|
||||
deletedSessionIds: [deletedArchived.id],
|
||||
});
|
||||
|
||||
expect(sessionsCalls).toEqual([{ cwd: workspace.path, machineId: "local" }]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([oldSession.id, nextSession.id]);
|
||||
expect(state.sessions[0]).toMatchObject({ id: oldSession.id, archived: true, archivedAt });
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
expect(state.sessionStatuses[oldSession.id]).toBeUndefined();
|
||||
expect(state.sessionStatuses[deletedArchived.id]).toBeUndefined();
|
||||
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not delete archived sessions when the selected machine runtime does not support it", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const deletedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchived: (session) => {
|
||||
deletedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([archivedSession]);
|
||||
|
||||
expect(deletedIds).toEqual([]);
|
||||
expect(state.sessions).toEqual([archivedSession]);
|
||||
expect(state.error).toContain("requires an updated Pi-Web runtime");
|
||||
});
|
||||
|
||||
it("reloads the selected session, discards the cached transcript, and re-fetches history", async () => {
|
||||
Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true });
|
||||
const reloadCalls: string[] = [];
|
||||
const messageCalls: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
reloadSession: (session) => {
|
||||
reloadCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve({ reloaded: true });
|
||||
},
|
||||
messages: (session) => {
|
||||
messageCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve(emptyPage);
|
||||
},
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.reloadSession(oldSession);
|
||||
|
||||
expect(reloadCalls).toEqual([oldSession.id]);
|
||||
expect(messageCalls).toContain(oldSession.id);
|
||||
expect(state.error).toBe("");
|
||||
});
|
||||
|
||||
it("does not reload sessions when the selected machine runtime does not support it", async () => {
|
||||
const reloadCalls: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
reloadSession: (session) => {
|
||||
reloadCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve({ reloaded: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.reloadSession(oldSession);
|
||||
|
||||
expect(reloadCalls).toEqual([]);
|
||||
expect(state.error).toContain("requires an updated Pi-Web runtime");
|
||||
});
|
||||
|
||||
it("forgets archived selections when the archived section collapse clears selection", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
|
||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(archivedSession, { updateUrl: false });
|
||||
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBe(archivedSession);
|
||||
|
||||
controller.clearSelectionAfterArchivedCollapse();
|
||||
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined();
|
||||
expect(urlUpdates).toEqual([undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
function sessionKey(sessionId: string): string {
|
||||
return machineSessionKey("local", sessionId);
|
||||
}
|
||||
|
||||
function sessionLookupId(session: string | SessionRef): string {
|
||||
return typeof session === "string" ? session : session.id;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import type { MessagePage, SessionInfo, SessionRef, SessionStatus, Workspace } from "../api";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import type { SessionUiEvent } from "../sessionSocket";
|
||||
import type { SessionEventSocket } from "./sessionController";
|
||||
|
||||
export { api as defaultApi } from "../api";
|
||||
export type { MessagePage, PromptAttachment, SessionActivity, SessionInfo, SessionRef, SessionStatus, Workspace } from "../api";
|
||||
export type { AppState } from "../appState";
|
||||
|
||||
export class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
get length(): number {
|
||||
return this.values.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.values.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSocket implements SessionEventSocket {
|
||||
readonly connectedSessionIds: string[] = [];
|
||||
|
||||
connect(session: SessionRef): void {
|
||||
this.connectedSessionIds.push(session.id);
|
||||
}
|
||||
|
||||
setHandler(): void {
|
||||
// Test socket does not emit events.
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// No-op.
|
||||
}
|
||||
}
|
||||
|
||||
export class EmitSocket implements SessionEventSocket {
|
||||
readonly connectedSessionIds: string[] = [];
|
||||
private handler: ((event: SessionUiEvent) => void) | undefined;
|
||||
|
||||
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void {
|
||||
this.connectedSessionIds.push(session.id);
|
||||
this.handler = onEvent;
|
||||
}
|
||||
|
||||
setHandler(onEvent: (event: SessionUiEvent) => void): void {
|
||||
this.handler = onEvent;
|
||||
}
|
||||
|
||||
emit(event: SessionUiEvent): void {
|
||||
this.handler?.(event);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.handler = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const workspace: Workspace = {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
path: "/repo",
|
||||
label: "repo",
|
||||
isMain: true,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: false,
|
||||
};
|
||||
|
||||
export const oldSession: SessionInfo = {
|
||||
id: "old-session",
|
||||
path: "/tmp/old-session.jsonl",
|
||||
cwd: "/repo",
|
||||
created: "2026-05-15T00:00:00.000Z",
|
||||
modified: "2026-05-15T00:00:00.000Z",
|
||||
messageCount: 0,
|
||||
firstMessage: "",
|
||||
};
|
||||
|
||||
export const replacementSession: SessionInfo = {
|
||||
...oldSession,
|
||||
id: "new-session",
|
||||
path: "/tmp/new-session.jsonl",
|
||||
};
|
||||
|
||||
export const emptyPage: MessagePage = { messages: [], start: 0, total: 0 };
|
||||
|
||||
export interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export function status(sessionId: string): 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,
|
||||
};
|
||||
}
|
||||
|
||||
const framesById = new Map<number, () => void>();
|
||||
let nextFrameId = 1;
|
||||
|
||||
// The controller coalesces status/activity/transcript updates behind
|
||||
// requestAnimationFrame. The node test environment has no rAF, so install a
|
||||
// controllable one: callbacks are queued and only run when a test drives a
|
||||
// frame, mirroring how the browser defers them until paint.
|
||||
beforeEach(() => {
|
||||
framesById.clear();
|
||||
nextFrameId = 1;
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
|
||||
const id = nextFrameId++;
|
||||
framesById.set(id, callback);
|
||||
return id;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => { framesById.delete(id); });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
|
||||
});
|
||||
|
||||
export function runPendingAnimationFrames(): void {
|
||||
const frames = Array.from(framesById.values());
|
||||
framesById.clear();
|
||||
for (const frame of frames) frame();
|
||||
}
|
||||
|
||||
export function sessionKey(sessionId: string): string {
|
||||
return machineSessionKey("local", sessionId);
|
||||
}
|
||||
|
||||
export function sessionLookupId(session: string | SessionRef): string {
|
||||
return typeof session === "string" ? session : session.id;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,18 +28,12 @@ describe("selectPreferredSession", () => {
|
||||
expect(selectPreferredSession(sessions)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can remember an archived selected session", () => {
|
||||
it("returns a remembered archived session before falling back to active sessions", () => {
|
||||
const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")];
|
||||
|
||||
expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1");
|
||||
});
|
||||
|
||||
it("can remember an archived selected session when only archived sessions remain", () => {
|
||||
const sessions = [{ ...testSession("s1"), archived: true }];
|
||||
|
||||
expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1");
|
||||
});
|
||||
|
||||
it("falls back to the first active session when the remembered session no longer exists", () => {
|
||||
const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")];
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
interface PendingRefresh {
|
||||
promise: Promise<void>;
|
||||
latestRefresh: () => Promise<void>;
|
||||
started: boolean;
|
||||
trailing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares refresh work requested in the same task and collapses requests made
|
||||
* during an active refresh into one trailing pass, without losing later passes.
|
||||
*/
|
||||
export class TrailingRefreshCoordinator<Key> {
|
||||
private readonly pendingByKey = new Map<Key, PendingRefresh>();
|
||||
|
||||
request(key: Key, refresh: () => Promise<void>): Promise<void> {
|
||||
const existing = this.pendingByKey.get(key);
|
||||
if (existing !== undefined) {
|
||||
existing.latestRefresh = refresh;
|
||||
if (existing.started) existing.trailing = true;
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
const pending: PendingRefresh = {
|
||||
promise: Promise.resolve(),
|
||||
latestRefresh: refresh,
|
||||
started: false,
|
||||
trailing: false,
|
||||
};
|
||||
pending.promise = Promise.resolve()
|
||||
.then(async () => {
|
||||
let latestError: unknown;
|
||||
let latestFailed: boolean;
|
||||
do {
|
||||
pending.trailing = false;
|
||||
const runRefresh = pending.latestRefresh;
|
||||
pending.started = true;
|
||||
latestFailed = false;
|
||||
try {
|
||||
await runRefresh();
|
||||
} catch (error) {
|
||||
latestError = error;
|
||||
latestFailed = true;
|
||||
}
|
||||
} while (this.hasTrailingRequest(pending));
|
||||
if (latestFailed) throw latestError;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pendingByKey.get(key) === pending) this.pendingByKey.delete(key);
|
||||
});
|
||||
this.pendingByKey.set(key, pending);
|
||||
return pending.promise;
|
||||
}
|
||||
|
||||
private hasTrailingRequest(pending: PendingRefresh): boolean {
|
||||
return pending.trailing;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ describe("selectPreferredWorkspace", () => {
|
||||
expect(selectPreferredWorkspace(workspaces, { latestWorkspaceId: "old" })?.id).toBe("main");
|
||||
});
|
||||
|
||||
it("preserves explicit invalid target behavior", () => {
|
||||
it("does not fall back to remembered workspace when the explicit target is invalid", () => {
|
||||
const workspaces = [testWorkspace("main"), testWorkspace("feature")];
|
||||
|
||||
expect(selectPreferredWorkspace(workspaces, { targetWorkspaceId: "old", latestWorkspaceId: "feature" })).toBeUndefined();
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
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", () => {
|
||||
describe("input mode helpers", () => {
|
||||
it("detects shell mode and context-excluded shell mode after leading whitespace", () => {
|
||||
expect(inputModeForDraft(" ! npm test")).toEqual({ kind: "shell", excludeFromContext: false });
|
||||
expect(inputModeForDraft("\n!! secret command")).toEqual({ kind: "shell", excludeFromContext: true });
|
||||
expect(isShellInput(" ! pwd")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects slash commands only for the current token", () => {
|
||||
it("detects slash-command mode from the current token", () => {
|
||||
expect(inputModeForDraft("/compact")).toEqual({ kind: "command" });
|
||||
expect(inputModeForDraft("please /compact")).toEqual({ kind: "command" });
|
||||
expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" });
|
||||
});
|
||||
|
||||
it("detects file completion contexts", () => {
|
||||
it("compares modes by kind and shell context-exclusion", () => {
|
||||
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("collapses file completion triggers to file mode", () => {
|
||||
expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open @ A FILE")).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, type PiWebCapability } from "../../../../shared/capabilities";
|
||||
import type { AppState } from "../../appState";
|
||||
import { isCachedNewSessionInfo } from "../../cachedNewSessions";
|
||||
import { selectedMachineId } from "../../controllers/types";
|
||||
import { isArchivableSessionInfo, isTransientNewSessionInfo, sessionPersistenceOptionsForRuntime } from "../../sessionPersistence";
|
||||
import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
|
||||
import type { PluginAction } from "../types";
|
||||
|
||||
@@ -177,8 +177,8 @@ 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,
|
||||
@@ -187,9 +187,9 @@ export function createCoreActions(): PluginAction[] {
|
||||
{
|
||||
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(),
|
||||
},
|
||||
{
|
||||
@@ -217,26 +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, sessionPersistenceOptions(context.state));
|
||||
}
|
||||
|
||||
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, sessionPersistenceOptions(context.state));
|
||||
}
|
||||
|
||||
function hasReloadableSession(context: { state: AppState }): boolean {
|
||||
const session = context.state.selectedSession;
|
||||
if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false;
|
||||
if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state))) return false;
|
||||
if (reloadSessionDisabledReason(context) !== undefined) return false;
|
||||
return !isSessionActive(context.state.status, context.state.activity);
|
||||
}
|
||||
|
||||
function reloadSessionDisabledReason(context: { state: AppState }): string | undefined {
|
||||
const session = context.state.selectedSession;
|
||||
if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return undefined;
|
||||
if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state))) return undefined;
|
||||
if (isSessionActive(context.state.status, context.state.activity)) return undefined;
|
||||
return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions");
|
||||
return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions from disk");
|
||||
}
|
||||
|
||||
function sessionPersistenceOptions(state: AppState) {
|
||||
return sessionPersistenceOptionsForRuntime(state.machineRuntimes[selectedMachineId(state)]);
|
||||
}
|
||||
|
||||
function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadExternalPlugins, resolvePluginModuleUrl } from "./external";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("external plugin manifests", () => {
|
||||
it("fetches the default manifest through the application base", async () => {
|
||||
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 404 })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(loadExternalPlugins()).resolves.toEqual([]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://pi.example.test/pi-web-plugins/manifest.json", { cache: "no-store" });
|
||||
});
|
||||
|
||||
it("loads manifest-relative modules from a nested deployment", async () => {
|
||||
const manifestUrl = "https://pi.example.test/test/ai/pi-web-plugins/manifest.json";
|
||||
const fetchMock = vi.fn(() => Promise.resolve(new Response(JSON.stringify({
|
||||
plugins: [{ id: "info", module: "./info/pi-web-plugin.js?v=1", machineSpecific: false }],
|
||||
}))));
|
||||
const moduleLoader = vi.fn(() => Promise.resolve({
|
||||
default: { apiVersion: 1, name: "Info", activate: () => ({ contributions: {} }) },
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const registrations = await loadExternalPlugins(manifestUrl, { moduleLoader });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(manifestUrl, { cache: "no-store" });
|
||||
expect(moduleLoader).toHaveBeenCalledWith("https://pi.example.test/test/ai/pi-web-plugins/info/pi-web-plugin.js?v=1");
|
||||
expect(registrations).toMatchObject([{ id: "info", machineSpecific: false, plugin: { apiVersion: 1, name: "Info" } }]);
|
||||
});
|
||||
|
||||
it("treats root-style modules from existing manifests as application-root paths", () => {
|
||||
const rootManifestUrl = "https://pi.example.test/pi-web-plugins/manifest.json";
|
||||
const nestedManifestUrl = "https://pi.example.test/test/ai/pi-web-plugins/manifest.json";
|
||||
|
||||
expect(resolvePluginModuleUrl("/pi-web-plugins/info/pi-web-plugin.js?v=1", rootManifestUrl, {
|
||||
viteBaseUrl: "/",
|
||||
documentBaseUrl: "https://pi.example.test/",
|
||||
})).toBe("https://pi.example.test/pi-web-plugins/info/pi-web-plugin.js?v=1");
|
||||
expect(resolvePluginModuleUrl("/pi-web-plugins/info/pi-web-plugin.js?v=1", nestedManifestUrl, {
|
||||
viteBaseUrl: "./",
|
||||
documentBaseUrl: "https://pi.example.test/test/ai/",
|
||||
})).toBe("https://pi.example.test/test/ai/pi-web-plugins/info/pi-web-plugin.js?v=1");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
|
||||
import { resolveAppUrl, type AppUrlContext } from "../appUrl";
|
||||
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
|
||||
|
||||
export interface PluginManifestEntry {
|
||||
@@ -14,18 +15,20 @@ interface PluginManifest {
|
||||
export interface LoadExternalPluginsOptions {
|
||||
machineId?: string;
|
||||
shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean;
|
||||
moduleLoader?: (moduleUrl: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
|
||||
const manifest = await fetchPluginManifest(manifestUrl);
|
||||
export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
|
||||
const resolvedManifestUrl = resolveAppUrl(manifestUrl);
|
||||
const manifest = await fetchPluginManifest(resolvedManifestUrl);
|
||||
if (manifest === undefined) return [];
|
||||
|
||||
const registrations: PiWebPluginRegistration[] = [];
|
||||
for (const entry of manifest.plugins) {
|
||||
if (options.shouldLoadPlugin?.(entry) === false) continue;
|
||||
try {
|
||||
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString();
|
||||
const module: unknown = await import(/* @vite-ignore */ moduleUrl);
|
||||
const moduleUrl = resolvePluginModuleUrl(entry.module, resolvedManifestUrl);
|
||||
const module = await (options.moduleLoader ?? importPluginModule)(moduleUrl);
|
||||
const plugin = parsePluginModule(module, moduleUrl);
|
||||
registrations.push({
|
||||
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id),
|
||||
@@ -40,6 +43,15 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
|
||||
return registrations;
|
||||
}
|
||||
|
||||
export function resolvePluginModuleUrl(moduleReference: string, manifestUrl: string, appUrlContext?: AppUrlContext): string {
|
||||
if (!moduleReference.startsWith("/")) return new URL(moduleReference, manifestUrl).toString();
|
||||
return appUrlContext === undefined ? resolveAppUrl(moduleReference) : resolveAppUrl(moduleReference, appUrlContext);
|
||||
}
|
||||
|
||||
async function importPluginModule(moduleUrl: string): Promise<unknown> {
|
||||
return import(/* @vite-ignore */ moduleUrl);
|
||||
}
|
||||
|
||||
async function fetchPluginManifest(manifestUrl: string): Promise<PluginManifest | undefined> {
|
||||
const response = await fetch(manifestUrl, { cache: "no-store" });
|
||||
if (response.status === 404) return undefined;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user