feat: show machine activity indicators

This commit is contained in:
Federico Jaramillo Martinez
2026-06-04 11:54:21 +02:00
parent ed647a359a
commit 193c9d0f8f
11 changed files with 205 additions and 20 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show machine activity indicators when sessions or terminals are active on any workspace for that machine.
+2
View File
@@ -26,6 +26,7 @@ export interface AppState {
sessionStatuses: Record<string, SessionStatus>;
sessionActivities: Record<string, SessionActivity>;
workspaceActivities: Record<string, WorkspaceActivity>;
machineActivities: Record<string, Record<string, WorkspaceActivity>>;
workspacesByProjectId: Record<string, Workspace[]>;
workspaceDeletionRuns: Record<string, TerminalCommandRun>;
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
@@ -119,6 +120,7 @@ export function initialAppState(): AppState {
sessionStatuses: {},
sessionActivities: {},
workspaceActivities: {},
machineActivities: {},
workspacesByProjectId: {},
workspaceDeletionRuns: {},
commandDialog: undefined,
+15 -2
View File
@@ -1,7 +1,9 @@
import { LitElement, css, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { Machine, MachineHealth } from "../api";
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 { listStyles } from "./shared";
@@ -10,6 +12,7 @@ export class MachineList extends LitElement {
@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({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
@@ -64,13 +67,20 @@ export class MachineList extends LitElement {
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
>
<div class="action-main">
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
<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>
</div>
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
</div>
`;
}
private renderActivity(machine: Machine) {
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");
}
private renderMachineMenu(machine: Machine) {
const open = this.openMenuMachineId === machine.id;
const menuId = machineMenuId(machine.id);
@@ -128,6 +138,9 @@ export class MachineList extends LitElement {
listStyles,
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); }
`,
+81 -7
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { configApi, piWebApi, terminalsApi, type Machine, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import { configApi, piWebApi, terminalsApi, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, 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";
@@ -32,6 +32,7 @@ import { readSettingsSection, writeSettingsSection, type SettingsSection } from
import { applyShortcutPreferences } from "../shortcutPreferences";
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
import { machineActivityIndicator } from "../workspaceActivity";
import "./MachineList";
import "./ProjectList";
import "./WorkspaceList";
@@ -115,6 +116,7 @@ export class PiWebApp extends LitElement {
);
private readonly keyboard = new KeyboardShortcutDispatcher();
private readonly realtime = new RealtimeSocket();
private readonly machineActivitySockets = new Map<string, RealtimeSocket>();
private readonly activeTerminalIds = new Set<string>();
private readonly machineNavigation = new InMemoryMachineNavigationMemory();
private readonly terminalSelection = new InMemoryTerminalSelectionMemory();
@@ -153,7 +155,7 @@ export class PiWebApp extends LitElement {
this.appShell.repairViewportPosition();
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity();
void this.refreshMachineActivities();
void this.refreshWorkspaceDeletionRuns();
};
private readonly onVisibilityChange = () => {
@@ -161,7 +163,7 @@ export class PiWebApp extends LitElement {
this.appShell.repairViewportPosition();
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity();
void this.refreshMachineActivities();
void this.refreshWorkspaceDeletionRuns();
}
};
@@ -212,6 +214,7 @@ export class PiWebApp extends LitElement {
this.auth.dispose();
this.sessions.dispose();
this.realtime.close();
this.closeMachineActivitySockets();
this.git.dispose();
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
this.piWebStatusTimer = undefined;
@@ -227,6 +230,7 @@ export class PiWebApp extends LitElement {
this.handleActivityTransition(previous, this.state);
this.handleWorkspaceChange(previous, this.state);
this.handleMachineChange(previous, this.state);
if (machineActivitySubscriptionInputsChanged(previous, this.state)) this.syncMachineActivitySubscriptions();
}
private async loadProjectsAndRestoreRoute() {
@@ -251,14 +255,23 @@ export class PiWebApp extends LitElement {
}
}
private async refreshWorkspaceActivity(): Promise<void> {
private async refreshWorkspaceActivity(machineId = selectedMachineId(this.state)): Promise<void> {
try {
await this.activity.refresh();
await this.activity.refresh(machineId);
} catch (error) {
console.warn("Failed to refresh workspace activity", error);
console.warn(`Failed to refresh workspace activity for ${machineId}`, error);
}
}
private async refreshMachineActivities(): Promise<void> {
const machineIds = this.state.machines.length === 0
? [selectedMachineId(this.state)]
: this.state.machines
.filter((machine) => shouldRefreshMachineActivity(machine, this.state.machineStatuses[machine.id]))
.map((machine) => machine.id);
await Promise.all(machineIds.map((machineId) => this.refreshWorkspaceActivity(machineId)));
}
private async loadClientConfig(): Promise<void> {
try {
this.applyClientConfig((await configApi.config()).config);
@@ -278,7 +291,7 @@ export class PiWebApp extends LitElement {
await Promise.all([
this.sessions.refreshSelectedSession(),
this.refreshPiWebStatus(),
this.refreshWorkspaceActivity(),
this.refreshMachineActivities(),
this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(),
this.refreshCurrentWorkspaceSurface(),
@@ -602,6 +615,42 @@ export class PiWebApp extends LitElement {
);
}
private syncMachineActivitySubscriptions(): void {
const desiredMachineIds = this.machineActivitySubscriptionIds();
for (const [machineId, socket] of this.machineActivitySockets.entries()) {
if (desiredMachineIds.has(machineId)) continue;
socket.close();
this.machineActivitySockets.delete(machineId);
}
for (const machineId of desiredMachineIds) {
if (this.machineActivitySockets.has(machineId)) continue;
const socket = new RealtimeSocket();
socket.connect(
(event) => { this.handleMachineActivityEvent(machineId, event); },
() => { void this.refreshWorkspaceActivity(machineId); },
machineId,
);
this.machineActivitySockets.set(machineId, socket);
}
}
private closeMachineActivitySockets(): void {
for (const socket of this.machineActivitySockets.values()) socket.close();
this.machineActivitySockets.clear();
}
private machineActivitySubscriptionIds(): Set<string> {
const selected = selectedMachineId(this.state);
return new Set(this.state.machines
.filter((machine) => machine.id !== selected)
.filter((machine) => shouldSubscribeToMachineActivity(machine, this.state.machineStatuses[machine.id]))
.map((machine) => machine.id));
}
private handleMachineActivityEvent(machineId: string, event: RealtimeEvent): void {
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity, machineId);
}
private handleRealtimeEvent(event: RealtimeEvent): void {
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
else if (isTerminalEvent(event)) {
@@ -718,6 +767,7 @@ export class PiWebApp extends LitElement {
.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 () => {
@@ -1208,6 +1258,7 @@ export class PiWebApp extends LitElement {
return html`
<app-context-bar
.machine=${this.state.selectedMachine}
.machineActivityKind=${selectedMachineActivityIndicator(this.state)}
.project=${this.state.selectedProject}
.workspace=${this.state.selectedWorkspace}
.session=${this.state.selectedSession}
@@ -1289,6 +1340,29 @@ function createPluginRegistry(): PluginRegistry {
return registry;
}
function machineActivitySubscriptionInputsChanged(previous: AppState, next: AppState): boolean {
return previous.machines !== next.machines
|| previous.machineStatuses !== next.machineStatuses
|| (previous.selectedMachine?.id ?? "local") !== (next.selectedMachine?.id ?? "local");
}
function shouldSubscribeToMachineActivity(machine: Machine, health: MachineHealth | undefined): boolean {
return shouldRefreshMachineActivity(machine, health);
}
function shouldRefreshMachineActivity(machine: Machine, health: MachineHealth | undefined): boolean {
if (machine.kind === "local") return true;
const status = health?.status ?? machine.status;
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);
@@ -2,10 +2,12 @@ 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 }) machine?: Machine;
@property({ attribute: false }) machineActivityKind?: ActivityIndicatorKind;
@property({ attribute: false }) project?: Project;
@property({ attribute: false }) workspace?: Workspace;
@property({ attribute: false }) session?: SessionInfo;
@@ -47,6 +49,7 @@ export class AppContextBar extends LitElement {
<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>
@@ -74,6 +77,10 @@ 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`
@@ -152,9 +159,13 @@ 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; } }
`;
}
@@ -12,6 +12,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) machines: Machine[] = [];
@property({ attribute: false }) selectedMachine?: Machine;
@property({ attribute: false }) machineStatuses: Record<string, MachineHealth> = {};
@property({ attribute: false }) machineActivities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) projects: Project[] = [];
@property({ attribute: false }) selectedProject?: Project;
@property({ attribute: false }) workspaces: Workspace[] = [];
@@ -65,6 +66,7 @@ export class AppNavigationPanel extends LitElement {
.machines=${this.machines}
.selected=${this.selectedMachine}
.statuses=${this.machineStatuses}
.activities=${this.machineActivities}
.collapsible=${this.collapsible}
.collapsed=${this.machinesCollapsed}
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import type { AppState } from "../appState";
import { initialAppState } from "../appState";
import type { WorkspaceActivity, WorkspaceActivityResponse } from "../api";
import { ActivityController } from "./activityController";
function activity(cwd: string, patch: Partial<WorkspaceActivity> = {}): WorkspaceActivity {
return { cwd, hasSessionActivity: true, hasTerminalActivity: false, updatedAt: "now", ...patch };
}
function snapshot(...workspaces: WorkspaceActivity[]): WorkspaceActivityResponse {
return { workspaces, generatedAt: "now" };
}
describe("ActivityController", () => {
it("stores workspace activity under the requested machine", async () => {
let state: AppState = { ...initialAppState(), selectedMachine: { id: "remote", name: "Remote", kind: "remote", createdAt: "now", updatedAt: "now" } };
const controller = new ActivityController(() => state, (patch) => { state = { ...state, ...patch }; }, {
api: { workspaceActivity: (machineId) => Promise.resolve(machineId === "remote" ? snapshot(activity("/remote")) : snapshot(activity("/local"))) },
});
await controller.refresh("remote");
await controller.refresh("local");
expect(state.workspaceActivities).toEqual({ "/remote": activity("/remote") });
expect(state.machineActivities).toEqual({
remote: { "/remote": activity("/remote") },
local: { "/local": activity("/local") },
});
});
it("applies live activity updates to the owning machine only", () => {
let state: AppState = { ...initialAppState(), selectedMachine: { id: "local", name: "Local", kind: "local", createdAt: "now", updatedAt: "now" } };
const controller = new ActivityController(() => state, (patch) => { state = { ...state, ...patch }; });
controller.applyWorkspaceActivity(activity("/remote"), "remote");
controller.applyWorkspaceActivity(activity("/local"), "local");
expect(state.workspaceActivities).toEqual({ "/local": activity("/local") });
expect(state.machineActivities["remote"]).toEqual({ "/remote": activity("/remote") });
expect(state.machineActivities["local"]).toEqual({ "/local": activity("/local") });
});
});
@@ -13,13 +13,27 @@ export class ActivityController {
this.api = deps.api ?? defaultApi;
}
async refresh(): Promise<void> {
const snapshot = await this.api.workspaceActivity(selectedMachineId(this.getState()));
this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) });
async refresh(machineId = selectedMachineId(this.getState())): Promise<void> {
this.applyMachineActivitySnapshot(machineId, indexWorkspaceActivities(await this.api.workspaceActivity(machineId)));
}
applyWorkspaceActivity(activity: WorkspaceActivity): void {
this.setState({ workspaceActivities: applyWorkspaceActivityToMap(this.getState().workspaceActivities, activity) });
applyWorkspaceActivity(activity: WorkspaceActivity, machineId = selectedMachineId(this.getState())): void {
const state = this.getState();
const isSelectedMachine = selectedMachineId(state) === machineId;
const currentMachineActivities = state.machineActivities[machineId] ?? (isSelectedMachine ? state.workspaceActivities : {});
const nextMachineActivities = applyWorkspaceActivityToMap(currentMachineActivities, activity);
this.setState({
machineActivities: { ...state.machineActivities, [machineId]: nextMachineActivities },
...(isSelectedMachine ? { workspaceActivities: nextMachineActivities } : {}),
});
}
private applyMachineActivitySnapshot(machineId: string, activities: Record<string, WorkspaceActivity>): void {
const state = this.getState();
this.setState({
machineActivities: { ...state.machineActivities, [machineId]: activities },
...(selectedMachineId(state) === machineId ? { workspaceActivities: activities } : {}),
});
}
}
@@ -11,7 +11,8 @@ export class MachineController {
try {
const machines = await api.machines();
const selectedMachine = await this.selectInitialMachine(machines, routeMachineId);
this.setState({ machines, selectedMachine });
const machineIds = new Set(machines.map((machine) => machine.id));
this.setState({ machines, selectedMachine, machineActivities: filterKeys(this.getState().machineActivities, machineIds) });
void this.refreshMachineHealthFor(machines);
} catch (error) {
this.setState({ error: String(error) });
@@ -70,7 +71,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) });
this.setState({ machines, machineStatuses: omitKey(this.getState().machineStatuses, machine.id), machineActivities: omitKey(this.getState().machineActivities, machine.id) });
if (wasSelected && local !== undefined) {
if (options.selectFallback === false) return local;
await this.selectMachine(local);
@@ -135,3 +136,7 @@ export class MachineController {
function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit));
}
function filterKeys<T>(record: Record<string, T>, allowedKeys: Set<string>): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([key]) => allowedKeys.has(key)));
}
+10 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Project, Workspace, WorkspaceActivity } from "./api";
import { projectActivityIndicator, workspaceActivityFor, workspaceActivityIndicator } from "./workspaceActivity";
import { machineActivityIndicator, projectActivityIndicator, workspaceActivityFor, workspaceActivityIndicator } from "./workspaceActivity";
function project(id = "p1", path = "/repo"): Project {
return { id, name: id, path, createdAt: "now" };
@@ -43,4 +43,13 @@ describe("workspace activity aggregation", () => {
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/repo/packages/app": activity("/repo/packages/app") })).toBe("session");
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/other": activity("/other") })).toBeUndefined();
});
it("aggregates machine activity across workspaces", () => {
expect(machineActivityIndicator({ "/repo": activity("/repo", { hasSessionActivity: false, hasTerminalActivity: true }) })).toBe("terminal");
expect(machineActivityIndicator({
"/repo": activity("/repo", { hasSessionActivity: false, hasTerminalActivity: true }),
"/other": activity("/other"),
})).toBe("session");
expect(machineActivityIndicator({})).toBeUndefined();
});
});
+10 -3
View File
@@ -12,9 +12,16 @@ export function workspaceActivityIndicator(activity: WorkspaceActivity | undefin
}
export function projectActivityIndicator(project: Project, knownWorkspaces: Workspace[], activities: Record<string, WorkspaceActivity>): ActivityIndicatorKind | undefined {
const matched = matchedProjectActivities(project, knownWorkspaces, activities);
if (matched.some((activity) => activity.hasSessionActivity)) return "session";
if (matched.some((activity) => activity.hasTerminalActivity)) return "terminal";
return workspaceActivitiesIndicator(matchedProjectActivities(project, knownWorkspaces, activities));
}
export function machineActivityIndicator(activities: Record<string, WorkspaceActivity> | undefined): ActivityIndicatorKind | undefined {
return workspaceActivitiesIndicator(Object.values(activities ?? {}));
}
function workspaceActivitiesIndicator(activities: WorkspaceActivity[]): ActivityIndicatorKind | undefined {
if (activities.some((activity) => activity.hasSessionActivity)) return "session";
if (activities.some((activity) => activity.hasTerminalActivity)) return "terminal";
return undefined;
}