feat: add Pi Web status panel

This commit is contained in:
Federico Jaramillo Martinez
2026-05-19 09:27:10 +02:00
parent c5dc6550cc
commit babb802912
19 changed files with 803 additions and 16 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
+6
View File
@@ -17,6 +17,7 @@ import {
parseMessagePage,
parseModelSelectionResponse,
parseOAuthFlowState,
parsePiWebStatusResponse,
parseProject,
parseRestored,
parseSessionInfo,
@@ -29,6 +30,10 @@ import {
} from "./parsers";
import { gitDiffUrl, messageUrl } from "./urls";
export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
};
export const projectsApi = {
projects: () => request("/api/projects", arrayOf(parseProject)),
addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
@@ -94,6 +99,7 @@ export const gitApi = {
};
export const api = {
...piWebApi,
...projectsApi,
...workspacesApi,
...sessionsApi,
+86 -1
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
import type { 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, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -298,6 +298,91 @@ export function parseTerminalInfo(value: unknown): TerminalInfo {
return { id: requireString(record, "id"), cwd: requireString(record, "cwd"), name: requireString(record, "name"), createdAt: requireString(record, "createdAt"), exited: requireBoolean(record, "exited"), ...optionalField("exitCode", optionalNumber(record, "exitCode")) };
}
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
const record = requireRecord(value);
return {
packageName: requireString(record, "packageName"),
generatedAt: requireString(record, "generatedAt"),
components: parsePiWebComponents(record["components"]),
release: parsePiWebReleaseStatus(record["release"]),
commands: parsePiWebCommands(record["commands"]),
messages: arrayOf(parsePiWebStatusMessage)(record["messages"]),
};
}
function parsePiWebComponents(value: unknown): PiWebStatusResponse["components"] {
const record = requireRecord(value);
return { web: parsePiWebComponentStatus(record["web"]), sessiond: parsePiWebComponentStatus(record["sessiond"]) };
}
function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus {
const record = requireRecord(value);
return {
component: parsePiWebServiceComponent(record["component"]),
label: requireString(record, "label"),
...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
...optionalField("installedVersion", optionalString(record, "installedVersion")),
stale: requireBoolean(record, "stale"),
available: requireBoolean(record, "available"),
...optionalField("installation", optionalPiWebInstallationInfo(record["installation"])),
...optionalField("error", optionalString(record, "error")),
};
}
function optionalPiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
if (value === undefined) return undefined;
const record = requireRecord(value);
const kind = requireString(record, "kind");
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") throw new Error("Invalid Pi Web installation kind");
const scope = record["scope"];
if (scope !== undefined && scope !== "user" && scope !== "project") throw new Error("Invalid Pi Web installation scope");
return {
kind,
...optionalField("path", optionalString(record, "path")),
...optionalField("source", optionalString(record, "source")),
...(scope === undefined ? {} : { scope }),
...optionalField("npmRoot", optionalString(record, "npmRoot")),
};
}
function parsePiWebReleaseStatus(value: unknown): PiWebReleaseStatus {
const record = requireRecord(value);
return {
packageName: requireString(record, "packageName"),
...optionalField("latestVersion", optionalString(record, "latestVersion")),
updateAvailable: requireBoolean(record, "updateAvailable"),
...optionalField("checkedAt", optionalString(record, "checkedAt")),
...(record["skipped"] === true ? { skipped: true } : {}),
...optionalField("error", optionalString(record, "error")),
};
}
function parsePiWebCommands(value: unknown): PiWebStatusResponse["commands"] {
const record = requireRecord(value);
return { update: requireString(record, "update"), restart: requireString(record, "restart"), restartSystemd: requireString(record, "restartSystemd"), restartDev: requireString(record, "restartDev") };
}
function parsePiWebStatusMessage(value: unknown): PiWebStatusMessage {
const record = requireRecord(value);
return {
id: requireString(record, "id"),
severity: parsePiWebStatusSeverity(record["severity"]),
title: requireString(record, "title"),
body: requireString(record, "body"),
...optionalField("command", optionalString(record, "command")),
};
}
function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent {
if (value !== "web" && value !== "sessiond") throw new Error("Invalid Pi Web service component");
return value;
}
function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity {
if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid Pi Web status severity");
return value;
}
export function parseCommandResult(value: unknown): CommandResult {
const record = requireRecord(value);
const type = requireString(record, "type");
+3 -1
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/types";
@@ -38,6 +38,7 @@ export interface AppState {
selectedStagedDiff: GitDiffResponse | undefined;
gitStale: boolean;
activeTerminalCount: number;
piWebStatus: PiWebStatusResponse | undefined;
error: string;
}
@@ -85,6 +86,7 @@ export function initialAppState(): AppState {
selectedStagedDiff: undefined,
gitStale: false,
activeTerminalCount: 0,
piWebStatus: undefined,
error: "",
};
}
+28 -5
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { AuthController } from "../controllers/authController";
@@ -36,6 +36,8 @@ import { appStyles } from "./shared";
type NavigationSection = "projects" | "workspaces" | "sessions";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
@customElement("pi-web-app")
export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState();
@@ -78,15 +80,22 @@ export class PiWebApp extends LitElement {
private readonly activeTerminalIds = new Set<string>();
private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined;
private terminalAutoStartWorkspaceId: string | undefined;
private piWebStatusTimer: number | undefined;
private readonly plugins = createPluginRegistry();
private preferredThemeId: QualifiedContributionId = readStoredThemeId() ?? DEFAULT_THEME_ID;
@state() private activeThemeId: QualifiedContributionId = DEFAULT_THEME_ID;
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
@state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined;
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
private readonly onFocus = () => { void this.sessions.refreshSelectedSession(); };
private readonly onFocus = () => {
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
};
private readonly onVisibilityChange = () => {
if (document.visibilityState === "visible") void this.sessions.refreshSelectedSession();
if (document.visibilityState === "visible") {
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
}
};
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
this.isMobileNavigationLayout = event.matches;
@@ -107,6 +116,8 @@ export class PiWebApp extends LitElement {
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
this.applyPreferredTheme(false);
this.connectRealtime();
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
void this.refreshPiWebStatus();
void this.loadExternalPlugins();
void this.loadProjectsAndRestoreRoute();
}
@@ -122,6 +133,8 @@ export class PiWebApp extends LitElement {
this.sessions.dispose();
this.realtime.close();
this.git.dispose();
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
this.piWebStatusTimer = undefined;
super.disconnectedCallback();
}
@@ -138,6 +151,14 @@ export class PiWebApp extends LitElement {
await this.withChatScrollTransition(() => this.restoreRoute(false));
}
private async refreshPiWebStatus(): Promise<void> {
try {
this.setState({ piWebStatus: await piWebApi.piWebStatus() });
} catch (error) {
console.warn("Failed to refresh Pi Web status", error);
}
}
private async restoreRoute(updateUrl: boolean) {
const route = readRoute();
const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file");
@@ -268,7 +289,7 @@ export class PiWebApp extends LitElement {
private renderWorkspacePanel() {
const workspaceLabelItems = this.state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, this.state.selectedWorkspace);
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .selectedStagedDiff=${this.state.selectedStagedDiff} .gitStale=${this.state.gitStale} .activeTerminalCount=${this.state.activeTerminalCount} .terminalAutoStart=${this.terminalAutoStartWorkspaceId === this.state.selectedWorkspace?.id} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .appState=${this.state} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .selectedStagedDiff=${this.state.selectedStagedDiff} .gitStale=${this.state.gitStale} .activeTerminalCount=${this.state.activeTerminalCount} .terminalAutoStart=${this.terminalAutoStartWorkspaceId === this.state.selectedWorkspace?.id} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
}
private renderNavigationPanel(autoSwitchToChat: boolean) {
@@ -351,7 +372,8 @@ export class PiWebApp extends LitElement {
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
const workspace = this.state.selectedWorkspace;
return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.({ workspace, state: this.state }) ?? true));
if (workspace === undefined) return [];
return this.plugins.getWorkspacePanels().filter((panel) => panel.visible?.({ workspace, state: this.state }) ?? true);
}
private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
@@ -365,6 +387,7 @@ export class PiWebApp extends LitElement {
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
return {
workspace,
state: this.state,
fileTree: this.state.fileTree,
expandedDirs: this.state.expandedDirs,
selectedFilePath: this.state.selectedFilePath,
@@ -1,6 +1,7 @@
import { LitElement, html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api";
import type { AppState } from "../appState";
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { workspacePanelStyles } from "./shared";
import { renderWorkspaceLabel } from "./workspaceLabel";
@@ -8,6 +9,7 @@ import { renderWorkspaceLabel } from "./workspaceLabel";
@customElement("workspace-panel")
export class WorkspacePanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined;
@property({ attribute: false }) appState!: AppState;
@property() tool: QualifiedContributionId = "core:workspace.files";
@property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = [];
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
@@ -65,6 +67,7 @@ export class WorkspacePanel extends LitElement {
private createPanelContext(workspace: Workspace): WorkspacePanelContext {
return {
workspace,
state: this.appState,
fileTree: this.fileTree,
expandedDirs: this.expandedDirs,
selectedFilePath: this.selectedFilePath,
+1
View File
@@ -75,6 +75,7 @@ export interface WorkspacePanelVisibilityContext {
export interface WorkspacePanelContext {
workspace: Workspace;
state: AppState;
fileTree: FileTreeEntry[];
expandedDirs: Record<string, FileTreeEntry[]>;
selectedFilePath: string | undefined;