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:
Federico Jaramillo Martinez
2026-06-10 20:43:10 +02:00
133 changed files with 4848 additions and 968 deletions
+1 -1
View File
@@ -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";
+32 -1
View File
@@ -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);
+12 -2
View File
@@ -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)),
+16 -3
View File
@@ -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 }],
});
});
+62 -1
View File
@@ -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"]);
});
});
+50 -6
View File
@@ -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;
}
+3 -1
View File
@@ -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: [],
+1 -22
View File
@@ -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);
+20 -8
View File
@@ -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`;
}
+281 -67
View File
@@ -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);
}
+26 -7
View File
@@ -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,
};
}
+256 -37
View File
@@ -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);
+12 -1
View File
@@ -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`
+2 -9
View File
@@ -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>
+21 -7
View File
@@ -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 {
+8 -11
View File
@@ -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 : "";
}
+7 -24
View File
@@ -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 {};
+131 -3
View File
@@ -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");
});
});
+328 -46
View File
@@ -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");
}
+2 -9
View File
@@ -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",
+12 -2
View File
@@ -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"];
+89 -3
View File
@@ -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", () => {
+62 -14
View File
@@ -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 {
+6
View File
@@ -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[]>;
+21 -1
View File
@@ -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 {
+8
View File
@@ -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];