feat: add local machine registry foundation

This commit is contained in:
Marc Kassubeck
2026-05-26 13:04:00 +02:00
parent 712456f953
commit 0405b384b1
18 changed files with 673 additions and 28 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { activityApi, api, filesApi, gitApi, machinesApi, piWebApi, 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, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, 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, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+9
View File
@@ -14,6 +14,8 @@ import {
parseFileTreeResponse,
parseGitDiffResponse,
parseGitStatusResponse,
parseMachine,
parseMachinesResponse,
parseMessagePage,
parseModelSelectionResponse,
parseOAuthFlowState,
@@ -36,6 +38,12 @@ export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
};
export const machinesApi = {
machines: () => request("/api/machines", parseMachinesResponse),
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
};
export const activityApi = {
workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse),
};
@@ -144,6 +152,7 @@ export const gitApi = {
export const api = {
...piWebApi,
...machinesApi,
...activityApi,
...projectsApi,
...workspacesApi,
+37 -1
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, 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, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -57,6 +57,42 @@ export function parseMessagePage(value: unknown): MessagePage {
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
}
export function parseMachinesResponse(value: unknown): Machine[] {
const record = requireRecord(value);
return arrayOf(parseMachine)(record["machines"]);
}
export function parseMachine(value: unknown): Machine {
const record = requireRecord(value);
const kind = requireMachineKind(record, "kind");
const baseUrl = optionalString(record, "baseUrl");
const status = optionalMachineStatus(record, "status");
const statusMessage = optionalString(record, "statusMessage");
return {
id: requireString(record, "id"),
name: requireString(record, "name"),
kind,
...(baseUrl === undefined ? {} : { baseUrl }),
createdAt: requireString(record, "createdAt"),
updatedAt: requireString(record, "updatedAt"),
...(status === undefined ? {} : { status }),
...(statusMessage === undefined ? {} : { statusMessage }),
};
}
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}`);
return value;
}
function optionalMachineStatus(record: Record<string, unknown>, key: string): MachineStatus | undefined {
const value = optionalString(record, key);
if (value === undefined) return undefined;
if (value !== "unknown" && value !== "online" && value !== "offline" && value !== "error") throw new Error(`Expected machine status field: ${key}`);
return value;
}
export function parseProject(value: unknown): Project {
const record = requireRecord(value);
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
+9 -1
View File
@@ -1,8 +1,12 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids";
export interface AppState {
machines: Machine[];
selectedMachine: Machine | undefined;
isLoadingMachines: boolean;
machineStatuses: Record<string, MachineHealth>;
projects: Project[];
workspaces: Workspace[];
sessions: SessionInfo[];
@@ -91,6 +95,10 @@ export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
export function initialAppState(): AppState {
return {
machines: [],
selectedMachine: undefined,
isLoadingMachines: false,
machineStatuses: {},
projects: [],
workspaces: [],
sessions: [],
+45
View File
@@ -0,0 +1,45 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { Machine } from "../api";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@customElement("machine-list")
export class MachineList extends LitElement {
@property({ attribute: false }) machines: Machine[] = [];
@property({ attribute: false }) selected?: Machine;
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
@property({ attribute: false }) onToggleCollapsed?: () => void;
override render() {
return html`
<section>
<h2>${this.renderHeading()}</h2>
${this.collapsed ? null : this.machines.map((machine) => html`
<div
class=${`action-row ${this.selected?.id === machine.id ? "selected" : ""}`}
tabindex="0"
title=${machine.baseUrl ?? machine.name}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }}
>
<div class="action-main">
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl}</small>
</div>
</div>
`)}
</section>
`;
}
private renderHeading() {
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>`;
}
static override styles = listStyles;
}
+24 -2
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import { piWebApi, terminalsApi, type Machine, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -8,6 +8,7 @@ import { ActivityController } from "../controllers/activityController";
import { AuthController } from "../controllers/authController";
import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController";
import { MachineController } from "../controllers/machineController";
import { ProjectController } from "../controllers/projectController";
import { SessionController } from "../controllers/sessionController";
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
@@ -25,6 +26,7 @@ import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMo
import { readRoute, writeRoute, type AppRoute } from "../route";
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
import "./MachineList";
import "./ProjectList";
import "./WorkspaceList";
import "./SessionList";
@@ -42,7 +44,7 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import { actionMenuPanelStyle } from "./actionMenu";
import { appStyles } from "./shared";
type NavigationSection = "projects" | "workspaces" | "sessions";
type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
@@ -86,6 +88,12 @@ export class PiWebApp extends LitElement {
(patch) => { this.setState(patch); },
this.workspaces,
);
private readonly machines = new MachineController(
() => this.state,
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
this.projects,
);
private readonly files = new FileExplorerController(
() => this.state,
(patch) => { this.setState(patch); },
@@ -252,6 +260,8 @@ export class PiWebApp extends LitElement {
}
private async loadProjectsAndRestoreRoute() {
const route = readRoute();
await this.machines.loadMachines(route.machineId);
await this.projects.loadProjects();
await this.withChatScrollTransition(() => this.restoreRoute(false));
await this.refreshWorkspaceDeletionRuns();
@@ -368,6 +378,7 @@ export class PiWebApp extends LitElement {
private updateUrl(options?: { replace?: boolean | undefined }) {
writeRoute({
machineId: this.state.selectedMachine?.id,
projectId: this.state.selectedProject?.id,
workspaceId: this.state.selectedWorkspace?.id,
sessionId: this.state.selectedSession?.id,
@@ -528,6 +539,17 @@ export class PiWebApp extends LitElement {
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.setState({ actionPaletteOpen: true }); }}>Actions</button>
</div>
</header>
<machine-list
.machines=${this.state.machines}
.selected=${this.state.selectedMachine}
.collapsible=${this.isMobileNavigationLayout}
.collapsed=${this.isNavigationSectionCollapsed("machines")}
.onToggleCollapsed=${() => { this.toggleNavigationSection("machines"); }}
.onSelect=${(machine: Machine) => this.withChatScrollTransition(async () => {
this.expandNavigationSection("projects");
await this.machines.selectMachine(machine);
})}
></machine-list>
<project-list
.projects=${this.state.projects}
.selected=${this.state.selectedProject}
@@ -0,0 +1,41 @@
import { api, type Machine } from "../api";
import { resetWorkspaceScopedState } from "../appState";
import type { GetState, SetState, UpdateUrl } from "./types";
import type { ProjectController } from "./projectController";
export class MachineController {
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl, private readonly projects: ProjectController) {}
async loadMachines(routeMachineId?: string): Promise<void> {
this.setState({ error: "", isLoadingMachines: true });
try {
const machines = await api.machines();
const selectedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local")) ?? machines.find((machine) => machine.id === "local") ?? machines[0];
this.setState({ machines, selectedMachine });
} catch (error) {
this.setState({ error: String(error) });
} finally {
this.setState({ isLoadingMachines: false });
}
}
async selectMachine(machine: Machine): Promise<void> {
if (this.getState().selectedMachine?.id === machine.id) return;
this.setState({
selectedMachine: machine,
projects: [],
workspaces: [],
selectedProject: undefined,
selectedWorkspace: undefined,
selectedSession: undefined,
messages: [],
messagePageStart: 0,
messagePageTotal: 0,
status: undefined,
activity: undefined,
...resetWorkspaceScopedState(),
});
this.updateUrl();
await this.projects.loadProjects();
}
}
+5 -3
View File
@@ -33,9 +33,10 @@ function installWindow(href: string): { pushed: string[] } {
describe("route helpers", () => {
it("reads only supported route fields from the current URL", () => {
installWindow("http://localhost/app?project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
installWindow("http://localhost/app?machine=remote&project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
expect(readRoute()).toEqual({
machineId: "remote",
projectId: "p1",
workspaceId: "w1",
sessionId: "s1",
@@ -53,6 +54,7 @@ describe("route helpers", () => {
it("writes compact URLs and preserves path/hash", () => {
const { pushed } = installWindow("http://localhost/app?old=1#section");
const route: AppRoute = {
machineId: "remote",
projectId: "project/id",
workspaceId: "workspace id",
sessionId: "",
@@ -62,13 +64,13 @@ describe("route helpers", () => {
writeRoute(route);
expect(pushed).toEqual(["http://localhost/app?old=1&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
expect(pushed).toEqual(["http://localhost/app?old=1&machine=remote&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
});
it("does not push history when the route is unchanged", () => {
const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git");
writeRoute({ projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
writeRoute({ machineId: undefined, projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
expect(pushed).toEqual([]);
});
+4
View File
@@ -1,6 +1,7 @@
import type { QualifiedContributionId } from "./plugins/types";
export interface AppRoute {
machineId: string | undefined;
projectId: string | undefined;
workspaceId: string | undefined;
sessionId: string | undefined;
@@ -11,6 +12,7 @@ export interface AppRoute {
export function readRoute(): AppRoute {
const params = new URLSearchParams(window.location.search);
return {
machineId: params.get("machine") ?? undefined,
projectId: params.get("project") ?? undefined,
workspaceId: params.get("workspace") ?? undefined,
sessionId: params.get("session") ?? undefined,
@@ -21,11 +23,13 @@ export function readRoute(): AppRoute {
export function writeRoute(route: AppRoute, options?: { replace?: boolean | undefined }): void {
const url = new URL(window.location.href);
url.searchParams.delete("machine");
url.searchParams.delete("project");
url.searchParams.delete("workspace");
url.searchParams.delete("session");
url.searchParams.delete("tool");
url.searchParams.delete("view");
if (route.machineId !== undefined && route.machineId !== "" && route.machineId !== "local") url.searchParams.set("machine", route.machineId);
if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId);
if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId);