Archived
feat: add local machine registry foundation
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") };
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user