Archived
feat: add Pi Web status panel
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface WorkspacePanelVisibilityContext {
|
||||
|
||||
export interface WorkspacePanelContext {
|
||||
workspace: Workspace;
|
||||
state: AppState;
|
||||
fileTree: FileTreeEntry[];
|
||||
expandedDirs: Record<string, FileTreeEntry[]>;
|
||||
selectedFilePath: string | undefined;
|
||||
|
||||
Reference in New Issue
Block a user