From 193c9d0f8f878cb7b02ec56cbe612eb01468dc02 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 4 Jun 2026 11:54:21 +0200 Subject: [PATCH] feat: show machine activity indicators --- .changeset/machine-activity-indicators.md | 5 ++ src/client/src/appState.ts | 2 + src/client/src/components/MachineList.ts | 17 +++- src/client/src/components/PiWebApp.ts | 88 +++++++++++++++++-- .../src/components/appShell/AppContextBar.ts | 11 +++ .../components/appShell/AppNavigationPanel.ts | 2 + .../controllers/activityController.test.ts | 43 +++++++++ .../src/controllers/activityController.ts | 24 +++-- .../src/controllers/machineController.ts | 9 +- src/client/src/workspaceActivity.test.ts | 11 ++- src/client/src/workspaceActivity.ts | 13 ++- 11 files changed, 205 insertions(+), 20 deletions(-) create mode 100644 .changeset/machine-activity-indicators.md create mode 100644 src/client/src/controllers/activityController.test.ts diff --git a/.changeset/machine-activity-indicators.md b/.changeset/machine-activity-indicators.md new file mode 100644 index 0000000..9a319b0 --- /dev/null +++ b/.changeset/machine-activity-indicators.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Show machine activity indicators when sessions or terminals are active on any workspace for that machine. diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index b753435..910217d 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -26,6 +26,7 @@ export interface AppState { sessionStatuses: Record; sessionActivities: Record; workspaceActivities: Record; + machineActivities: Record>; workspacesByProjectId: Record; workspaceDeletionRuns: Record; commandDialog: Extract | undefined; @@ -119,6 +120,7 @@ export function initialAppState(): AppState { sessionStatuses: {}, sessionActivities: {}, workspaceActivities: {}, + machineActivities: {}, workspacesByProjectId: {}, workspaceDeletionRuns: {}, commandDialog: undefined, diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts index e23b193..cfaf4cf 100644 --- a/src/client/src/components/MachineList.ts +++ b/src/client/src/components/MachineList.ts @@ -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 = {}; + @property({ attribute: false }) activities: Record> = {}; @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); }} >
- ${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} + ${this.renderActivity(machine)}${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}
${hasRemoveAction ? this.renderMachineMenu(machine) : null} `; } + 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); } `, diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 0770f21..00a5520 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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(); private readonly activeTerminalIds = new Set(); 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 { + private async refreshWorkspaceActivity(machineId = selectedMachineId(this.state)): Promise { 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 { + 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 { 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 { + 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` ): boolean { return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value); diff --git a/src/client/src/components/appShell/AppContextBar.ts b/src/client/src/components/appShell/AppContextBar.ts index 766a454..6a014f7 100644 --- a/src/client/src/components/appShell/AppContextBar.ts +++ b/src/client/src/components/appShell/AppContextBar.ts @@ -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 {
  • @@ -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; } } `; } diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index f81b59b..56a8dee 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -12,6 +12,7 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) machines: Machine[] = []; @property({ attribute: false }) selectedMachine?: Machine; @property({ attribute: false }) machineStatuses: Record = {}; + @property({ attribute: false }) machineActivities: Record> = {}; @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?.(); }} diff --git a/src/client/src/controllers/activityController.test.ts b/src/client/src/controllers/activityController.test.ts new file mode 100644 index 0000000..589b784 --- /dev/null +++ b/src/client/src/controllers/activityController.test.ts @@ -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 { + 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") }); + }); +}); diff --git a/src/client/src/controllers/activityController.ts b/src/client/src/controllers/activityController.ts index 7dc97fd..d50b34e 100644 --- a/src/client/src/controllers/activityController.ts +++ b/src/client/src/controllers/activityController.ts @@ -13,13 +13,27 @@ export class ActivityController { this.api = deps.api ?? defaultApi; } - async refresh(): Promise { - const snapshot = await this.api.workspaceActivity(selectedMachineId(this.getState())); - this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) }); + async refresh(machineId = selectedMachineId(this.getState())): Promise { + 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): void { + const state = this.getState(); + this.setState({ + machineActivities: { ...state.machineActivities, [machineId]: activities }, + ...(selectedMachineId(state) === machineId ? { workspaceActivities: activities } : {}), + }); } } diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index 683e4af..d878815 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -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(record: Record, keyToOmit: string): Record { return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit)); } + +function filterKeys(record: Record, allowedKeys: Set): Record { + return Object.fromEntries(Object.entries(record).filter(([key]) => allowedKeys.has(key))); +} diff --git a/src/client/src/workspaceActivity.test.ts b/src/client/src/workspaceActivity.test.ts index ef7c237..379308c 100644 --- a/src/client/src/workspaceActivity.test.ts +++ b/src/client/src/workspaceActivity.test.ts @@ -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(); + }); }); diff --git a/src/client/src/workspaceActivity.ts b/src/client/src/workspaceActivity.ts index 6fd25bd..3fde6ce 100644 --- a/src/client/src/workspaceActivity.ts +++ b/src/client/src/workspaceActivity.ts @@ -12,9 +12,16 @@ export function workspaceActivityIndicator(activity: WorkspaceActivity | undefin } export function projectActivityIndicator(project: Project, knownWorkspaces: Workspace[], activities: Record): 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 | 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; }