Archived
Merge remote-tracking branch 'origin/main' into investigate/issue-12-session-dir
# Conflicts: # src/client/src/api.ts # src/client/src/api/clients.ts # src/client/src/api/federatedRouteContract.test.ts # src/server/sessions/piSessionService.ts # src/server/sessions/sessionRoutes.ts
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { terminalsApi, workspacesApi } from "./clients";
|
||||
import { machinesApi, piWebApi, terminalsApi, workspacesApi } from "./clients";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w/1",
|
||||
@@ -29,6 +30,36 @@ 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: [],
|
||||
});
|
||||
|
||||
await piWebApi.piWebStatus("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status");
|
||||
});
|
||||
|
||||
it("reads machine runtime through the gateway route", async () => {
|
||||
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
|
||||
await machinesApi.runtime("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/runtime");
|
||||
});
|
||||
});
|
||||
|
||||
describe("machine-scoped terminal command-run API", () => {
|
||||
it("deletes workspaces through the selected machine scope", async () => {
|
||||
const fetchMock = stubJsonFetch(commandRun);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseAuthProvidersResponse,
|
||||
parseClosed,
|
||||
parseCommandResult,
|
||||
parseDeleted,
|
||||
parseDetached,
|
||||
parseFileContentResponse,
|
||||
parseFileSuggestion,
|
||||
@@ -16,12 +17,14 @@ import {
|
||||
parseGitStatusResponse,
|
||||
parseMachine,
|
||||
parseMachineHealth,
|
||||
parseMachineRuntime,
|
||||
parseMachinesResponse,
|
||||
parseMessagePage,
|
||||
parseModelSelectionResponse,
|
||||
parseOAuthFlowState,
|
||||
parsePiWebConfigResponse,
|
||||
parsePiWebPluginsResponse,
|
||||
parsePiWebRuntimeResponse,
|
||||
parsePiWebStatusResponse,
|
||||
parseProject,
|
||||
parseRestored,
|
||||
@@ -39,8 +42,12 @@ import { machineGitDiffUrl, messageUrl } from "./urls";
|
||||
|
||||
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
|
||||
|
||||
function sessionBaseUrl(session: SessionRef, machineId = "local"): string {
|
||||
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(session.id)}`;
|
||||
}
|
||||
|
||||
function sessionUrl(session: SessionRef, endpoint: string, machineId = "local"): string {
|
||||
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(session.id)}/${endpoint}`;
|
||||
return `${sessionBaseUrl(session, machineId)}/${endpoint}`;
|
||||
}
|
||||
|
||||
function sessionQueryUrl(session: SessionRef, endpoint: string, machineId = "local"): string {
|
||||
@@ -49,7 +56,8 @@ function sessionQueryUrl(session: SessionRef, endpoint: string, machineId = "loc
|
||||
}
|
||||
|
||||
export const piWebApi = {
|
||||
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
|
||||
piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse),
|
||||
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
|
||||
};
|
||||
|
||||
export const machinesApi = {
|
||||
@@ -57,6 +65,7 @@ export const machinesApi = {
|
||||
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),
|
||||
};
|
||||
|
||||
export const configApi = {
|
||||
@@ -107,6 +116,7 @@ export const sessionsApi = {
|
||||
archive: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "archive", machineId), parseArchived, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
archiveWithDescendants: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "archive-tree", machineId), parseArchived, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
restore: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
deleteArchived: (session: SessionRef, machineId = "local") => request(`${sessionBaseUrl(session, machineId)}?${new URLSearchParams({ cwd: session.cwd }).toString()}`, parseDeleted, { method: "DELETE" }),
|
||||
detachParent: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Workspace } from "../../../shared/apiTypes";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||
import { activityApi, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { activityApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||
import { workspaceImagePreviewUrl } from "./urls";
|
||||
|
||||
@@ -27,6 +27,7 @@ describe("federated route contract", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await Promise.all([
|
||||
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
|
||||
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
|
||||
ignoreParseFailure(projectsApi.projects(machineId)),
|
||||
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
||||
@@ -59,6 +60,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(sessionsApi.archive(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.archiveWithDescendants(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.restore(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.deleteArchived(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
||||
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers";
|
||||
|
||||
describe("API parsers", () => {
|
||||
it("parses PI WEB config responses", () => {
|
||||
@@ -18,11 +19,23 @@ describe("API parsers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses PI WEB runtime responses", () => {
|
||||
expect(parsePiWebRuntimeResponse({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
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] });
|
||||
});
|
||||
|
||||
it("parses PI WEB plugin status responses", () => {
|
||||
expect(parsePiWebPluginsResponse({
|
||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }],
|
||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
|
||||
})).toEqual({
|
||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }],
|
||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import { isPiWebCapability } from "../../../shared/capabilities";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
@@ -95,6 +96,21 @@ export function parseMachineHealth(value: unknown): MachineHealth {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMachineRuntime(value: unknown): MachineRuntime {
|
||||
const record = requireRecord(value);
|
||||
const error = optionalString(record, "error");
|
||||
return {
|
||||
machineId: requireString(record, "machineId"),
|
||||
ok: requireBoolean(record, "ok"),
|
||||
checkedAt: requireString(record, "checkedAt"),
|
||||
...optionalField("packageName", optionalString(record, "packageName")),
|
||||
...optionalField("generatedAt", optionalString(record, "generatedAt")),
|
||||
...(record["components"] === undefined ? {} : { components: parsePiWebRuntimeComponents(record["components"]) }),
|
||||
...(record["capabilities"] === undefined ? {} : { capabilities: parsePiWebCapabilities(record["capabilities"]) }),
|
||||
...(error === undefined ? {} : { error }),
|
||||
};
|
||||
}
|
||||
|
||||
function requireMachineKind(record: Record<string, unknown>, key: string): MachineKind {
|
||||
const value = requireString(record, key);
|
||||
if (value !== "local" && value !== "remote") throw new Error(`Expected machine kind field: ${key}`);
|
||||
@@ -476,6 +492,7 @@ function parsePiWebPluginInfo(value: unknown): PiWebPluginInfo {
|
||||
module: requireString(record, "module"),
|
||||
source: requireString(record, "source"),
|
||||
scope: parsePiWebPluginScope(record["scope"]),
|
||||
machineSpecific: parseOptionalBoolean(record["machineSpecific"], "machineSpecific") ?? false,
|
||||
enabled: requireBoolean(record, "enabled"),
|
||||
};
|
||||
}
|
||||
@@ -485,6 +502,12 @@ function parsePiWebPluginScope(value: unknown): PiWebPluginScope {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOptionalBoolean(value: unknown, key: string): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "boolean") throw new Error(`Expected optional boolean field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
@@ -497,11 +520,38 @@ export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePiWebRuntimeResponse(value: unknown): PiWebRuntimeResponse {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
packageName: requireString(record, "packageName"),
|
||||
generatedAt: requireString(record, "generatedAt"),
|
||||
components: parsePiWebRuntimeComponents(record["components"]),
|
||||
capabilities: parsePiWebCapabilities(record["capabilities"]),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiWebComponents(value: unknown): PiWebStatusResponse["components"] {
|
||||
const record = requireRecord(value);
|
||||
return { web: parsePiWebComponentStatus(record["web"]), sessiond: parsePiWebComponentStatus(record["sessiond"]) };
|
||||
}
|
||||
|
||||
function parsePiWebRuntimeComponents(value: unknown): PiWebRuntimeResponse["components"] {
|
||||
const record = requireRecord(value);
|
||||
return { web: parsePiWebRuntimeComponent(record["web"]), sessiond: parsePiWebRuntimeComponent(record["sessiond"]) };
|
||||
}
|
||||
|
||||
function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
component: parsePiWebServiceComponent(record["component"]),
|
||||
label: requireString(record, "label"),
|
||||
...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
|
||||
available: requireBoolean(record, "available"),
|
||||
capabilities: parsePiWebCapabilities(record["capabilities"]),
|
||||
...optionalField("error", optionalString(record, "error")),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
@@ -571,6 +621,11 @@ function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePiWebCapabilities(value: unknown): PiWebCapability[] {
|
||||
if (!Array.isArray(value) || !value.every(isPiWebCapability)) throw new Error("Invalid PI WEB capabilities");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity {
|
||||
if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid PI WEB status severity");
|
||||
return value;
|
||||
@@ -638,6 +693,12 @@ export function parseRestored(value: unknown): { restored: true } {
|
||||
return { restored: true };
|
||||
}
|
||||
|
||||
export function parseDeleted(value: unknown): { deleted: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["deleted"] !== true) throw new Error("Expected deleted response");
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
export function parseDetached(value: unknown): { detached: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["detached"] !== true) throw new Error("Expected detached response");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleNavigationSection } from "./navigationState";
|
||||
import { defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleCollapsedNavigationSection, toggleNavigationSection } from "./navigationState";
|
||||
|
||||
describe("navigationState", () => {
|
||||
it("defaults to the first incomplete selection section", () => {
|
||||
@@ -16,15 +16,22 @@ describe("navigationState", () => {
|
||||
expect(expandedNavigationSection("none", state)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("only collapses sections in mobile navigation layouts", () => {
|
||||
it("uses the mobile accordion state on mobile layouts", () => {
|
||||
const state = { selectedProject: {}, selectedWorkspace: {} };
|
||||
|
||||
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state })).toBe(false);
|
||||
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: true, expanded: "sessions", state })).toBe(true);
|
||||
expect(isNavigationSectionCollapsed("sessions", { isMobileLayout: true, expanded: "sessions", state })).toBe(false);
|
||||
});
|
||||
|
||||
it("toggles the effective section, including the implicit default section", () => {
|
||||
it("uses independent collapsed sections on desktop layouts", () => {
|
||||
const state = { selectedProject: {}, selectedWorkspace: {} };
|
||||
|
||||
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state })).toBe(false);
|
||||
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state, collapsedSections: ["projects"] })).toBe(true);
|
||||
expect(isNavigationSectionCollapsed("sessions", { isMobileLayout: false, expanded: "sessions", state, collapsedSections: ["projects"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("toggles the effective mobile section, including the implicit default section", () => {
|
||||
const state = { selectedProject: undefined, selectedWorkspace: undefined };
|
||||
|
||||
expect(toggleNavigationSection(undefined, "projects", { isMobileLayout: true, state })).toBe("none");
|
||||
@@ -37,4 +44,11 @@ describe("navigationState", () => {
|
||||
|
||||
expect(toggleNavigationSection("projects", "projects", { isMobileLayout: false, state })).toBe("projects");
|
||||
});
|
||||
|
||||
it("toggles desktop sections independently", () => {
|
||||
expect(toggleCollapsedNavigationSection([], "projects")).toEqual(["projects"]);
|
||||
expect(toggleCollapsedNavigationSection(["machines", "projects"], "projects")).toEqual(["machines"]);
|
||||
expect(toggleCollapsedNavigationSection(["sessions"], "machines")).toEqual(["machines", "sessions"]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
|
||||
export type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
|
||||
export const NAVIGATION_SECTION_ORDER = ["machines", "projects", "workspaces", "sessions"] as const;
|
||||
export type NavigationSection = (typeof NAVIGATION_SECTION_ORDER)[number];
|
||||
export type ExpandedNavigationSection = NavigationSection | "none" | undefined;
|
||||
|
||||
export interface NavigationSelectionState {
|
||||
@@ -19,8 +20,9 @@ export function expandedNavigationSection(expanded: ExpandedNavigationSection, s
|
||||
return expanded ?? defaultNavigationSection(state);
|
||||
}
|
||||
|
||||
export function isNavigationSectionCollapsed(section: NavigationSection, options: { isMobileLayout: boolean; expanded: ExpandedNavigationSection; state: NavigationSelectionState }): boolean {
|
||||
return options.isMobileLayout && expandedNavigationSection(options.expanded, options.state) !== section;
|
||||
export function isNavigationSectionCollapsed(section: NavigationSection, options: { isMobileLayout: boolean; expanded: ExpandedNavigationSection; state: NavigationSelectionState; collapsedSections?: readonly NavigationSection[] | undefined }): boolean {
|
||||
if (options.isMobileLayout) return expandedNavigationSection(options.expanded, options.state) !== section;
|
||||
return options.collapsedSections?.includes(section) ?? false;
|
||||
}
|
||||
|
||||
export function toggleNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, options: { isMobileLayout: boolean; state: NavigationSelectionState }): ExpandedNavigationSection {
|
||||
@@ -32,8 +34,20 @@ export function expandNavigationSection(expanded: ExpandedNavigationSection, sec
|
||||
return isMobileLayout ? section : expanded;
|
||||
}
|
||||
|
||||
export class MobileNavigationController implements ReactiveController {
|
||||
export function toggleCollapsedNavigationSection(collapsedSections: readonly NavigationSection[], section: NavigationSection): NavigationSection[] {
|
||||
const collapsed = new Set(collapsedSections);
|
||||
if (collapsed.has(section)) collapsed.delete(section);
|
||||
else collapsed.add(section);
|
||||
return orderedNavigationSections(collapsed);
|
||||
}
|
||||
|
||||
export function nextNavigationSection(section: NavigationSection): NavigationSection | undefined {
|
||||
return NAVIGATION_SECTION_ORDER[NAVIGATION_SECTION_ORDER.indexOf(section) + 1];
|
||||
}
|
||||
|
||||
export class NavigationSectionsController implements ReactiveController {
|
||||
private expanded: ExpandedNavigationSection;
|
||||
private collapsedSections: readonly NavigationSection[] = [];
|
||||
|
||||
hostConnected(): void {
|
||||
return;
|
||||
@@ -56,15 +70,30 @@ export class MobileNavigationController implements ReactiveController {
|
||||
isMobileLayout: this.isMobileLayout(),
|
||||
expanded: this.expanded,
|
||||
state: this.getState(),
|
||||
collapsedSections: this.collapsedSections,
|
||||
});
|
||||
}
|
||||
|
||||
toggle(section: NavigationSection): void {
|
||||
this.setExpanded(toggleNavigationSection(this.expanded, section, { isMobileLayout: this.isMobileLayout(), state: this.getState() }));
|
||||
if (this.isMobileLayout()) {
|
||||
this.setExpanded(toggleNavigationSection(this.expanded, section, { isMobileLayout: true, state: this.getState() }));
|
||||
return;
|
||||
}
|
||||
this.setCollapsedSections(toggleCollapsedNavigationSection(this.collapsedSections, section));
|
||||
}
|
||||
|
||||
expand(section: NavigationSection): void {
|
||||
this.setExpanded(expandNavigationSection(this.expanded, section, this.isMobileLayout()));
|
||||
if (this.isMobileLayout()) {
|
||||
this.setExpanded(expandNavigationSection(this.expanded, section, true));
|
||||
return;
|
||||
}
|
||||
this.setCollapsedSections(this.collapsedSections.filter((collapsedSection) => collapsedSection !== section));
|
||||
}
|
||||
|
||||
advanceAfterSelection(section: NavigationSection): void {
|
||||
if (!this.isMobileLayout()) return;
|
||||
const next = nextNavigationSection(section);
|
||||
if (next !== undefined) this.expand(next);
|
||||
}
|
||||
|
||||
open(section: NavigationSection, openNavigationView: () => void): void {
|
||||
@@ -78,4 +107,19 @@ export class MobileNavigationController implements ReactiveController {
|
||||
this.expanded = expanded;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
private setCollapsedSections(collapsedSections: readonly NavigationSection[]): void {
|
||||
if (navigationSectionListsEqual(this.collapsedSections, collapsedSections)) return;
|
||||
this.collapsedSections = collapsedSections;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
function orderedNavigationSections(sections: Iterable<NavigationSection>): NavigationSection[] {
|
||||
const sectionSet = new Set(sections);
|
||||
return NAVIGATION_SECTION_ORDER.filter((section) => sectionSet.has(section));
|
||||
}
|
||||
|
||||
function navigationSectionListsEqual(first: readonly NavigationSection[], second: readonly NavigationSection[]): boolean {
|
||||
return first.length === second.length && first.every((section, index) => section === second[index]);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@ export class PanelCollapseController implements ReactiveController {
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
expandNavigationPanel(): void {
|
||||
if (!this.navigationPanelCollapsed) return;
|
||||
this.navigationPanelCollapsed = false;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
expandWorkspacePanel(): void {
|
||||
if (!this.workspacePanelCollapsed) return;
|
||||
this.workspacePanelCollapsed = false;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
shellClass(mainView: AppState["mainView"]): string {
|
||||
return [
|
||||
"shell",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampPanelWidth,
|
||||
PANEL_SIZE_STORAGE_KEY,
|
||||
panelResizeDelta,
|
||||
panelWidthFromDrag,
|
||||
panelWidthFromKeyboard,
|
||||
readStoredPanelSizes,
|
||||
writeStoredPanelSizes,
|
||||
} from "./panelResizeController";
|
||||
|
||||
describe("panel resize behavior", () => {
|
||||
it("resizes left and right panels in opposite drag directions", () => {
|
||||
expect(panelResizeDelta("navigation", 100, 140)).toBe(40);
|
||||
expect(panelWidthFromDrag("navigation", 300, 100, 140)).toBe(340);
|
||||
|
||||
expect(panelResizeDelta("workspace", 100, 140)).toBe(-40);
|
||||
expect(panelWidthFromDrag("workspace", 500, 100, 140)).toBe(460);
|
||||
});
|
||||
|
||||
it("clamps panel widths to broad fallback bounds", () => {
|
||||
expect(clampPanelWidth("navigation", 50)).toBe(180);
|
||||
expect(clampPanelWidth("navigation", 9000)).toBe(4096);
|
||||
expect(clampPanelWidth("workspace", 50)).toBe(240);
|
||||
expect(clampPanelWidth("workspace", 9000)).toBe(4096);
|
||||
});
|
||||
|
||||
it("supports narrower viewport-aware constraints", () => {
|
||||
const constraints = { minWidth: 200, maxWidth: 900, defaultWidth: 340, keyboardStep: 24, largeKeyboardStep: 72 };
|
||||
|
||||
expect(clampPanelWidth("navigation", 100, constraints)).toBe(200);
|
||||
expect(clampPanelWidth("navigation", 1200, constraints)).toBe(900);
|
||||
});
|
||||
|
||||
it("supports keyboard resizing in panel-relative directions", () => {
|
||||
expect(panelWidthFromKeyboard("navigation", 300, "ArrowRight")).toBe(324);
|
||||
expect(panelWidthFromKeyboard("navigation", 300, "ArrowLeft")).toBe(276);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "ArrowLeft")).toBe(524);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "ArrowRight")).toBe(476);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "Home")).toBe(240);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "End")).toBe(4096);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "Enter")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads, writes, and clears stored panel widths", () => {
|
||||
const storage = new FakeStorage();
|
||||
|
||||
expect(readStoredPanelSizes(storage)).toEqual({});
|
||||
writeStoredPanelSizes({ navigationPanelWidth: 260, workspacePanelWidth: 640 }, storage);
|
||||
|
||||
expect(JSON.parse(storage.value(PANEL_SIZE_STORAGE_KEY) ?? "{}")).toEqual({
|
||||
version: 1,
|
||||
navigationPanelWidth: 260,
|
||||
workspacePanelWidth: 640,
|
||||
});
|
||||
expect(readStoredPanelSizes(storage)).toEqual({ navigationPanelWidth: 260, workspacePanelWidth: 640 });
|
||||
|
||||
writeStoredPanelSizes({}, storage);
|
||||
expect(storage.value(PANEL_SIZE_STORAGE_KEY)).toBeUndefined();
|
||||
expect(readStoredPanelSizes(storage)).toEqual({});
|
||||
});
|
||||
|
||||
it("clamps stored panel widths and ignores invalid values", () => {
|
||||
const storage = new FakeStorage({
|
||||
[PANEL_SIZE_STORAGE_KEY]: JSON.stringify({ version: 1, navigationPanelWidth: 9999, workspacePanelWidth: "wide" }),
|
||||
});
|
||||
|
||||
expect(readStoredPanelSizes(storage)).toEqual({ navigationPanelWidth: 4096 });
|
||||
});
|
||||
|
||||
it("ignores storage failures", () => {
|
||||
const storage = new ThrowingStorage();
|
||||
|
||||
expect(readStoredPanelSizes(storage)).toEqual({});
|
||||
expect(() => { writeStoredPanelSizes({ navigationPanelWidth: 260 }, storage); }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
class FakeStorage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
constructor(seed: Record<string, string> = {}) {
|
||||
for (const [key, value] of Object.entries(seed)) this.values.set(key, value);
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
value(key: string): string | undefined {
|
||||
return this.values.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
class ThrowingStorage {
|
||||
getItem(): string | null {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
|
||||
setItem(): void {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
|
||||
removeItem(): void {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
|
||||
export type ResizablePanelSide = "navigation" | "workspace";
|
||||
|
||||
export interface PanelResizeConstraints {
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
defaultWidth: number;
|
||||
keyboardStep: number;
|
||||
largeKeyboardStep: number;
|
||||
}
|
||||
|
||||
export interface PanelSizePreferences {
|
||||
navigationPanelWidth?: number;
|
||||
workspacePanelWidth?: number;
|
||||
}
|
||||
|
||||
export interface PanelResizeControllerOptions {
|
||||
storage?: PanelSizeStorage;
|
||||
}
|
||||
|
||||
export interface PanelResizeOptions {
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
export interface PanelResetOptions {
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
export interface PanelKeyboardResizeOptions {
|
||||
largeStep?: boolean;
|
||||
constraints?: PanelResizeConstraints;
|
||||
}
|
||||
|
||||
export type PanelSizeStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
export type PanelResizeConstraintsBySide = Partial<Record<ResizablePanelSide, PanelResizeConstraints>>;
|
||||
|
||||
export const PANEL_SIZE_STORAGE_KEY = "pi-web:panel-sizes:v1";
|
||||
export const PANEL_RESIZE_CONSTRAINTS = {
|
||||
navigation: { minWidth: 180, maxWidth: 4096, defaultWidth: 340, keyboardStep: 24, largeKeyboardStep: 72 },
|
||||
workspace: { minWidth: 240, maxWidth: 4096, defaultWidth: 480, keyboardStep: 24, largeKeyboardStep: 72 },
|
||||
} as const satisfies Record<ResizablePanelSide, PanelResizeConstraints>;
|
||||
|
||||
interface StoredPanelSizeEnvelope {
|
||||
version: 1;
|
||||
navigationPanelWidth?: number;
|
||||
workspacePanelWidth?: number;
|
||||
}
|
||||
|
||||
export class PanelResizeController implements ReactiveController {
|
||||
private readonly storage: PanelSizeStorage | undefined;
|
||||
private panelSizes: PanelSizePreferences;
|
||||
|
||||
constructor(private readonly host: ReactiveControllerHost, options: PanelResizeControllerOptions = {}) {
|
||||
host.addController(this);
|
||||
this.storage = options.storage ?? browserPanelSizeStorage();
|
||||
this.panelSizes = readStoredPanelSizes(this.storage);
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
constraints(side: ResizablePanelSide): PanelResizeConstraints {
|
||||
return panelResizeConstraints(side);
|
||||
}
|
||||
|
||||
panelWidth(side: ResizablePanelSide, measuredWidth?: number): number {
|
||||
return clampPanelWidth(side, measuredWidth ?? this.storedPanelWidth(side) ?? this.constraints(side).defaultWidth);
|
||||
}
|
||||
|
||||
resizePanel(side: ResizablePanelSide, width: number, options: PanelResizeOptions = {}): void {
|
||||
const nextWidth = clampPanelWidth(side, width);
|
||||
if (this.storedPanelWidth(side) === nextWidth) return;
|
||||
this.panelSizes = panelSizesWithWidth(this.panelSizes, side, nextWidth);
|
||||
if (options.persist !== false) this.persistPanelSizes();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
resetPanel(side: ResizablePanelSide, options: PanelResetOptions = {}): void {
|
||||
if (this.storedPanelWidth(side) === undefined) return;
|
||||
this.panelSizes = panelSizesWithoutSide(this.panelSizes, side);
|
||||
if (options.persist !== false) this.persistPanelSizes();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
resetPanels(options: PanelResetOptions = {}): void {
|
||||
if (this.panelSizes.navigationPanelWidth === undefined && this.panelSizes.workspacePanelWidth === undefined) return;
|
||||
this.panelSizes = {};
|
||||
if (options.persist !== false) this.persistPanelSizes();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
persistPanelSizes(): void {
|
||||
writeStoredPanelSizes(this.panelSizes, this.storage);
|
||||
}
|
||||
|
||||
shellStyle(constraintsBySide: PanelResizeConstraintsBySide = {}): string {
|
||||
const declarations: string[] = [];
|
||||
if (this.panelSizes.navigationPanelWidth !== undefined) {
|
||||
declarations.push(`--navigation-panel-size: ${formatPanelWidth(clampPanelWidth("navigation", this.panelSizes.navigationPanelWidth, constraintsBySide.navigation))};`);
|
||||
}
|
||||
if (this.panelSizes.workspacePanelWidth !== undefined) {
|
||||
declarations.push(`--workspace-panel-size: ${formatPanelWidth(clampPanelWidth("workspace", this.panelSizes.workspacePanelWidth, constraintsBySide.workspace))};`);
|
||||
}
|
||||
return declarations.join(" ");
|
||||
}
|
||||
|
||||
private storedPanelWidth(side: ResizablePanelSide): number | undefined {
|
||||
return side === "navigation" ? this.panelSizes.navigationPanelWidth : this.panelSizes.workspacePanelWidth;
|
||||
}
|
||||
}
|
||||
|
||||
export function panelResizeConstraints(side: ResizablePanelSide): PanelResizeConstraints {
|
||||
return PANEL_RESIZE_CONSTRAINTS[side];
|
||||
}
|
||||
|
||||
export function panelResizeDelta(side: ResizablePanelSide, startClientX: number, currentClientX: number): number {
|
||||
return side === "navigation" ? currentClientX - startClientX : startClientX - currentClientX;
|
||||
}
|
||||
|
||||
export function panelWidthFromDrag(side: ResizablePanelSide, startWidth: number, startClientX: number, currentClientX: number, constraints = panelResizeConstraints(side)): number {
|
||||
return clampPanelWidth(side, startWidth + panelResizeDelta(side, startClientX, currentClientX), constraints);
|
||||
}
|
||||
|
||||
export function panelWidthFromKeyboard(side: ResizablePanelSide, currentWidth: number, key: string, options: PanelKeyboardResizeOptions = {}): number | undefined {
|
||||
const constraints = options.constraints ?? panelResizeConstraints(side);
|
||||
if (key === "Home") return constraints.minWidth;
|
||||
if (key === "End") return constraints.maxWidth;
|
||||
|
||||
const step = options.largeStep === true ? constraints.largeKeyboardStep : constraints.keyboardStep;
|
||||
const delta = keyboardResizeDelta(side, key, step);
|
||||
if (delta === undefined) return undefined;
|
||||
return clampPanelWidth(side, currentWidth + delta, constraints);
|
||||
}
|
||||
|
||||
export function clampPanelWidth(side: ResizablePanelSide, width: number, constraints = panelResizeConstraints(side)): number {
|
||||
if (!Number.isFinite(width)) return constraints.defaultWidth;
|
||||
return Math.round(Math.min(Math.max(width, constraints.minWidth), constraints.maxWidth));
|
||||
}
|
||||
|
||||
export function readStoredPanelSizes(storage: PanelSizeStorage | undefined = browserPanelSizeStorage()): PanelSizePreferences {
|
||||
try {
|
||||
const raw = storage?.getItem(PANEL_SIZE_STORAGE_KEY);
|
||||
if (raw === undefined || raw === null || raw === "") return {};
|
||||
const value: unknown = JSON.parse(raw);
|
||||
return parseStoredPanelSizes(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredPanelSizes(panelSizes: PanelSizePreferences, storage: PanelSizeStorage | undefined = browserPanelSizeStorage()): void {
|
||||
if (storage === undefined) return;
|
||||
try {
|
||||
if (panelSizes.navigationPanelWidth === undefined && panelSizes.workspacePanelWidth === undefined) {
|
||||
storage.removeItem(PANEL_SIZE_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
const envelope: StoredPanelSizeEnvelope = { version: 1 };
|
||||
if (panelSizes.navigationPanelWidth !== undefined) envelope.navigationPanelWidth = clampPanelWidth("navigation", panelSizes.navigationPanelWidth);
|
||||
if (panelSizes.workspacePanelWidth !== undefined) envelope.workspacePanelWidth = clampPanelWidth("workspace", panelSizes.workspacePanelWidth);
|
||||
storage.setItem(PANEL_SIZE_STORAGE_KEY, JSON.stringify(envelope));
|
||||
} catch {
|
||||
// Ignore localStorage quota/privacy errors; the resized layout still applies in memory for this tab.
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredPanelSizes(value: unknown): PanelSizePreferences {
|
||||
if (!isRecord(value) || value["version"] !== 1) return {};
|
||||
const panelSizes: PanelSizePreferences = {};
|
||||
const navigationWidth = parseStoredPanelWidth(value["navigationPanelWidth"]);
|
||||
const workspaceWidth = parseStoredPanelWidth(value["workspacePanelWidth"]);
|
||||
if (navigationWidth !== undefined) panelSizes.navigationPanelWidth = clampPanelWidth("navigation", navigationWidth);
|
||||
if (workspaceWidth !== undefined) panelSizes.workspacePanelWidth = clampPanelWidth("workspace", workspaceWidth);
|
||||
return panelSizes;
|
||||
}
|
||||
|
||||
function parseStoredPanelWidth(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function panelSizesWithWidth(panelSizes: PanelSizePreferences, side: ResizablePanelSide, width: number): PanelSizePreferences {
|
||||
if (side === "navigation") return { ...panelSizes, navigationPanelWidth: width };
|
||||
return { ...panelSizes, workspacePanelWidth: width };
|
||||
}
|
||||
|
||||
function panelSizesWithoutSide(panelSizes: PanelSizePreferences, side: ResizablePanelSide): PanelSizePreferences {
|
||||
if (side === "navigation") {
|
||||
return panelSizes.workspacePanelWidth === undefined ? {} : { workspacePanelWidth: panelSizes.workspacePanelWidth };
|
||||
}
|
||||
return panelSizes.navigationPanelWidth === undefined ? {} : { navigationPanelWidth: panelSizes.navigationPanelWidth };
|
||||
}
|
||||
|
||||
function keyboardResizeDelta(side: ResizablePanelSide, key: string, step: number): number | undefined {
|
||||
if (side === "navigation") {
|
||||
if (key === "ArrowRight") return step;
|
||||
if (key === "ArrowLeft") return -step;
|
||||
return undefined;
|
||||
}
|
||||
if (key === "ArrowLeft") return step;
|
||||
if (key === "ArrowRight") return -step;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatPanelWidth(width: number): string {
|
||||
return `${String(Math.round(width))}px`;
|
||||
}
|
||||
|
||||
function browserPanelSizeStorage(): PanelSizeStorage | undefined {
|
||||
try {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, 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, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
import type { QualifiedContributionId } from "./plugins/ids";
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AppState {
|
||||
selectedMachine: Machine | undefined;
|
||||
isLoadingMachines: boolean;
|
||||
machineStatuses: Record<string, MachineHealth>;
|
||||
machineRuntimes: Record<string, MachineRuntime>;
|
||||
projects: Project[];
|
||||
workspaces: Workspace[];
|
||||
sessions: SessionInfo[];
|
||||
@@ -102,6 +103,7 @@ export function initialAppState(): AppState {
|
||||
selectedMachine: undefined,
|
||||
isLoadingMachines: false,
|
||||
machineStatuses: {},
|
||||
machineRuntimes: {},
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
sessions: [],
|
||||
|
||||
@@ -9,7 +9,7 @@ export class FormattedText extends LitElement {
|
||||
@property() text = "";
|
||||
|
||||
override render() {
|
||||
return html`<div class="formatted" @click=${this.onFormattedClick} @copy=${this.onFormattedCopy}>${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`;
|
||||
return html`<div class="formatted" @click=${this.onFormattedClick}>${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`;
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
@@ -48,13 +48,6 @@ export class FormattedText extends LitElement {
|
||||
void this.copyCode(code.textContent, button);
|
||||
};
|
||||
|
||||
private readonly onFormattedCopy = (event: ClipboardEvent): void => {
|
||||
if (event.clipboardData === null || !hasSelectedText(currentSelection(this))) return;
|
||||
event.clipboardData.setData("text/plain", this.text);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
private async copyCode(text: string, button: HTMLButtonElement): Promise<void> {
|
||||
const ok = await writeClipboard(text);
|
||||
this.setCopyButtonState(button, ok ? "copied" : "failed");
|
||||
@@ -74,20 +67,6 @@ export class FormattedText extends LitElement {
|
||||
static override styles = formattedTextStyles;
|
||||
}
|
||||
|
||||
function currentSelection(element: Element): Selection | null {
|
||||
const root = element.getRootNode();
|
||||
if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot && hasRootSelection(root)) return root.getSelection();
|
||||
return element.ownerDocument.getSelection();
|
||||
}
|
||||
|
||||
function hasRootSelection(root: Node): root is Node & { getSelection: () => Selection | null } {
|
||||
return "getSelection" in root && typeof root.getSelection === "function";
|
||||
}
|
||||
|
||||
function hasSelectedText(selection: Selection | null): boolean {
|
||||
return selection !== null && !selection.isCollapsed && selection.toString() !== "";
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
@@ -3,12 +3,13 @@ import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { Machine, MachineHealth, WorkspaceActivity } from "../api";
|
||||
import { machineActivityIndicator } from "../workspaceActivity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { renderActionActivityIndicator } from "./activityBadge";
|
||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
|
||||
@customElement("machine-list")
|
||||
export class MachineList extends LitElement {
|
||||
export class MachineList extends LitElement implements KeyboardNavigableSection {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@property({ attribute: false }) selected?: Machine;
|
||||
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
||||
@@ -18,6 +19,8 @@ export class MachineList extends LitElement {
|
||||
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
|
||||
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
@state() private openMenuMachineId: string | undefined;
|
||||
@state() private menuStyle = "";
|
||||
|
||||
@@ -41,6 +44,11 @@ export class MachineList extends LitElement {
|
||||
if (changed.has("collapsed") && this.collapsed) this.openMenuMachineId = undefined;
|
||||
}
|
||||
|
||||
async focusSelectedOrFirst(): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section>
|
||||
@@ -67,7 +75,8 @@ export class MachineList extends LitElement {
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name machine-primary">${this.renderActivity(machine)}<span class="machine-primary-label">${machine.name}</span></span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
|
||||
<span class="action-name machine-primary"><span class="machine-primary-label">${machine.name}</span></span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
|
||||
${this.renderActivity(machine)}
|
||||
</div>
|
||||
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
|
||||
</div>
|
||||
@@ -78,7 +87,7 @@ export class MachineList extends LitElement {
|
||||
const status = this.statuses[machine.id]?.status ?? machine.status;
|
||||
if (status === "offline" || status === "error") return undefined;
|
||||
const kind = machineActivityIndicator(this.activities[machine.id]);
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
}
|
||||
|
||||
private renderMachineMenu(machine: Machine) {
|
||||
@@ -107,7 +116,7 @@ export class MachineList extends LitElement {
|
||||
if (!this.collapsible) return "Machines";
|
||||
const selectedSummary = this.selected?.name ?? "No machine selected";
|
||||
const selectedTitle = this.selected?.baseUrl ?? selectedSummary;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.machines.length}</small></button>`;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.machines.length}</small></button>`;
|
||||
}
|
||||
|
||||
private toggleMenu(machineId: string, target: EventTarget | null): void {
|
||||
@@ -131,7 +140,11 @@ export class MachineList extends LitElement {
|
||||
this.openMenuMachineId = undefined;
|
||||
return;
|
||||
}
|
||||
activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine));
|
||||
handleSelectableRowKeyboard(event, {
|
||||
activate: () => this.onSelect?.(machine),
|
||||
nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); },
|
||||
cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); },
|
||||
});
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
@@ -139,7 +152,6 @@ export class MachineList extends LitElement {
|
||||
css`
|
||||
.machine-row.no-actions .action-main { border-radius: 8px; }
|
||||
.machine-primary { display: flex; align-items: baseline; gap: 6px; }
|
||||
.machine-primary .activity-indicator { flex: 0 0 auto; margin-right: 0; }
|
||||
.machine-primary-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.machine-menu-panel button.danger { color: var(--pi-danger); }
|
||||
.machine-menu-panel button.danger:hover, .machine-menu-panel button.danger:focus { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api";
|
||||
import { machineActivityIndicator } from "../workspaceActivity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { canRemoveMachine } from "./MachineList";
|
||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||
|
||||
@customElement("machine-switcher")
|
||||
export class MachineSwitcher extends LitElement implements KeyboardNavigableSection {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@property({ attribute: false }) selected?: Machine;
|
||||
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
||||
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
|
||||
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
@state() private open = false;
|
||||
@state() private menuStyle = "";
|
||||
@state() private openActionsMachineId: string | undefined;
|
||||
@state() private actionMenuStyle = "";
|
||||
|
||||
private readonly onDocumentClick = (event: MouseEvent) => {
|
||||
if (event.composedPath().includes(this)) return;
|
||||
this.open = false;
|
||||
this.openActionsMachineId = undefined;
|
||||
};
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("click", this.onDocumentClick);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
document.removeEventListener("click", this.onDocumentClick);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>): void {
|
||||
if (changed.has("machines") && this.open && this.selectedMachine() === undefined) this.open = false;
|
||||
if (changed.has("machines") && this.openActionsMachineId !== undefined && !this.machines.some((machine) => machine.id === this.openActionsMachineId)) this.openActionsMachineId = undefined;
|
||||
}
|
||||
|
||||
async focusSelectedOrFirst(): Promise<boolean> {
|
||||
const button = this.switcherButton();
|
||||
if (button === null) return false;
|
||||
return await this.openMenuAndFocusOption(button);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const selected = this.selectedMachine();
|
||||
if (selected === undefined) return null;
|
||||
const status = machineStatus(selected, this.statuses);
|
||||
const label = selected.name;
|
||||
return html`
|
||||
<div class="machine-switcher">
|
||||
<button
|
||||
type="button"
|
||||
class="machine-switcher-button"
|
||||
title=${machineTitle(selected)}
|
||||
aria-label=${`Machine: ${label}. Switch machine.`}
|
||||
aria-expanded=${String(this.open)}
|
||||
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
|
||||
>
|
||||
${this.renderActivity(selected)}
|
||||
<span class="machine-switcher-text">
|
||||
<span class="machine-switcher-kicker">Machine</span>
|
||||
<span class="machine-switcher-label">${label}</span>
|
||||
</span>
|
||||
<span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span>
|
||||
<span class="machine-chevron" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
${this.open ? html`
|
||||
<div class="machine-switcher-menu" style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||
${this.machines.map((machine) => this.renderMachineOption(machine))}
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMachineOption(machine: Machine): TemplateResult {
|
||||
const selected = this.selected?.id === machine.id;
|
||||
const status = machineStatus(machine, this.statuses);
|
||||
const hasActions = canRemoveMachine(machine) && this.onRemove !== undefined;
|
||||
const actionsOpen = this.openActionsMachineId === machine.id;
|
||||
return html`
|
||||
<div class=${`machine-option ${selected ? "selected" : ""} ${hasActions ? "" : "no-actions"}`}>
|
||||
<button
|
||||
type="button"
|
||||
class="machine-option-main"
|
||||
title=${machineTitle(machine)}
|
||||
data-machine-id=${machine.id}
|
||||
@click=${() => { this.select(machine); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }}
|
||||
>
|
||||
<span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span></span>
|
||||
<small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small>
|
||||
</button>
|
||||
${hasActions ? html`
|
||||
<div class="machine-option-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="machine-option-actions-toggle"
|
||||
title="Machine actions"
|
||||
aria-label=${`Actions for ${machine.name}`}
|
||||
aria-expanded=${String(actionsOpen)}
|
||||
@click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleActionsMenu(machine.id, event.currentTarget); }}
|
||||
>⋯</button>
|
||||
${actionsOpen ? html`
|
||||
<div class="machine-option-actions-panel" style=${this.actionMenuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||
<button class="danger" title=${`Remove ${machine.name}`} @click=${() => { this.removeMachine(machine); }}>Remove</button>
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderActivity(machine: Machine): TemplateResult | undefined {
|
||||
const status = machineStatus(machine, this.statuses);
|
||||
if (status === "offline" || status === "error") return undefined;
|
||||
const kind = machineActivityIndicator(this.activities[machine.id]);
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
}
|
||||
|
||||
private selectedMachine(): Machine | undefined {
|
||||
return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0];
|
||||
}
|
||||
|
||||
private switcherButton(): HTMLElement | null {
|
||||
return this.renderRoot.querySelector<HTMLElement>(".machine-switcher-button");
|
||||
}
|
||||
|
||||
private handleSwitcherButtonKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void this.openMenuAndFocusOption(event.currentTarget);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowRight" && this.onFocusNextSection !== undefined) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void this.onFocusNextSection();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && this.onCancelKeyboardNavigation !== undefined) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void this.onCancelKeyboardNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMachineOptionKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === "ArrowUp") {
|
||||
this.focusRelativeMachineOption(event.currentTarget, -1, event);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
this.focusRelativeMachineOption(event.currentTarget, 1, event);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
this.focusIndexedMachineOption(0, event);
|
||||
return;
|
||||
}
|
||||
if (event.key === "End") {
|
||||
this.focusIndexedMachineOption(-1, event);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowRight" && this.onFocusNextSection !== undefined) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.open = false;
|
||||
void this.onFocusNextSection();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.open = false;
|
||||
void this.updateComplete.then(() => { this.focusSwitcherButton(); });
|
||||
}
|
||||
}
|
||||
|
||||
private toggleMenu(target: EventTarget | null): void {
|
||||
this.menuStyle = machineSwitcherMenuStyle(target);
|
||||
this.open = !this.open;
|
||||
this.openActionsMachineId = undefined;
|
||||
}
|
||||
|
||||
private focusSwitcherButton(): boolean {
|
||||
const button = this.switcherButton();
|
||||
if (button === null) return false;
|
||||
button.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async openMenuAndFocusOption(target: EventTarget | null): Promise<boolean> {
|
||||
this.menuStyle = machineSwitcherMenuStyle(target);
|
||||
this.open = true;
|
||||
this.openActionsMachineId = undefined;
|
||||
await this.updateComplete;
|
||||
return this.focusSelectedMachineOption();
|
||||
}
|
||||
|
||||
private focusSelectedMachineOption(): boolean {
|
||||
const selected = this.renderRoot.querySelector<HTMLElement>(".machine-option.selected .machine-option-main");
|
||||
const first = this.machineOptionButtons()[0];
|
||||
const target = selected ?? first;
|
||||
if (target === undefined) return false;
|
||||
target.focus();
|
||||
target.scrollIntoView({ block: "nearest" });
|
||||
return true;
|
||||
}
|
||||
|
||||
private focusRelativeMachineOption(target: EventTarget | null, delta: number, event: KeyboardEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const buttons = this.machineOptionButtons();
|
||||
if (buttons.length === 0 || !(target instanceof HTMLElement)) return;
|
||||
const index = buttons.indexOf(target);
|
||||
if (index < 0) return;
|
||||
this.focusMachineOptionAt(index + delta);
|
||||
}
|
||||
|
||||
private focusIndexedMachineOption(index: number, event: KeyboardEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.focusMachineOptionAt(index < 0 ? this.machineOptionButtons().length - 1 : index);
|
||||
}
|
||||
|
||||
private focusMachineOptionAt(index: number): void {
|
||||
const buttons = this.machineOptionButtons();
|
||||
const target = buttons[Math.min(Math.max(index, 0), buttons.length - 1)];
|
||||
target?.focus();
|
||||
target?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
private machineOptionButtons(): HTMLElement[] {
|
||||
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>(".machine-option-main"));
|
||||
}
|
||||
|
||||
private toggleActionsMenu(machineId: string, target: EventTarget | null): void {
|
||||
if (this.openActionsMachineId === machineId) {
|
||||
this.openActionsMachineId = undefined;
|
||||
return;
|
||||
}
|
||||
this.actionMenuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" });
|
||||
this.openActionsMachineId = machineId;
|
||||
}
|
||||
|
||||
private select(machine: Machine): void {
|
||||
this.open = false;
|
||||
this.openActionsMachineId = undefined;
|
||||
void this.onSelect?.(machine);
|
||||
}
|
||||
|
||||
private removeMachine(machine: Machine): void {
|
||||
this.open = false;
|
||||
this.openActionsMachineId = undefined;
|
||||
void this.onRemove?.(machine);
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { min-width: 0; display: block; }
|
||||
.machine-switcher { min-width: 0; }
|
||||
.machine-switcher-button { box-sizing: border-box; width: 100%; min-width: 0; display: flex; align-items: center; gap: 6px; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 8px; cursor: pointer; text-align: left; }
|
||||
.machine-switcher-button:hover, .machine-switcher-button:focus-visible { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.machine-switcher-text { flex: 1 1 auto; min-width: 0; display: grid; gap: 1px; }
|
||||
.machine-switcher-kicker { color: var(--pi-muted); font-size: 10px; line-height: 1; text-transform: uppercase; letter-spacing: .02em; }
|
||||
.machine-switcher-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; line-height: 1.2; }
|
||||
.machine-status { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; }
|
||||
.machine-status.online { color: var(--pi-success); }
|
||||
.machine-status.offline, .machine-status.error { color: var(--pi-danger); }
|
||||
.machine-chevron { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; }
|
||||
.activity-indicator { flex: 0 0 auto; display: inline-block; width: 7px; height: 7px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; }
|
||||
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
|
||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
||||
.machine-switcher-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(280px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); }
|
||||
.machine-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px; align-items: stretch; margin: 2px 0; }
|
||||
.machine-option.no-actions { grid-template-columns: minmax(0, 1fr); }
|
||||
.machine-option-main, .machine-option-actions-toggle, .machine-option-actions-panel button { border: 0; border-radius: 7px; background: transparent; color: var(--pi-text); cursor: pointer; }
|
||||
.machine-option-main { min-width: 0; display: grid; gap: 2px; padding: 7px 8px; text-align: left; }
|
||||
.machine-option-name { min-width: 0; display: flex; align-items: baseline; gap: 6px; }
|
||||
.machine-option-name span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-option-main small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-muted); }
|
||||
.machine-option-actions { position: relative; align-self: stretch; }
|
||||
.machine-option-actions-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); }
|
||||
.machine-option-actions-panel { position: fixed; z-index: 10001; box-sizing: border-box; min-width: min(120px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); }
|
||||
.machine-option-actions-panel button { display: block; width: 100%; padding: 7px 9px; text-align: left; white-space: nowrap; }
|
||||
.machine-option-actions-panel button.danger { color: var(--pi-danger); }
|
||||
.machine-option-main:hover, .machine-option-main:focus-visible, .machine-option-actions-toggle:hover, .machine-option-actions-toggle:focus-visible, .machine-option.selected .machine-option-main { background: var(--pi-selection-bg); }
|
||||
.machine-option-actions-panel button:hover, .machine-option-actions-panel button:focus-visible { background: var(--pi-selection-bg); }
|
||||
.machine-option-actions-panel button.danger:hover, .machine-option-actions-panel button.danger:focus-visible { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
@keyframes pulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
|
||||
`;
|
||||
}
|
||||
|
||||
export function shouldShowMachineSwitcher(machines: readonly Machine[]): boolean {
|
||||
return machines.length > 1;
|
||||
}
|
||||
|
||||
function machineStatus(machine: Machine, statuses: Record<string, MachineHealth>): MachineStatus {
|
||||
return statuses[machine.id]?.status ?? machine.status ?? "unknown";
|
||||
}
|
||||
|
||||
function machineStatusLabel(status: MachineStatus): string {
|
||||
return status === "online" ? "online" : status === "offline" ? "offline" : status === "error" ? "error" : "unknown";
|
||||
}
|
||||
|
||||
function machineTitle(machine: Machine): string {
|
||||
return machine.baseUrl ?? machine.name;
|
||||
}
|
||||
|
||||
function machineSwitcherMenuStyle(target: EventTarget | null): string {
|
||||
if (typeof HTMLElement === "undefined" || typeof window === "undefined" || !(target instanceof HTMLElement)) return "";
|
||||
const trigger = target.getBoundingClientRect();
|
||||
const viewportPadding = 8;
|
||||
const menuWidth = Math.min(280, Math.max(0, window.innerWidth - viewportPadding * 2));
|
||||
const left = Math.min(Math.max(viewportPadding, trigger.left), Math.max(viewportPadding, window.innerWidth - viewportPadding - menuWidth));
|
||||
const availableBelow = Math.max(0, window.innerHeight - trigger.bottom - viewportPadding);
|
||||
return [`top: ${px(trigger.bottom)};`, `left: ${px(left)};`, `width: ${px(menuWidth)};`, `max-height: ${px(availableBelow)};`].join(" ");
|
||||
}
|
||||
|
||||
function px(value: number): string {
|
||||
return `${String(Math.round(value))}px`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { configApi, piWebApi, terminalsApi, workspacesApi, type Machine, type Ma
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
|
||||
import { ActivityController } from "../controllers/activityController";
|
||||
import { AuthController } from "../controllers/authController";
|
||||
import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||
@@ -27,14 +28,14 @@ import { loadExternalPlugins } from "../plugins/external";
|
||||
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
|
||||
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||
import { AppShellController } from "../appShell/appShellController";
|
||||
import { MobileNavigationController, type NavigationSection } from "../appShell/navigationState";
|
||||
import { NavigationSectionsController, type NavigationSection } from "../appShell/navigationState";
|
||||
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
|
||||
import { PanelResizeController, type PanelResizeConstraints, type ResizablePanelSide } from "../appShell/panelResizeController";
|
||||
import { readRoute, writeRoute, type AppRoute } from "../route";
|
||||
import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute";
|
||||
import { applyShortcutPreferences } from "../shortcutPreferences";
|
||||
import { applyActiveShortcutPreferences } from "../shortcutPreferences";
|
||||
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
|
||||
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
||||
import { machineActivityIndicator } from "../workspaceActivity";
|
||||
import "./MachineList";
|
||||
import "./ProjectList";
|
||||
import "./WorkspaceList";
|
||||
@@ -56,13 +57,14 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
|
||||
import "./appShell/AppContextBar";
|
||||
import "./appShell/AppMobileMainTabs";
|
||||
import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs";
|
||||
import "./appShell/AppNavigationPanel";
|
||||
import { shouldShowMachinesSection, type AppNavigationPanel, type NavigationFocusTarget } from "./appShell/AppNavigationPanel";
|
||||
import "./appShell/AppPanelEdgeControl";
|
||||
import "./appShell/AppRefreshControl";
|
||||
import { appStyles } from "./shared";
|
||||
|
||||
|
||||
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
|
||||
const PI_WEB_STATUS_DEFER_MS = 750;
|
||||
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
||||
const THEME_AUTO_ON_VALUE = "auto:on";
|
||||
const THEME_AUTO_OFF_VALUE = "auto:off";
|
||||
@@ -70,12 +72,18 @@ const THEME_OPTION_PREFIX = "theme:";
|
||||
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
|
||||
const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git");
|
||||
const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal");
|
||||
const MIN_RESIZABLE_CHAT_WIDTH_PX = 320;
|
||||
const PANEL_EDGE_COLUMNS_WIDTH_PX = 2;
|
||||
const DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY = "(min-width: 1181px)";
|
||||
|
||||
@customElement("pi-web-app")
|
||||
export class PiWebApp extends LitElement {
|
||||
@state() private state: AppState = initialAppState();
|
||||
@query("chat-view") private chatView?: ChatView;
|
||||
@query("prompt-editor") private promptEditor?: PromptEditor;
|
||||
@query("app-navigation-panel") private navigationPanel?: AppNavigationPanel;
|
||||
@query("#navigation-panel") private navigationPanelFrame?: HTMLElement;
|
||||
@query("#workspace-panel") private workspacePanelFrame?: HTMLElement;
|
||||
|
||||
private readonly sessions = new SessionController(
|
||||
() => this.state,
|
||||
@@ -128,7 +136,8 @@ export class PiWebApp extends LitElement {
|
||||
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
|
||||
private readonly appShell = new AppShellController(this);
|
||||
private readonly panelCollapse = new PanelCollapseController(this);
|
||||
private readonly mobileNavigation = new MobileNavigationController(
|
||||
private readonly panelResize = new PanelResizeController(this);
|
||||
private readonly navigationSections = new NavigationSectionsController(
|
||||
this,
|
||||
() => this.state,
|
||||
() => this.appShell.isMobileNavigationLayout,
|
||||
@@ -136,6 +145,7 @@ export class PiWebApp extends LitElement {
|
||||
private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined;
|
||||
private terminalAutoStartWorkspaceId: string | undefined;
|
||||
private piWebStatusTimer: number | undefined;
|
||||
private piWebStatusDeferredTimer: number | undefined;
|
||||
private workspaceDeletionPollTimer: number | undefined;
|
||||
private refreshingWorkspaceDeletionRuns = false;
|
||||
private readonly handledWorkspaceDeletionRunIds = new Set<string>();
|
||||
@@ -163,7 +173,7 @@ export class PiWebApp extends LitElement {
|
||||
private readonly onFocus = () => {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
void this.refreshPiWebStatus();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
void this.refreshMachineActivities();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
};
|
||||
@@ -171,7 +181,7 @@ export class PiWebApp extends LitElement {
|
||||
if (document.visibilityState === "visible") {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
void this.refreshPiWebStatus();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
void this.refreshMachineActivities();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
@@ -184,7 +194,8 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||
if (this.keyboard.handle(event, this.getActions())) {
|
||||
if (this.settingsSection !== undefined) return;
|
||||
if (this.keyboard.handle(event, this.getDefaultActions(), { shortcuts: this.shortcutConfig })) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
@@ -204,12 +215,11 @@ export class PiWebApp extends LitElement {
|
||||
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
|
||||
this.applyPreferredTheme(false);
|
||||
this.connectRealtime();
|
||||
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
|
||||
void this.refreshPiWebStatus();
|
||||
this.piWebStatusTimer = window.setInterval(() => { this.schedulePiWebStatusRefresh(); }, PI_WEB_STATUS_REFRESH_MS);
|
||||
void this.refreshWorkspaceActivity();
|
||||
void this.loadClientConfig();
|
||||
void this.ensureGatewayPluginsLoaded();
|
||||
void this.loadProjectsAndRestoreRoute();
|
||||
void this.loadProjectsAndRestoreRoute().finally(() => { this.schedulePiWebStatusRefresh(); });
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
@@ -227,6 +237,7 @@ export class PiWebApp extends LitElement {
|
||||
this.git.dispose();
|
||||
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
|
||||
this.piWebStatusTimer = undefined;
|
||||
this.clearScheduledPiWebStatusRefresh();
|
||||
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
|
||||
this.workspaceDeletionPollTimer = undefined;
|
||||
super.disconnectedCallback();
|
||||
@@ -256,11 +267,28 @@ export class PiWebApp extends LitElement {
|
||||
await this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
|
||||
private schedulePiWebStatusRefresh(delayMs = PI_WEB_STATUS_DEFER_MS): void {
|
||||
this.clearScheduledPiWebStatusRefresh();
|
||||
this.piWebStatusDeferredTimer = window.setTimeout(() => {
|
||||
this.piWebStatusDeferredTimer = undefined;
|
||||
void this.refreshPiWebStatus();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private clearScheduledPiWebStatusRefresh(): void {
|
||||
if (this.piWebStatusDeferredTimer === undefined) return;
|
||||
window.clearTimeout(this.piWebStatusDeferredTimer);
|
||||
this.piWebStatusDeferredTimer = undefined;
|
||||
}
|
||||
|
||||
private async refreshPiWebStatus(): Promise<void> {
|
||||
const machineId = selectedMachineId(this.state);
|
||||
try {
|
||||
this.setState({ piWebStatus: await piWebApi.piWebStatus() });
|
||||
const piWebStatus = await piWebApi.piWebStatus(machineId);
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus });
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh PI WEB status", error);
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined });
|
||||
console.warn(`Failed to refresh PI WEB status for ${machineId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,12 +327,12 @@ export class PiWebApp extends LitElement {
|
||||
try {
|
||||
await Promise.all([
|
||||
this.sessions.refreshSelectedSession(),
|
||||
this.refreshPiWebStatus(),
|
||||
this.refreshMachineActivities(),
|
||||
this.loadClientConfig(),
|
||||
this.refreshWorkspaceDeletionRuns(),
|
||||
this.refreshCurrentWorkspaceSurface(),
|
||||
]);
|
||||
this.schedulePiWebStatusRefresh();
|
||||
} finally {
|
||||
this.isRefreshingApp = false;
|
||||
}
|
||||
@@ -328,6 +356,7 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private async restoreRouteFor(route: AppRoute, updateUrl: boolean, surface = this.readWorkspaceRouteSurface(route), restoredMainView?: AppState["mainView"]) {
|
||||
const machineBeforeRestore = selectedMachineId(this.state);
|
||||
const routeSurface = route.projectId === undefined || route.projectId === "" ? emptyWorkspaceRouteSurface() : surface;
|
||||
const restoreSeq = ++this.routeRestoreSeq;
|
||||
this.routeRestoreDepth += 1;
|
||||
@@ -371,6 +400,7 @@ export class PiWebApp extends LitElement {
|
||||
} finally {
|
||||
this.routeRestoreDepth = Math.max(0, this.routeRestoreDepth - 1);
|
||||
if (this.routeRestoreDepth === 0) this.restoringRouteTerminalId = undefined;
|
||||
if (selectedMachineId(this.state) !== machineBeforeRestore) this.schedulePiWebStatusRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,6 +744,7 @@ export class PiWebApp extends LitElement {
|
||||
this.realtime.close();
|
||||
this.connectRealtime();
|
||||
this.activeTerminalIds.clear();
|
||||
this.setState({ piWebStatus: undefined });
|
||||
this.git.updatePolling();
|
||||
void this.loadPluginsForSelectedMachine();
|
||||
}
|
||||
@@ -726,7 +757,6 @@ export class PiWebApp extends LitElement {
|
||||
private renderWorkspacePanel() {
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace);
|
||||
const workspaceLabelItems = workspace === undefined ? [] : this.workspaceLabelItems(workspace);
|
||||
const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined;
|
||||
return html`
|
||||
<workspace-panel
|
||||
@@ -736,56 +766,134 @@ export class PiWebApp extends LitElement {
|
||||
.emptyState=${emptyState}
|
||||
.tool=${this.state.workspaceTool}
|
||||
.panels=${this.visibleWorkspacePanels()}
|
||||
.workspaceLabelItems=${workspaceLabelItems}
|
||||
.onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }}
|
||||
></workspace-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNavigationPanelEdgeControl() {
|
||||
const constraints = this.resizablePanelConstraints("navigation");
|
||||
return html`
|
||||
<app-panel-edge-control
|
||||
side="navigation"
|
||||
controls="navigation-panel"
|
||||
resizeLabel="Resize navigation panel"
|
||||
expandLabel="Expand navigation panel"
|
||||
collapseLabel="Collapse navigation panel"
|
||||
.collapsed=${this.panelCollapse.navigationPanelCollapsed}
|
||||
.resizable=${!this.appShell.isMobileNavigationLayout}
|
||||
.panelWidth=${this.panelResize.panelWidth("navigation")}
|
||||
.minWidth=${constraints.minWidth}
|
||||
.maxWidth=${constraints.maxWidth}
|
||||
.onToggle=${() => { this.panelCollapse.toggleNavigationPanel(); }}
|
||||
.onResizeStart=${() => this.startPanelResize("navigation")}
|
||||
.onResize=${(width: number) => { this.panelResize.resizePanel("navigation", width, { persist: false }); }}
|
||||
.onResizeEnd=${() => { this.panelResize.persistPanelSizes(); }}
|
||||
.onReset=${() => { this.resetResizablePanel("navigation"); }}
|
||||
></app-panel-edge-control>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWorkspacePanelEdgeControl() {
|
||||
const constraints = this.resizablePanelConstraints("workspace");
|
||||
return html`
|
||||
<app-panel-edge-control
|
||||
side="workspace"
|
||||
controls="workspace-panel"
|
||||
resizeLabel="Resize workspace panel"
|
||||
expandLabel="Expand workspace panel"
|
||||
collapseLabel="Collapse workspace panel"
|
||||
.collapsed=${this.panelCollapse.workspacePanelCollapsed}
|
||||
.resizable=${!this.appShell.isMobileNavigationLayout}
|
||||
.panelWidth=${this.panelResize.panelWidth("workspace")}
|
||||
.minWidth=${constraints.minWidth}
|
||||
.maxWidth=${constraints.maxWidth}
|
||||
.onToggle=${() => { this.panelCollapse.toggleWorkspacePanel(); }}
|
||||
.onResizeStart=${() => this.startPanelResize("workspace")}
|
||||
.onResize=${(width: number) => { this.panelResize.resizePanel("workspace", width, { persist: false }); }}
|
||||
.onResizeEnd=${() => { this.panelResize.persistPanelSizes(); }}
|
||||
.onReset=${() => { this.resetResizablePanel("workspace"); }}
|
||||
></app-panel-edge-control>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNavigationPanel(autoSwitchToChat: boolean) {
|
||||
const openChatAfter = (action: () => Promise<void>) => this.withChatScrollTransition(async () => {
|
||||
await action();
|
||||
if (autoSwitchToChat) this.setState({ mainView: "chat" });
|
||||
if (autoSwitchToChat) this.updateUrl();
|
||||
});
|
||||
private startPanelResize(side: ResizablePanelSide): number {
|
||||
if (side === "navigation") this.panelCollapse.expandNavigationPanel();
|
||||
else this.panelCollapse.expandWorkspacePanel();
|
||||
return this.measuredPanelWidth(side) ?? this.panelResize.panelWidth(side);
|
||||
}
|
||||
|
||||
private resizablePanelConstraints(side: ResizablePanelSide): PanelResizeConstraints {
|
||||
const constraints = this.panelResize.constraints(side);
|
||||
return {
|
||||
...constraints,
|
||||
maxWidth: this.resizablePanelMaxWidth(side, constraints),
|
||||
};
|
||||
}
|
||||
|
||||
private resizablePanelMaxWidth(side: ResizablePanelSide, constraints: PanelResizeConstraints): number {
|
||||
const shellWidth = this.getBoundingClientRect().width || (typeof window === "undefined" ? 0 : window.innerWidth);
|
||||
if (shellWidth <= 0) return constraints.maxWidth;
|
||||
|
||||
const otherPanelWidth = this.oppositeResizablePanelWidth(side);
|
||||
const maxWidth = Math.floor(shellWidth - otherPanelWidth - PANEL_EDGE_COLUMNS_WIDTH_PX - MIN_RESIZABLE_CHAT_WIDTH_PX);
|
||||
return Math.max(constraints.minWidth, Math.min(constraints.maxWidth, maxWidth));
|
||||
}
|
||||
|
||||
private oppositeResizablePanelWidth(side: ResizablePanelSide): number {
|
||||
const otherSide: ResizablePanelSide = side === "navigation" ? "workspace" : "navigation";
|
||||
if (this.isResizablePanelCollapsedOrStacked(otherSide)) return 0;
|
||||
return this.measuredPanelWidth(otherSide) ?? this.panelResize.panelWidth(otherSide);
|
||||
}
|
||||
|
||||
private isResizablePanelCollapsedOrStacked(side: ResizablePanelSide): boolean {
|
||||
if (side === "navigation") return this.panelCollapse.navigationPanelCollapsed;
|
||||
return this.panelCollapse.workspacePanelCollapsed || !this.isDesktopSideBySideLayout();
|
||||
}
|
||||
|
||||
private isDesktopSideBySideLayout(): boolean {
|
||||
if (typeof window === "undefined" || !("matchMedia" in window)) return true;
|
||||
return window.matchMedia(DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
private measuredPanelWidth(side: ResizablePanelSide): number | undefined {
|
||||
const element = side === "navigation" ? this.navigationPanelFrame : this.workspacePanelFrame;
|
||||
const width = element?.getBoundingClientRect().width;
|
||||
return width === undefined || width <= 0 ? undefined : width;
|
||||
}
|
||||
|
||||
private resetResizablePanel(side: ResizablePanelSide): void {
|
||||
this.panelResize.resetPanel(side);
|
||||
}
|
||||
|
||||
private resetResizablePanels(): void {
|
||||
this.panelResize.resetPanels();
|
||||
}
|
||||
|
||||
private canDeleteArchivedSessions(): boolean {
|
||||
const runtime = this.selectedMachineRuntime();
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
|
||||
}
|
||||
|
||||
private archivedDeleteUnavailableMessage(): string {
|
||||
const machineName = this.state.selectedMachine?.name ?? "this machine";
|
||||
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
|
||||
}
|
||||
|
||||
private selectedMachineRuntime() {
|
||||
return this.state.machineRuntimes[selectedMachineId(this.state)];
|
||||
}
|
||||
|
||||
private renderNavigationPanel() {
|
||||
return html`
|
||||
<app-navigation-panel
|
||||
.machines=${this.state.machines}
|
||||
.selectedMachine=${this.state.selectedMachine}
|
||||
.machineStatuses=${this.state.machineStatuses}
|
||||
.machineActivities=${this.state.machineActivities}
|
||||
.machinesCollapsed=${this.mobileNavigation.isCollapsed("machines")}
|
||||
.onToggleMachines=${() => { this.mobileNavigation.toggle("machines"); }}
|
||||
.onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => {
|
||||
this.mobileNavigation.expand("projects");
|
||||
await this.selectMachineWithMemory(machine);
|
||||
})}
|
||||
.machinesCollapsed=${this.navigationSections.isCollapsed("machines")}
|
||||
.onToggleMachines=${() => { this.navigationSections.toggle("machines"); }}
|
||||
.onSelectMachine=${(machine: Machine) => this.selectNavigationItem("machines", "projects", () => this.selectMachineWithMemory(machine))}
|
||||
.onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }}
|
||||
.projects=${this.state.projects}
|
||||
.selectedProject=${this.state.selectedProject}
|
||||
@@ -799,40 +907,78 @@ export class PiWebApp extends LitElement {
|
||||
.sessionActivities=${this.state.sessionActivities}
|
||||
.selectedSession=${this.state.selectedSession}
|
||||
.canStartSession=${!!this.state.selectedWorkspace}
|
||||
.collapsible=${this.appShell.isMobileNavigationLayout}
|
||||
.projectsCollapsed=${this.mobileNavigation.isCollapsed("projects")}
|
||||
.workspacesCollapsed=${this.mobileNavigation.isCollapsed("workspaces")}
|
||||
.sessionsCollapsed=${this.mobileNavigation.isCollapsed("sessions")}
|
||||
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
|
||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
|
||||
.collapsible=${true}
|
||||
.compact=${this.appShell.isMobileNavigationLayout}
|
||||
.projectsCollapsed=${this.navigationSections.isCollapsed("projects")}
|
||||
.workspacesCollapsed=${this.navigationSections.isCollapsed("workspaces")}
|
||||
.sessionsCollapsed=${this.navigationSections.isCollapsed("sessions")}
|
||||
.workspaceLabelItems=${(workspace: Workspace) => this.workspaceLabelItems(workspace)}
|
||||
.refreshControl=${this.appShell.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : undefined}
|
||||
.onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }}
|
||||
.onToggleProjects=${() => { this.mobileNavigation.toggle("projects"); }}
|
||||
.onToggleWorkspaces=${() => { this.mobileNavigation.toggle("workspaces"); }}
|
||||
.onToggleSessions=${() => { this.mobileNavigation.toggle("sessions"); }}
|
||||
.onSelectProject=${(project: Project) => this.withChatScrollTransition(async () => {
|
||||
this.mobileNavigation.expand("workspaces");
|
||||
await this.workspaces.selectProject(project);
|
||||
})}
|
||||
.onToggleProjects=${() => { this.navigationSections.toggle("projects"); }}
|
||||
.onToggleWorkspaces=${() => { this.navigationSections.toggle("workspaces"); }}
|
||||
.onToggleSessions=${() => { this.navigationSections.toggle("sessions"); }}
|
||||
.onSelectProject=${(project: Project) => this.selectNavigationItem("projects", "workspaces", () => this.workspaces.selectProject(project))}
|
||||
.onCloseProject=${(project: Project) => this.projects.closeProject(project.id)}
|
||||
.onSelectWorkspace=${(workspace: Workspace) => this.withChatScrollTransition(async () => {
|
||||
this.mobileNavigation.expand("sessions");
|
||||
await this.workspaces.selectWorkspace(workspace);
|
||||
})}
|
||||
.onSelectWorkspace=${(workspace: Workspace) => this.selectNavigationItem("workspaces", "sessions", () => this.workspaces.selectWorkspace(workspace))}
|
||||
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
|
||||
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
|
||||
.onStartSession=${() => openChatAfter(() => this.sessions.startSession())}
|
||||
.onSelectSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
|
||||
.onStartSession=${() => this.selectNavigationItem("sessions", "chat", () => this.sessions.startSession())}
|
||||
.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)}
|
||||
.onRestoreSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
|
||||
.onArchiveSessions=${(sessions: SessionInfo[]) => this.sessions.archiveSessions(sessions)}
|
||||
.onRestoreSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.restoreSession(session))}
|
||||
.onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
|
||||
.onDeleteArchivedSession=${(session: SessionInfo) => this.sessions.deleteArchivedSessions([session])}
|
||||
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
|
||||
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
||||
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
|
||||
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
|
||||
></app-navigation-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private openNavigationSection(section: NavigationSection): void {
|
||||
this.mobileNavigation.open(section, () => { this.selectMainView("navigation"); });
|
||||
this.navigationSections.open(section, () => { this.selectMainView("navigation"); });
|
||||
}
|
||||
|
||||
private async selectNavigationItem(section: NavigationSection, nextTarget: NavigationFocusTarget, action: () => Promise<void>): Promise<void> {
|
||||
await this.withChatScrollTransition(async () => {
|
||||
this.navigationSections.advanceAfterSelection(section);
|
||||
await action();
|
||||
});
|
||||
await this.focusNavigationTarget(nextTarget);
|
||||
}
|
||||
|
||||
private async focusNavigationTarget(target: NavigationFocusTarget): Promise<void> {
|
||||
if (target === "chat") {
|
||||
await this.focusChatComposer();
|
||||
return;
|
||||
}
|
||||
await this.focusNavigationSection(target);
|
||||
}
|
||||
|
||||
private async focusNavigationSection(section: NavigationSection): Promise<void> {
|
||||
if (section === "machines" && !shouldShowMachinesSection(this.state.machines)) {
|
||||
await this.focusNavigationSection("projects");
|
||||
return;
|
||||
}
|
||||
this.panelCollapse.expandNavigationPanel();
|
||||
if (this.appShell.isMobileNavigationLayout) this.selectMainView("navigation");
|
||||
this.navigationSections.expand(section);
|
||||
await this.updateComplete;
|
||||
await nextFrame();
|
||||
await this.navigationPanel?.focusSection(section);
|
||||
}
|
||||
|
||||
private async focusChatComposer(): Promise<void> {
|
||||
if (this.state.mainView !== "chat") this.selectMainView("chat");
|
||||
await this.updateComplete;
|
||||
await nextFrame();
|
||||
this.promptEditor?.focusInput();
|
||||
}
|
||||
|
||||
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
|
||||
@@ -943,6 +1089,7 @@ export class PiWebApp extends LitElement {
|
||||
open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
|
||||
runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }),
|
||||
},
|
||||
openTerminal: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
|
||||
host: this.createWorkspaceHost(),
|
||||
piWebUnstable: { terminalCommandRuns },
|
||||
fileTree: this.state.fileTree,
|
||||
@@ -970,7 +1117,74 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private getActions(): AppAction[] {
|
||||
return applyShortcutPreferences(this.plugins.getActions(this.createPluginRuntimeContext()), this.shortcutConfig);
|
||||
return applyActiveShortcutPreferences(this.getDefaultActions(), this.shortcutConfig);
|
||||
}
|
||||
|
||||
private getDefaultActions(): AppAction[] {
|
||||
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions(), ...this.panelLayoutActions()];
|
||||
}
|
||||
|
||||
private panelLayoutActions(): AppAction[] {
|
||||
return [
|
||||
{
|
||||
id: "app.layout.reset-navigation-panel-size",
|
||||
title: "Reset Navigation Panel Size",
|
||||
description: "Restore the navigation panel to its default width",
|
||||
group: "View",
|
||||
run: () => { this.resetResizablePanel("navigation"); },
|
||||
},
|
||||
{
|
||||
id: "app.layout.reset-workspace-panel-size",
|
||||
title: "Reset Workspace Panel Size",
|
||||
description: "Restore the workspace panel to its default width",
|
||||
group: "View",
|
||||
run: () => { this.resetResizablePanel("workspace"); },
|
||||
},
|
||||
{
|
||||
id: "app.layout.reset-panel-sizes",
|
||||
title: "Reset Panel Sizes",
|
||||
description: "Restore all side panels to their default widths",
|
||||
group: "View",
|
||||
run: () => { this.resetResizablePanels(); },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private navigationFocusActions(): AppAction[] {
|
||||
return [
|
||||
{
|
||||
id: "app.navigation.focus-machines",
|
||||
title: "Focus Machines",
|
||||
description: "Move keyboard focus to the machine selector",
|
||||
shortcut: "mod+g m",
|
||||
group: "Navigation",
|
||||
run: () => this.focusNavigationSection("machines"),
|
||||
},
|
||||
{
|
||||
id: "app.navigation.focus-projects",
|
||||
title: "Focus Projects",
|
||||
description: "Move keyboard focus to the projects list",
|
||||
shortcut: "mod+g p",
|
||||
group: "Navigation",
|
||||
run: () => this.focusNavigationSection("projects"),
|
||||
},
|
||||
{
|
||||
id: "app.navigation.focus-workspaces",
|
||||
title: "Focus Workspaces",
|
||||
description: "Move keyboard focus to the workspaces list",
|
||||
shortcut: "mod+g w",
|
||||
group: "Navigation",
|
||||
run: () => this.focusNavigationSection("workspaces"),
|
||||
},
|
||||
{
|
||||
id: "app.navigation.focus-sessions",
|
||||
title: "Focus Sessions",
|
||||
description: "Move keyboard focus to the sessions list",
|
||||
shortcut: "mod+g s",
|
||||
group: "Navigation",
|
||||
run: () => this.focusNavigationSection("sessions"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private ensureGatewayPluginsLoaded(): Promise<void> {
|
||||
@@ -994,7 +1208,10 @@ 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`, { machineId: machine.id }))
|
||||
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),
|
||||
}))
|
||||
.then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); })
|
||||
.finally(() => { this.machinePluginLoadPromises.delete(machine.id); });
|
||||
this.machinePluginLoadPromises.set(machine.id, load);
|
||||
@@ -1028,10 +1245,12 @@ export class PiWebApp extends LitElement {
|
||||
openSettings: (section) => { this.openSettings(section); },
|
||||
},
|
||||
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
|
||||
focusPrompt: () => { this.promptEditor?.focusInput(); },
|
||||
focusPrompt: () => { void this.focusChatComposer(); },
|
||||
addProject: () => { this.setState({ projectDialogOpen: true }); },
|
||||
addMachine: () => { this.openMachineDialog(); },
|
||||
refreshSelectedMachine: () => this.machines.refreshMachineHealth(),
|
||||
refreshSelectedMachine: async () => {
|
||||
await Promise.all([this.machines.refreshMachineHealth(), this.machines.refreshMachineRuntime()]);
|
||||
},
|
||||
removeSelectedMachine: () => this.removeMachine(),
|
||||
openSelectedMachine: () => { this.openSelectedMachine(); },
|
||||
configureAuth: () => this.auth.openLogin(),
|
||||
@@ -1158,7 +1377,10 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private async submitMachineDialog(input: MachineDialogSubmit): Promise<void> {
|
||||
const machine = await this.machines.addMachine(input);
|
||||
if (machine !== undefined) this.setState({ machineDialogOpen: false });
|
||||
if (machine !== undefined) {
|
||||
this.setState({ machineDialogOpen: false });
|
||||
this.schedulePiWebStatusRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise<void> {
|
||||
@@ -1327,8 +1549,8 @@ export class PiWebApp extends LitElement {
|
||||
if (!this.appShell.isMobileNavigationLayout) return null;
|
||||
return html`
|
||||
<app-context-bar
|
||||
.machines=${this.state.machines}
|
||||
.machine=${this.state.selectedMachine}
|
||||
.machineActivityKind=${selectedMachineActivityIndicator(this.state)}
|
||||
.project=${this.state.selectedProject}
|
||||
.workspace=${this.state.selectedWorkspace}
|
||||
.session=${this.state.selectedSession}
|
||||
@@ -1366,24 +1588,24 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private renderAppRefresh() {
|
||||
return html`<app-refresh-control .isRefreshing=${this.isRefreshingApp} .onRefresh=${() => this.refreshAppData()} .onReload=${() => { this.hardReloadApp(); }}></app-refresh-control>`;
|
||||
return html`<app-refresh-control .onReload=${() => { this.hardReloadApp(); }}></app-refresh-control>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const state = this.state;
|
||||
return html`
|
||||
<div class=${this.panelCollapse.shellClass(state.mainView)}>
|
||||
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
|
||||
<div class=${this.panelCollapse.shellClass(state.mainView)} style=${this.panelResize.shellStyle({ navigation: this.resizablePanelConstraints("navigation"), workspace: this.resizablePanelConstraints("workspace") })}>
|
||||
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel()}</aside>
|
||||
${this.renderNavigationPanelEdgeControl()}
|
||||
<main class=${mainViewClass(state.mainView)}>
|
||||
${this.renderContextBar()}
|
||||
${this.renderMobileMainTabs()}
|
||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
|
||||
<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} .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)} .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} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||
<status-bar .status=${state.status} .machine=${state.selectedMachine} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.workspaceLabelItems(state.selectedWorkspace)}></status-bar>
|
||||
<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}
|
||||
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
|
||||
@@ -1396,7 +1618,7 @@ export class PiWebApp extends LitElement {
|
||||
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
|
||||
${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.getActions()} .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} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1433,14 +1655,6 @@ function shouldRefreshMachineActivity(machine: Machine, health: MachineHealth |
|
||||
return status === undefined || status === "unknown" || status === "online";
|
||||
}
|
||||
|
||||
function selectedMachineActivityIndicator(state: AppState) {
|
||||
const machineId = selectedMachineId(state);
|
||||
const machine = state.selectedMachine;
|
||||
const status = state.machineStatuses[machineId]?.status ?? machine?.status;
|
||||
if (status === "offline" || status === "error") return undefined;
|
||||
return machineActivityIndicator(state.machineActivities[machineId]);
|
||||
}
|
||||
|
||||
function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
|
||||
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { Project, Workspace, WorkspaceActivity } from "../api";
|
||||
import { projectActivityIndicator } from "../workspaceActivity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { renderActionActivityIndicator } from "./activityBadge";
|
||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
|
||||
@customElement("project-list")
|
||||
export class ProjectList extends LitElement {
|
||||
export class ProjectList extends LitElement implements KeyboardNavigableSection {
|
||||
@property({ attribute: false }) projects: Project[] = [];
|
||||
@property({ attribute: false }) selected?: Project;
|
||||
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
||||
@@ -18,6 +19,9 @@ export class ProjectList extends LitElement {
|
||||
@property({ attribute: false }) onSelect?: (project: Project) => void;
|
||||
@property({ attribute: false }) onClose?: (project: Project) => void;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
@property({ attribute: false }) onFocusPreviousSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
@state() private openMenuProjectId: string | undefined;
|
||||
@state() private menuStyle = "";
|
||||
private readonly onDocumentClick = (event: MouseEvent) => {
|
||||
@@ -40,6 +44,11 @@ export class ProjectList extends LitElement {
|
||||
if (changed.has("collapsed") && this.collapsed) this.openMenuProjectId = undefined;
|
||||
}
|
||||
|
||||
async focusSelectedOrFirst(): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section>
|
||||
@@ -52,10 +61,11 @@ export class ProjectList extends LitElement {
|
||||
tabindex="0"
|
||||
title=${project.path}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(project)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${project.name}</span><small>${this.renderActivity(project)}${project.path}</small>
|
||||
<span class="action-name">${project.name}</span><small>${project.path}</small>
|
||||
${this.renderActivity(project)}
|
||||
</div>
|
||||
<div class="action-menu">
|
||||
<button class="action-menu-toggle" title="Project actions" aria-label=${`Actions for ${project.name}`} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(project.id, event.currentTarget); }}>⋯</button>
|
||||
@@ -73,16 +83,25 @@ export class ProjectList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private handleProjectKeydown(event: KeyboardEvent, project: Project): void {
|
||||
handleSelectableRowKeyboard(event, {
|
||||
activate: () => this.onSelect?.(project),
|
||||
previousSection: this.onFocusPreviousSection === undefined ? undefined : () => { void this.onFocusPreviousSection?.(); },
|
||||
nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); },
|
||||
cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); },
|
||||
});
|
||||
}
|
||||
|
||||
private renderHeading() {
|
||||
if (!this.collapsible) return "Projects";
|
||||
const selectedSummary = this.selected?.name ?? "No project selected";
|
||||
const selectedTitle = this.selected?.path ?? selectedSummary;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Projects</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.projects.length}</small></button>`;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Projects</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.projects.length}</small></button>`;
|
||||
}
|
||||
|
||||
private renderActivity(project: Project) {
|
||||
const kind = projectActivityIndicator(project, this.workspacesByProjectId[project.id] ?? [], this.activities);
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active") ?? "";
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active");
|
||||
}
|
||||
|
||||
private toggleMenu(projectId: string, target: EventTarget | null) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionInfo } from "../api";
|
||||
import { sessionRowsForCurrentTree } from "./SessionList";
|
||||
|
||||
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" };
|
||||
const child = session("child", { parentSessionPath: parent.path });
|
||||
|
||||
expect(rowSummaries(sessionRowsForCurrentTree([parent, child]))).toEqual([
|
||||
{ id: "parent", depth: 0, hasMissingParent: false },
|
||||
{ id: "child", depth: 1, hasMissingParent: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides archived parents from the current tree once children are detached", () => {
|
||||
const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
|
||||
const detachedChild = session("child");
|
||||
|
||||
expect(rowSummaries(sessionRowsForCurrentTree([parent, detachedChild]))).toEqual([
|
||||
{ id: "child", depth: 0, hasMissingParent: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still marks unavailable parents when the parent record is missing", () => {
|
||||
const child = session("child", { parentSessionPath: "/sessions/missing.jsonl" });
|
||||
|
||||
expect(rowSummaries(sessionRowsForCurrentTree([child]))).toEqual([
|
||||
{ id: "child", depth: 0, hasMissingParent: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function rowSummaries(rows: ReturnType<typeof sessionRowsForCurrentTree>) {
|
||||
return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent }));
|
||||
}
|
||||
|
||||
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
path: `/sessions/${id}.jsonl`,
|
||||
cwd: "/workspace",
|
||||
created: "2026-06-09T00:00:00.000Z",
|
||||
modified: "2026-06-09T00:00:00.000Z",
|
||||
messageCount: 1,
|
||||
firstMessage: id,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { LitElement, html, type PropertyValues } from "lit";
|
||||
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 { isSessionActive } from "../../../shared/activity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { renderActionActivityIndicator } from "./activityBadge";
|
||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
|
||||
function sessionLabel(session: SessionInfo): string {
|
||||
@@ -13,37 +14,51 @@ function sessionLabel(session: SessionInfo): string {
|
||||
return session.firstMessage !== "" ? session.firstMessage : session.id.slice(0, 8);
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
export interface SessionRow {
|
||||
session: SessionInfo;
|
||||
depth: number;
|
||||
hasMissingParent: boolean;
|
||||
}
|
||||
|
||||
type SessionSelectionScope = "current" | "archived";
|
||||
|
||||
@customElement("session-list")
|
||||
export class SessionList extends LitElement {
|
||||
export class SessionList extends LitElement implements KeyboardNavigableSection {
|
||||
@property({ attribute: false }) sessions: SessionInfo[] = [];
|
||||
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
|
||||
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
|
||||
@property({ attribute: false }) selected?: SessionInfo;
|
||||
@property({ type: Boolean }) canStart = false;
|
||||
@property({ type: Boolean }) canDeleteArchived = false;
|
||||
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onStart?: () => void;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
@property({ attribute: false }) onArchivedCollapsed?: () => void;
|
||||
@property({ attribute: false }) onFocusPreviousSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onArchiveWithDescendants?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onArchiveMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onDelete?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
||||
|
||||
@state() private openMenuSessionId: string | undefined;
|
||||
@state() private menuStyle = "";
|
||||
@state() private archivedExpanded = false;
|
||||
@state() private selectionScopes: ReadonlySet<SessionSelectionScope> = new Set();
|
||||
@state() private selectedSessionIds: ReadonlySet<string> = new Set();
|
||||
|
||||
private readonly onDocumentClick = (event: MouseEvent) => {
|
||||
if (event.composedPath().includes(this)) return;
|
||||
this.openMenuSessionId = undefined;
|
||||
};
|
||||
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onArchiveWithDescendants?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onDelete?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
@@ -56,9 +71,12 @@ export class SessionList extends LitElement {
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>): void {
|
||||
if (changed.has("sessions") && this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined;
|
||||
if (changed.has("sessions")) {
|
||||
if (this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined;
|
||||
if (!this.sessions.some((session) => session.archived === true)) this.archivedExpanded = false;
|
||||
this.pruneSelectedSessionIds();
|
||||
}
|
||||
if (changed.has("collapsed") && this.collapsed) this.openMenuSessionId = undefined;
|
||||
if (changed.has("sessions") && !this.sessions.some((session) => session.archived === true)) this.archivedExpanded = false;
|
||||
const previousSelected = changed.get("selected");
|
||||
if (changed.has("selected") && this.selected?.archived === true && (previousSelected?.id !== this.selected.id || previousSelected.archived !== true) && !this.archivedExpanded) {
|
||||
this.archivedExpanded = true;
|
||||
@@ -68,20 +86,30 @@ export class SessionList extends LitElement {
|
||||
if ((changed.has("selected") || changed.has("sessions") || changed.has("collapsed")) && !this.collapsed) this.scrollSelectedIntoView();
|
||||
}
|
||||
|
||||
async focusSelectedOrFirst(): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle, h2 button:not([disabled])" });
|
||||
}
|
||||
|
||||
override render() {
|
||||
const activeRows = sessionRowsForActiveTree(this.sessions);
|
||||
const activeIds = new Set(activeRows.map((row) => row.session.id));
|
||||
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !activeIds.has(session.id)));
|
||||
const currentRows = sessionRowsForCurrentTree(this.sessions);
|
||||
const currentRowIds = new Set(currentRows.map((row) => row.session.id));
|
||||
const currentSelectableSessions = currentRows.map((row) => row.session).filter((session) => sessionSelectionScope(session) === "current");
|
||||
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !currentRowIds.has(session.id)));
|
||||
const descendantCounts = unarchivedDescendantCounts(this.sessions);
|
||||
return html`
|
||||
<section>
|
||||
${this.renderHeading(activeRows.length + archivedRows.length)}
|
||||
${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions)}
|
||||
${this.collapsed ? null : html`
|
||||
<div class="list-body">
|
||||
${activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))}
|
||||
${this.renderCurrentSelectionToolbar(currentSelectableSessions)}
|
||||
${currentRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "current"))}
|
||||
${archivedRows.length > 0 ? html`
|
||||
<h2 class="subheading"><button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span><small>${archivedRows.length}</small></button></h2>
|
||||
${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null}
|
||||
${this.renderArchivedHeading(archivedRows.map((row) => row.session))}
|
||||
${this.archivedExpanded ? html`
|
||||
${this.renderArchivedSelectionToolbar(archivedRows.map((row) => row.session))}
|
||||
${archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "archived"))}
|
||||
` : null}
|
||||
` : null}
|
||||
</div>
|
||||
`}
|
||||
@@ -89,43 +117,115 @@ export class SessionList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderHeading(sessionCount: number) {
|
||||
if (!this.collapsible) return html`<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>`;
|
||||
private renderHeading(sessionCount: number, currentSessions: SessionInfo[]) {
|
||||
if (!this.collapsible) {
|
||||
return html`
|
||||
<h2>
|
||||
Sessions
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
<button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button>
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
const selectedSummary = this.selected === undefined ? "No session selected" : sessionLabel(this.selected);
|
||||
const selectedTitle = this.selected?.path ?? selectedSummary;
|
||||
return html`
|
||||
<h2>
|
||||
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${sessionCount}</small></button>
|
||||
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
<small class="section-count">${sessionCount}</small>
|
||||
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSession(row: SessionRow, descendantCount: number) {
|
||||
private renderCurrentSelectionButton(currentSessions: SessionInfo[]) {
|
||||
if (this.collapsed || currentSessions.length === 0) return null;
|
||||
const active = this.selectionScopes.has("current");
|
||||
return html`<button class="bulk-select-entry ${active ? "selected" : ""}" title=${active ? "Close current session selection" : "Select current sessions"} aria-label=${active ? "Close current session selection" : "Select current sessions"} aria-expanded=${String(active)} aria-pressed=${String(active)} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleSelection("current", currentSessions); }}>☑</button>`;
|
||||
}
|
||||
|
||||
private renderArchivedHeading(archivedSessions: SessionInfo[]) {
|
||||
const active = this.selectionScopes.has("archived");
|
||||
return html`
|
||||
<h2 class="subheading">
|
||||
<button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span></button>
|
||||
${this.archivedExpanded ? html`<button class="bulk-select-entry ${active ? "selected" : ""}" title=${active ? "Close archived session selection" : "Select archived sessions"} aria-label=${active ? "Close archived session selection" : "Select archived sessions"} aria-expanded=${String(active)} aria-pressed=${String(active)} @click=${() => { this.toggleSelection("archived", archivedSessions); }}>☑</button>` : null}
|
||||
<small class="section-count">${archivedSessions.length}</small>
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCurrentSelectionToolbar(visibleSessions: SessionInfo[]) {
|
||||
if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null;
|
||||
|
||||
const selectedSessions = this.selectedSessions("current");
|
||||
const archivableSessions = selectedSessions.filter((session) => !isCachedNewSessionInfo(session));
|
||||
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`
|
||||
<div class="bulk-row selecting">
|
||||
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
|
||||
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
|
||||
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive selected</button>
|
||||
<button @click=${() => { this.clearSelection("current"); }}>Clear</button>
|
||||
<button @click=${() => { this.closeSelection("current"); }}>Done</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderArchivedSelectionToolbar(visibleSessions: SessionInfo[]) {
|
||||
if (visibleSessions.length === 0 || !this.selectionScopes.has("archived")) return null;
|
||||
|
||||
const selectedSessions = this.selectedSessions("archived");
|
||||
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`
|
||||
<div class="bulk-row selecting">
|
||||
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
|
||||
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete selected archived sessions" : this.archivedDeleteUnavailableMessage} ?disabled=${selectedSessions.length === 0 || !this.canDeleteArchived} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete selected</button>
|
||||
<button @click=${() => { this.clearSelection("archived"); }}>Clear</button>
|
||||
<button @click=${() => { this.closeSelection("archived"); }}>Done</button>
|
||||
${this.canDeleteArchived ? null : html`<small class="capability-hint">${this.archivedDeleteUnavailableMessage}</small>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSession(row: SessionRow, descendantCount: number, scope: SessionSelectionScope) {
|
||||
const { session } = row;
|
||||
const cappedDepth = Math.min(row.depth, 2);
|
||||
const canBulkSelect = sessionSelectionScope(session) === scope;
|
||||
const selectionActive = this.selectionScopes.has(scope);
|
||||
const showsCheckbox = selectionActive && canBulkSelect;
|
||||
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
|
||||
return html`
|
||||
<div
|
||||
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}"
|
||||
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""}"
|
||||
style=${`--depth:${String(cappedDepth)}`}
|
||||
tabindex="0"
|
||||
title=${session.path}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(session)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(session)); }}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => { this.activateSessionRow(session, scope); }); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleSessionKeydown(event, session, scope); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
|
||||
<div class="action-main ${selectionActive ? "selecting" : ""}">
|
||||
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
|
||||
<span class="action-name">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages</small>
|
||||
${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}>
|
||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||
${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>`
|
||||
? 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>
|
||||
`
|
||||
: html`
|
||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||
<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}
|
||||
`}
|
||||
@@ -136,11 +236,101 @@ export class SessionList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private handleSessionKeydown(event: KeyboardEvent, session: SessionInfo, scope: SessionSelectionScope): void {
|
||||
handleSelectableRowKeyboard(event, {
|
||||
activate: () => { this.activateSessionRow(session, scope); },
|
||||
previousSection: this.onFocusPreviousSection === undefined ? undefined : () => { void this.onFocusPreviousSection?.(); },
|
||||
nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); },
|
||||
cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); },
|
||||
});
|
||||
}
|
||||
|
||||
private activateSessionRow(session: SessionInfo, scope: SessionSelectionScope): void {
|
||||
if (this.selectionScopes.has(scope) && sessionSelectionScope(session) === scope) {
|
||||
this.toggleSelected(session.id);
|
||||
return;
|
||||
}
|
||||
this.onSelect?.(session);
|
||||
}
|
||||
|
||||
private confirmArchiveWithDescendants(session: SessionInfo, descendantCount: number): void {
|
||||
const noun = descendantCount === 1 ? "descendant session" : "descendant sessions";
|
||||
if (confirm(`Archive “${sessionLabel(session)}” and ${String(descendantCount)} ${noun}?`)) this.onArchiveWithDescendants?.(session);
|
||||
}
|
||||
|
||||
private confirmDeleteArchived(session: SessionInfo): void {
|
||||
if (!this.canDeleteArchived) return;
|
||||
if (confirm(`Permanently delete archived session “${sessionLabel(session)}”? This cannot be undone.`)) void this.onDeleteArchived?.(session);
|
||||
}
|
||||
|
||||
private confirmDeleteSelectedArchived(): void {
|
||||
if (!this.canDeleteArchived) return;
|
||||
const archived = this.selectedSessions("archived");
|
||||
if (archived.length === 0) return;
|
||||
const noun = archived.length === 1 ? "archived session" : "archived sessions";
|
||||
if (!confirm(`Permanently delete ${String(archived.length)} selected ${noun}? This cannot be undone.`)) return;
|
||||
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, archived.map((session) => session.id));
|
||||
void this.onDeleteArchivedMany?.(archived);
|
||||
}
|
||||
|
||||
private archiveSelectedCurrent(): void {
|
||||
const sessions = this.selectedSessions("current").filter((session) => !isCachedNewSessionInfo(session));
|
||||
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
|
||||
void this.onArchiveMany?.(sessions);
|
||||
}
|
||||
|
||||
private toggleSelection(scope: SessionSelectionScope, visibleSessions: SessionInfo[]): void {
|
||||
if (this.selectionScopes.has(scope)) {
|
||||
this.closeSelection(scope);
|
||||
return;
|
||||
}
|
||||
this.startSelection(scope, visibleSessions);
|
||||
}
|
||||
|
||||
private startSelection(scope: SessionSelectionScope, visibleSessions: SessionInfo[]): void {
|
||||
this.selectionScopes = new Set([...this.selectionScopes, scope]);
|
||||
const onlyVisibleSession = visibleSessions.length === 1 ? visibleSessions[0] : undefined;
|
||||
if (onlyVisibleSession !== undefined) this.selectedSessionIds = new Set([...this.selectedSessionIds, onlyVisibleSession.id]);
|
||||
}
|
||||
|
||||
private closeSelection(scope: SessionSelectionScope): void {
|
||||
this.selectionScopes = new Set([...this.selectionScopes].filter((candidate) => candidate !== scope));
|
||||
this.clearSelection(scope);
|
||||
}
|
||||
|
||||
private clearSelection(scope: SessionSelectionScope): void {
|
||||
const sessionIds = this.sessions.filter((session) => sessionSelectionScope(session) === scope).map((session) => session.id);
|
||||
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessionIds);
|
||||
}
|
||||
|
||||
private toggleSelected(sessionId: string): void {
|
||||
const next = new Set(this.selectedSessionIds);
|
||||
if (next.has(sessionId)) next.delete(sessionId);
|
||||
else next.add(sessionId);
|
||||
this.selectedSessionIds = next;
|
||||
}
|
||||
|
||||
private toggleVisibleSelection(sessions: SessionInfo[], selected: boolean): void {
|
||||
const next = new Set(this.selectedSessionIds);
|
||||
for (const session of sessions) {
|
||||
if (selected) next.add(session.id);
|
||||
else next.delete(session.id);
|
||||
}
|
||||
this.selectedSessionIds = next;
|
||||
}
|
||||
|
||||
private selectedSessions(scope: SessionSelectionScope): SessionInfo[] {
|
||||
return this.sessions.filter((session) => this.selectedSessionIds.has(session.id) && sessionSelectionScope(session) === scope);
|
||||
}
|
||||
|
||||
private pruneSelectedSessionIds(): void {
|
||||
const existing = new Set(this.sessions.map((session) => session.id));
|
||||
const next = new Set([...this.selectedSessionIds].filter((sessionId) => existing.has(sessionId)));
|
||||
if (next.size !== this.selectedSessionIds.size) this.selectedSessionIds = next;
|
||||
if (this.selectionScopes.has("archived") && !this.sessions.some((session) => session.archived === true)) this.closeSelection("archived");
|
||||
if (this.selectionScopes.has("current") && !this.sessions.some((session) => session.archived !== true)) this.closeSelection("current");
|
||||
}
|
||||
|
||||
private toggleMenu(sessionId: string, target: EventTarget | null) {
|
||||
if (this.openMenuSessionId === sessionId) {
|
||||
this.openMenuSessionId = undefined;
|
||||
@@ -154,6 +344,7 @@ export class SessionList extends LitElement {
|
||||
this.archivedExpanded = !this.archivedExpanded;
|
||||
if (!this.archivedExpanded) {
|
||||
this.openMenuSessionId = undefined;
|
||||
if (this.selectionScopes.has("archived")) this.closeSelection("archived");
|
||||
this.onArchivedCollapsed?.();
|
||||
}
|
||||
}
|
||||
@@ -162,13 +353,41 @@ export class SessionList extends LitElement {
|
||||
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
private renderStatus(session: SessionInfo) {
|
||||
private renderSessionMetaPrefix(session: SessionInfo) {
|
||||
if (isCachedNewSessionInfo(session)) return "new · ";
|
||||
if (session.archived === true) return "read-only · ";
|
||||
return renderActivityIndicator(isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "session" : undefined, "Session active") ?? "";
|
||||
return "";
|
||||
}
|
||||
|
||||
static override styles = listStyles;
|
||||
private renderActivity(session: SessionInfo) {
|
||||
if (isCachedNewSessionInfo(session) || session.archived === true) return undefined;
|
||||
return renderActionActivityIndicator(isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "session" : undefined, "Session active");
|
||||
}
|
||||
|
||||
static override styles = [listStyles, css`
|
||||
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; }
|
||||
.bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; }
|
||||
.bulk-row button { padding: 5px 7px; font-size: 12px; }
|
||||
.bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); }
|
||||
.bulk-row .capability-hint { flex: 1 0 100%; color: var(--pi-warning); }
|
||||
.bulk-row.selecting { padding: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: color-mix(in srgb, var(--pi-surface) 65%, transparent); }
|
||||
button.danger, .action-menu-panel button.danger { color: var(--pi-danger); }
|
||||
button.danger:hover, .action-menu-panel button.danger:hover { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
.action-row.bulk-selected .action-main { border-color: var(--pi-accent); box-shadow: inset 3px 0 0 var(--pi-accent); }
|
||||
.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; }
|
||||
`];
|
||||
}
|
||||
|
||||
function sessionSelectionScope(session: SessionInfo): SessionSelectionScope {
|
||||
return session.archived === true ? "archived" : "current";
|
||||
}
|
||||
|
||||
function removeSessionIds(sessionIds: ReadonlySet<string>, removedIds: readonly string[]): ReadonlySet<string> {
|
||||
const removed = new Set(removedIds);
|
||||
return new Set([...sessionIds].filter((sessionId) => !removed.has(sessionId)));
|
||||
}
|
||||
|
||||
function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number> {
|
||||
@@ -196,16 +415,16 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number
|
||||
return new Map(sessions.map((session) => [session.id, countFor(session, new Set())]));
|
||||
}
|
||||
|
||||
function sessionRowsForActiveTree(sessions: SessionInfo[]): SessionRow[] {
|
||||
export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] {
|
||||
const byPath = new Map(sessions.map((session) => [session.path, session]));
|
||||
const visible = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
if (session.archived === true) continue;
|
||||
visible.add(session.id);
|
||||
let parentPath = session.parentSessionPath;
|
||||
const seen = new Set<string>([session.path]);
|
||||
while (parentPath !== undefined && !seen.has(parentPath)) {
|
||||
seen.add(parentPath);
|
||||
const seenPaths = new Set<string>([session.path]);
|
||||
while (parentPath !== undefined && !seenPaths.has(parentPath)) {
|
||||
seenPaths.add(parentPath);
|
||||
const parent = byPath.get(parentPath);
|
||||
if (parent === undefined) break;
|
||||
visible.add(parent.id);
|
||||
|
||||
@@ -61,7 +61,18 @@ export class SettingsDialog extends LitElement {
|
||||
|
||||
private renderActiveSection(): TemplateResult {
|
||||
if (this.section === "shortcuts") {
|
||||
return html`<settings-shortcuts-panel .actions=${this.actions} .configResponse=${this.configResponse}></settings-shortcuts-panel>`;
|
||||
return html`
|
||||
<settings-shortcuts-panel
|
||||
.actions=${this.actions}
|
||||
.configResponse=${this.configResponse}
|
||||
.loading=${this.loading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
></settings-shortcuts-panel>
|
||||
`;
|
||||
}
|
||||
if (this.section === "plugins") {
|
||||
return html`
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { Machine, SessionStatus, Workspace } from "../api";
|
||||
import type { WorkspaceLabelItem } from "../plugins/types";
|
||||
import type { SessionStatus } from "../api";
|
||||
import { formatCost, formatTokenCount } from "../utils/format";
|
||||
import { statusBarStyles } from "./shared";
|
||||
import { renderWorkspaceLabel } from "./workspaceLabel";
|
||||
|
||||
@customElement("status-bar")
|
||||
export class StatusBar extends LitElement {
|
||||
@property({ attribute: false }) status?: SessionStatus;
|
||||
@property({ attribute: false }) machine?: Machine;
|
||||
@property({ attribute: false }) workspace?: Workspace;
|
||||
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
|
||||
|
||||
override render() {
|
||||
const status = this.status;
|
||||
@@ -25,11 +20,9 @@ export class StatusBar extends LitElement {
|
||||
const tokens = status.tokens;
|
||||
return html`
|
||||
<div class="bar">
|
||||
<span>${this.machine?.name ?? "Local"}</span>
|
||||
<span>${renderWorkspaceLabel(this.workspace?.label ?? "workspace", this.workspaceLabelItems, this.workspace?.path)}</span>
|
||||
<span>↑${formatTokenCount(tokens.input)}</span>
|
||||
<span>↓${formatTokenCount(tokens.output)}</span>
|
||||
<span>${contextText}</span>
|
||||
<span class="context">${contextText}</span>
|
||||
<span>${formatCost(status.cost)}</span>
|
||||
${status.pendingMessageCount > 0 ? html`<span>${String(status.pendingMessageCount)} queued</span>` : null}
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,14 @@ import type { Workspace, WorkspaceActivity } from "../api";
|
||||
import type { WorkspaceLabelItem } from "../plugins/types";
|
||||
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { renderActionActivityIndicator } from "./activityBadge";
|
||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
import { renderWorkspaceLabelInlineItems } from "./workspaceLabel";
|
||||
|
||||
@customElement("workspace-list")
|
||||
export class WorkspaceList extends LitElement {
|
||||
export class WorkspaceList extends LitElement implements KeyboardNavigableSection {
|
||||
@property({ attribute: false }) workspaces: Workspace[] = [];
|
||||
@property({ attribute: false }) selected?: Workspace;
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@@ -21,6 +22,9 @@ export class WorkspaceList extends LitElement {
|
||||
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
|
||||
@property({ attribute: false }) onDelete?: (workspace: Workspace) => void;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
@property({ attribute: false }) onFocusPreviousSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
@state() private openMenuWorkspaceId: string | undefined;
|
||||
@state() private menuStyle = "";
|
||||
|
||||
@@ -45,6 +49,11 @@ export class WorkspaceList extends LitElement {
|
||||
if ((changed.has("selected") || changed.has("workspaces") || changed.has("collapsed")) && !this.collapsed) this.scrollSelectedIntoView();
|
||||
}
|
||||
|
||||
async focusSelectedOrFirst(): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section>
|
||||
@@ -79,18 +88,17 @@ export class WorkspaceList extends LitElement {
|
||||
if (!this.collapsible) return "Workspaces";
|
||||
const selectedSummary = this.selected === undefined ? "No workspace selected" : `${this.selected.label}${this.selected.isMain ? " · main" : ""} · ${this.selected.path}`;
|
||||
const selectedTitle = this.selected?.path ?? selectedSummary;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Workspaces</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.workspaces.length}</small></button>`;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Workspaces</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.workspaces.length}</small></button>`;
|
||||
}
|
||||
|
||||
private renderActivity(workspace: Workspace): TemplateResult | undefined {
|
||||
const kind = workspaceActivityIndicator(workspaceActivityFor(workspace, this.activities));
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active");
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active");
|
||||
}
|
||||
|
||||
private renderWorkspaceMain(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult {
|
||||
return html`
|
||||
<span class="workspace-primary">
|
||||
${this.renderActivity(workspace)}
|
||||
<span class="workspace-primary-label">${label}</span>
|
||||
${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null}
|
||||
</span>
|
||||
@@ -99,6 +107,7 @@ export class WorkspaceList extends LitElement {
|
||||
<span class="workspace-label">${renderWorkspaceLabelInlineItems(items)}</span>
|
||||
</small>
|
||||
`}
|
||||
${this.renderActivity(workspace)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -182,7 +191,12 @@ export class WorkspaceList extends LitElement {
|
||||
this.openMenuWorkspaceId = undefined;
|
||||
return;
|
||||
}
|
||||
activateSelectableRowFromKeyboard(event, () => this.onSelect?.(workspace));
|
||||
handleSelectableRowKeyboard(event, {
|
||||
activate: () => this.onSelect?.(workspace),
|
||||
previousSection: this.onFocusPreviousSection === undefined ? undefined : () => { void this.onFocusPreviousSection?.(); },
|
||||
nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); },
|
||||
cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); },
|
||||
});
|
||||
}
|
||||
|
||||
private scrollSelectedIntoView(): void {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { LitElement, html, type TemplateResult } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import type { Workspace } from "../api";
|
||||
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspacePanelContext } from "../plugins/types";
|
||||
import { workspacePanelStyles } from "./shared";
|
||||
import { renderWorkspaceLabel } from "./workspaceLabel";
|
||||
|
||||
export interface WorkspacePanelEmptyState {
|
||||
title: string;
|
||||
@@ -19,7 +18,6 @@ export class WorkspacePanel extends LitElement {
|
||||
@property({ attribute: false }) emptyState: WorkspacePanelEmptyState | undefined;
|
||||
@property() tool: QualifiedContributionId = "core:workspace.files";
|
||||
@property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = [];
|
||||
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
|
||||
@property({ type: Boolean }) hideToolTabs = false;
|
||||
@property({ attribute: false }) onSelectTool: (tool: QualifiedContributionId) => void = () => undefined;
|
||||
@query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null;
|
||||
@@ -63,10 +61,10 @@ export class WorkspacePanel extends LitElement {
|
||||
const visiblePanels = this.panels;
|
||||
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
|
||||
return html`
|
||||
<header>
|
||||
<div class=${this.workspaceHeaderFrameClass()}>
|
||||
<div class="workspace-header-strip" @scroll=${this.onWorkspaceHeaderScroll}>
|
||||
${this.hideToolTabs ? null : html`
|
||||
${this.hideToolTabs ? null : html`
|
||||
<header>
|
||||
<div class=${this.workspaceHeaderFrameClass()}>
|
||||
<div class="workspace-header-strip" @scroll=${this.onWorkspaceHeaderScroll}>
|
||||
<div class="tabs">
|
||||
${visiblePanels.map((panel) => {
|
||||
const selected = selectedPanel?.id === panel.id;
|
||||
@@ -79,11 +77,10 @@ export class WorkspacePanel extends LitElement {
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
<small>${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
`}
|
||||
${selectedPanel === undefined ? this.renderEmptyState({
|
||||
title: "No workspace tools available",
|
||||
body: "No tools are available for this workspace.",
|
||||
|
||||
@@ -6,3 +6,9 @@ export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined,
|
||||
if (kind === undefined) return undefined;
|
||||
return html`<span class=${`activity-indicator ${kind}`} role="img" aria-label=${label} title=${label}></span>`;
|
||||
}
|
||||
|
||||
export function renderActionActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined {
|
||||
const indicator = renderActivityIndicator(kind, label);
|
||||
if (indicator === undefined) return undefined;
|
||||
return html`<span class="action-activity">${indicator}</span>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Machine } from "../../api";
|
||||
import { shouldShowMachineContext } from "./AppContextBar";
|
||||
|
||||
describe("shouldShowMachineContext", () => {
|
||||
it("hides the machine crumb when there is no machine choice", () => {
|
||||
expect(shouldShowMachineContext([])).toBe(false);
|
||||
expect(shouldShowMachineContext([machine("local")])).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the machine crumb when multiple machines exist", () => {
|
||||
expect(shouldShowMachineContext([machine("local"), machine("remote-a")])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function machine(id: string): Machine {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
kind: id === "local" ? "local" : "remote",
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,11 @@ import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import type { Machine, Project, SessionInfo, Workspace } from "../../api";
|
||||
import type { NavigationSection } from "../../appShell/navigationState";
|
||||
import { renderActivityIndicator, type ActivityIndicatorKind } from "../activityBadge";
|
||||
|
||||
@customElement("app-context-bar")
|
||||
export class AppContextBar extends LitElement {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@property({ attribute: false }) machine?: Machine;
|
||||
@property({ attribute: false }) machineActivityKind?: ActivityIndicatorKind;
|
||||
@property({ attribute: false }) project?: Project;
|
||||
@property({ attribute: false }) workspace?: Workspace;
|
||||
@property({ attribute: false }) session?: SessionInfo;
|
||||
@@ -38,6 +37,7 @@ export class AppContextBar extends LitElement {
|
||||
}
|
||||
|
||||
override render() {
|
||||
const showMachineContext = shouldShowMachineContext(this.machines);
|
||||
const machineLabel = machineContextLabel(this.machine);
|
||||
const projectLabel = projectContextLabel(this.project);
|
||||
const workspaceLabel = workspaceContextLabel(this.workspace);
|
||||
@@ -46,13 +46,14 @@ export class AppContextBar extends LitElement {
|
||||
<nav class=${this.contextBarClass()} aria-label="Current location">
|
||||
<span class="context-bar-label">Location</span>
|
||||
<ol class="context-items" @scroll=${this.onContextScroll}>
|
||||
<li class="context-item">
|
||||
<button type="button" class=${this.machine === undefined ? "context-chip empty" : "context-chip"} title=${machineContextTitle(this.machine)} aria-label=${`Machine: ${machineLabel}. Open machine selection.`} @click=${() => { this.onOpenSection?.("machines"); }}>
|
||||
<span class="context-kind">Machine</span>
|
||||
${this.renderMachineActivity()}
|
||||
<span class="context-value">${machineLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
${showMachineContext ? html`
|
||||
<li class="context-item">
|
||||
<button type="button" class=${this.machine === undefined ? "context-chip empty" : "context-chip"} title=${machineContextTitle(this.machine)} aria-label=${`Machine: ${machineLabel}. Open machine selection.`} @click=${() => { this.onOpenSection?.("machines"); }}>
|
||||
<span class="context-kind">Machine</span>
|
||||
<span class="context-value">${machineLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
` : null}
|
||||
<li class="context-item">
|
||||
<button type="button" class=${this.project === undefined ? "context-chip empty" : "context-chip"} title=${projectContextTitle(this.project)} aria-label=${`Project: ${projectLabel}. Open project selection.`} @click=${() => { this.onOpenSection?.("projects"); }}>
|
||||
<span class="context-kind">Project</span>
|
||||
@@ -77,10 +78,6 @@ export class AppContextBar extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMachineActivity() {
|
||||
return renderActivityIndicator(this.machineActivityKind, this.machineActivityKind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
}
|
||||
|
||||
private renderActionsButton() {
|
||||
if (this.onShowActions === undefined) return null;
|
||||
return html`
|
||||
@@ -159,16 +156,16 @@ export class AppContextBar extends LitElement {
|
||||
.context-chip:hover { background: var(--pi-surface-hover); }
|
||||
.context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
|
||||
.context-chip.empty { border-style: dashed; color: var(--pi-muted); }
|
||||
.activity-indicator { flex: 0 0 auto; display: inline-block; width: 7px; height: 7px; margin-right: 0; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; }
|
||||
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
|
||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
||||
.context-kind { display: none; }
|
||||
.context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; }
|
||||
button { cursor: pointer; }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } }
|
||||
`;
|
||||
}
|
||||
|
||||
export function shouldShowMachineContext(machines: readonly Machine[]): boolean {
|
||||
return machines.length > 1;
|
||||
}
|
||||
|
||||
function machineContextLabel(machine: Machine | undefined): string {
|
||||
return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { Machine } from "../../api";
|
||||
import { shouldShowMachinesSection } from "./AppNavigationPanel";
|
||||
|
||||
describe("shouldShowMachinesSection", () => {
|
||||
it("hides the machines section when there is no machine choice", () => {
|
||||
it("hides machine navigation when there is no machine choice", () => {
|
||||
expect(shouldShowMachinesSection([])).toBe(false);
|
||||
expect(shouldShowMachinesSection([machine("local")])).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the machines section when there are multiple machines", () => {
|
||||
it("shows machine navigation when there are multiple machines", () => {
|
||||
expect(shouldShowMachinesSection([machine("local"), machine("remote-a")])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
|
||||
import type { WorkspaceLabelItem } from "../../plugins/types";
|
||||
import type { NavigationSection } from "../../appShell/navigationState";
|
||||
import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState";
|
||||
import type { KeyboardNavigableSection } from "../navigationFocus";
|
||||
import "../MachineList";
|
||||
import "../MachineSwitcher";
|
||||
import "../ProjectList";
|
||||
import "../WorkspaceList";
|
||||
import "../SessionList";
|
||||
|
||||
export type NavigationFocusTarget = NavigationSection | "chat";
|
||||
|
||||
@customElement("app-navigation-panel")
|
||||
export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@@ -27,11 +33,14 @@ export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
||||
@property({ attribute: false }) refreshControl: unknown;
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@property({ type: Boolean, reflect: true }) compact = false;
|
||||
@property({ type: Boolean }) machinesCollapsed = false;
|
||||
@property({ type: Boolean }) projectsCollapsed = false;
|
||||
@property({ type: Boolean }) workspacesCollapsed = false;
|
||||
@property({ type: Boolean }) sessionsCollapsed = false;
|
||||
@property({ type: Boolean }) canStartSession = false;
|
||||
@property({ type: Boolean }) canDeleteArchivedSessions = false;
|
||||
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
||||
@property({ attribute: false }) onShowActions?: () => void;
|
||||
@property({ attribute: false }) onToggleMachines?: () => void;
|
||||
@property({ attribute: false }) onToggleProjects?: () => void;
|
||||
@@ -45,23 +54,56 @@ export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) onSelectSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onArchiveSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onArchiveSessionWithDescendants?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onArchiveSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRestoreSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDeleteCachedNewSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNavigationTarget?: (target: NavigationFocusTarget) => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
|
||||
@query("machine-list") private machineList?: KeyboardNavigableSection;
|
||||
@query("machine-switcher") private machineSwitcher?: KeyboardNavigableSection;
|
||||
@query("project-list") private projectList?: KeyboardNavigableSection;
|
||||
@query("workspace-list") private workspaceList?: KeyboardNavigableSection;
|
||||
@query("session-list") private sessionList?: KeyboardNavigableSection;
|
||||
|
||||
async focusSection(section: NavigationSection): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
switch (section) {
|
||||
case "machines": return await this.focusNavigableSection(this.compact ? this.machineList : this.machineSwitcher);
|
||||
case "projects": return await this.focusNavigableSection(this.projectList);
|
||||
case "workspaces": return await this.focusNavigableSection(this.workspaceList);
|
||||
case "sessions": return await this.focusNavigableSection(this.sessionList);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<header>
|
||||
<strong>PI WEB</strong>
|
||||
${shouldShowMachinesSection(this.machines) ? html`
|
||||
<machine-switcher
|
||||
.machines=${this.machines}
|
||||
.selected=${this.selectedMachine}
|
||||
.statuses=${this.machineStatuses}
|
||||
.activities=${this.machineActivities}
|
||||
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
></machine-switcher>
|
||||
` : null}
|
||||
<div class="header-actions">
|
||||
${this.refreshControl}
|
||||
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.onShowActions?.(); }}>Actions</button>
|
||||
</div>
|
||||
</header>
|
||||
${shouldShowMachinesSection(this.machines) ? html`
|
||||
${this.compact && shouldShowMachinesSection(this.machines) ? html`
|
||||
<machine-list
|
||||
.machines=${this.machines}
|
||||
.selected=${this.selectedMachine}
|
||||
@@ -72,6 +114,8 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
|
||||
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
></machine-list>
|
||||
` : null}
|
||||
<project-list
|
||||
@@ -84,6 +128,9 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
|
||||
.onSelect=${(project: Project) => this.onSelectProject?.(project)}
|
||||
.onClose=${(project: Project) => this.onCloseProject?.(project)}
|
||||
.onFocusPreviousSection=${() => { this.focusPreviousFrom("projects"); }}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("projects"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
></project-list>
|
||||
<workspace-list
|
||||
.workspaces=${this.workspaces}
|
||||
@@ -96,6 +143,9 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onToggleCollapsed=${() => { this.onToggleWorkspaces?.(); }}
|
||||
.onSelect=${(workspace: Workspace) => this.onSelectWorkspace?.(workspace)}
|
||||
.onDelete=${(workspace: Workspace) => this.onDeleteWorkspace?.(workspace)}
|
||||
.onFocusPreviousSection=${() => { this.focusPreviousFrom("workspaces"); }}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("workspaces"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
></workspace-list>
|
||||
<session-list
|
||||
.sessions=${this.sessions}
|
||||
@@ -103,6 +153,8 @@ export class AppNavigationPanel extends LitElement {
|
||||
.activities=${this.sessionActivities}
|
||||
.selected=${this.selectedSession}
|
||||
.canStart=${this.canStartSession}
|
||||
.canDeleteArchived=${this.canDeleteArchivedSessions}
|
||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
|
||||
.collapsible=${this.collapsible}
|
||||
.collapsed=${this.sessionsCollapsed}
|
||||
.onToggleCollapsed=${() => { this.onToggleSessions?.(); }}
|
||||
@@ -111,29 +163,59 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onSelect=${(session: SessionInfo) => this.onSelectSession?.(session)}
|
||||
.onArchive=${(session: SessionInfo) => this.onArchiveSession?.(session)}
|
||||
.onArchiveWithDescendants=${(session: SessionInfo) => this.onArchiveSessionWithDescendants?.(session)}
|
||||
.onArchiveMany=${(sessions: SessionInfo[]) => this.onArchiveSessions?.(sessions)}
|
||||
.onRestore=${(session: SessionInfo) => this.onRestoreSession?.(session)}
|
||||
.onDelete=${(session: SessionInfo) => this.onDeleteCachedNewSession?.(session)}
|
||||
.onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)}
|
||||
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
|
||||
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
|
||||
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("sessions"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
></session-list>
|
||||
`;
|
||||
}
|
||||
|
||||
private async focusNavigableSection(section: KeyboardNavigableSection | undefined): Promise<boolean> {
|
||||
if (section === undefined) return false;
|
||||
return await section.focusSelectedOrFirst();
|
||||
}
|
||||
|
||||
private focusPreviousFrom(section: NavigationSection): void {
|
||||
const target = previousVisibleNavigationTarget(section, this.machines);
|
||||
if (target !== undefined) void this.onFocusNavigationTarget?.(target);
|
||||
}
|
||||
|
||||
private focusNextFrom(section: NavigationSection): void {
|
||||
void this.onFocusNavigationTarget?.(nextVisibleNavigationTarget(section, this.machines));
|
||||
}
|
||||
|
||||
private cancelKeyboardNavigation(): void {
|
||||
void this.onCancelKeyboardNavigation?.();
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
:host([collapsible]) { flex: 1 1 auto; }
|
||||
:host([compact]) { flex: 1 1 auto; }
|
||||
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
|
||||
:host([collapsible]) header { display: none; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
header strong { flex: 0 0 auto; }
|
||||
machine-switcher { flex: 1 1 auto; min-width: 0; }
|
||||
:host([compact]) header { display: none; }
|
||||
.header-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
|
||||
machine-list, project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); }
|
||||
session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||
:host([collapsible]) machine-list,
|
||||
:host([collapsible]) project-list,
|
||||
:host([collapsible]) workspace-list,
|
||||
:host([collapsible]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
|
||||
:host([collapsible]) machine-list[collapsed],
|
||||
:host([collapsible]) project-list[collapsed],
|
||||
:host([collapsible]) workspace-list[collapsed],
|
||||
:host([collapsible]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
|
||||
machine-list[collapsed],
|
||||
project-list[collapsed],
|
||||
workspace-list[collapsed],
|
||||
session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
|
||||
:host([compact]) machine-list,
|
||||
:host([compact]) project-list,
|
||||
:host([compact]) workspace-list,
|
||||
:host([compact]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
|
||||
:host([compact]) machine-list[collapsed],
|
||||
:host([compact]) project-list[collapsed],
|
||||
:host([compact]) workspace-list[collapsed],
|
||||
:host([compact]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
`;
|
||||
}
|
||||
@@ -141,3 +223,17 @@ export class AppNavigationPanel extends LitElement {
|
||||
export function shouldShowMachinesSection(machines: readonly Machine[]): boolean {
|
||||
return machines.length > 1;
|
||||
}
|
||||
|
||||
function previousVisibleNavigationTarget(section: NavigationSection, machines: readonly Machine[]): NavigationSection | undefined {
|
||||
const sections = visibleNavigationSections(machines);
|
||||
return sections[sections.indexOf(section) - 1];
|
||||
}
|
||||
|
||||
function nextVisibleNavigationTarget(section: NavigationSection, machines: readonly Machine[]): NavigationFocusTarget {
|
||||
const sections = visibleNavigationSections(machines);
|
||||
return sections[sections.indexOf(section) + 1] ?? "chat";
|
||||
}
|
||||
|
||||
function visibleNavigationSections(machines: readonly Machine[]): NavigationSection[] {
|
||||
return NAVIGATION_SECTION_ORDER.filter((section) => section !== "machines" || shouldShowMachinesSection(machines));
|
||||
}
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { clampPanelWidth, panelResizeConstraints, panelWidthFromDrag, panelWidthFromKeyboard, type PanelResizeConstraints, type ResizablePanelSide } from "../../appShell/panelResizeController";
|
||||
|
||||
export type PanelEdgeSide = "navigation" | "workspace";
|
||||
export type PanelEdgeSide = ResizablePanelSide;
|
||||
|
||||
interface ActivePanelResize {
|
||||
pointerId: number;
|
||||
startClientX: number;
|
||||
startWidth: number;
|
||||
handle: HTMLElement;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
const RESIZE_KEYS = new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
|
||||
const DOUBLE_TAP_RESET_MS = 420;
|
||||
const TAP_MOVE_TOLERANCE_PX = 4;
|
||||
|
||||
@customElement("app-panel-edge-control")
|
||||
export class AppPanelEdgeControl extends LitElement {
|
||||
@property({ reflect: true }) side: PanelEdgeSide = "navigation";
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ type: Boolean }) resizable = false;
|
||||
@property({ type: Number }) panelWidth?: number;
|
||||
@property({ type: Number }) minWidth?: number;
|
||||
@property({ type: Number }) maxWidth?: number;
|
||||
@property() controls = "";
|
||||
@property() resizeLabel = "Resize panel";
|
||||
@property() expandLabel = "Expand panel";
|
||||
@property() collapseLabel = "Collapse panel";
|
||||
@property({ attribute: false }) onToggle?: () => void;
|
||||
@property({ attribute: false }) onResizeStart?: () => number | undefined;
|
||||
@property({ attribute: false }) onResize?: (width: number) => void;
|
||||
@property({ attribute: false }) onResizeEnd?: () => void;
|
||||
@property({ attribute: false }) onReset?: () => void;
|
||||
|
||||
private activeResize: ActivePanelResize | undefined;
|
||||
private lastTapAt = 0;
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.finishActiveResize();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const label = this.collapsed ? this.expandLabel : this.collapseLabel;
|
||||
return html`
|
||||
${this.renderResizeHandle()}
|
||||
<button
|
||||
type="button"
|
||||
class="edge-button"
|
||||
@@ -27,6 +58,35 @@ export class AppPanelEdgeControl extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderResizeHandle() {
|
||||
if (!this.resizable) return nothing;
|
||||
const constraints = this.resizeConstraints();
|
||||
return html`
|
||||
<div
|
||||
class="resize-handle"
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
aria-label=${this.resizeLabel}
|
||||
title=${`${this.resizeLabel}. Double-click or double-tap to reset.`}
|
||||
aria-controls=${this.controls}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin=${String(constraints.minWidth)}
|
||||
aria-valuemax=${String(constraints.maxWidth)}
|
||||
aria-valuenow=${this.resizeAriaValueNow()}
|
||||
@pointerdown=${(event: PointerEvent) => { this.onResizePointerDown(event); }}
|
||||
@pointermove=${(event: PointerEvent) => { this.onResizePointerMove(event); }}
|
||||
@pointerup=${(event: PointerEvent) => { this.onResizePointerUp(event); }}
|
||||
@pointercancel=${(event: PointerEvent) => { this.onResizePointerCancel(event); }}
|
||||
@dblclick=${(event: MouseEvent) => { this.onResizeDoubleClick(event); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.onResizeKeyDown(event); }}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
private resizeAriaValueNow() {
|
||||
return this.panelWidth === undefined ? nothing : String(Math.round(this.panelWidth));
|
||||
}
|
||||
|
||||
private renderIcon() {
|
||||
const direction = this.iconDirection();
|
||||
const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
|
||||
@@ -38,10 +98,115 @@ export class AppPanelEdgeControl extends LitElement {
|
||||
return this.collapsed ? "left" : "right";
|
||||
}
|
||||
|
||||
private onResizePointerDown(event: PointerEvent): void {
|
||||
if (!this.resizable || event.button !== 0) return;
|
||||
const handle = event.currentTarget;
|
||||
if (!(handle instanceof HTMLElement)) return;
|
||||
const startWidth = this.resizeStartWidth();
|
||||
if (startWidth === undefined) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
this.activeResize = { pointerId: event.pointerId, startClientX: event.clientX, startWidth, handle, moved: false };
|
||||
this.toggleAttribute("resizing", true);
|
||||
}
|
||||
|
||||
private onResizePointerMove(event: PointerEvent): void {
|
||||
const activeResize = this.activeResize;
|
||||
if (activeResize?.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
if (Math.abs(event.clientX - activeResize.startClientX) <= TAP_MOVE_TOLERANCE_PX) return;
|
||||
activeResize.moved = true;
|
||||
this.commitPanelWidth(panelWidthFromDrag(this.side, activeResize.startWidth, activeResize.startClientX, event.clientX, this.resizeConstraints()));
|
||||
}
|
||||
|
||||
private onResizePointerUp(event: PointerEvent): void {
|
||||
const activeResize = this.activeResize;
|
||||
if (activeResize?.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
this.finishActiveResize();
|
||||
if (!activeResize.moved) this.registerTapForReset();
|
||||
}
|
||||
|
||||
private onResizePointerCancel(event: PointerEvent): void {
|
||||
if (this.activeResize?.pointerId !== event.pointerId) return;
|
||||
this.finishActiveResize();
|
||||
}
|
||||
|
||||
private onResizeDoubleClick(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.resetPanelSize();
|
||||
}
|
||||
|
||||
private onResizeKeyDown(event: KeyboardEvent): void {
|
||||
if (!this.resizable || !RESIZE_KEYS.has(event.key)) return;
|
||||
const currentWidth = this.resizeStartWidth();
|
||||
if (currentWidth === undefined) return;
|
||||
const nextWidth = panelWidthFromKeyboard(this.side, currentWidth, event.key, { largeStep: event.shiftKey, constraints: this.resizeConstraints() });
|
||||
if (nextWidth === undefined) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.commitPanelWidth(nextWidth);
|
||||
this.onResizeEnd?.();
|
||||
}
|
||||
|
||||
private registerTapForReset(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastTapAt <= DOUBLE_TAP_RESET_MS) {
|
||||
this.lastTapAt = 0;
|
||||
this.resetPanelSize();
|
||||
return;
|
||||
}
|
||||
this.lastTapAt = now;
|
||||
}
|
||||
|
||||
private resetPanelSize(): void {
|
||||
this.finishActiveResize();
|
||||
this.onReset?.();
|
||||
}
|
||||
|
||||
private resizeStartWidth(): number | undefined {
|
||||
const width = this.onResizeStart?.() ?? this.panelWidth;
|
||||
if (width === undefined) return undefined;
|
||||
return clampPanelWidth(this.side, width, this.resizeConstraints());
|
||||
}
|
||||
|
||||
private commitPanelWidth(width: number): void {
|
||||
this.onResize?.(clampPanelWidth(this.side, width, this.resizeConstraints()));
|
||||
}
|
||||
|
||||
private finishActiveResize(): void {
|
||||
const activeResize = this.activeResize;
|
||||
if (activeResize === undefined) return;
|
||||
try {
|
||||
activeResize.handle.releasePointerCapture(activeResize.pointerId);
|
||||
} catch {
|
||||
// Pointer capture may already be gone if the browser canceled the drag.
|
||||
}
|
||||
this.activeResize = undefined;
|
||||
this.toggleAttribute("resizing", false);
|
||||
this.onResizeEnd?.();
|
||||
}
|
||||
|
||||
private resizeConstraints(): PanelResizeConstraints {
|
||||
const defaults = panelResizeConstraints(this.side);
|
||||
return {
|
||||
...defaults,
|
||||
minWidth: this.minWidth ?? defaults.minWidth,
|
||||
maxWidth: this.maxWidth ?? defaults.maxWidth,
|
||||
};
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
|
||||
:host { position: relative; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
|
||||
:host([side="navigation"]) { grid-column: 2; }
|
||||
:host([side="workspace"]) { grid-column: 4; }
|
||||
.resize-handle { position: absolute; inset: 0 -6px; z-index: 0; cursor: col-resize; touch-action: none; outline: none; }
|
||||
.resize-handle::after { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; transform: translateX(-50%); background: transparent; transition: width .12s ease, background .12s ease, opacity .12s ease; }
|
||||
.resize-handle:hover::after, .resize-handle:focus-visible::after, :host([resizing]) .resize-handle::after { width: 3px; background: var(--pi-accent); opacity: .72; }
|
||||
.edge-button { position: relative; z-index: 1; box-sizing: border-box; display: grid; place-items: center; width: 18px; height: 48px; padding: 0; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-bg); color: var(--pi-muted); opacity: .75; cursor: pointer; }
|
||||
.edge-button:hover, .edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; }
|
||||
:host([side="navigation"][collapsed]) .edge-button { transform: translateX(calc(50% - .5px)); }
|
||||
|
||||
@@ -1,51 +1,18 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { actionMenuPanelStyle } from "../actionMenu";
|
||||
|
||||
const REFRESH_LONG_PRESS_MS = 550;
|
||||
const REFRESH_MENU_PORTAL_STYLE_ID = "pi-web-app-refresh-menu-portal-style";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("app-refresh-control")
|
||||
export class AppRefreshControl extends LitElement {
|
||||
@property({ type: Boolean }) isRefreshing = false;
|
||||
@property({ attribute: false }) onRefresh?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onReload?: () => void;
|
||||
@state() private menuOpen = false;
|
||||
private menuStyle = "";
|
||||
private menuPortal: HTMLDivElement | undefined;
|
||||
private longPressTimer: number | undefined;
|
||||
private suppressNextClick = false;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("click", this.onDocumentClick);
|
||||
document.addEventListener("keydown", this.onDocumentKeyDown);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
document.removeEventListener("click", this.onDocumentClick);
|
||||
document.removeEventListener("keydown", this.onDocumentKeyDown);
|
||||
this.clearLongPressTimer();
|
||||
this.removePortalMenu();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const label = this.isRefreshing ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options.";
|
||||
const label = "Full page reload";
|
||||
return html`
|
||||
<button
|
||||
class=${`app-refresh-button${this.isRefreshing ? " refreshing" : ""}`}
|
||||
class="app-refresh-button"
|
||||
title=${label}
|
||||
aria-label=${label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded=${String(this.menuOpen)}
|
||||
aria-busy=${String(this.isRefreshing)}
|
||||
@click=${this.onRefreshClick}
|
||||
@contextmenu=${this.onRefreshContextMenu}
|
||||
@pointerdown=${this.onRefreshPointerDown}
|
||||
@pointerup=${() => { this.clearLongPressTimer(); }}
|
||||
@pointercancel=${() => { this.clearLongPressTimer(); }}
|
||||
@pointerleave=${() => { this.clearLongPressTimer(); }}
|
||||
@click=${this.onReloadClick}
|
||||
>${this.renderRefreshIcon()}</button>
|
||||
`;
|
||||
}
|
||||
@@ -61,114 +28,9 @@ export class AppRefreshControl extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private readonly onRefreshClick = (event: MouseEvent): void => {
|
||||
private readonly onReloadClick = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
if (this.suppressNextClick) {
|
||||
this.suppressNextClick = false;
|
||||
return;
|
||||
}
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
private readonly onRefreshPointerDown = (event: PointerEvent): void => {
|
||||
if (!event.isPrimary || event.button !== 0) return;
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
this.clearLongPressTimer();
|
||||
this.suppressNextClick = false;
|
||||
this.longPressTimer = window.setTimeout(() => {
|
||||
this.longPressTimer = undefined;
|
||||
this.suppressNextClick = true;
|
||||
this.openMenu(target);
|
||||
}, REFRESH_LONG_PRESS_MS);
|
||||
};
|
||||
|
||||
private readonly onRefreshContextMenu = (event: MouseEvent): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.clearLongPressTimer();
|
||||
this.suppressNextClick = true;
|
||||
this.openMenu(event.currentTarget);
|
||||
};
|
||||
|
||||
private readonly onDocumentClick = (event: MouseEvent): void => {
|
||||
const path = event.composedPath();
|
||||
if (path.includes(this) || (this.menuPortal !== undefined && path.includes(this.menuPortal))) return;
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
private readonly onDocumentKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== "Escape" || !this.menuOpen) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
private openMenu(target: EventTarget | null): void {
|
||||
this.menuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" });
|
||||
this.menuOpen = true;
|
||||
this.renderPortalMenu();
|
||||
}
|
||||
|
||||
private closeMenu(): void {
|
||||
this.menuOpen = false;
|
||||
this.suppressNextClick = false;
|
||||
this.removePortalMenu();
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
this.closeMenu();
|
||||
void this.onRefresh?.();
|
||||
}
|
||||
|
||||
private reload(): void {
|
||||
this.closeMenu();
|
||||
this.onReload?.();
|
||||
}
|
||||
|
||||
private clearLongPressTimer(): void {
|
||||
if (this.longPressTimer === undefined) return;
|
||||
window.clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = undefined;
|
||||
}
|
||||
|
||||
private renderPortalMenu(): void {
|
||||
const ownerDocument = this.ownerDocument;
|
||||
ensurePortalMenuStyles(ownerDocument);
|
||||
|
||||
const menu = this.menuPortal ?? ownerDocument.createElement("div");
|
||||
this.menuPortal = menu;
|
||||
menu.className = "pi-web-app-refresh-menu-portal";
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("style", this.menuStyle);
|
||||
menu.replaceChildren(
|
||||
this.createPortalMenuButton("Refresh app data", () => { this.refresh(); }),
|
||||
this.createPortalMenuButton("Full page reload", () => { this.reload(); }),
|
||||
);
|
||||
menu.addEventListener("click", this.onPortalMenuClick);
|
||||
if (!menu.isConnected) ownerDocument.body.append(menu);
|
||||
}
|
||||
|
||||
private createPortalMenuButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = this.ownerDocument.createElement("button");
|
||||
button.type = "button";
|
||||
button.setAttribute("role", "menuitem");
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
private removePortalMenu(): void {
|
||||
this.menuPortal?.removeEventListener("click", this.onPortalMenuClick);
|
||||
this.menuPortal?.remove();
|
||||
this.menuPortal = undefined;
|
||||
}
|
||||
|
||||
private readonly onPortalMenuClick = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
static override styles = css`
|
||||
@@ -176,52 +38,5 @@ export class AppRefreshControl extends LitElement {
|
||||
:host, :host * { -webkit-user-select: none; user-select: none; }
|
||||
.app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; }
|
||||
.app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
.app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; }
|
||||
@keyframes app-refresh-spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
}
|
||||
|
||||
function ensurePortalMenuStyles(ownerDocument: Document): void {
|
||||
if (ownerDocument.getElementById(REFRESH_MENU_PORTAL_STYLE_ID) !== null) return;
|
||||
const style = ownerDocument.createElement("style");
|
||||
style.id = REFRESH_MENU_PORTAL_STYLE_ID;
|
||||
style.textContent = `
|
||||
.pi-web-app-refresh-menu-portal {
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
box-sizing: border-box;
|
||||
min-width: min(170px, calc(100vw - 16px));
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--pi-border);
|
||||
border-radius: 8px;
|
||||
background: var(--pi-surface);
|
||||
color: var(--pi-text);
|
||||
box-shadow: 0 8px 24px var(--pi-shadow);
|
||||
overflow-wrap: anywhere;
|
||||
font: 14px system-ui, sans-serif;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.pi-web-app-refresh-menu-portal button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--pi-text);
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pi-web-app-refresh-menu-portal button:hover,
|
||||
.pi-web-app-refresh-menu-portal button:focus {
|
||||
background: var(--pi-selection-bg);
|
||||
}
|
||||
`;
|
||||
ownerDocument.head.append(style);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface KeyboardNavigableSection {
|
||||
focusSelectedOrFirst(): boolean | Promise<boolean>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
|
||||
describe("selectable row activation", () => {
|
||||
it("activates rows from non-interactive click targets", () => {
|
||||
@@ -38,10 +38,30 @@ describe("selectable row activation", () => {
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes row keyboard navigation to adjacent section callbacks", () => {
|
||||
const nextSection = vi.fn();
|
||||
const event = keyboardEventWithPath("ArrowRight", matchTarget(() => false));
|
||||
|
||||
expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), nextSection })).toBe(true);
|
||||
|
||||
expect(nextSection).toHaveBeenCalledOnce();
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(event.stopPropagation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("routes Escape row keyboard navigation to cancel", () => {
|
||||
const cancel = vi.fn();
|
||||
const event = keyboardEventWithPath("Escape", matchTarget(() => false));
|
||||
|
||||
expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), cancel })).toBe(true);
|
||||
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
type EventWithPath = Pick<Event, "composedPath">;
|
||||
type KeyboardEventWithPath = EventWithPath & Pick<KeyboardEvent, "key" | "preventDefault">;
|
||||
type KeyboardEventWithPath = EventWithPath & Pick<KeyboardEvent, "key" | "preventDefault" | "stopPropagation">;
|
||||
type MatchTarget = EventTarget & Pick<Element, "matches">;
|
||||
|
||||
function matchTarget(matches: Element["matches"]): MatchTarget {
|
||||
@@ -53,5 +73,5 @@ function eventWithPath(target: MatchTarget): EventWithPath {
|
||||
}
|
||||
|
||||
function keyboardEventWithPath(key: string, target: MatchTarget): KeyboardEventWithPath {
|
||||
return { key, preventDefault: vi.fn<() => void>(), composedPath: () => [target] };
|
||||
return { key, preventDefault: vi.fn<() => void>(), stopPropagation: vi.fn<() => void>(), composedPath: () => [target] };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ const interactiveSelector = [
|
||||
|
||||
type ComposedPathEvent = Pick<Event, "composedPath">;
|
||||
type SelectableKeyboardEvent = ComposedPathEvent & Pick<KeyboardEvent, "key" | "preventDefault">;
|
||||
type SelectableNavigationKeyboardEvent = SelectableKeyboardEvent & Partial<Pick<KeyboardEvent, "currentTarget" | "stopPropagation">>;
|
||||
|
||||
export interface SelectableRowKeyboardOptions {
|
||||
activate: () => void;
|
||||
previousSection?: (() => void) | undefined;
|
||||
nextSection?: (() => void) | undefined;
|
||||
cancel?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export function isFromInteractiveElement(event: ComposedPathEvent): boolean {
|
||||
return event.composedPath().some((target) => targetMatches(target, interactiveSelector));
|
||||
@@ -35,3 +43,74 @@ export function activateSelectableRowFromKeyboard(event: SelectableKeyboardEvent
|
||||
event.preventDefault();
|
||||
action();
|
||||
}
|
||||
|
||||
export function handleSelectableRowKeyboard(event: SelectableNavigationKeyboardEvent, options: SelectableRowKeyboardOptions): boolean {
|
||||
if (isFromInteractiveElement(event)) return false;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
options.activate();
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowUp") return handleRowFocusKey(event, () => { focusRelativeSelectableRow(event.currentTarget, -1); });
|
||||
if (event.key === "ArrowDown") return handleRowFocusKey(event, () => { focusRelativeSelectableRow(event.currentTarget, 1); });
|
||||
if (event.key === "Home") return handleRowFocusKey(event, () => { focusIndexedSelectableRow(event.currentTarget, 0); });
|
||||
if (event.key === "End") return handleRowFocusKey(event, () => { focusIndexedSelectableRow(event.currentTarget, -1); });
|
||||
if (event.key === "ArrowLeft" && options.previousSection !== undefined) return handleRowFocusKey(event, options.previousSection);
|
||||
if (event.key === "ArrowRight" && options.nextSection !== undefined) return handleRowFocusKey(event, options.nextSection);
|
||||
if (event.key === "Escape" && options.cancel !== undefined) return handleRowFocusKey(event, options.cancel);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function focusSelectedOrFirstSelectableRow(root: ParentNode, options: { fallbackSelector?: string | undefined } = {}): boolean {
|
||||
const target = root.querySelector<HTMLElement>(".action-row.selected")
|
||||
?? root.querySelector<HTMLElement>(".action-row")
|
||||
?? (options.fallbackSelector === undefined ? undefined : root.querySelector<HTMLElement>(options.fallbackSelector));
|
||||
if (target === undefined || target === null) return false;
|
||||
target.focus();
|
||||
target.scrollIntoView({ block: "nearest" });
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleRowFocusKey(event: SelectableNavigationKeyboardEvent, action: () => void): true {
|
||||
event.preventDefault();
|
||||
event.stopPropagation?.();
|
||||
action();
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusRelativeSelectableRow(target: EventTarget | null | undefined, delta: number): void {
|
||||
const rows = selectableRowsForTarget(target);
|
||||
const current = currentSelectableRow(target);
|
||||
if (current === undefined || rows.length === 0) return;
|
||||
const index = rows.indexOf(current);
|
||||
if (index < 0) return;
|
||||
focusSelectableRowAt(rows, index + delta);
|
||||
}
|
||||
|
||||
function focusIndexedSelectableRow(target: EventTarget | null | undefined, index: number): void {
|
||||
const rows = selectableRowsForTarget(target);
|
||||
if (rows.length === 0) return;
|
||||
focusSelectableRowAt(rows, index < 0 ? rows.length - 1 : index);
|
||||
}
|
||||
|
||||
function focusSelectableRowAt(rows: HTMLElement[], index: number): void {
|
||||
const target = rows[Math.min(Math.max(index, 0), rows.length - 1)];
|
||||
target?.focus();
|
||||
target?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function selectableRowsForTarget(target: EventTarget | null | undefined): HTMLElement[] {
|
||||
const root = currentSelectableRow(target)?.getRootNode();
|
||||
if (root === undefined || !isSelectableRowRoot(root)) return [];
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(".action-row"));
|
||||
}
|
||||
|
||||
function isSelectableRowRoot(root: Node): root is Document | DocumentFragment {
|
||||
return (typeof Document !== "undefined" && root instanceof Document)
|
||||
|| (typeof DocumentFragment !== "undefined" && root instanceof DocumentFragment);
|
||||
}
|
||||
|
||||
function currentSelectableRow(target: EventTarget | null | undefined): HTMLElement | undefined {
|
||||
if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return undefined;
|
||||
return target.closest<HTMLElement>(".action-row") ?? undefined;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export class SettingsPluginsPanel extends LitElement {
|
||||
<article class=${`plugin-card${plugin.enabled ? "" : " disabled"}`}>
|
||||
<div class="plugin-main">
|
||||
<strong>${plugin.id}</strong>
|
||||
<small>${plugin.source} · ${plugin.scope}</small>
|
||||
<small>${plugin.source} · ${plugin.scope}${plugin.machineSpecific ? " · machine-specific" : ""}</small>
|
||||
<small>${configuredState}</small>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
|
||||
@@ -1,55 +1,283 @@
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { AppAction } from "../../actions";
|
||||
import type { PiWebConfigResponse, PiWebShortcutConfig } from "../../api";
|
||||
import { formatShortcut } from "../../keyboardShortcuts";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api";
|
||||
import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts";
|
||||
|
||||
const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
||||
|
||||
@customElement("settings-shortcuts-panel")
|
||||
export class SettingsShortcutsPanel extends LitElement {
|
||||
@property({ attribute: false }) actions: AppAction[] = [];
|
||||
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
|
||||
@property({ type: Boolean }) loading = false;
|
||||
@property({ type: Boolean }) saving = false;
|
||||
@property() error = "";
|
||||
@property() savedMessage = "";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
@state() private drafts: Record<string, string> = {};
|
||||
@state() private localError = "";
|
||||
@state() private recording: RecordingState | undefined;
|
||||
private recordingTimer: number | undefined;
|
||||
private recordingListenerActive = false;
|
||||
|
||||
private readonly onRecordKeyDown = (event: KeyboardEvent): void => {
|
||||
const recording = this.recording;
|
||||
if (recording === undefined) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.stopRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = shortcutTokenFromEvent(event);
|
||||
if (token === undefined) {
|
||||
this.localError = "Press a letter, number, punctuation, function, or navigation key. Press Esc to cancel recording.";
|
||||
return;
|
||||
}
|
||||
if (recording.tokens.length === 0 && !isShortcutSequenceStarter(token)) {
|
||||
this.localError = "Start shortcuts with Ctrl/⌘ or Alt so normal typing is not captured.";
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = [...recording.tokens, token];
|
||||
this.localError = "";
|
||||
this.drafts = { [recording.actionId]: tokens.join(" ") };
|
||||
this.recording = { actionId: recording.actionId, tokens };
|
||||
this.armRecordingTimer();
|
||||
};
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>): void {
|
||||
if (changed.has("configResponse") && this.configResponse !== undefined) {
|
||||
this.drafts = {};
|
||||
this.localError = "";
|
||||
this.stopRecording();
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.stopRecording();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
const groups = shortcutGroups(this.actions);
|
||||
const shortcutResolutions = this.shortcutResolutions();
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<p>Review registered app actions and the shortcut config that will become editable here. Manual config entries use action ids and can override a default shortcut or set it to <code>null</code> to disable it.</p>
|
||||
<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>
|
||||
<div class="shortcut-note">Config key: <code>shortcuts</code>. Example: <code>{ "core:view.chat": "mod+1", "core:session.stop": null }</code></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))}
|
||||
</div>
|
||||
</section>
|
||||
`)}
|
||||
${this.renderMessages()}
|
||||
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${this.configResponse?.path ?? "Unknown"}</code>
|
||||
<small>Shortcut overrides are saved under <code>shortcuts</code>. A value of <code>null</code> disables the action shortcut.</small>
|
||||
</div>
|
||||
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
|
||||
<section class="shortcut-group">
|
||||
<h3>${group.name}</h3>
|
||||
<div class="shortcut-list">
|
||||
${group.actions.map((action) => this.renderShortcutRow(action, shortcutResolutions.get(action.id)))}
|
||||
</div>
|
||||
</section>
|
||||
`)}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderShortcutRow(action: AppAction): TemplateResult {
|
||||
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 renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult {
|
||||
const shortcuts = this.configResponse?.config.shortcuts;
|
||||
const configured = shortcutPreference(action.id, shortcuts);
|
||||
const shortcut = configured === null ? undefined : configured ?? action.shortcut;
|
||||
const state = shortcutState(action, shortcuts);
|
||||
const inputText = this.shortcutInputText(action);
|
||||
const parsedInput = inputText.trim() === "" ? undefined : parseShortcutInput(inputText);
|
||||
const previewShortcut = parsedInput?.ok === true ? parsedInput.shortcut : effectiveShortcut(action, shortcuts);
|
||||
const hasConfiguredShortcut = configured !== undefined;
|
||||
const hasDraft = this.drafts[action.id] !== undefined;
|
||||
const displayState = hasDraft && inputText.trim() !== "" ? "custom" : state;
|
||||
const recordingHint = this.recordingHint(action.id);
|
||||
const conflictLabel = shortcutConflictLabel(resolution);
|
||||
return html`
|
||||
<div class="shortcut-row">
|
||||
<article class=${shortcutRowClass(resolution)}>
|
||||
<div class="shortcut-main">
|
||||
<strong>${action.title}</strong>
|
||||
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
|
||||
<small class="shortcut-id">${action.id}</small>
|
||||
<small>${action.shortcut !== undefined && action.shortcut !== "" ? html`Default: <kbd>${formatShortcut(action.shortcut)}</kbd>` : "No default shortcut"}</small>
|
||||
</div>
|
||||
<div class="shortcut-value">
|
||||
${shortcut !== undefined && shortcut !== "" ? html`<kbd>${formatShortcut(shortcut)}</kbd>` : html`<span class="unassigned">${state === "disabled" ? "Disabled" : "Unassigned"}</span>`}
|
||||
<small class=${state}>${shortcutStateLabel(state)}</small>
|
||||
<div class="shortcut-editor">
|
||||
<div class="shortcut-status">
|
||||
${previewShortcut !== undefined && previewShortcut !== "" ? html`<kbd>${formatShortcut(previewShortcut)}</kbd>` : html`<span class="unassigned">${state === "disabled" ? "Disabled" : "Unassigned"}</span>`}
|
||||
<small class=${displayState}>${shortcutStateLabel(displayState)}${hasDraft ? " · Unsaved" : ""}</small>
|
||||
${conflictLabel === undefined ? null : html`<small class=${shortcutConflictClass(resolution)}>${conflictLabel}</small>`}
|
||||
</div>
|
||||
<label class="shortcut-input-label">
|
||||
<span>Shortcut</span>
|
||||
<input
|
||||
class="shortcut-input"
|
||||
data-action-id=${action.id}
|
||||
.value=${inputText}
|
||||
placeholder=${action.shortcut ?? "mod+k"}
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
?disabled=${this.saving}
|
||||
@input=${(event: Event) => { this.updateDraft(action.id, inputValue(event)); }}
|
||||
>
|
||||
</label>
|
||||
${recordingHint !== "" ? html`<small class="recording-hint">${recordingHint}</small>` : null}
|
||||
<div class="shortcut-actions">
|
||||
<button class="primary" ?disabled=${this.loading || this.saving || !hasDraft || inputText.trim() === ""} @click=${() => { void this.saveShortcut(action); }}>Save</button>
|
||||
<button ?disabled=${this.loading || this.saving} @click=${() => { void this.toggleRecording(action.id); }}>${this.recording?.actionId === action.id ? "Cancel recording" : "Record"}</button>
|
||||
<button ?disabled=${this.loading || this.saving || configured === null} @click=${() => { void this.setShortcutNone(action.id); }}>None</button>
|
||||
<button ?disabled=${this.loading || this.saving || !hasConfiguredShortcut} @click=${() => { void this.resetShortcut(action.id); }}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
private shortcutInputText(action: AppAction): string {
|
||||
const draft = this.drafts[action.id];
|
||||
if (draft !== undefined) return draft;
|
||||
const configured = shortcutPreference(action.id, this.configResponse?.config.shortcuts);
|
||||
if (configured === null) return "";
|
||||
return configured ?? action.shortcut ?? "";
|
||||
}
|
||||
|
||||
private recordingHint(actionId: string): string {
|
||||
const recording = this.recording;
|
||||
if (recording?.actionId !== actionId) return "";
|
||||
if (recording.tokens.length === 0) return "Recording: press Ctrl/⌘ or Alt with a key. Press Esc to cancel.";
|
||||
return `Recording: ${formatShortcut(recording.tokens.join(" "))}. Press another key to add a sequence, or wait to finish.`;
|
||||
}
|
||||
|
||||
private updateDraft(actionId: string, value: string): void {
|
||||
this.drafts = { [actionId]: value };
|
||||
this.localError = "";
|
||||
}
|
||||
|
||||
private async saveShortcut(action: AppAction): Promise<void> {
|
||||
this.stopRecording();
|
||||
const input = this.shortcutInputText(action).trim();
|
||||
const parsed = parseShortcutInput(input);
|
||||
if (!parsed.ok) {
|
||||
this.localError = parsed.message;
|
||||
return;
|
||||
}
|
||||
this.localError = "";
|
||||
await this.saveShortcutPreference(action.id, parsed.shortcut);
|
||||
}
|
||||
|
||||
private async setShortcutNone(actionId: string): Promise<void> {
|
||||
this.stopRecording();
|
||||
this.localError = "";
|
||||
await this.saveShortcutPreference(actionId, null);
|
||||
}
|
||||
|
||||
private async resetShortcut(actionId: string): Promise<void> {
|
||||
this.stopRecording();
|
||||
this.localError = "";
|
||||
await this.saveShortcutPreference(actionId, undefined);
|
||||
}
|
||||
|
||||
private async saveShortcutPreference(actionId: string, shortcut: string | null | undefined): Promise<void> {
|
||||
const config: PiWebConfigValues = { ...(this.configResponse?.config ?? {}) };
|
||||
const currentShortcuts = config.shortcuts ?? {};
|
||||
const shortcuts = shortcut === undefined ? withoutShortcutPreference(currentShortcuts, actionId) : { ...currentShortcuts, [actionId]: shortcut };
|
||||
if (Object.keys(shortcuts).length === 0) {
|
||||
delete config.shortcuts;
|
||||
} else {
|
||||
config.shortcuts = shortcuts;
|
||||
}
|
||||
await this.onSave?.(config);
|
||||
}
|
||||
|
||||
private shortcutResolutions(): Map<string, ShortcutBindingResolution> {
|
||||
return new Map(resolveShortcutBindings(this.actions, this.previewShortcutConfig(), { enabledOnly: true }).map((resolution) => [resolution.action.id, resolution]));
|
||||
}
|
||||
|
||||
private previewShortcutConfig(): PiWebShortcutConfig | undefined {
|
||||
const shortcuts = { ...(this.configResponse?.config.shortcuts ?? {}) };
|
||||
for (const [actionId, draft] of Object.entries(this.drafts)) {
|
||||
const trimmedDraft = draft.trim();
|
||||
if (trimmedDraft === "") continue;
|
||||
const parsed = parseShortcutInput(trimmedDraft);
|
||||
if (parsed.ok) shortcuts[actionId] = parsed.shortcut;
|
||||
}
|
||||
return Object.keys(shortcuts).length === 0 ? undefined : shortcuts;
|
||||
}
|
||||
|
||||
private async toggleRecording(actionId: string): Promise<void> {
|
||||
if (this.recording?.actionId === actionId) {
|
||||
this.stopRecording();
|
||||
return;
|
||||
}
|
||||
this.stopRecording();
|
||||
this.localError = "";
|
||||
this.recording = { actionId, tokens: [] };
|
||||
this.ensureRecordingListener();
|
||||
await this.updateComplete;
|
||||
this.focusShortcutInput(actionId);
|
||||
}
|
||||
|
||||
private focusShortcutInput(actionId: string): void {
|
||||
for (const input of this.renderRoot.querySelectorAll<HTMLInputElement>(".shortcut-input")) {
|
||||
if (input.dataset["actionId"] === actionId) {
|
||||
input.focus();
|
||||
input.select();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private armRecordingTimer(): void {
|
||||
this.clearRecordingTimer();
|
||||
this.recordingTimer = window.setTimeout(() => {
|
||||
this.recordingTimer = undefined;
|
||||
this.stopRecording();
|
||||
}, shortcutSequenceTimeoutMs);
|
||||
}
|
||||
|
||||
private stopRecording(): void {
|
||||
this.clearRecordingTimer();
|
||||
this.removeRecordingListener();
|
||||
this.recording = undefined;
|
||||
}
|
||||
|
||||
private clearRecordingTimer(): void {
|
||||
if (this.recordingTimer === undefined) return;
|
||||
window.clearTimeout(this.recordingTimer);
|
||||
this.recordingTimer = undefined;
|
||||
}
|
||||
|
||||
private ensureRecordingListener(): void {
|
||||
if (this.recordingListenerActive) return;
|
||||
window.addEventListener("keydown", this.onRecordKeyDown, RECORD_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.recordingListenerActive = true;
|
||||
}
|
||||
|
||||
private removeRecordingListener(): void {
|
||||
if (!this.recordingListenerActive) return;
|
||||
window.removeEventListener("keydown", this.onRecordKeyDown, RECORD_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.recordingListenerActive = false;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { display: block; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
@@ -58,37 +286,83 @@ export class SettingsShortcutsPanel extends LitElement {
|
||||
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; }
|
||||
button, input { font: inherit; }
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
button:disabled, input:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
|
||||
.secondary { flex: 0 0 auto; }
|
||||
.message, .loading-card, .config-path-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.message { margin-bottom: 12px; }
|
||||
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
|
||||
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
|
||||
.loading-card, .config-path-card { color: var(--pi-muted); }
|
||||
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
|
||||
.config-path-card span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||
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; }
|
||||
.loading-card, .shortcut-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.loading-card, .shortcut-note { color: var(--pi-muted); }
|
||||
.shortcut-note { margin-bottom: 14px; }
|
||||
.shortcut-group { margin: 0 0 16px; }
|
||||
.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) auto; gap: 10px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); }
|
||||
.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); }
|
||||
.shortcut-row.shadowed { background: color-mix(in srgb, var(--pi-warning) 5%, var(--pi-surface)); }
|
||||
.shortcut-row.shadowing { background: color-mix(in srgb, var(--pi-accent) 5%, var(--pi-surface)); }
|
||||
.shortcut-row:last-child { border-bottom: 0; }
|
||||
.shortcut-main { min-width: 0; display: grid; gap: 3px; }
|
||||
.shortcut-main { min-width: 0; display: grid; gap: 4px; }
|
||||
.shortcut-main strong, .shortcut-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.shortcut-main small { color: var(--pi-muted); }
|
||||
.shortcut-id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.shortcut-value { justify-self: end; display: grid; justify-items: end; gap: 3px; }
|
||||
kbd { justify-self: end; 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; }
|
||||
.unassigned { justify-self: end; color: var(--pi-muted); font-size: 12px; }
|
||||
.shortcut-value small { color: var(--pi-muted); font-size: 11px; }
|
||||
.shortcut-value small.custom { color: var(--pi-accent); }
|
||||
.shortcut-value small.disabled { color: var(--pi-warning); }
|
||||
.shortcut-editor { min-width: 0; display: grid; gap: 8px; }
|
||||
.shortcut-status { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.shortcut-status small { color: var(--pi-muted); font-size: 11px; }
|
||||
.shortcut-status small.custom { color: var(--pi-accent); }
|
||||
.shortcut-status small.disabled { color: var(--pi-warning); }
|
||||
.shortcut-status small.conflict { border: 1px solid currentColor; border-radius: 999px; padding: 2px 7px; }
|
||||
.shortcut-status small.conflict.shadowing { color: var(--pi-accent); }
|
||||
.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: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; }
|
||||
.unassigned { color: var(--pi-muted); font-size: 12px; }
|
||||
.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; }
|
||||
.shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; }
|
||||
.shortcut-value { justify-self: start; justify-items: start; }
|
||||
kbd, .unassigned { justify-self: start; }
|
||||
.shortcut-status, .shortcut-actions { justify-content: flex-start; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
interface RecordingState {
|
||||
actionId: string;
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
type ShortcutState = "default" | "custom" | "disabled" | "unassigned";
|
||||
|
||||
function shortcutRowClass(resolution: ShortcutBindingResolution | undefined): string {
|
||||
if (resolution?.active === false) return "shortcut-row shadowed";
|
||||
if (resolution?.active === true && resolution.shadows.length > 0) return "shortcut-row shadowing";
|
||||
return "shortcut-row";
|
||||
}
|
||||
|
||||
function shortcutConflictClass(resolution: ShortcutBindingResolution | undefined): string {
|
||||
return resolution?.active === false ? "conflict shadowed" : "conflict shadowing";
|
||||
}
|
||||
|
||||
function shortcutConflictLabel(resolution: ShortcutBindingResolution | undefined): string | undefined {
|
||||
if (resolution === undefined) return undefined;
|
||||
if (!resolution.active) return `Shadowed by ${resolution.shadowedBy?.action.title ?? "another action"}`;
|
||||
const shadowedCount = resolution.shadows.length;
|
||||
if (shadowedCount === 0) return undefined;
|
||||
const shadowedNames = resolution.shadows.slice(0, 2).map((binding) => binding.action.title).join(", ");
|
||||
const suffix = shadowedCount > 2 ? `, +${String(shadowedCount - 2)} more` : "";
|
||||
return `Shadows ${String(shadowedCount)} ${shadowedCount === 1 ? "action" : "actions"}: ${shadowedNames}${suffix}`;
|
||||
}
|
||||
|
||||
function shortcutGroups(actions: AppAction[]): { name: string; actions: AppAction[] }[] {
|
||||
const grouped = new Map<string, AppAction[]>();
|
||||
for (const action of [...actions].sort(compareActions)) {
|
||||
@@ -107,6 +381,16 @@ function shortcutPreference(actionId: string, shortcuts: PiWebShortcutConfig | u
|
||||
return shortcuts[actionId];
|
||||
}
|
||||
|
||||
function withoutShortcutPreference(shortcuts: PiWebShortcutConfig, actionId: string): PiWebShortcutConfig {
|
||||
return Object.fromEntries(Object.entries(shortcuts).filter(([shortcutActionId]) => shortcutActionId !== actionId));
|
||||
}
|
||||
|
||||
function effectiveShortcut(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): string | undefined {
|
||||
const configured = shortcutPreference(action.id, shortcuts);
|
||||
if (configured === null) return undefined;
|
||||
return configured ?? action.shortcut;
|
||||
}
|
||||
|
||||
function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): ShortcutState {
|
||||
const configured = shortcutPreference(action.id, shortcuts);
|
||||
if (configured === null) return "disabled";
|
||||
@@ -117,8 +401,12 @@ function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undef
|
||||
function shortcutStateLabel(state: ShortcutState): string {
|
||||
switch (state) {
|
||||
case "default": return "Default";
|
||||
case "custom": return "Config override";
|
||||
case "disabled": return "Config disabled";
|
||||
case "custom": return "Custom";
|
||||
case "disabled": return "Disabled";
|
||||
case "unassigned": return "No default";
|
||||
}
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
return event.target instanceof HTMLInputElement ? event.target.value : "";
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export const appStyles = css`
|
||||
@media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) {
|
||||
:host { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); }
|
||||
}
|
||||
.shell { --navigation-panel-width: 340px; --workspace-panel-width: minmax(360px, 42vw); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(420px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; }
|
||||
.shell { --navigation-panel-size: 340px; --workspace-panel-size: minmax(360px, 42vw); --navigation-panel-width: var(--navigation-panel-size); --workspace-panel-width: var(--workspace-panel-size); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(320px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; }
|
||||
aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
aside app-navigation-panel { flex: 1 1 auto; min-height: 0; }
|
||||
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
|
||||
@@ -75,14 +75,6 @@ export const appStyles = css`
|
||||
.context-item { flex: 0 0 auto; min-width: 0; display: flex; }
|
||||
.context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; }
|
||||
.context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; }
|
||||
.app-refresh { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
|
||||
.app-refresh, .app-refresh * { -webkit-user-select: none; user-select: none; }
|
||||
.app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 999px; padding: 0; line-height: 1; touch-action: manipulation; -webkit-touch-callout: none; }
|
||||
.app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
.app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; }
|
||||
.app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; }
|
||||
.app-refresh-menu button { display: block; width: 100%; border: 0; background: transparent; color: var(--pi-text); text-align: left; white-space: normal; overflow-wrap: anywhere; }
|
||||
.app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); }
|
||||
.context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; }
|
||||
.context-chip:hover { background: var(--pi-surface-hover); }
|
||||
.context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
|
||||
@@ -155,7 +147,6 @@ export const appStyles = css`
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
.empty { margin: auto; color: var(--pi-muted); }
|
||||
.error { padding: 10px 16px; border-bottom: 1px solid var(--pi-border); color: var(--pi-danger); }
|
||||
@keyframes app-refresh-spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
|
||||
export const workspacePanelStyles = css`
|
||||
@@ -186,10 +177,7 @@ export const workspacePanelStyles = css`
|
||||
.empty-state h2 { margin: 0; color: var(--pi-text); font-size: 15px; line-height: 1.3; }
|
||||
.empty-state p { margin: 0; line-height: 1.45; }
|
||||
small, .muted { color: var(--pi-muted); }
|
||||
header small { flex: 0 0 auto; min-width: max-content; overflow: visible; text-overflow: clip; white-space: nowrap; }
|
||||
header .workspace-label { width: max-content; max-width: none; overflow: visible; }
|
||||
header .workspace-label-base, header .workspace-label-item, header .workspace-label-render { overflow: visible; text-overflow: clip; }
|
||||
@media (max-width: 1180px) { .tabs { display: none; } }
|
||||
@media (max-width: 1180px) { header { display: none; } }
|
||||
.workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; }
|
||||
.workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); }
|
||||
@@ -237,12 +225,11 @@ export const listStyles = css`
|
||||
.action-row:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; border-radius: 8px; }
|
||||
.action-row.selected .action-main, .action-row.selected .action-menu-toggle { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.action-row.archived .action-main { color: var(--pi-muted); }
|
||||
.action-main { box-sizing: border-box; min-width: 0; width: 100%; border: 1px solid var(--pi-border); border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px 7px calc(9px + var(--depth, 0) * 16px); text-align: left; }
|
||||
.action-main { position: relative; box-sizing: border-box; min-width: 0; width: 100%; border: 1px solid var(--pi-border); border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 22px 7px calc(9px + var(--depth, 0) * 16px); text-align: left; }
|
||||
.action-name { display: -webkit-box; max-height: 2.5em; overflow: hidden; overflow-wrap: anywhere; line-height: 1.25; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.action-row:not(.selected):hover .action-main { background: var(--pi-surface-hover); }
|
||||
.workspace-row .action-main { border-radius: 8px 0 0 8px; }
|
||||
.workspace-primary { min-width: 0; display: flex; align-items: baseline; gap: 6px; }
|
||||
.workspace-primary .activity-indicator { flex: 0 0 auto; margin-right: 0; }
|
||||
.workspace-primary-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-status { flex: 0 0 auto; color: var(--pi-warning); font-size: 12px; }
|
||||
.workspace-secondary { margin-top: 3px; }
|
||||
@@ -256,6 +243,8 @@ export const listStyles = css`
|
||||
.workspace-detail-row dd { min-width: 0; margin: 0; overflow-wrap: anywhere; white-space: normal; }
|
||||
.tree-marker { color: var(--pi-dim); margin-right: 5px; }
|
||||
.badge { display: inline-block; margin-left: 5px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; }
|
||||
.action-activity { position: absolute; top: 5px; right: 6px; z-index: 1; display: grid; place-items: center; width: 10px; height: 10px; }
|
||||
.action-activity .activity-indicator { margin: 0; vertical-align: 0; }
|
||||
.activity-indicator { display: inline-block; width: 7px; height: 7px; margin-right: 6px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; }
|
||||
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
|
||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
||||
@@ -393,14 +382,8 @@ export const formattedTextStyles = css`
|
||||
|
||||
export const statusBarStyles = css`
|
||||
:host { display: block; color: var(--pi-muted); font: 12px system-ui, sans-serif; }
|
||||
.bar { display: flex; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; }
|
||||
span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; }
|
||||
.workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); }
|
||||
.workspace-label-link { color: var(--pi-accent); text-decoration: none; }
|
||||
.workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; }
|
||||
.bar > span:first-child { flex: 1 1 auto; min-width: 80px; }
|
||||
.bar { display: flex; justify-content: flex-end; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; }
|
||||
span { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.activity { display: inline-flex; align-items: center; gap: 6px; color: var(--pi-muted); }
|
||||
.activity.active { color: var(--pi-success); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
|
||||
|
||||
@@ -12,8 +12,9 @@ export class MachineController {
|
||||
const machines = await api.machines();
|
||||
const selectedMachine = await this.selectInitialMachine(machines, routeMachineId);
|
||||
const machineIds = new Set(machines.map((machine) => machine.id));
|
||||
this.setState({ machines, selectedMachine, machineActivities: filterKeys(this.getState().machineActivities, machineIds) });
|
||||
this.setState({ machines, selectedMachine, machineActivities: filterKeys(this.getState().machineActivities, machineIds), machineRuntimes: filterKeys(this.getState().machineRuntimes, machineIds) });
|
||||
void this.refreshMachineHealthFor(machines);
|
||||
void this.refreshMachineRuntimeFor(machines);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
} finally {
|
||||
@@ -47,6 +48,7 @@ export class MachineController {
|
||||
if (options.updateUrl !== false) this.updateUrl();
|
||||
await this.projects.loadProjects();
|
||||
void this.refreshMachineHealth(machine.id);
|
||||
void this.refreshMachineRuntime(machine.id);
|
||||
}
|
||||
|
||||
async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise<Machine | undefined> {
|
||||
@@ -73,7 +75,7 @@ export class MachineController {
|
||||
await api.deleteMachine(machine.id);
|
||||
const machines = this.getState().machines.filter((candidate) => candidate.id !== machine.id);
|
||||
const local = machines.find((candidate) => candidate.id === "local") ?? machines[0];
|
||||
this.setState({ machines, machineStatuses: omitKey(this.getState().machineStatuses, machine.id), machineActivities: omitKey(this.getState().machineActivities, machine.id) });
|
||||
this.setState({ machines, machineStatuses: omitKey(this.getState().machineStatuses, machine.id), machineRuntimes: omitKey(this.getState().machineRuntimes, machine.id), machineActivities: omitKey(this.getState().machineActivities, machine.id) });
|
||||
if (wasSelected && local !== undefined) {
|
||||
if (options.selectFallback === false) return local;
|
||||
await this.selectMachine(local);
|
||||
@@ -95,6 +97,15 @@ export class MachineController {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
|
||||
try {
|
||||
const runtime = await api.runtime(machineId);
|
||||
this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } });
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
private async selectInitialMachine(machines: Machine[], routeMachineId?: string): Promise<Machine | undefined> {
|
||||
const requestedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local"));
|
||||
if (requestedMachine?.kind !== "remote") return requestedMachine ?? this.localMachine(machines);
|
||||
@@ -133,6 +144,12 @@ export class MachineController {
|
||||
const health = Object.fromEntries(results.flatMap((result) => result.status === "fulfilled" ? [[result.value.machineId, result.value] as const] : []));
|
||||
if (Object.keys(health).length > 0) this.setState({ machineStatuses: { ...this.getState().machineStatuses, ...health } });
|
||||
}
|
||||
|
||||
private async refreshMachineRuntimeFor(machines: Machine[]): Promise<void> {
|
||||
const results = await Promise.allSettled(machines.map((machine) => api.runtime(machine.id)));
|
||||
const runtimes = Object.fromEntries(results.flatMap((result) => result.status === "fulfilled" ? [[result.value.machineId, result.value] as const] : []));
|
||||
if (Object.keys(runtimes).length > 0) this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, ...runtimes } });
|
||||
}
|
||||
}
|
||||
|
||||
function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api as defaultApi, type MessagePage, type SessionActivity, type Session
|
||||
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";
|
||||
@@ -290,6 +291,98 @@ describe("SessionController", () => {
|
||||
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(session.id);
|
||||
return Promise.resolve({ archived: true });
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(session.id)),
|
||||
};
|
||||
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(session.id);
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(session.id)),
|
||||
};
|
||||
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("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(session.id);
|
||||
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("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] };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||
import { isShellInput } from "../inputModes";
|
||||
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
|
||||
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
|
||||
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||
|
||||
@@ -261,6 +262,57 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
async archiveSessions(sessions: readonly SessionInfo[]): Promise<void> {
|
||||
const candidates = uniqueSessionsById(sessions).filter((session) => session.archived !== true && !isCachedNewSessionInfo(session));
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const results = await Promise.allSettled(candidates.map(async (session) => {
|
||||
await this.api.archive(session, machineId);
|
||||
return session.id;
|
||||
}));
|
||||
const archivedIds = fulfilledValues(results);
|
||||
if (archivedIds.length > 0) {
|
||||
const state = this.getState();
|
||||
const nextSessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
|
||||
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
|
||||
this.setState({ sessions: nextSessions });
|
||||
|
||||
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
|
||||
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
|
||||
}
|
||||
this.applyBulkSessionError("Archive", results);
|
||||
}
|
||||
|
||||
async deleteArchivedSessions(sessions: readonly SessionInfo[]): Promise<void> {
|
||||
const candidates = uniqueSessionsById(sessions).filter((session) => session.archived === true);
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const runtime = this.getState().machineRuntimes[machineId];
|
||||
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived)) {
|
||||
this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." });
|
||||
return;
|
||||
}
|
||||
const results = await Promise.allSettled(candidates.map(async (session) => {
|
||||
await this.api.deleteArchived(session, machineId);
|
||||
return session.id;
|
||||
}));
|
||||
const deletedIds = fulfilledValues(results);
|
||||
if (deletedIds.length > 0) {
|
||||
const deletedIdSet = new Set(deletedIds);
|
||||
const state = this.getState();
|
||||
const nextSessions = state.sessions.filter((session) => !deletedIdSet.has(session.id));
|
||||
this.setState({ sessions: nextSessions });
|
||||
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
|
||||
const next = nextSessions.find((session) => session.archived !== true) ?? nextSessions[0];
|
||||
if (next !== undefined) await this.selectSession(next);
|
||||
else this.deselectSession({ forgetRememberedSelection: true });
|
||||
}
|
||||
}
|
||||
this.applyBulkSessionError("Delete", results);
|
||||
}
|
||||
|
||||
async deleteCachedNewSession(session = this.getState().selectedSession) {
|
||||
if (!isCachedNewSessionInfo(session)) return;
|
||||
void this.api.stop(session, selectedMachineId(this.getState())).catch(() => {
|
||||
@@ -397,6 +449,12 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
private applyBulkSessionError(action: string, results: readonly PromiseSettledResult<string>[]): void {
|
||||
const failures = rejectedReasons(results);
|
||||
if (failures.length === 0) return;
|
||||
this.setState({ error: `${action} failed for ${String(failures.length)} session${failures.length === 1 ? "" : "s"}: ${failures.join("; ")}` });
|
||||
}
|
||||
|
||||
private sessionCacheKey(sessionId: string): string {
|
||||
return machineSessionKey(selectedMachineId(this.getState()), sessionId);
|
||||
}
|
||||
@@ -563,6 +621,37 @@ function omitSessionActivity(activities: Record<string, SessionActivity>, sessio
|
||||
return Object.fromEntries(Object.entries(activities).filter(([id]) => id !== sessionId));
|
||||
}
|
||||
|
||||
function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: SessionInfo[] = [];
|
||||
for (const session of sessions) {
|
||||
if (seen.has(session.id)) continue;
|
||||
seen.add(session.id);
|
||||
unique.push(session);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function fulfilledValues<T>(results: readonly PromiseSettledResult<T>[]): T[] {
|
||||
return results.filter(isFulfilled).map((result) => result.value);
|
||||
}
|
||||
|
||||
function rejectedReasons(results: readonly PromiseSettledResult<unknown>[]): string[] {
|
||||
return results.filter(isRejected).map((result) => errorMessage(result.reason));
|
||||
}
|
||||
|
||||
function isFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> {
|
||||
return result.status === "fulfilled";
|
||||
}
|
||||
|
||||
function isRejected<T>(result: PromiseSettledResult<T>): result is PromiseRejectedResult {
|
||||
return result.status === "rejected";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function sessionMessageCountPatch(state: AppState, sessionId: string, messageCount: number | undefined): Pick<Partial<AppState>, "sessions" | "selectedSession"> {
|
||||
if (messageCount === undefined) return {};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AppAction } from "./actions";
|
||||
import { KeyboardShortcutDispatcher, type ShortcutKeyEvent } from "./keyboardShortcuts";
|
||||
import { KeyboardShortcutDispatcher, parseShortcutInput, resolveShortcutBindings, shortcutTokenFromEvent, type ShortcutKeyEvent } from "./keyboardShortcuts";
|
||||
|
||||
function keyEvent(key: string, modifiers: Partial<ShortcutKeyEvent> = {}): ShortcutKeyEvent {
|
||||
return {
|
||||
@@ -16,10 +16,14 @@ function keyEvent(key: string, modifiers: Partial<ShortcutKeyEvent> = {}): Short
|
||||
}
|
||||
|
||||
function action(shortcut: string, enabled = true) {
|
||||
return actionWithId(shortcut, shortcut, enabled);
|
||||
}
|
||||
|
||||
function actionWithId(id: string, shortcut: string, enabled = true) {
|
||||
const run = vi.fn();
|
||||
const value: AppAction = {
|
||||
id: shortcut,
|
||||
title: shortcut,
|
||||
id,
|
||||
title: id,
|
||||
shortcut,
|
||||
enabled,
|
||||
run,
|
||||
@@ -48,6 +52,26 @@ describe("KeyboardShortcutDispatcher", () => {
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches manually typed Ctrl shortcuts as the cross-platform Mod modifier", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const { value, run } = action("ctrl+k");
|
||||
|
||||
const handled = dispatcher.handle(keyEvent("k", { ctrlKey: true }), [value]);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores shift-only shortcuts so capitalized typing is never captured", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const { value, run } = action("shift+r");
|
||||
|
||||
const handled = dispatcher.handle(keyEvent("r", { shiftKey: true }), [value]);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores disabled matching shortcuts", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const { value, run } = action("mod+enter", false);
|
||||
@@ -66,4 +90,108 @@ describe("KeyboardShortcutDispatcher", () => {
|
||||
expect(dispatcher.handle(keyEvent("r", { ctrlKey: true, shiftKey: true }), [value])).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs a shortcut sequence that starts with a modified key", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const { value, run } = action("mod+g p");
|
||||
|
||||
expect(dispatcher.handle(keyEvent("g", { ctrlKey: true }), [value])).toBe(true);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(dispatcher.handle(keyEvent("p"), [value])).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deterministically runs the lowest action id when default shortcuts conflict", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const later = actionWithId("plugin:z", "mod+k");
|
||||
const earlier = actionWithId("plugin:a", "mod+k");
|
||||
|
||||
const handled = dispatcher.handle(keyEvent("k", { ctrlKey: true }), [later.value, earlier.value]);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(earlier.run).toHaveBeenCalledTimes(1);
|
||||
expect(later.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs custom shortcut winners before default shortcut conflicts", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const defaultAction = actionWithId("plugin:a", "mod+j");
|
||||
const customAction = actionWithId("plugin:z", "mod+k");
|
||||
|
||||
const handled = dispatcher.handle(keyEvent("j", { ctrlKey: true }), [defaultAction.value, customAction.value], { shortcuts: { "plugin:z": "mod+j" } });
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(customAction.run).toHaveBeenCalledTimes(1);
|
||||
expect(defaultAction.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the same shadowing rules when a standalone shortcut is a sequence prefix", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const standalone = actionWithId("plugin:standalone", "mod+g");
|
||||
const sequence = actionWithId("plugin:sequence", "mod+g p");
|
||||
|
||||
const handled = dispatcher.handle(keyEvent("g", { ctrlKey: true }), [standalone.value, sequence.value]);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(standalone.run).toHaveBeenCalledTimes(1);
|
||||
expect(sequence.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a standalone modified shortcut when a pending sequence misses", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const sequence = action("mod+g p");
|
||||
const standalone = action("mod+k");
|
||||
|
||||
expect(dispatcher.handle(keyEvent("g", { ctrlKey: true }), [sequence.value, standalone.value])).toBe(true);
|
||||
expect(dispatcher.handle(keyEvent("k", { ctrlKey: true }), [sequence.value, standalone.value])).toBe(true);
|
||||
expect(sequence.run).not.toHaveBeenCalled();
|
||||
expect(standalone.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortcut conflict resolution", () => {
|
||||
it("reports which duplicate bindings shadow and which are shadowed", () => {
|
||||
const defaultAction = actionWithId("plugin:a", "mod+k");
|
||||
const customAction = actionWithId("plugin:z", "mod+j");
|
||||
|
||||
const resolutions = resolveShortcutBindings([defaultAction.value, customAction.value], { "plugin:z": "mod+k" });
|
||||
const defaultResolution = resolutions.find((resolution) => resolution.action.id === "plugin:a");
|
||||
const customResolution = resolutions.find((resolution) => resolution.action.id === "plugin:z");
|
||||
|
||||
expect(customResolution?.active).toBe(true);
|
||||
expect(customResolution?.shadows.map((binding) => binding.action.id)).toEqual(["plugin:a"]);
|
||||
expect(defaultResolution?.active).toBe(false);
|
||||
expect(defaultResolution?.shadowedBy?.action.id).toBe("plugin:z");
|
||||
});
|
||||
|
||||
it("reports sequence bindings shadowed by shorter shortcut prefixes", () => {
|
||||
const standalone = actionWithId("plugin:standalone", "mod+g");
|
||||
const sequence = actionWithId("plugin:sequence", "mod+g p");
|
||||
|
||||
const resolutions = resolveShortcutBindings([sequence.value, standalone.value]);
|
||||
const standaloneResolution = resolutions.find((resolution) => resolution.action.id === "plugin:standalone");
|
||||
const sequenceResolution = resolutions.find((resolution) => resolution.action.id === "plugin:sequence");
|
||||
|
||||
expect(standaloneResolution?.active).toBe(true);
|
||||
expect(standaloneResolution?.shadows.map((binding) => binding.action.id)).toEqual(["plugin:sequence"]);
|
||||
expect(sequenceResolution?.active).toBe(false);
|
||||
expect(sequenceResolution?.shadowedBy?.action.id).toBe("plugin:standalone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortcut input parsing", () => {
|
||||
it("normalizes manually typed shortcuts", () => {
|
||||
expect(parseShortcutInput("Ctrl + Shift + K")).toEqual({ ok: true, shortcut: "mod+shift+k", tokens: ["mod+shift+k"] });
|
||||
expect(parseShortcutInput("cmd+g p")).toEqual({ ok: true, shortcut: "mod+g p", tokens: ["mod+g", "p"] });
|
||||
});
|
||||
|
||||
it("rejects shortcuts that would capture normal typing", () => {
|
||||
expect(parseShortcutInput("r")).toEqual({ ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." });
|
||||
expect(parseShortcutInput("shift+r")).toEqual({ ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." });
|
||||
});
|
||||
|
||||
it("builds canonical tokens from recorded key events", () => {
|
||||
expect(shortcutTokenFromEvent(keyEvent("K", { metaKey: true, shiftKey: true }))).toBe("mod+shift+k");
|
||||
expect(shortcutTokenFromEvent(keyEvent("ArrowDown", { altKey: true }))).toBe("alt+arrowdown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { AppAction } from "./actions";
|
||||
|
||||
const sequenceTimeoutMs = 1200;
|
||||
export const shortcutSequenceTimeoutMs = 1200;
|
||||
|
||||
const modifierOrder = ["mod", "alt", "shift"] as const;
|
||||
type ShortcutModifier = typeof modifierOrder[number];
|
||||
|
||||
export type ShortcutPreferenceConfig = Record<string, string | null>;
|
||||
export type ShortcutBindingSource = "default" | "custom";
|
||||
|
||||
export interface ShortcutKeyEvent {
|
||||
key: string;
|
||||
@@ -12,22 +18,58 @@ export interface ShortcutKeyEvent {
|
||||
target: EventTarget | null;
|
||||
}
|
||||
|
||||
export type ShortcutParseResult =
|
||||
| { ok: true; shortcut: string; tokens: string[] }
|
||||
| { ok: false; message: string };
|
||||
|
||||
export interface ShortcutBindingSummary {
|
||||
action: AppAction;
|
||||
shortcut: string;
|
||||
source: ShortcutBindingSource;
|
||||
}
|
||||
|
||||
export interface ShortcutBindingResolution extends ShortcutBindingSummary {
|
||||
tokens: string[];
|
||||
key: string;
|
||||
order: number;
|
||||
active: boolean;
|
||||
shadows: ShortcutBindingSummary[];
|
||||
shadowedBy?: ShortcutBindingSummary;
|
||||
}
|
||||
|
||||
interface ShortcutBinding extends ShortcutBindingSummary {
|
||||
tokens: string[];
|
||||
key: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export class KeyboardShortcutDispatcher {
|
||||
private pendingTokens: string[] = [];
|
||||
private pendingTimer: number | undefined;
|
||||
private pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
handle(event: ShortcutKeyEvent, actions: AppAction[]): boolean {
|
||||
const token = eventToken(event);
|
||||
if (token === undefined || !isModifiedShortcut(token)) return false;
|
||||
handle(event: ShortcutKeyEvent, actions: AppAction[], options: { shortcuts?: ShortcutPreferenceConfig } = {}): boolean {
|
||||
const token = shortcutTokenFromEvent(event);
|
||||
if (token === undefined) return false;
|
||||
|
||||
const shortcuts = actions
|
||||
.filter((action) => action.shortcut !== undefined && action.enabled !== false)
|
||||
.map((action) => ({ action, tokens: normalizeShortcut(action.shortcut ?? "") }))
|
||||
.filter((entry) => entry.tokens.length > 0);
|
||||
const shortcuts = resolveShortcutBindings(actions, options.shortcuts, { enabledOnly: true })
|
||||
.filter((binding) => binding.active)
|
||||
.map((binding) => ({ action: binding.action, tokens: binding.tokens }));
|
||||
|
||||
const sequence = this.pendingTokens.length > 0 && !isModifiedShortcut(token)
|
||||
? [...this.pendingTokens, token]
|
||||
: [token];
|
||||
if (this.pendingTokens.length > 0) {
|
||||
const handledPending = this.handleSequence([...this.pendingTokens, token], shortcuts);
|
||||
if (handledPending) return true;
|
||||
this.clearPending();
|
||||
if (!isShortcutSequenceStarter(token)) return false;
|
||||
} else if (!isShortcutSequenceStarter(token)) return false;
|
||||
|
||||
return this.handleSequence([token], shortcuts);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.clearPending();
|
||||
}
|
||||
|
||||
private handleSequence(sequence: string[], shortcuts: { action: AppAction; tokens: string[] }[]): boolean {
|
||||
const exact = shortcuts.find((entry) => sameTokens(entry.tokens, sequence));
|
||||
if (exact !== undefined) {
|
||||
this.clearPending();
|
||||
@@ -41,51 +83,88 @@ export class KeyboardShortcutDispatcher {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.clearPending();
|
||||
return false;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.clearPending();
|
||||
}
|
||||
|
||||
private setPending(tokens: string[]): void {
|
||||
this.clearPending();
|
||||
this.pendingTokens = tokens;
|
||||
this.pendingTimer = window.setTimeout(() => {
|
||||
this.pendingTimer = globalThis.setTimeout(() => {
|
||||
this.pendingTokens = [];
|
||||
this.pendingTimer = undefined;
|
||||
}, sequenceTimeoutMs);
|
||||
}, shortcutSequenceTimeoutMs);
|
||||
}
|
||||
|
||||
private clearPending(): void {
|
||||
this.pendingTokens = [];
|
||||
if (this.pendingTimer !== undefined) {
|
||||
window.clearTimeout(this.pendingTimer);
|
||||
globalThis.clearTimeout(this.pendingTimer);
|
||||
this.pendingTimer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveShortcutBindings(actions: AppAction[], shortcuts?: ShortcutPreferenceConfig, options: { enabledOnly?: boolean } = {}): ShortcutBindingResolution[] {
|
||||
const bindings = actions.flatMap((action, order) => {
|
||||
if (options.enabledOnly === true && action.enabled === false) return [];
|
||||
const binding = shortcutBindingForAction(action, shortcuts, order);
|
||||
return binding === undefined ? [] : [binding];
|
||||
});
|
||||
const bindingsByKey = new Map<string, ShortcutBinding[]>();
|
||||
for (const binding of bindings) {
|
||||
bindingsByKey.set(binding.key, [...(bindingsByKey.get(binding.key) ?? []), binding]);
|
||||
}
|
||||
|
||||
const exactWinnersByKey = new Map<string, ShortcutBinding>();
|
||||
for (const conflictSet of bindingsByKey.values()) {
|
||||
const winner = [...conflictSet].sort(compareShortcutBindings)[0];
|
||||
if (winner !== undefined) exactWinnersByKey.set(winner.key, winner);
|
||||
}
|
||||
|
||||
const exactWinners = [...exactWinnersByKey.values()].sort(compareShortcutPrefixCandidates);
|
||||
const shadowsByWinner = new Map<ShortcutBinding, ShortcutBinding[]>();
|
||||
const winnerByBinding = new Map<ShortcutBinding, ShortcutBinding>();
|
||||
for (const binding of bindings) {
|
||||
const exactWinner = exactWinnersByKey.get(binding.key);
|
||||
if (exactWinner === undefined) continue;
|
||||
const winner = prefixWinnerFor(exactWinner, exactWinners) ?? exactWinner;
|
||||
winnerByBinding.set(binding, winner);
|
||||
if (binding !== winner) shadowsByWinner.set(winner, [...(shadowsByWinner.get(winner) ?? []), binding]);
|
||||
}
|
||||
|
||||
return bindings.map((binding) => {
|
||||
const winner = winnerByBinding.get(binding);
|
||||
const active = winner === binding;
|
||||
const shadowedBy = winner === undefined || active ? undefined : shortcutBindingSummary(winner);
|
||||
const shadows = active ? [...(shadowsByWinner.get(binding) ?? [])].sort(compareShortcutBindings).map(shortcutBindingSummary) : [];
|
||||
return {
|
||||
...binding,
|
||||
active,
|
||||
shadows,
|
||||
...(shadowedBy === undefined ? {} : { shadowedBy }),
|
||||
};
|
||||
}).sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
export function parseShortcutInput(shortcut: string): ShortcutParseResult {
|
||||
return parseShortcut(shortcut, { requireFirstChordActivator: true });
|
||||
}
|
||||
|
||||
export function normalizeShortcut(shortcut: string): string[] {
|
||||
const parsed = parseShortcut(shortcut, { requireFirstChordActivator: false });
|
||||
return parsed.ok ? parsed.tokens : [];
|
||||
}
|
||||
|
||||
export function formatShortcut(shortcut: string): string {
|
||||
return normalizeShortcut(shortcut)
|
||||
.map((token) => token
|
||||
.split("+")
|
||||
.map((part) => {
|
||||
if (part === "mod") return isMac() ? "⌘" : "Ctrl";
|
||||
if (part === "shift") return "Shift";
|
||||
if (part === "alt") return isMac() ? "⌥" : "Alt";
|
||||
if (part === "ctrl") return "Ctrl";
|
||||
if (part === "enter") return "Enter";
|
||||
if (part === "escape") return "Esc";
|
||||
if (part === ".") return ".";
|
||||
return part.length === 1 ? part.toUpperCase() : `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
|
||||
})
|
||||
.map(formatShortcutPart)
|
||||
.join("+"))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function eventToken(event: ShortcutKeyEvent): string | undefined {
|
||||
export function shortcutTokenFromEvent(event: ShortcutKeyEvent): string | undefined {
|
||||
if (event.isComposing) return undefined;
|
||||
const key = normalizeKey(event.key);
|
||||
if (key === undefined) return undefined;
|
||||
@@ -97,21 +176,228 @@ function eventToken(event: ShortcutKeyEvent): string | undefined {
|
||||
return modifiers.join("+");
|
||||
}
|
||||
|
||||
function normalizeShortcut(shortcut: string): string[] {
|
||||
return shortcut
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/u)
|
||||
.filter((token) => token !== "")
|
||||
.map((token) => token.split("+").filter((part) => part !== "").join("+"));
|
||||
export function isShortcutSequenceStarter(token: string): boolean {
|
||||
return token.split("+").includes("mod") || token.split("+").includes("alt");
|
||||
}
|
||||
|
||||
function shortcutBindingForAction(action: AppAction, shortcuts: ShortcutPreferenceConfig | undefined, order: number): ShortcutBinding | undefined {
|
||||
const configured = shortcutPreference(action.id, shortcuts);
|
||||
if (configured === null) return undefined;
|
||||
const shortcut = configured ?? action.shortcut;
|
||||
if (shortcut === undefined || shortcut === "") return undefined;
|
||||
const tokens = normalizeShortcut(shortcut);
|
||||
const firstToken = tokens[0];
|
||||
if (firstToken === undefined || !isShortcutSequenceStarter(firstToken)) return undefined;
|
||||
return {
|
||||
action,
|
||||
shortcut: tokens.join(" "),
|
||||
source: configured === undefined ? "default" : "custom",
|
||||
tokens,
|
||||
key: shortcutBindingKey(tokens),
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
function shortcutPreference(actionId: string, shortcuts: ShortcutPreferenceConfig | undefined): string | null | undefined {
|
||||
if (shortcuts === undefined || !Object.hasOwn(shortcuts, actionId)) return undefined;
|
||||
return shortcuts[actionId];
|
||||
}
|
||||
|
||||
function shortcutBindingKey(tokens: string[]): string {
|
||||
return tokens.join("\u0000");
|
||||
}
|
||||
|
||||
function shortcutBindingSummary(binding: ShortcutBinding): ShortcutBindingSummary {
|
||||
return { action: binding.action, shortcut: binding.shortcut, source: binding.source };
|
||||
}
|
||||
|
||||
function compareShortcutBindings(left: ShortcutBinding, right: ShortcutBinding): number {
|
||||
return shortcutSourceRank(left.source) - shortcutSourceRank(right.source)
|
||||
|| compareStrings(left.action.id, right.action.id)
|
||||
|| compareStrings(left.action.title, right.action.title)
|
||||
|| left.order - right.order;
|
||||
}
|
||||
|
||||
function compareShortcutPrefixCandidates(left: ShortcutBinding, right: ShortcutBinding): number {
|
||||
return left.tokens.length - right.tokens.length || compareShortcutBindings(left, right);
|
||||
}
|
||||
|
||||
function prefixWinnerFor(binding: ShortcutBinding, exactWinners: ShortcutBinding[]): ShortcutBinding | undefined {
|
||||
return exactWinners.find((candidate) => candidate !== binding && candidate.tokens.length < binding.tokens.length && startsWithTokens(binding.tokens, candidate.tokens));
|
||||
}
|
||||
|
||||
function shortcutSourceRank(source: ShortcutBindingSource): number {
|
||||
switch (source) {
|
||||
case "custom": return 0;
|
||||
case "default": return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function compareStrings(left: string, right: string): number {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseShortcut(shortcut: string, options: { requireFirstChordActivator: boolean }): ShortcutParseResult {
|
||||
const cleaned = shortcut.trim().toLowerCase().replace(/\s*\+\s*/gu, "+");
|
||||
if (cleaned === "") return { ok: false, message: "Enter a shortcut, choose None, or reset to the default." };
|
||||
|
||||
const tokens: string[] = [];
|
||||
const chordInputs = cleaned.split(/\s+/u).filter((token) => token !== "");
|
||||
for (const [index, chordInput] of chordInputs.entries()) {
|
||||
const parsed = parseShortcutChord(chordInput);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (index === 0 && options.requireFirstChordActivator && !isShortcutSequenceStarter(parsed.token)) {
|
||||
return { ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." };
|
||||
}
|
||||
tokens.push(parsed.token);
|
||||
}
|
||||
|
||||
return { ok: true, shortcut: tokens.join(" "), tokens };
|
||||
}
|
||||
|
||||
type ShortcutChordParseResult =
|
||||
| { ok: true; token: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
function parseShortcutChord(chord: string): ShortcutChordParseResult {
|
||||
const parts = chord.split("+").filter((part) => part !== "");
|
||||
if (parts.length === 0) return { ok: false, message: "Shortcut chords must include a key." };
|
||||
|
||||
const modifiers = new Set<ShortcutModifier>();
|
||||
let key: string | undefined;
|
||||
for (const part of parts) {
|
||||
const modifier = modifierAlias(part);
|
||||
if (modifier !== undefined) {
|
||||
if (modifiers.has(modifier)) return { ok: false, message: `Shortcut has duplicate ${formatShortcutPart(modifier)} modifiers.` };
|
||||
modifiers.add(modifier);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedKey = normalizeShortcutKeyName(part);
|
||||
if (normalizedKey === undefined) return { ok: false, message: `Unsupported shortcut key: ${part}` };
|
||||
if (key !== undefined) return { ok: false, message: "Each shortcut chord can include only one non-modifier key." };
|
||||
key = normalizedKey;
|
||||
}
|
||||
|
||||
if (key === undefined) return { ok: false, message: "Shortcut chords must include a key." };
|
||||
|
||||
const orderedModifiers = modifierOrder.filter((modifier) => modifiers.has(modifier));
|
||||
return { ok: true, token: [...orderedModifiers, key].join("+") };
|
||||
}
|
||||
|
||||
function modifierAlias(part: string): ShortcutModifier | undefined {
|
||||
switch (part) {
|
||||
case "mod":
|
||||
case "meta":
|
||||
case "cmd":
|
||||
case "command":
|
||||
case "ctrl":
|
||||
case "control":
|
||||
case "primary":
|
||||
return "mod";
|
||||
case "alt":
|
||||
case "option":
|
||||
case "opt":
|
||||
return "alt";
|
||||
case "shift":
|
||||
return "shift";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeShortcutKeyName(key: string): string | undefined {
|
||||
const alias = keyAlias(key);
|
||||
if (alias !== undefined) return alias;
|
||||
if (/^f(?:[1-9]|1[0-9]|2[0-4])$/u.test(key)) return key;
|
||||
if (key.length === 1) return key;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeKey(key: string): string | undefined {
|
||||
if (key === " ") return "space";
|
||||
if (key.length === 1) return key.toLowerCase();
|
||||
const normalized = key.toLowerCase();
|
||||
if (["enter", "escape", "tab", "arrowup", "arrowdown", "arrowleft", "arrowright", "backspace", "delete"].includes(normalized)) return normalized;
|
||||
return undefined;
|
||||
return normalizeShortcutKeyName(normalized);
|
||||
}
|
||||
|
||||
function keyAlias(key: string): string | undefined {
|
||||
switch (key) {
|
||||
case " ":
|
||||
case "spacebar":
|
||||
case "space":
|
||||
return "space";
|
||||
case "esc":
|
||||
case "escape":
|
||||
return "escape";
|
||||
case "return":
|
||||
case "enter":
|
||||
return "enter";
|
||||
case "del":
|
||||
case "delete":
|
||||
return "delete";
|
||||
case "backspace":
|
||||
return "backspace";
|
||||
case "tab":
|
||||
return "tab";
|
||||
case "up":
|
||||
case "arrowup":
|
||||
return "arrowup";
|
||||
case "down":
|
||||
case "arrowdown":
|
||||
return "arrowdown";
|
||||
case "left":
|
||||
case "arrowleft":
|
||||
return "arrowleft";
|
||||
case "right":
|
||||
case "arrowright":
|
||||
return "arrowright";
|
||||
case "pageup":
|
||||
case "pagedown":
|
||||
case "home":
|
||||
case "end":
|
||||
return key;
|
||||
case "+":
|
||||
case "plus":
|
||||
return "plus";
|
||||
case "period":
|
||||
case "dot":
|
||||
return ".";
|
||||
case "comma":
|
||||
return ",";
|
||||
case "slash":
|
||||
return "/";
|
||||
case "backslash":
|
||||
return "\\";
|
||||
case "minus":
|
||||
return "-";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortcutPart(part: string): string {
|
||||
if (part === "mod") return isMac() ? "⌘" : "Ctrl";
|
||||
if (part === "shift") return "Shift";
|
||||
if (part === "alt") return isMac() ? "⌥" : "Alt";
|
||||
if (part === "enter") return "Enter";
|
||||
if (part === "escape") return "Esc";
|
||||
if (part === "space") return "Space";
|
||||
if (part === "tab") return "Tab";
|
||||
if (part === "backspace") return "Backspace";
|
||||
if (part === "delete") return "Delete";
|
||||
if (part === "arrowup") return "↑";
|
||||
if (part === "arrowdown") return "↓";
|
||||
if (part === "arrowleft") return "←";
|
||||
if (part === "arrowright") return "→";
|
||||
if (part === "pageup") return "PageUp";
|
||||
if (part === "pagedown") return "PageDown";
|
||||
if (part === "home") return "Home";
|
||||
if (part === "end") return "End";
|
||||
if (part === "plus") return "+";
|
||||
if (/^f(?:[1-9]|1[0-9]|2[0-4])$/u.test(part)) return part.toUpperCase();
|
||||
return part.length === 1 ? part.toUpperCase() : `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
|
||||
}
|
||||
|
||||
function sameTokens(left: string[], right: string[]): boolean {
|
||||
@@ -122,10 +408,6 @@ function startsWithTokens(tokens: string[], prefix: string[]): boolean {
|
||||
return prefix.every((token, index) => tokens[index] === token);
|
||||
}
|
||||
|
||||
function isModifiedShortcut(token: string): boolean {
|
||||
return token.includes("+");
|
||||
}
|
||||
|
||||
function isMac(): boolean {
|
||||
return navigator.userAgent.toLowerCase().includes("mac");
|
||||
return typeof navigator !== "undefined" && navigator.userAgent.toLowerCase().includes("mac");
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ export function createCoreActions(): PluginAction[] {
|
||||
id: "prompt.focus",
|
||||
title: "Focus Prompt",
|
||||
description: "Move keyboard focus to the message composer",
|
||||
shortcut: "mod+g c",
|
||||
group: "General",
|
||||
enabled: (context) => context.state.selectedSession !== undefined,
|
||||
run: (context) => { context.focusPrompt(); },
|
||||
},
|
||||
{
|
||||
@@ -87,13 +87,6 @@ export function createCoreActions(): PluginAction[] {
|
||||
group: "Preferences",
|
||||
run: (context) => { context.piWebUnstable?.openSettings?.(); },
|
||||
},
|
||||
{
|
||||
id: "app.refresh-data",
|
||||
title: "Refresh App Data",
|
||||
description: "Refresh session, status, activity, and the current workspace surface without reloading the page",
|
||||
group: "General",
|
||||
run: (context) => context.refreshAppData(),
|
||||
},
|
||||
{
|
||||
id: "app.reload-page",
|
||||
title: "Full Page Reload",
|
||||
@@ -106,7 +99,7 @@ export function createCoreActions(): PluginAction[] {
|
||||
title: "Go to Chat",
|
||||
shortcut: "mod+1",
|
||||
group: "Navigation",
|
||||
run: (context) => { context.selectMainView("chat"); },
|
||||
run: (context) => { context.focusPrompt(); },
|
||||
},
|
||||
{
|
||||
id: "view.files",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
|
||||
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
|
||||
|
||||
interface PluginManifestEntry {
|
||||
export interface PluginManifestEntry {
|
||||
id: string;
|
||||
module: string;
|
||||
machineSpecific: boolean;
|
||||
}
|
||||
|
||||
interface PluginManifest {
|
||||
@@ -12,6 +13,7 @@ interface PluginManifest {
|
||||
|
||||
export interface LoadExternalPluginsOptions {
|
||||
machineId?: string;
|
||||
shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean;
|
||||
}
|
||||
|
||||
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
|
||||
@@ -20,6 +22,7 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
|
||||
|
||||
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);
|
||||
@@ -27,6 +30,7 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
|
||||
registrations.push({
|
||||
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id),
|
||||
plugin,
|
||||
machineSpecific: entry.machineSpecific,
|
||||
...(options.machineId === undefined ? {} : { machineId: options.machineId, sourcePluginId: entry.id }),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -48,11 +52,17 @@ function parseManifest(value: unknown): PluginManifest {
|
||||
return {
|
||||
plugins: value["plugins"].map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry["id"] !== "string" || entry["id"] === "" || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry");
|
||||
return { id: entry["id"], module: entry["module"] };
|
||||
return { id: entry["id"], module: entry["module"], machineSpecific: parseMachineSpecific(entry["machineSpecific"]) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMachineSpecific(value: unknown): boolean {
|
||||
if (value === undefined) return false;
|
||||
if (typeof value !== "boolean") throw new Error("Invalid plugin manifest entry");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin {
|
||||
if (!isRecord(module)) throw new Error(`Plugin module ${moduleUrl} did not export an object`);
|
||||
const plugin = module["default"];
|
||||
|
||||
@@ -174,17 +174,17 @@ describe("PluginRegistry", () => {
|
||||
expect(calls).toEqual(["refreshGit"]);
|
||||
});
|
||||
|
||||
it("routes app refresh, reload, and settings actions through the runtime context", () => {
|
||||
it("routes app reload and settings actions through the runtime context", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
const { context, calls } = createContext();
|
||||
const actions = registry.getActions(context);
|
||||
|
||||
void actions.find((candidate) => candidate.id === "core:app.refresh-data")?.run();
|
||||
expect(actions.some((candidate) => candidate.id === "core:app.refresh-data")).toBe(false);
|
||||
void actions.find((candidate) => candidate.id === "core:app.reload-page")?.run();
|
||||
void actions.find((candidate) => candidate.id === "core:settings.open")?.run();
|
||||
|
||||
expect(calls).toEqual(["refreshAppData", "reloadPage", "openSettings"]);
|
||||
expect(calls).toEqual(["reloadPage", "openSettings"]);
|
||||
});
|
||||
|
||||
it("exposes terminal navigation as a shortcut-backed action", () => {
|
||||
@@ -208,6 +208,7 @@ describe("PluginRegistry", () => {
|
||||
|
||||
expect(shortcuts).toEqual([
|
||||
["core:actions.show", "mod+k"],
|
||||
["core:prompt.focus", "mod+g c"],
|
||||
["core:settings.open", "mod+,"],
|
||||
["core:view.chat", "mod+1"],
|
||||
["core:view.files", "mod+2"],
|
||||
@@ -405,6 +406,91 @@ describe("PluginRegistry", () => {
|
||||
expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.remote`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false);
|
||||
expect(panels.find((panel) => panel.id === "shared-tools:workspace.gateway")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
|
||||
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "gateway" }]);
|
||||
expect(registry.shouldLoadRemotePlugin("shared-tools")).toBe(false);
|
||||
expect(registry.shouldLoadRemotePlugin("shared-tools", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses machine-specific remote duplicates instead of the gateway plugin for that machine", () => {
|
||||
const registry = new PluginRegistry();
|
||||
const workspace = testWorkspace();
|
||||
const remotePluginId = machineScopedPluginId("remote-1", "updates");
|
||||
registry.register({
|
||||
id: "updates",
|
||||
machineSpecific: true,
|
||||
plugin: {
|
||||
apiVersion: 1,
|
||||
name: "Gateway Updates",
|
||||
activate: () => ({
|
||||
contributions: {
|
||||
actions: [{ id: "open", title: "Open Gateway Updates", run: () => undefined }],
|
||||
workspacePanels: [{ id: "workspace.updates", title: "Gateway Updates", render: () => html`<p>Gateway</p>` }],
|
||||
workspaceLabels: [{ id: "label", items: () => [{ type: "text", text: "gateway" }] }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.getActions(createContext().context).map((action) => action.id)).toContain("updates:open");
|
||||
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).not.toContain("updates:open");
|
||||
expect(registry.shouldLoadRemotePlugin("updates")).toBe(true);
|
||||
|
||||
registry.register({
|
||||
id: remotePluginId,
|
||||
machineId: "remote-1",
|
||||
sourcePluginId: "updates",
|
||||
plugin: {
|
||||
apiVersion: 1,
|
||||
name: "Remote Updates",
|
||||
activate: () => ({
|
||||
contributions: {
|
||||
actions: [{ id: "open", title: "Open Remote Updates", run: () => undefined }],
|
||||
workspacePanels: [{ id: "workspace.updates", title: "Remote Updates", render: () => html`<p>Remote</p>` }],
|
||||
workspaceLabels: [{ id: "label", items: () => [{ type: "text", text: "remote" }] }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.getActions(createContext().context).map((action) => action.id)).toContain("updates:open");
|
||||
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toEqual([`${remotePluginId}:open`]);
|
||||
|
||||
const panels = registry.getWorkspacePanels();
|
||||
expect(panels.find((panel) => panel.id === "updates:workspace.updates")?.visible?.(createWorkspacePanelContext("local"))).toBe(true);
|
||||
expect(panels.find((panel) => panel.id === "updates:workspace.updates")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false);
|
||||
expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.updates`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
|
||||
|
||||
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("local", workspace))).toEqual([{ type: "text", text: "gateway" }]);
|
||||
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "remote" }]);
|
||||
});
|
||||
|
||||
it("allows a machine-specific remote duplicate to override a portable gateway plugin for that machine", () => {
|
||||
const registry = new PluginRegistry();
|
||||
const remotePluginId = machineScopedPluginId("remote-1", "status-tools");
|
||||
registry.register({
|
||||
id: "status-tools",
|
||||
plugin: {
|
||||
apiVersion: 1,
|
||||
name: "Gateway Status Tools",
|
||||
activate: () => ({ contributions: { actions: [{ id: "open", title: "Open Gateway Status", run: () => undefined }] } }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.shouldLoadRemotePlugin("status-tools")).toBe(false);
|
||||
expect(registry.shouldLoadRemotePlugin("status-tools", true)).toBe(true);
|
||||
registry.register({
|
||||
id: remotePluginId,
|
||||
machineId: "remote-1",
|
||||
sourcePluginId: "status-tools",
|
||||
machineSpecific: true,
|
||||
plugin: {
|
||||
apiVersion: 1,
|
||||
name: "Remote Status Tools",
|
||||
activate: () => ({ contributions: { actions: [{ id: "open", title: "Open Remote Status", run: () => undefined }] } }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.getActions(createContext().context).map((action) => action.id)).toEqual(["status-tools:open"]);
|
||||
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toEqual([`${remotePluginId}:open`]);
|
||||
});
|
||||
|
||||
it("does not activate remote duplicates when the gateway plugin is already registered", () => {
|
||||
|
||||
@@ -22,13 +22,16 @@ export class PluginRegistry {
|
||||
private readonly themePairs: QualifiedThemePairContribution[] = [];
|
||||
private readonly pluginIds = new Set<string>();
|
||||
private readonly gatewayPluginIds = new Set<string>();
|
||||
private readonly gatewayMachineSpecificPluginIds = new Set<string>();
|
||||
private readonly remoteMachineSpecificPluginIds = new Map<string, Set<string>>();
|
||||
private readonly contributionIds = new Set<QualifiedContributionId>();
|
||||
|
||||
register(registration: PiWebPluginRegistration): void {
|
||||
const { id, plugin } = registration;
|
||||
this.validatePluginId(id);
|
||||
const machineSpecific = this.parseMachineSpecific(id, registration.machineSpecific);
|
||||
if (this.pluginIds.has(id)) throw new Error(`Duplicate plugin id: ${id}`);
|
||||
if (isDuplicateOfGatewayPlugin(registration, this.gatewayPluginIds)) return;
|
||||
if (this.isRemoteDuplicateHiddenByGateway(registration.sourcePluginId, registration.machineId, machineSpecific)) return;
|
||||
this.pluginIds.add(id);
|
||||
|
||||
const apiVersion: unknown = plugin.apiVersion;
|
||||
@@ -42,11 +45,19 @@ export class PluginRegistry {
|
||||
for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme));
|
||||
for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair));
|
||||
this.gatewayPluginIds.add(id);
|
||||
if (machineSpecific) this.gatewayMachineSpecificPluginIds.add(id);
|
||||
} else if (registration.sourcePluginId !== undefined && machineSpecific) {
|
||||
addMappedSetValue(this.remoteMachineSpecificPluginIds, registration.sourcePluginId, registration.machineId);
|
||||
}
|
||||
}
|
||||
|
||||
shouldLoadRemotePlugin(sourcePluginId: string, machineSpecific = false): boolean {
|
||||
return !this.gatewayPluginIds.has(sourcePluginId) || this.gatewayMachineSpecificPluginIds.has(sourcePluginId) || machineSpecific;
|
||||
}
|
||||
|
||||
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
|
||||
return this.actions.filter((action) => isActiveForMachine(action.machineId, runtimeContextMachineId(context), action.sourcePluginId, this.gatewayPluginIds)).map((action) => {
|
||||
const selectedMachineId = runtimeContextMachineId(context);
|
||||
return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).map((action) => {
|
||||
const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
|
||||
const enabled = action.enabled?.(scopedContext);
|
||||
const qualified: QualifiedPluginAction = {
|
||||
@@ -101,8 +112,8 @@ export class PluginRegistry {
|
||||
pluginId,
|
||||
localId: panel.id,
|
||||
...(machineId === undefined ? {} : { machineId }),
|
||||
visible: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
|
||||
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
|
||||
visible: (context: WorkspacePanelContext) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
|
||||
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
|
||||
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
|
||||
};
|
||||
}
|
||||
@@ -117,8 +128,8 @@ export class PluginRegistry {
|
||||
pluginId,
|
||||
localId: contribution.id,
|
||||
...(machineId === undefined ? {} : { machineId }),
|
||||
visible: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) && (visible?.(context) ?? true),
|
||||
items: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? items(context) : [],
|
||||
visible: (context) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) && (visible?.(context) ?? true),
|
||||
items: (context) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) ? items(context) : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,6 +163,33 @@ export class PluginRegistry {
|
||||
return `${pluginId}:${localId}`;
|
||||
}
|
||||
|
||||
private isContributionActive(pluginId: string, machineId: string | undefined, selectedMachineId: string, sourcePluginId: string | undefined): boolean {
|
||||
if (machineId === undefined) return !this.isGatewayPluginHiddenForMachine(pluginId, selectedMachineId);
|
||||
return machineId === selectedMachineId && !this.isRemotePluginHiddenByGateway(sourcePluginId, machineId);
|
||||
}
|
||||
|
||||
private isRemoteDuplicateHiddenByGateway(sourcePluginId: string | undefined, machineId: string | undefined, machineSpecific: boolean): boolean {
|
||||
return sourcePluginId !== undefined
|
||||
&& machineId !== undefined
|
||||
&& this.gatewayPluginIds.has(sourcePluginId)
|
||||
&& !this.gatewayMachineSpecificPluginIds.has(sourcePluginId)
|
||||
&& !machineSpecific;
|
||||
}
|
||||
|
||||
private isRemotePluginHiddenByGateway(sourcePluginId: string | undefined, machineId: string): boolean {
|
||||
if (sourcePluginId === undefined) return false;
|
||||
if (this.gatewayMachineSpecificPluginIds.has(sourcePluginId)) return false;
|
||||
if (this.remoteMachineSpecificPluginIds.get(sourcePluginId)?.has(machineId) === true) return false;
|
||||
return this.gatewayPluginIds.has(sourcePluginId);
|
||||
}
|
||||
|
||||
private isGatewayPluginHiddenForMachine(pluginId: string, machineId: string): boolean {
|
||||
return machineId !== "local" && (
|
||||
this.gatewayMachineSpecificPluginIds.has(pluginId)
|
||||
|| this.remoteMachineSpecificPluginIds.get(pluginId)?.has(machineId) === true
|
||||
);
|
||||
}
|
||||
|
||||
private validatePluginId(pluginId: string): void {
|
||||
if (!idPattern.test(pluginId)) throw new Error(`Invalid plugin id: ${pluginId}`);
|
||||
}
|
||||
@@ -159,6 +197,12 @@ export class PluginRegistry {
|
||||
private validateLocalId(localId: string): void {
|
||||
if (!localIdPattern.test(localId)) throw new Error(`Invalid contribution id: ${localId}`);
|
||||
}
|
||||
|
||||
private parseMachineSpecific(pluginId: string, value: unknown): boolean {
|
||||
if (value === undefined) return false;
|
||||
if (typeof value !== "boolean") throw new Error(`Invalid plugin machineSpecific value for ${pluginId}: ${formatUnknownValue(value)}`);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function pluginRuntimeContextFor(context: PluginRuntimeContext, pluginId: string): PluginRuntimeContext {
|
||||
@@ -179,16 +223,20 @@ export function installWorkspacePanelScope(context: WorkspacePanelContext, scope
|
||||
return context;
|
||||
}
|
||||
|
||||
function isDuplicateOfGatewayPlugin(registration: PiWebPluginRegistration, gatewayPluginIds: ReadonlySet<string>): boolean {
|
||||
return registration.machineId !== undefined && registration.sourcePluginId !== undefined && gatewayPluginIds.has(registration.sourcePluginId);
|
||||
function addMappedSetValue(map: Map<string, Set<string>>, key: string, value: string): void {
|
||||
const existing = map.get(key);
|
||||
if (existing === undefined) map.set(key, new Set([value]));
|
||||
else existing.add(value);
|
||||
}
|
||||
|
||||
function isActiveForMachine(machineId: string | undefined, selectedMachineId: string, sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet<string>): boolean {
|
||||
return machineId === undefined || (machineId === selectedMachineId && !isHiddenByGatewayPlugin(sourcePluginId, gatewayPluginIds));
|
||||
}
|
||||
|
||||
function isHiddenByGatewayPlugin(sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet<string>): boolean {
|
||||
return sourcePluginId !== undefined && gatewayPluginIds.has(sourcePluginId);
|
||||
function formatUnknownValue(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return Object.prototype.toString.call(value);
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeContextMachineId(context: PluginRuntimeContext): string {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface PiWebPluginRegistration {
|
||||
plugin: PiWebPlugin;
|
||||
machineId?: string;
|
||||
sourcePluginId?: PluginId;
|
||||
machineSpecific?: boolean;
|
||||
}
|
||||
|
||||
export interface PiWebPlugin {
|
||||
@@ -131,6 +132,11 @@ export interface QualifiedPluginAction extends AppAction {
|
||||
|
||||
export interface WorkspacePanelContext extends WorkspaceContext {
|
||||
terminal: WorkspacePanelTerminal;
|
||||
/**
|
||||
* @deprecated Runtime-only compatibility alias for pre-v2 plugins. Use `terminal.open()` instead.
|
||||
* This is intentionally not part of the public `@jmfederico/pi-web/plugin-api` declarations.
|
||||
*/
|
||||
openTerminal?: (options?: { terminalId?: string | undefined }) => void;
|
||||
piWebUnstable?: Pick<PiWebUnstableRuntimeContext, "terminalCommandRuns">;
|
||||
fileTree: FileTreeEntry[];
|
||||
expandedDirs: Record<string, FileTreeEntry[]>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AppAction } from "./actions";
|
||||
import { applyShortcutPreferences } from "./shortcutPreferences";
|
||||
import { applyActiveShortcutPreferences, applyShortcutPreferences } from "./shortcutPreferences";
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
@@ -26,6 +26,26 @@ describe("shortcut preferences", () => {
|
||||
action({ id: "core:view.chat" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps only active shortcuts when applying preferences for display", () => {
|
||||
expect(applyActiveShortcutPreferences([
|
||||
action({ id: "core:z", title: "Later", shortcut: "mod+k" }),
|
||||
action({ id: "core:a", title: "Earlier", shortcut: "mod+k" }),
|
||||
], undefined)).toEqual([
|
||||
action({ id: "core:z", title: "Later" }),
|
||||
action({ id: "core:a", title: "Earlier", shortcut: "mod+k" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides default shortcut labels shadowed by user-defined shortcuts", () => {
|
||||
expect(applyActiveShortcutPreferences([
|
||||
action({ id: "core:a", title: "Default", shortcut: "mod+1" }),
|
||||
action({ id: "core:z", title: "Custom", shortcut: "mod+2" }),
|
||||
], { "core:z": "mod+1" })).toEqual([
|
||||
action({ id: "core:a", title: "Default" }),
|
||||
action({ id: "core:z", title: "Custom", shortcut: "mod+1" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function action(patch: Partial<AppAction>): AppAction {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { AppAction } from "./actions";
|
||||
import type { PiWebShortcutConfig } from "./api";
|
||||
import { resolveShortcutBindings } from "./keyboardShortcuts";
|
||||
|
||||
export function applyShortcutPreferences(actions: AppAction[], shortcuts: PiWebShortcutConfig | undefined): AppAction[] {
|
||||
if (shortcuts === undefined) return actions;
|
||||
return actions.map((action) => applyShortcutPreference(action, shortcuts));
|
||||
}
|
||||
|
||||
export function applyActiveShortcutPreferences(actions: AppAction[], shortcuts: PiWebShortcutConfig | undefined): AppAction[] {
|
||||
const activeShortcutActionIds = new Set(resolveShortcutBindings(actions, shortcuts, { enabledOnly: true })
|
||||
.filter((binding) => binding.active)
|
||||
.map((binding) => binding.action.id));
|
||||
return applyShortcutPreferences(actions, shortcuts).map((action) => action.shortcut !== undefined && !activeShortcutActionIds.has(action.id) ? withoutShortcut(action) : action);
|
||||
}
|
||||
|
||||
export function applyShortcutPreference(action: AppAction, shortcuts: PiWebShortcutConfig): AppAction {
|
||||
if (!Object.hasOwn(shortcuts, action.id)) return action;
|
||||
const shortcut = shortcuts[action.id];
|
||||
|
||||
@@ -212,4 +212,3 @@ export interface ThemePairContribution {
|
||||
light: LocalContributionId;
|
||||
dark: LocalContributionId;
|
||||
}
|
||||
|
||||
|
||||
+75
-12
@@ -12,6 +12,7 @@ import { MachineService } from "./machines/machineService.js";
|
||||
import { MachineStore } from "./machines/machineStore.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
@@ -36,22 +37,20 @@ beforeEach(async () => {
|
||||
return remoteClient;
|
||||
},
|
||||
now: () => new Date("2026-05-25T00:00:00.000Z"),
|
||||
localStatus: () => Promise.resolve({
|
||||
localRuntime: () => Promise.resolve({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", stale: false, available: true },
|
||||
web: { component: "web", label: "PI WEB", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
|
||||
messages: [],
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
}),
|
||||
}),
|
||||
sessionDaemon: fakeSessionDaemon(),
|
||||
piWebPlugins: {
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
|
||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }),
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
|
||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
|
||||
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
|
||||
},
|
||||
clientDist: false,
|
||||
@@ -109,6 +108,31 @@ describe("buildApp", () => {
|
||||
expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" });
|
||||
});
|
||||
|
||||
it("reports effective machine runtime capabilities for remote machines", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
},
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const runtime = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
|
||||
|
||||
expect(runtime.statusCode).toBe(200);
|
||||
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
||||
});
|
||||
|
||||
it("proxies allowlisted remote HTTP routes through the selected machine", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -297,11 +321,11 @@ describe("buildApp", () => {
|
||||
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
||||
expect(manifestResponse.statusCode).toBe(200);
|
||||
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] });
|
||||
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] });
|
||||
|
||||
const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" });
|
||||
expect(pluginsResponse.statusCode).toBe(200);
|
||||
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] });
|
||||
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
|
||||
|
||||
const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
|
||||
expect(assetResponse.statusCode).toBe(200);
|
||||
@@ -318,7 +342,7 @@ describe("buildApp", () => {
|
||||
const requestJson = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local" }] },
|
||||
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] },
|
||||
}));
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
@@ -331,7 +355,7 @@ describe("buildApp", () => {
|
||||
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
|
||||
expect(manifestResponse.statusCode).toBe(200);
|
||||
expect(manifestResponse.json()).toEqual({
|
||||
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local" }],
|
||||
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }],
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
|
||||
|
||||
@@ -343,6 +367,45 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123");
|
||||
});
|
||||
|
||||
it("drops unsafe remote machine plugin manifest modules", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
remoteClient = fakeRemoteClient({
|
||||
requestJson: vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: {
|
||||
plugins: [
|
||||
{ id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" },
|
||||
{ id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" },
|
||||
{ id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" },
|
||||
],
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const manifestResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
|
||||
|
||||
expect(manifestResponse.statusCode).toBe(200);
|
||||
expect(manifestResponse.json()).toEqual({
|
||||
plugins: [{ id: "safe-tools", module: `/pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects remote machine plugin asset traversal before proxying", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/..%2F..%2Fapi%2Fconfig` });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Invalid remote PI WEB plugin asset path" });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns stable errors for invalid project requests", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
+11
-4
@@ -17,7 +17,8 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
||||
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
|
||||
@@ -91,8 +92,13 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
||||
const machines = deps.machines ?? new MachineService();
|
||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
|
||||
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
|
||||
});
|
||||
const machines = deps.machines ?? new MachineService(undefined, {
|
||||
localRuntime: () => getPiWebRuntime(sessionDaemon),
|
||||
});
|
||||
|
||||
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
|
||||
|
||||
@@ -104,8 +110,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
return reply.type(asset.contentType).send(asset.content);
|
||||
});
|
||||
|
||||
app.get("/api/pi-web/status", async () => getPiWebStatus());
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
|
||||
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
|
||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
registerConfigRoutes(app, deps.config);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ interface RemotePluginManifestEntry {
|
||||
module: string;
|
||||
source?: string;
|
||||
scope?: string;
|
||||
machineSpecific?: boolean;
|
||||
}
|
||||
|
||||
interface RemotePluginManifest {
|
||||
@@ -59,8 +60,14 @@ export async function proxyMachinePluginAsset(machines: MachinePluginProxyMachin
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestPath = remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl);
|
||||
if (requestPath === undefined) {
|
||||
await reply.code(400).send({ error: "Invalid remote PI WEB plugin asset path" });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await client.request("GET", remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl));
|
||||
const upstream = await client.request("GET", requestPath);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
if (upstream.body === undefined) await reply.send();
|
||||
@@ -87,29 +94,57 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa
|
||||
|
||||
function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined {
|
||||
if (!isPiWebPluginId(pluginId)) return undefined;
|
||||
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`;
|
||||
const base = new URL(prefix, "http://pi-web.local");
|
||||
try {
|
||||
const url = new URL(module, "http://pi-web.local");
|
||||
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`;
|
||||
if (url.pathname.startsWith(prefix)) {
|
||||
return { path: url.pathname.slice(prefix.length), query: url.search };
|
||||
}
|
||||
if (!module.startsWith("/") && !/^https?:\/\//iu.test(module)) {
|
||||
const [path, query = ""] = module.split("?", 2);
|
||||
if (path !== undefined && path !== "") return { path, query: query === "" ? "" : `?${query}` };
|
||||
}
|
||||
const url = new URL(module, base);
|
||||
if (url.origin !== base.origin || !url.pathname.startsWith(prefix)) return undefined;
|
||||
const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length));
|
||||
return path === undefined ? undefined : { path, query: url.search };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string {
|
||||
function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string | undefined {
|
||||
const path = safeRemotePluginAssetPath(assetPath);
|
||||
if (path === undefined) return undefined;
|
||||
const query = requestUrl.includes("?") ? requestUrl.slice(requestUrl.indexOf("?")) : "";
|
||||
return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${encodePathSegments(assetPath)}${query}`;
|
||||
return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${path}${query}`;
|
||||
}
|
||||
|
||||
function encodePathSegments(path: string): string {
|
||||
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
||||
function safeRemotePluginAssetPath(path: string): string | undefined {
|
||||
const segments: string[] = [];
|
||||
for (const rawSegment of path.split("/")) {
|
||||
const segment = safeRemotePluginAssetPathSegment(rawSegment);
|
||||
if (segment === undefined) return undefined;
|
||||
if (segment === "") continue;
|
||||
segments.push(segment);
|
||||
}
|
||||
if (segments.length === 0) return undefined;
|
||||
return segments.map((segment) => encodeURIComponent(segment)).join("/");
|
||||
}
|
||||
|
||||
function safeRemotePluginAssetPathSegment(rawSegment: string): string | undefined {
|
||||
if (rawSegment === "" || rawSegment === ".") return "";
|
||||
if (/%(?:2f|5c)/iu.test(rawSegment)) return undefined;
|
||||
let segment: string;
|
||||
try {
|
||||
segment = decodeURIComponent(rawSegment);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (segment === "" || segment === ".") return "";
|
||||
if (segment === ".." || segment.includes("/") || segment.includes("\\") || hasControlCharacter(segment)) return undefined;
|
||||
return segment;
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x1f || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseRemoteManifest(value: unknown): RemotePluginManifest {
|
||||
@@ -124,11 +159,18 @@ function parseRemoteManifest(value: unknown): RemotePluginManifest {
|
||||
module: entry["module"],
|
||||
...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}),
|
||||
...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}),
|
||||
...(parseRemoteMachineSpecific(entry["machineSpecific"])),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRemoteMachineSpecific(value: unknown): { machineSpecific?: boolean } {
|
||||
if (value === undefined) return {};
|
||||
if (typeof value !== "boolean") throw new Error("Invalid remote PI WEB plugin manifest entry");
|
||||
return { machineSpecific: value };
|
||||
}
|
||||
|
||||
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined) continue;
|
||||
|
||||
@@ -18,6 +18,12 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
|
||||
return health;
|
||||
});
|
||||
|
||||
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
|
||||
const runtime = await machines.runtime(request.params.machineId);
|
||||
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
|
||||
return runtime;
|
||||
});
|
||||
|
||||
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
|
||||
const machine = await machines.get(request.params.machineId);
|
||||
if (machine === undefined) return reply.code(404).send({ error: "Machine not found" });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MachineService } from "./machineService.js";
|
||||
import { MachineStore, machineStorePath } from "./machineStore.js";
|
||||
|
||||
@@ -69,6 +69,34 @@ describe("MachineService", () => {
|
||||
await expect(service.add({ name: "Bad", baseUrl: "https://example.test", headers: { Connection: "close" } })).rejects.toThrow("not allowed");
|
||||
});
|
||||
|
||||
it("uses the lightweight runtime check for local machine health", async () => {
|
||||
const localRuntime = vi.fn(() => Promise.resolve({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web" as const, label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [] },
|
||||
sessiond: { component: "sessiond" as const, label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [] },
|
||||
},
|
||||
capabilities: [],
|
||||
}));
|
||||
const healthService = new MachineService(new MachineStore(storePath), {
|
||||
localRuntime,
|
||||
now: () => new Date("2026-05-25T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const health = await healthService.health("local");
|
||||
|
||||
expect(localRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(health).toEqual({
|
||||
machineId: "local",
|
||||
ok: true,
|
||||
checkedAt: "2026-05-25T00:00:00.000Z",
|
||||
status: "online",
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", stale: false, available: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not allow local machine mutation", async () => {
|
||||
await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed");
|
||||
await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Machine, MachineHealth, PiWebComponentStatus, PiWebStatusResponse } from "../../shared/apiTypes.js";
|
||||
import { getPiWebStatus } from "../piWebStatus.js";
|
||||
import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
|
||||
import { isPiWebCapability } from "../../shared/capabilities.js";
|
||||
import { getPiWebRuntime } from "../piWebStatus.js";
|
||||
import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js";
|
||||
import { MachineStore, type StoredMachine } from "./machineStore.js";
|
||||
|
||||
@@ -13,10 +14,11 @@ export interface CreateMachineInput {
|
||||
export type UpdateMachineInput = Partial<CreateMachineInput>;
|
||||
|
||||
export interface MachineServiceDependencies {
|
||||
localStatus?: () => Promise<PiWebStatusResponse>;
|
||||
localRuntime?: () => Promise<PiWebRuntimeResponse>;
|
||||
remoteClientFactory?: (machine: StoredMachine) => MachineClient;
|
||||
now?: () => Date;
|
||||
healthCacheTtlMs?: number;
|
||||
runtimeCacheTtlMs?: number;
|
||||
}
|
||||
|
||||
const LOCAL_MACHINE_TIMESTAMP = "1970-01-01T00:00:00.000Z";
|
||||
@@ -24,6 +26,7 @@ const DEFAULT_HEALTH_CACHE_TTL_MS = 5_000;
|
||||
|
||||
export class MachineService {
|
||||
private readonly healthCache = new Map<string, { expiresAt: number; health: MachineHealth }>();
|
||||
private readonly runtimeCache = new Map<string, { expiresAt: number; runtime: MachineRuntime }>();
|
||||
|
||||
constructor(private readonly store = new MachineStore(), private readonly deps: MachineServiceDependencies = {}) {}
|
||||
|
||||
@@ -52,14 +55,20 @@ export class MachineService {
|
||||
if (input.token !== undefined) patch.token = input.token;
|
||||
if (input.headers !== undefined) patch.headers = validateHeaders(input.headers);
|
||||
const stored = await this.store.update(id, patch);
|
||||
if (stored !== undefined) this.healthCache.delete(id);
|
||||
if (stored !== undefined) {
|
||||
this.healthCache.delete(id);
|
||||
this.runtimeCache.delete(id);
|
||||
}
|
||||
return stored === undefined ? undefined : publicMachine(stored);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
if (id === "local") throw new Error("Local machine cannot be deleted");
|
||||
const removed = await this.store.remove(id);
|
||||
if (removed) this.healthCache.delete(id);
|
||||
if (removed) {
|
||||
this.healthCache.delete(id);
|
||||
this.runtimeCache.delete(id);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
@@ -84,11 +93,29 @@ export class MachineService {
|
||||
return health;
|
||||
}
|
||||
|
||||
async runtime(id: string): Promise<MachineRuntime | undefined> {
|
||||
const cached = this.runtimeCache.get(id);
|
||||
const now = this.now().getTime();
|
||||
if (cached !== undefined && cached.expiresAt > now) return cached.runtime;
|
||||
|
||||
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
|
||||
if (runtime === undefined) return undefined;
|
||||
this.runtimeCache.set(id, { expiresAt: now + (this.deps.runtimeCacheTtlMs ?? DEFAULT_HEALTH_CACHE_TTL_MS), runtime });
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private async localHealth(): Promise<MachineHealth> {
|
||||
const checkedAt = this.now().toISOString();
|
||||
try {
|
||||
const status = await (this.deps.localStatus ?? getPiWebStatus)();
|
||||
return { machineId: "local", ok: true, checkedAt, status: "online", web: status.components.web, sessiond: status.components.sessiond };
|
||||
const runtime = await (this.deps.localRuntime ?? getPiWebRuntime)();
|
||||
return {
|
||||
machineId: "local",
|
||||
ok: true,
|
||||
checkedAt,
|
||||
status: "online",
|
||||
web: componentStatusFromRuntime(runtime.components.web),
|
||||
sessiond: componentStatusFromRuntime(runtime.components.sessiond),
|
||||
};
|
||||
} catch (error) {
|
||||
return { machineId: "local", ok: false, checkedAt, status: "error", error: errorMessage(error) };
|
||||
}
|
||||
@@ -109,6 +136,28 @@ export class MachineService {
|
||||
}
|
||||
}
|
||||
|
||||
private async localRuntime(): Promise<MachineRuntime> {
|
||||
const checkedAt = this.now().toISOString();
|
||||
try {
|
||||
return machineRuntime("local", checkedAt, await (this.deps.localRuntime ?? getPiWebRuntime)());
|
||||
} catch (error) {
|
||||
return { machineId: "local", ok: false, checkedAt, error: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
private async remoteRuntime(id: string): Promise<MachineRuntime | undefined> {
|
||||
const machine = await this.storedRemote(id);
|
||||
if (machine === undefined) return undefined;
|
||||
const checkedAt = this.now().toISOString();
|
||||
try {
|
||||
const response = await this.clientFor(machine).requestJson("GET", "/api/pi-web/runtime", undefined, { timeoutMs: DEFAULT_REMOTE_HEALTH_TIMEOUT_MS });
|
||||
if (response.statusCode >= 200 && response.statusCode < 300 && isPiWebRuntimeResponse(response.body)) return machineRuntime(id, checkedAt, response.body);
|
||||
return { machineId: id, ok: false, checkedAt, error: `Remote runtime returned HTTP ${String(response.statusCode)}` };
|
||||
} catch (error) {
|
||||
return { machineId: id, ok: false, checkedAt, error: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
private clientFor(machine: StoredMachine): MachineClient {
|
||||
return this.deps.remoteClientFactory?.(machine) ?? new RemoteMachineClient(machine);
|
||||
}
|
||||
@@ -162,6 +211,29 @@ function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function componentStatusFromRuntime(runtime: PiWebRuntimeComponent): PiWebComponentStatus {
|
||||
return {
|
||||
component: runtime.component,
|
||||
label: runtime.label,
|
||||
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
|
||||
stale: false,
|
||||
available: runtime.available,
|
||||
...(runtime.error === undefined ? {} : { error: runtime.error }),
|
||||
};
|
||||
}
|
||||
|
||||
function machineRuntime(machineId: string, checkedAt: string, runtime: PiWebRuntimeResponse): MachineRuntime {
|
||||
return {
|
||||
machineId,
|
||||
ok: true,
|
||||
checkedAt,
|
||||
packageName: runtime.packageName,
|
||||
generatedAt: runtime.generatedAt,
|
||||
components: runtime.components,
|
||||
capabilities: runtime.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
|
||||
if (!isRecord(value)) return false;
|
||||
const components = value["components"];
|
||||
@@ -169,6 +241,16 @@ function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
|
||||
return isPiWebComponentStatus(components["web"]) && isPiWebComponentStatus(components["sessiond"]);
|
||||
}
|
||||
|
||||
function isPiWebRuntimeResponse(value: unknown): value is PiWebRuntimeResponse {
|
||||
if (!isRecord(value)) return false;
|
||||
const packageName = value["packageName"];
|
||||
const generatedAt = value["generatedAt"];
|
||||
const components = value["components"];
|
||||
const capabilities = value["capabilities"];
|
||||
if (typeof packageName !== "string" || typeof generatedAt !== "string" || !isRecord(components) || !isPiWebCapabilityArray(capabilities)) return false;
|
||||
return isPiWebRuntimeComponent(components["web"]) && isPiWebRuntimeComponent(components["sessiond"]);
|
||||
}
|
||||
|
||||
function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
|
||||
if (!isRecord(value)) return false;
|
||||
const component = value["component"];
|
||||
@@ -178,6 +260,19 @@ function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
|
||||
&& typeof value["available"] === "boolean";
|
||||
}
|
||||
|
||||
function isPiWebRuntimeComponent(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
const component = value["component"];
|
||||
return (component === "web" || component === "sessiond")
|
||||
&& typeof value["label"] === "string"
|
||||
&& typeof value["available"] === "boolean"
|
||||
&& isPiWebCapabilityArray(value["capabilities"]);
|
||||
}
|
||||
|
||||
function isPiWebCapabilityArray(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.every(isPiWebCapability);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("PiWebPluginService", () => {
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
|
||||
await expect(service.manifest()).resolves.toEqual({
|
||||
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local" })],
|
||||
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })],
|
||||
});
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u);
|
||||
@@ -35,6 +35,18 @@ describe("PiWebPluginService", () => {
|
||||
expect(asset?.content.toString("utf8")).toContain("export default");
|
||||
});
|
||||
|
||||
it("includes machine-specific preferences in plugin manifests", async () => {
|
||||
await writePlugin(join(tempDir, "plugins", "updates"), {
|
||||
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
|
||||
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] });
|
||||
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] });
|
||||
});
|
||||
|
||||
it("discovers Pi package plugins through an injected package provider", async () => {
|
||||
const packageDir = join(tempDir, "pkg");
|
||||
await writePlugin(packageDir, {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface PiWebPluginManifestEntry {
|
||||
module: string;
|
||||
source: string;
|
||||
scope: PiWebPluginScope;
|
||||
machineSpecific: boolean;
|
||||
}
|
||||
|
||||
export interface ConfiguredPiPackage {
|
||||
@@ -38,6 +39,7 @@ interface PluginRecord {
|
||||
version: string;
|
||||
source: string;
|
||||
scope: PiWebPluginScope;
|
||||
machineSpecific: boolean;
|
||||
}
|
||||
|
||||
interface PiWebPluginServiceOptions {
|
||||
@@ -61,6 +63,7 @@ interface PiWebPackageConfig {
|
||||
interface PiWebPluginEntry {
|
||||
id: string;
|
||||
module: string;
|
||||
machineSpecific: boolean;
|
||||
}
|
||||
|
||||
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
||||
@@ -102,7 +105,7 @@ export class PiWebPluginService {
|
||||
return {
|
||||
plugins: (await this.plugins()).plugins
|
||||
.filter((plugin) => plugin.enabled)
|
||||
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope })),
|
||||
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope, machineSpecific: plugin.machineSpecific })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,6 +138,7 @@ export class PiWebPluginService {
|
||||
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
|
||||
source: plugin.source,
|
||||
scope: plugin.scope,
|
||||
machineSpecific: plugin.machineSpecific,
|
||||
enabled: config.plugins?.[plugin.id]?.enabled !== false,
|
||||
};
|
||||
}
|
||||
@@ -228,7 +232,7 @@ async function discoverPluginEntries(root: string, config: PiWebPackageConfig):
|
||||
const entryPath = join(root, entry.module);
|
||||
const entryStat = await stat(entryPath).catch(() => undefined);
|
||||
if (entryStat?.isFile() !== true) throw new Error(`PI WEB plugin module not found for ${entry.id}: ${entry.module}`);
|
||||
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)) });
|
||||
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)), machineSpecific: entry.machineSpecific });
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
@@ -248,7 +252,7 @@ async function readPiWebPackageConfig(root: string): Promise<PiWebPackageConfig
|
||||
}
|
||||
|
||||
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
|
||||
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module } entries`);
|
||||
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module, machineSpecific? } entries`);
|
||||
const plugins = piWeb["plugins"];
|
||||
if (plugins === undefined) return [];
|
||||
if (!Array.isArray(plugins)) throw new Error(`PI WEB plugins must be an array in ${packagePath}`);
|
||||
@@ -259,10 +263,26 @@ function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string)
|
||||
const module = entry["module"];
|
||||
if (typeof id !== "string" || !isPiWebPluginId(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`);
|
||||
if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`);
|
||||
return { id, module };
|
||||
return { id, module, machineSpecific: parseMachineSpecific(entry["machineSpecific"], packagePath, id) };
|
||||
});
|
||||
}
|
||||
|
||||
function parseMachineSpecific(value: unknown, packagePath: string, pluginId: string): boolean {
|
||||
if (value === undefined) return false;
|
||||
if (typeof value !== "boolean") throw new Error(`Invalid PI WEB plugin machineSpecific value for ${pluginId} in ${packagePath}: ${formatUnknownValue(value)}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatUnknownValue(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return Object.prototype.toString.call(value);
|
||||
}
|
||||
}
|
||||
|
||||
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
|
||||
if (records.has(plugin.id)) {
|
||||
warnInvalidPlugin(plugin.source, `Duplicate PI WEB plugin id: ${plugin.id}`);
|
||||
|
||||
+136
-41
@@ -1,12 +1,14 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
||||
import { parsePiWebComponentStatus } from "../shared/piWebStatusParsing.js";
|
||||
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
||||
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
|
||||
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
||||
@@ -32,6 +34,8 @@ interface NativeServiceCommands {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const serviceRefs: Record<ServiceId, NativeServiceRef> = {
|
||||
sessiond: {
|
||||
id: "sessiond",
|
||||
@@ -61,10 +65,35 @@ interface PackageInfo {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PiWebStatusDaemon {
|
||||
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
|
||||
}
|
||||
|
||||
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
|
||||
|
||||
const runtimePackageInfo = readPackageInfoSync();
|
||||
|
||||
export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent {
|
||||
return {
|
||||
component,
|
||||
label: component === "web" ? "Web/UI" : "Session daemon",
|
||||
runtimeVersion: runtimePackageInfo?.version ?? DEFAULT_VERSION,
|
||||
available: true,
|
||||
capabilities: [...capabilities],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebRuntimeResponse> {
|
||||
const web = getPiWebRuntimeComponent("web", WEB_RUNTIME_CAPABILITIES);
|
||||
const sessiond = await getSessiondRuntimeComponent(daemon);
|
||||
return {
|
||||
packageName: PI_WEB_PACKAGE_NAME,
|
||||
generatedAt: new Date().toISOString(),
|
||||
components: { web, sessiond },
|
||||
capabilities: effectivePiWebCapabilities({ web, sessiond }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise<PiWebComponentStatus> {
|
||||
const [installed, installation] = await Promise.all([
|
||||
readInstalledPackageInfo(),
|
||||
@@ -83,7 +112,7 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent):
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
|
||||
export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
|
||||
const [web, sessiond] = await Promise.all([
|
||||
getPiWebComponentStatus("web"),
|
||||
getSessiondComponentStatus(daemon),
|
||||
@@ -95,12 +124,12 @@ export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()):
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
|
||||
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
|
||||
const versionStatus = await getPiWebVersionStatus(daemon);
|
||||
const { web, sessiond } = versionStatus.components;
|
||||
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
|
||||
const components = { web, sessiond };
|
||||
const commands = commandsFor(components);
|
||||
const commands = await commandsFor(components);
|
||||
const messages = buildMessages(components, release, commands);
|
||||
return {
|
||||
...versionStatus,
|
||||
@@ -187,18 +216,21 @@ async function detectPiPackageInstallation(realRoot: string, displayPath: string
|
||||
}
|
||||
|
||||
async function detectNpmGlobalInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
|
||||
const npmRoot = npmGlobalRoot();
|
||||
const npmRoot = await npmGlobalRoot();
|
||||
if (npmRoot === undefined) return undefined;
|
||||
const realNpmRoot = await realPathOrSelf(npmRoot);
|
||||
if (!isSameOrWithin(realNpmRoot, realRoot)) return undefined;
|
||||
return { kind: "npm-global", path: displayPath, npmRoot };
|
||||
}
|
||||
|
||||
function npmGlobalRoot(): string | undefined {
|
||||
const result = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
|
||||
if (result.status !== 0) return undefined;
|
||||
const root = result.stdout.trim();
|
||||
return root === "" ? undefined : root;
|
||||
async function npmGlobalRoot(): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("npm", ["root", "-g"], { encoding: "utf8" });
|
||||
const root = stdout.trim();
|
||||
return root === "" ? undefined : root;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function packageRootPath(): string {
|
||||
@@ -214,21 +246,78 @@ function isSameOrWithin(parent: string, candidate: string): boolean {
|
||||
return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
|
||||
}
|
||||
|
||||
async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise<PiWebComponentStatus> {
|
||||
async function getSessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<PiWebRuntimeComponent> {
|
||||
try {
|
||||
const upstream = await daemon.request("GET", "/health");
|
||||
const upstream = await daemon.request("GET", "/runtime");
|
||||
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
|
||||
return unavailableSessiond(`health check returned HTTP ${String(upstream.statusCode)}`);
|
||||
return await legacySessiondRuntimeComponent(daemon) ?? unavailableSessiondRuntime(`runtime check returned HTTP ${String(upstream.statusCode)}`);
|
||||
}
|
||||
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
|
||||
const version = isRecord(parsed) ? parsed["version"] : undefined;
|
||||
const component = parsePiWebComponentStatus(version);
|
||||
return component ?? unavailableSessiond("health response did not include version information");
|
||||
const runtime = parsePiWebRuntimeComponent(parsed);
|
||||
if (runtime !== undefined) return runtime;
|
||||
const legacyVersion = isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
|
||||
if (legacyVersion !== undefined) return runtimeComponentFromStatus(legacyVersion);
|
||||
return await legacySessiondRuntimeComponent(daemon) ?? unavailableSessiondRuntime("runtime response did not include valid runtime information");
|
||||
} catch (error) {
|
||||
return unavailableSessiondRuntime(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function getSessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<PiWebComponentStatus> {
|
||||
try {
|
||||
const upstream = await daemon.request("GET", "/runtime");
|
||||
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
|
||||
return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(`runtime check returned HTTP ${String(upstream.statusCode)}`);
|
||||
}
|
||||
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
|
||||
const legacyVersion = isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
|
||||
if (legacyVersion !== undefined) return legacyVersion;
|
||||
const runtime = parsePiWebRuntimeComponent(parsed);
|
||||
if (runtime?.available !== true) return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(runtime?.error ?? "runtime response did not include valid runtime information");
|
||||
const status = await getPiWebComponentStatus("sessiond");
|
||||
return { ...status, ...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }), available: true };
|
||||
} catch (error) {
|
||||
return unavailableSessiond(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function legacySessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<PiWebRuntimeComponent | undefined> {
|
||||
const status = await legacySessiondComponentStatus(daemon);
|
||||
return status === undefined ? undefined : runtimeComponentFromStatus(status);
|
||||
}
|
||||
|
||||
async function legacySessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<PiWebComponentStatus | undefined> {
|
||||
try {
|
||||
const upstream = await daemon.request("GET", "/health");
|
||||
if (upstream.statusCode < 200 || upstream.statusCode >= 300) return undefined;
|
||||
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
|
||||
return isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeComponentFromStatus(status: PiWebComponentStatus): PiWebRuntimeComponent {
|
||||
return {
|
||||
component: status.component,
|
||||
label: status.label,
|
||||
...(status.runtimeVersion === undefined ? {} : { runtimeVersion: status.runtimeVersion }),
|
||||
available: status.available,
|
||||
capabilities: [],
|
||||
...(status.error === undefined ? {} : { error: status.error }),
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableSessiondRuntime(error: string): PiWebRuntimeComponent {
|
||||
return {
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
available: false,
|
||||
capabilities: [],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableSessiond(error: string): PiWebComponentStatus {
|
||||
return {
|
||||
component: "sessiond",
|
||||
@@ -282,15 +371,17 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
|
||||
return version;
|
||||
}
|
||||
|
||||
function commandsFor(components: PiWebStatusResponse["components"]): PiWebStatusResponse["commands"] {
|
||||
async function commandsFor(components: PiWebStatusResponse["components"]): Promise<PiWebStatusResponse["commands"]> {
|
||||
const installation = preferredInstallation(components);
|
||||
const serviceCommands = nativeServiceCommands();
|
||||
const cliCommands = piWebCliCommands(installation);
|
||||
const [serviceCommands, cliCommands] = await Promise.all([
|
||||
nativeServiceCommands(),
|
||||
piWebCliCommands(installation),
|
||||
]);
|
||||
const restart = restartCommandFor(installation, serviceCommands, cliCommands);
|
||||
const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
|
||||
const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
|
||||
const status = serviceCommands.status ?? cliCommands.status;
|
||||
const update = updateCommandFor(installation, restart);
|
||||
const update = await updateCommandFor(installation, restart);
|
||||
|
||||
return {
|
||||
...(update === undefined ? {} : { update }),
|
||||
@@ -308,8 +399,8 @@ function preferredInstallation(components: PiWebStatusResponse["components"]): P
|
||||
return web ?? sessiond;
|
||||
}
|
||||
|
||||
function piWebCliCommands(installation: PiWebInstallationInfo | undefined): NativeServiceCommands {
|
||||
if (installation?.kind !== "npm-global" || !hasCommand("pi-web")) return {};
|
||||
async function piWebCliCommands(installation: PiWebInstallationInfo | undefined): Promise<NativeServiceCommands> {
|
||||
if (installation?.kind !== "npm-global" || !(await hasCommand("pi-web"))) return {};
|
||||
return { restart: "pi-web restart", status: "pi-web status" };
|
||||
}
|
||||
|
||||
@@ -318,22 +409,22 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
|
||||
return cliCommands.restart ?? serviceCommands.restart;
|
||||
}
|
||||
|
||||
function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): string | undefined {
|
||||
async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): Promise<string | undefined> {
|
||||
if (restartCommand === undefined) return undefined;
|
||||
if (installation?.kind === "pi-package") {
|
||||
if (!hasCommand("pi")) return undefined;
|
||||
if (!(await hasCommand("pi"))) return undefined;
|
||||
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`;
|
||||
}
|
||||
if (installation?.kind === "local" && installation.path !== undefined) {
|
||||
if (!hasCommand("npm") || !isGitCheckoutWithUpstream(installation.path)) return undefined;
|
||||
if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined;
|
||||
return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`;
|
||||
}
|
||||
if (installation?.kind !== "npm-global" || !hasCommand("npm")) return undefined;
|
||||
if (installation?.kind !== "npm-global" || !(await hasCommand("npm"))) return undefined;
|
||||
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`;
|
||||
}
|
||||
|
||||
function nativeServiceCommands(): NativeServiceCommands {
|
||||
const backend = nativeServiceBackend();
|
||||
async function nativeServiceCommands(): Promise<NativeServiceCommands> {
|
||||
const backend = await nativeServiceBackend();
|
||||
if (backend === undefined) return {};
|
||||
const installed = installedServiceIds(backend);
|
||||
if (installed.size === 0) return {};
|
||||
@@ -349,9 +440,9 @@ function nativeServiceCommands(): NativeServiceCommands {
|
||||
};
|
||||
}
|
||||
|
||||
function nativeServiceBackend(): NativeServiceBackendKind | undefined {
|
||||
if (process.platform === "linux" && hasCommand("systemctl")) return "systemd";
|
||||
if (process.platform === "darwin" && hasCommand("launchctl")) return "launchd";
|
||||
async function nativeServiceBackend(): Promise<NativeServiceBackendKind | undefined> {
|
||||
if (process.platform === "linux" && await hasCommand("systemctl")) return "systemd";
|
||||
if (process.platform === "darwin" && await hasCommand("launchctl")) return "launchd";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -385,19 +476,23 @@ function statusNativeServicesCommand(backend: NativeServiceBackendKind, refs: Na
|
||||
return refs.map((ref) => `launchctl print gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
|
||||
}
|
||||
|
||||
function isGitCheckoutWithUpstream(path: string): boolean {
|
||||
return hasCommand("git")
|
||||
&& commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
|
||||
&& commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
||||
async function isGitCheckoutWithUpstream(path: string): Promise<boolean> {
|
||||
return await hasCommand("git")
|
||||
&& await commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
|
||||
&& await commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
||||
}
|
||||
|
||||
function hasCommand(command: string): boolean {
|
||||
function hasCommand(command: string): Promise<boolean> {
|
||||
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]);
|
||||
}
|
||||
|
||||
function commandSucceeds(command: string, args: string[]): boolean {
|
||||
const result = spawnSync(command, args, { encoding: "utf8" });
|
||||
return result.status === 0;
|
||||
async function commandSucceeds(command: string, args: string[]): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync(command, args, { encoding: "utf8" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
|
||||
describe("createPiWebStatusCache", () => {
|
||||
it("serves cached status while it is fresh", async () => {
|
||||
const now = 1_000;
|
||||
const load = vi.fn(() => Promise.resolve(status("first")));
|
||||
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
|
||||
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns stale status immediately while refreshing in the background", async () => {
|
||||
let now = 1_000;
|
||||
const load = vi.fn()
|
||||
.mockResolvedValueOnce(status("first"))
|
||||
.mockResolvedValueOnce(status("second"));
|
||||
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
|
||||
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
|
||||
now = 1_101;
|
||||
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
|
||||
await waitForMicrotasks();
|
||||
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent cold loads", async () => {
|
||||
const deferred = createDeferred<PiWebStatusResponse>();
|
||||
const load = vi.fn(() => deferred.promise);
|
||||
const cache = createPiWebStatusCache(load);
|
||||
|
||||
const first = cache.get();
|
||||
const second = cache.get();
|
||||
deferred.resolve(status("ready"));
|
||||
|
||||
await expect(first).resolves.toMatchObject({ generatedAt: "ready" });
|
||||
await expect(second).resolves.toMatchObject({ generatedAt: "ready" });
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function status(generatedAt: string): 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 },
|
||||
commands: {},
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
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,46 @@
|
||||
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
|
||||
|
||||
const DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
export interface PiWebStatusCacheOptions {
|
||||
ttlMs?: number;
|
||||
now?: () => number;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface PiWebStatusCache {
|
||||
get(): Promise<PiWebStatusResponse>;
|
||||
refresh(): Promise<PiWebStatusResponse>;
|
||||
}
|
||||
|
||||
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS;
|
||||
const now = options.now ?? Date.now;
|
||||
let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined;
|
||||
let pending: Promise<PiWebStatusResponse> | undefined;
|
||||
|
||||
const refresh = (): Promise<PiWebStatusResponse> => {
|
||||
pending ??= Promise.resolve()
|
||||
.then(load)
|
||||
.then((status) => {
|
||||
cached = { status, expiresAt: now() + ttlMs };
|
||||
return status;
|
||||
})
|
||||
.finally(() => {
|
||||
pending = undefined;
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
|
||||
return {
|
||||
async get(): Promise<PiWebStatusResponse> {
|
||||
if (cached !== undefined) {
|
||||
if (cached.expiresAt > now()) return cached.status;
|
||||
void refresh().catch((error: unknown) => { options.onError?.(error); });
|
||||
return cached.status;
|
||||
}
|
||||
return refresh();
|
||||
},
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
+19
-7
@@ -13,7 +13,8 @@ import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
|
||||
import { sessiondSocketPath } from "../sessiond/config.js";
|
||||
import { TerminalService } from "./terminals/terminalService.js";
|
||||
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
|
||||
import { getPiWebComponentStatus } from "./piWebStatus.js";
|
||||
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
|
||||
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
await app.register(fastifyWebsocket);
|
||||
@@ -29,12 +30,23 @@ registerAuthRoutes(app, auth);
|
||||
registerSessionRoutes(app, sessions, eventHub);
|
||||
registerTerminalRoutes(app, terminals);
|
||||
|
||||
app.get("/health", async () => ({
|
||||
ok: true,
|
||||
activeSessions: sessions.activeCount(),
|
||||
checkedAt: new Date().toISOString(),
|
||||
version: await getPiWebComponentStatus("sessiond"),
|
||||
}));
|
||||
app.get("/health", () => {
|
||||
const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES);
|
||||
return {
|
||||
ok: true,
|
||||
activeSessions: sessions.activeCount(),
|
||||
checkedAt: new Date().toISOString(),
|
||||
version: {
|
||||
component: runtime.component,
|
||||
label: runtime.label,
|
||||
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
|
||||
stale: false,
|
||||
available: runtime.available,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES));
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown(signal: NodeJS.Signals): Promise<void> {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon: Session
|
||||
};
|
||||
|
||||
app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply));
|
||||
app.get(`${prefix}/sessiond/runtime`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/runtime` }, reply));
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
|
||||
bridgeSockets(socket, daemon.connectWebSocket(stripPrefix(request.url, prefix)));
|
||||
|
||||
@@ -49,8 +49,9 @@ function sessionRef(id: string, cwd = "/workspace") {
|
||||
|
||||
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
|
||||
const promptCalls: { text: string; options: unknown }[] = [];
|
||||
const bindExtensionCalls: unknown[] = [];
|
||||
const listeners: ((event: unknown) => void)[] = [];
|
||||
const calls = { abort: 0, clearQueue: 0, dispose: 0, prompt: promptCalls };
|
||||
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls };
|
||||
const session: TestSession = {
|
||||
sessionId,
|
||||
sessionFile: `/tmp/${sessionId}.jsonl`,
|
||||
@@ -75,6 +76,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
|
||||
if (index !== -1) listeners.splice(index, 1);
|
||||
};
|
||||
},
|
||||
bindExtensions: (bindings: unknown) => {
|
||||
calls.bindExtensions.push(bindings);
|
||||
return Promise.resolve();
|
||||
},
|
||||
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
|
||||
getContextUsage: () => undefined,
|
||||
prompt: (text: string, options: unknown) => {
|
||||
@@ -149,6 +154,7 @@ describe("PiSessionService", () => {
|
||||
const session = await service.start("/workspace");
|
||||
|
||||
expect(createCalls).toBe(1);
|
||||
expect(fake.calls.bindExtensions).toHaveLength(1);
|
||||
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
|
||||
expect(service.activeCount()).toBe(1);
|
||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
|
||||
@@ -158,6 +164,59 @@ describe("PiSessionService", () => {
|
||||
expect(fake.calls.dispose).toBe(1);
|
||||
});
|
||||
|
||||
it("binds extensions again when the SDK runtime replaces the active session", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("session-1");
|
||||
const replacement = fakeRuntime("session-2");
|
||||
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
|
||||
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
Object.defineProperty(fake.runtime, "session", { configurable: true, value: replacement.session });
|
||||
await rebindSession?.(replacement.session);
|
||||
|
||||
expect(fake.calls.bindExtensions).toHaveLength(1);
|
||||
expect(replacement.calls.bindExtensions).toHaveLength(1);
|
||||
expect(service.activeCount()).toBe(1);
|
||||
expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" });
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("publishes extension errors reported while binding session extensions", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("extension-session", {
|
||||
bindExtensions: (bindings) => {
|
||||
bindings.onError?.({ extensionPath: "pi-mcp-adapter", event: "session_start", error: "MCP failed" });
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
expect(hub.sessionEvents).toContainEqual({
|
||||
sessionId: "extension-session",
|
||||
event: { type: "session.error", message: "pi-mcp-adapter: MCP failed" },
|
||||
});
|
||||
const extensionErrorActivity = hub.globalEvents.find((event) => event.type === "activity.update" && event.activity.sessionId === "extension-session");
|
||||
expect(extensionErrorActivity).toMatchObject({
|
||||
type: "activity.update",
|
||||
activity: { sessionId: "extension-session", phase: "error", label: "extension error", detail: "pi-mcp-adapter: MCP failed" },
|
||||
});
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("clears stale active activity once a previously active session becomes idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
let service: PiSessionService | undefined;
|
||||
@@ -318,6 +377,33 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("permanently deletes archived sessions through the archive store", async () => {
|
||||
const deletedSessionIds: string[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
||||
? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
|
||||
: undefined),
|
||||
archive: () => { throw new Error("archive should not be called for records that already have archive files"); },
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: (sessionId) => {
|
||||
deletedSessionIds.push(sessionId);
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
sessionManager: sessionGateway([sessionRecord("active")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.deleteArchived("arch")).resolves.toBeUndefined();
|
||||
await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found");
|
||||
|
||||
expect(deletedSessionIds).toEqual(["archived"]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reconciles workspace activity when listing only archived sessions", async () => {
|
||||
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
@@ -365,6 +451,20 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects malformed prompt text before opening the runtime", async () => {
|
||||
const fake = fakeRuntime("prompt-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("includes queued message details in session status", async () => {
|
||||
const fake = fakeRuntime("status-session", {
|
||||
messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }],
|
||||
|
||||
@@ -54,7 +54,18 @@ interface QueuedPrompt {
|
||||
text: string;
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived">;
|
||||
function requirePromptText(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("Prompt text is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === "steer" || value === "followUp") return value;
|
||||
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & { deleteArchived?: (sessionId: string) => Promise<void> };
|
||||
|
||||
export type PiSessionRef = ClientSessionRef;
|
||||
|
||||
@@ -95,6 +106,17 @@ export interface PiSessionManagerGateway {
|
||||
open(path: string): PiSessionManager;
|
||||
}
|
||||
|
||||
interface PiExtensionError {
|
||||
extensionPath: string;
|
||||
event: string;
|
||||
error: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
interface PiExtensionBindings {
|
||||
onError?: (error: PiExtensionError) => void;
|
||||
}
|
||||
|
||||
export interface PiAgentSession {
|
||||
modelRegistry: ModelRegistryInstance;
|
||||
sessionManager: PiSessionManager;
|
||||
@@ -113,6 +135,7 @@ export interface PiAgentSession {
|
||||
promptTemplates: readonly { name: string; description?: string }[];
|
||||
resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } };
|
||||
subscribe(listener: (event: unknown) => void): () => void;
|
||||
bindExtensions(bindings: PiExtensionBindings): Promise<void>;
|
||||
compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>;
|
||||
getUserMessagesForForking(): readonly { entryId: string; text: string }[];
|
||||
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
|
||||
@@ -377,22 +400,24 @@ export class PiSessionService {
|
||||
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async prompt(ref: PiSessionLookup, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
|
||||
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown): Promise<void> {
|
||||
const promptText = requirePromptText(text);
|
||||
const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.maybeGenerateSessionName(session, text);
|
||||
this.maybeGenerateSessionName(session, promptText);
|
||||
const isQueued = session.isStreaming || session.isCompacting;
|
||||
const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined;
|
||||
if (isQueued && this.hasQueuedMessageText(session, text)) {
|
||||
const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
|
||||
if (isQueued && this.hasQueuedMessageText(session, promptText)) {
|
||||
this.publishActivity(session, "duplicate queued message ignored", "active");
|
||||
this.publishStatus(session);
|
||||
return;
|
||||
}
|
||||
if (session.isCompacting) {
|
||||
this.enqueuePromptDuringCompaction(session, text, behavior ?? "followUp");
|
||||
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp");
|
||||
return;
|
||||
}
|
||||
void this.submitPrompt(session, text, behavior);
|
||||
void this.submitPrompt(session, promptText, behavior);
|
||||
}
|
||||
|
||||
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined): Promise<void> {
|
||||
@@ -497,6 +522,16 @@ export class PiSessionService {
|
||||
await this.archiveStore.restore(archived.sessionId);
|
||||
}
|
||||
|
||||
async deleteArchived(ref: PiSessionLookup): Promise<void> {
|
||||
const record = await this.getArchived(ref);
|
||||
if (record === undefined) throw new Error("Archived session not found");
|
||||
if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
|
||||
|
||||
await this.closeActive(record.sessionId);
|
||||
if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record);
|
||||
await this.archiveStore.deleteArchived(record.sessionId);
|
||||
}
|
||||
|
||||
async detachParent(ref: PiSessionLookup): Promise<void> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
const sessionFile = session.sessionFile;
|
||||
@@ -541,6 +576,12 @@ export class PiSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureArchivedRecordMoved(record: ArchivedSessionRecord): Promise<ArchivedSessionRecord> {
|
||||
const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
|
||||
if (session === undefined) return record;
|
||||
return this.archiveStore.archive(archiveInputFromListEntry(session));
|
||||
}
|
||||
|
||||
private async archiveInputForSession(session: PiAgentSession): Promise<ArchiveSessionInput> {
|
||||
const cwd = session.sessionManager.getCwd();
|
||||
const sessionFile = session.sessionFile;
|
||||
@@ -655,17 +696,28 @@ export class PiSessionService {
|
||||
|
||||
private async create(sessionManager: PiSessionManager, cwd: string): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
|
||||
await this.bindSessionExtensions(runtime.session);
|
||||
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
|
||||
this.bindRuntime(active);
|
||||
runtime.setRebindSession(() => {
|
||||
runtime.setRebindSession(async (session) => {
|
||||
await this.bindSessionExtensions(session);
|
||||
this.bindRuntime(active);
|
||||
return Promise.resolve();
|
||||
});
|
||||
this.active.set(runtime.session.sessionId, active);
|
||||
this.publishStatus(runtime.session);
|
||||
return active;
|
||||
}
|
||||
|
||||
private async bindSessionExtensions(session: PiAgentSession): Promise<void> {
|
||||
await session.bindExtensions({
|
||||
onError: (error) => {
|
||||
const message = `${error.extensionPath}: ${error.error}`;
|
||||
this.publishActivity(session, "extension error", "error", message);
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private bindRuntime(active: ActiveSession<PiSessionRuntime>): void {
|
||||
active.unsubscribe();
|
||||
const { session } = active.runtime;
|
||||
|
||||
@@ -44,6 +44,33 @@ describe("SessionArchiveStore", () => {
|
||||
expect(await exists(record.archivePath)).toBe(false);
|
||||
await expect(store.list()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("permanently deletes archived session files and records", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-delete-"));
|
||||
tempRoots.push(root);
|
||||
const activeDir = join(root, "active");
|
||||
await mkdir(activeDir, { recursive: true });
|
||||
const sourcePath = join(activeDir, "2026-01-01_s1.jsonl");
|
||||
await writeFile(sourcePath, "session contents\n", "utf8");
|
||||
|
||||
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
|
||||
const record = await store.archive({
|
||||
sessionId: "s1",
|
||||
cwd: "/workspace",
|
||||
path: sourcePath,
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:01:00.000Z",
|
||||
messageCount: 2,
|
||||
firstMessage: "hello",
|
||||
});
|
||||
|
||||
if (record.archivePath === undefined) throw new Error("Expected archive path");
|
||||
await store.deleteArchived("s1");
|
||||
|
||||
expect(await exists(sourcePath)).toBe(false);
|
||||
expect(await exists(record.archivePath)).toBe(false);
|
||||
await expect(store.list()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
|
||||
@@ -88,6 +88,18 @@ export class SessionArchiveStore {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteArchived(sessionId: string): Promise<void> {
|
||||
await this.exclusive(async () => {
|
||||
const data = await this.read();
|
||||
const record = data.sessions.find((session) => session.sessionId === sessionId);
|
||||
if (record === undefined) return;
|
||||
|
||||
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
|
||||
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
|
||||
await this.write({ sessions });
|
||||
});
|
||||
}
|
||||
|
||||
async isArchived(sessionId: string): Promise<boolean> {
|
||||
return (await this.get(sessionId)) !== undefined;
|
||||
}
|
||||
|
||||
@@ -15,4 +15,8 @@ describe("sessionNameGenerator", () => {
|
||||
expect(fallbackSessionName('<skill name="x" location="/x">\nDo x\n</skill>\n\nCheck the UI now'))
|
||||
.toBe("Check the UI now");
|
||||
});
|
||||
|
||||
it("skips fallback names when the first request is missing", () => {
|
||||
expect(fallbackSessionName(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,9 @@ export async function generateShortSessionName<TApi extends Api>(modelRegistry:
|
||||
return cleanSessionName(finalMessage === undefined ? streamedText : textFromAssistant(finalMessage));
|
||||
}
|
||||
|
||||
export function fallbackSessionName(firstMessage: string): string | undefined {
|
||||
export function fallbackSessionName(firstMessage: unknown): string | undefined {
|
||||
if (typeof firstMessage !== "string") return undefined;
|
||||
|
||||
return cleanSessionName(firstMessage
|
||||
.replace(/<skill name="[^"]+" location="[^"]+">[\s\S]*?<\/skill>/g, "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
|
||||
import { registerSessionRoutes } from "./sessionRoutes.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let service: PiSessionService;
|
||||
let sessionManager: RejectingSessionManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyWebsocket);
|
||||
sessionManager = new RejectingSessionManager();
|
||||
const eventHub = new SessionEventHub();
|
||||
service = new PiSessionService(eventHub, { sessionManager, heartbeatIntervalMs: 60_000 });
|
||||
registerSessionRoutes(app, service, eventHub);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await service.dispose();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("session routes", () => {
|
||||
it("rejects prompt payloads that omit text without opening a session", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { cwd: "/repo", body: "Build the thing" } });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Prompt text is required" });
|
||||
expect(sessionManager.calls).toEqual({ create: 0, list: 0, listAll: 0, open: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
class RejectingSessionManager implements PiSessionManagerGateway {
|
||||
readonly calls = { create: 0, list: 0, listAll: 0, open: 0 };
|
||||
|
||||
list() {
|
||||
this.calls.list += 1;
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
create(): never {
|
||||
this.calls.create += 1;
|
||||
throw new Error("Session manager should not create sessions for invalid prompt payloads");
|
||||
}
|
||||
|
||||
listAll() {
|
||||
this.calls.listAll += 1;
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
open(): never {
|
||||
this.calls.open += 1;
|
||||
throw new Error("Session manager should not open sessions for invalid prompt payloads");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ interface MessageQuery extends SessionQuery {
|
||||
|
||||
class SessionRouteValidationError extends Error {}
|
||||
|
||||
interface PromptRequestBody {
|
||||
cwd?: unknown;
|
||||
text?: unknown;
|
||||
streamingBehavior?: unknown;
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
|
||||
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
@@ -106,12 +112,10 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown; streamingBehavior?: "steer" | "followUp" } }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: PromptRequestBody | undefined }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
|
||||
try {
|
||||
const body = requireRecord(request.body);
|
||||
const streamingBehavior = body["streamingBehavior"];
|
||||
if (streamingBehavior !== undefined && streamingBehavior !== "steer" && streamingBehavior !== "followUp") throw new Error("streamingBehavior must be steer or followUp");
|
||||
await sessions.prompt(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"), streamingBehavior);
|
||||
await sessions.prompt(sessionRefFromBody(request.params.sessionId, body), body["text"], body["streamingBehavior"]);
|
||||
return { accepted: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
@@ -190,6 +194,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.deleteArchived(sessionRefFromQuery(request.params.sessionId, request.query));
|
||||
return { deleted: true };
|
||||
} catch (error) {
|
||||
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.detachParent(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
export type MachineKind = "local" | "remote";
|
||||
export type MachineStatus = "unknown" | "online" | "offline" | "error";
|
||||
|
||||
export const PI_WEB_CAPABILITIES = {
|
||||
sessionsDeleteArchived: "sessions.deleteArchived",
|
||||
} as const;
|
||||
|
||||
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
|
||||
|
||||
export interface Machine {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -22,6 +28,17 @@ export interface MachineHealth {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MachineRuntime {
|
||||
machineId: string;
|
||||
ok: boolean;
|
||||
checkedAt: string;
|
||||
packageName?: string;
|
||||
generatedAt?: string;
|
||||
components?: PiWebRuntimeResponse["components"];
|
||||
capabilities?: PiWebCapability[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type PiWebShortcutConfig = Record<string, string | null>;
|
||||
export type PiWebPluginSettings = Record<string, unknown>;
|
||||
export type PiWebPluginConfigMap = Record<string, PiWebPluginConfig>;
|
||||
@@ -47,6 +64,7 @@ export interface PiWebPluginInfo {
|
||||
module: string;
|
||||
source: string;
|
||||
scope: PiWebPluginScope;
|
||||
machineSpecific: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -340,6 +358,15 @@ export interface PiWebComponentStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PiWebRuntimeComponent {
|
||||
component: PiWebServiceComponent;
|
||||
label: string;
|
||||
runtimeVersion?: string;
|
||||
available: boolean;
|
||||
capabilities: PiWebCapability[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PiWebReleaseStatus {
|
||||
packageName: string;
|
||||
latestVersion?: string;
|
||||
@@ -366,6 +393,16 @@ export interface PiWebVersionResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PiWebRuntimeResponse {
|
||||
packageName: string;
|
||||
generatedAt: string;
|
||||
components: {
|
||||
web: PiWebRuntimeComponent;
|
||||
sessiond: PiWebRuntimeComponent;
|
||||
};
|
||||
capabilities: PiWebCapability[];
|
||||
}
|
||||
|
||||
export interface PiWebStatusResponse extends PiWebVersionResponse {
|
||||
release: PiWebReleaseStatus;
|
||||
commands: {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PI_WEB_CAPABILITIES, type PiWebCapability, type PiWebRuntimeComponent, type PiWebServiceComponent } from "./apiTypes.js";
|
||||
|
||||
export { PI_WEB_CAPABILITIES };
|
||||
export type { PiWebCapability };
|
||||
|
||||
export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES);
|
||||
const knownPiWebCapabilities: ReadonlySet<string> = new Set(KNOWN_PI_WEB_CAPABILITIES);
|
||||
|
||||
export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived] as const satisfies readonly PiWebCapability[];
|
||||
export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived] as const satisfies readonly PiWebCapability[];
|
||||
|
||||
const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
||||
[PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"],
|
||||
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
|
||||
|
||||
export function isPiWebCapability(value: unknown): value is PiWebCapability {
|
||||
return typeof value === "string" && knownPiWebCapabilities.has(value);
|
||||
}
|
||||
|
||||
export function supportsPiWebCapability(source: { capabilities?: readonly PiWebCapability[] } | undefined, capability: PiWebCapability): boolean {
|
||||
return source?.capabilities?.includes(capability) === true;
|
||||
}
|
||||
|
||||
export function effectivePiWebCapabilities(components: Partial<Record<PiWebServiceComponent, Pick<PiWebRuntimeComponent, "available" | "capabilities">>>): PiWebCapability[] {
|
||||
return KNOWN_PI_WEB_CAPABILITIES.filter((capability) => {
|
||||
const requiredComponents = EFFECTIVE_CAPABILITY_REQUIREMENTS[capability];
|
||||
return requiredComponents.every((component) => {
|
||||
const runtime = components[component];
|
||||
return runtime?.available === true && supportsPiWebCapability(runtime, capability);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface FederatedHttpRouteSpec {
|
||||
}
|
||||
|
||||
export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "GET", path: "/pi-web/status" },
|
||||
{ method: "GET", path: "/projects" },
|
||||
{ method: "POST", path: "/projects" },
|
||||
{ method: "DELETE", path: "/projects/:projectId" },
|
||||
@@ -48,6 +49,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "POST", path: "/sessions/:sessionId/archive" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/archive-tree" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/restore" },
|
||||
{ method: "DELETE", path: "/sessions/:sessionId" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
||||
{ method: "GET", path: "/auth/providers" },
|
||||
{ method: "POST", path: "/auth/api-key" },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebVersionResponse } from "./apiTypes.js";
|
||||
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js";
|
||||
import { isPiWebCapability } from "./capabilities.js";
|
||||
|
||||
export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
@@ -12,6 +13,39 @@ export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse
|
||||
return { packageName, generatedAt, components: { web, sessiond } };
|
||||
}
|
||||
|
||||
export function parsePiWebRuntimeResponse(value: unknown): PiWebRuntimeResponse | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const packageName = value["packageName"];
|
||||
const generatedAt = value["generatedAt"];
|
||||
const components = value["components"];
|
||||
if (typeof packageName !== "string" || packageName === "" || typeof generatedAt !== "string" || generatedAt === "" || !isRecord(components)) return undefined;
|
||||
const web = parsePiWebRuntimeComponent(components["web"]);
|
||||
const sessiond = parsePiWebRuntimeComponent(components["sessiond"]);
|
||||
const capabilities = parsePiWebCapabilities(value["capabilities"]);
|
||||
if (web === undefined || sessiond === undefined || capabilities === undefined) return undefined;
|
||||
return { packageName, generatedAt, components: { web, sessiond }, capabilities };
|
||||
}
|
||||
|
||||
export function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const component = value["component"];
|
||||
const label = value["label"];
|
||||
const runtimeVersion = value["runtimeVersion"];
|
||||
const available = value["available"];
|
||||
const capabilities = parsePiWebCapabilities(value["capabilities"]);
|
||||
const error = value["error"];
|
||||
if (component !== "web" && component !== "sessiond") return undefined;
|
||||
if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined;
|
||||
return {
|
||||
component,
|
||||
label,
|
||||
...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
|
||||
available,
|
||||
capabilities,
|
||||
...(typeof error === "string" ? { error } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const component = value["component"];
|
||||
@@ -36,6 +70,11 @@ export function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiWebCapabilities(value: unknown): PiWebCapability[] | undefined {
|
||||
if (!Array.isArray(value) || !value.every(isPiWebCapability)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const kind = value["kind"];
|
||||
|
||||
Reference in New Issue
Block a user