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>; sessionStatuses: Record<string, SessionStatus>;
sessionActivities: Record<string, SessionActivity>; sessionActivities: Record<string, SessionActivity>;
workspaceActivities: Record<string, WorkspaceActivity>; workspaceActivities: Record<string, WorkspaceActivity>;
machineActivities: Record<string, Record<string, WorkspaceActivity>>;
workspacesByProjectId: Record<string, Workspace[]>; workspacesByProjectId: Record<string, Workspace[]>;
workspaceDeletionRuns: Record<string, TerminalCommandRun>; workspaceDeletionRuns: Record<string, TerminalCommandRun>;
commandDialog: Extract<CommandResult, { type: "select" }> | undefined; commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
@@ -119,6 +120,7 @@ export function initialAppState(): AppState {
sessionStatuses: {}, sessionStatuses: {},
sessionActivities: {}, sessionActivities: {},
workspaceActivities: {}, workspaceActivities: {},
machineActivities: {},
workspacesByProjectId: {}, workspacesByProjectId: {},
workspaceDeletionRuns: {}, workspaceDeletionRuns: {},
commandDialog: undefined, commandDialog: undefined,
+15 -2
View File
@@ -1,7 +1,9 @@
import { LitElement, css, html, type PropertyValues } from "lit"; import { LitElement, css, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js"; 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 { actionMenuPanelStyle } from "./actionMenu";
import { renderActivityIndicator } from "./activityBadge";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
@@ -10,6 +12,7 @@ export class MachineList extends LitElement {
@property({ attribute: false }) machines: Machine[] = []; @property({ attribute: false }) machines: Machine[] = [];
@property({ attribute: false }) selected?: Machine; @property({ attribute: false }) selected?: Machine;
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {}; @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 }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false; @property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (machine: Machine) => void; @property({ attribute: false }) onSelect?: (machine: Machine) => void;
@@ -64,13 +67,20 @@ export class MachineList extends LitElement {
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }} @keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
> >
<div class="action-main"> <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> </div>
${hasRemoveAction ? this.renderMachineMenu(machine) : null} ${hasRemoveAction ? this.renderMachineMenu(machine) : null}
</div> </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) { private renderMachineMenu(machine: Machine) {
const open = this.openMenuMachineId === machine.id; const open = this.openMenuMachineId === machine.id;
const menuId = machineMenuId(machine.id); const menuId = machineMenuId(machine.id);
@@ -128,6 +138,9 @@ export class MachineList extends LitElement {
listStyles, listStyles,
css` css`
.machine-row.no-actions .action-main { border-radius: 8px; } .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 { 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); } .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 { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js"; 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 type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
@@ -32,6 +32,7 @@ import { readSettingsSection, writeSettingsSection, type SettingsSection } from
import { applyShortcutPreferences } from "../shortcutPreferences"; import { applyShortcutPreferences } from "../shortcutPreferences";
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime"; import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion"; import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
import { machineActivityIndicator } from "../workspaceActivity";
import "./MachineList"; import "./MachineList";
import "./ProjectList"; import "./ProjectList";
import "./WorkspaceList"; import "./WorkspaceList";
@@ -115,6 +116,7 @@ export class PiWebApp extends LitElement {
); );
private readonly keyboard = new KeyboardShortcutDispatcher(); private readonly keyboard = new KeyboardShortcutDispatcher();
private readonly realtime = new RealtimeSocket(); private readonly realtime = new RealtimeSocket();
private readonly machineActivitySockets = new Map<string, RealtimeSocket>();
private readonly activeTerminalIds = new Set<string>(); private readonly activeTerminalIds = new Set<string>();
private readonly machineNavigation = new InMemoryMachineNavigationMemory(); private readonly machineNavigation = new InMemoryMachineNavigationMemory();
private readonly terminalSelection = new InMemoryTerminalSelectionMemory(); private readonly terminalSelection = new InMemoryTerminalSelectionMemory();
@@ -153,7 +155,7 @@ export class PiWebApp extends LitElement {
this.appShell.repairViewportPosition(); this.appShell.repairViewportPosition();
void this.sessions.refreshSelectedSession(); void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus(); void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity(); void this.refreshMachineActivities();
void this.refreshWorkspaceDeletionRuns(); void this.refreshWorkspaceDeletionRuns();
}; };
private readonly onVisibilityChange = () => { private readonly onVisibilityChange = () => {
@@ -161,7 +163,7 @@ export class PiWebApp extends LitElement {
this.appShell.repairViewportPosition(); this.appShell.repairViewportPosition();
void this.sessions.refreshSelectedSession(); void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus(); void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity(); void this.refreshMachineActivities();
void this.refreshWorkspaceDeletionRuns(); void this.refreshWorkspaceDeletionRuns();
} }
}; };
@@ -212,6 +214,7 @@ export class PiWebApp extends LitElement {
this.auth.dispose(); this.auth.dispose();
this.sessions.dispose(); this.sessions.dispose();
this.realtime.close(); this.realtime.close();
this.closeMachineActivitySockets();
this.git.dispose(); this.git.dispose();
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer); if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
this.piWebStatusTimer = undefined; this.piWebStatusTimer = undefined;
@@ -227,6 +230,7 @@ export class PiWebApp extends LitElement {
this.handleActivityTransition(previous, this.state); this.handleActivityTransition(previous, this.state);
this.handleWorkspaceChange(previous, this.state); this.handleWorkspaceChange(previous, this.state);
this.handleMachineChange(previous, this.state); this.handleMachineChange(previous, this.state);
if (machineActivitySubscriptionInputsChanged(previous, this.state)) this.syncMachineActivitySubscriptions();
} }
private async loadProjectsAndRestoreRoute() { 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 { try {
await this.activity.refresh(); await this.activity.refresh(machineId);
} catch (error) { } 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> { private async loadClientConfig(): Promise<void> {
try { try {
this.applyClientConfig((await configApi.config()).config); this.applyClientConfig((await configApi.config()).config);
@@ -278,7 +291,7 @@ export class PiWebApp extends LitElement {
await Promise.all([ await Promise.all([
this.sessions.refreshSelectedSession(), this.sessions.refreshSelectedSession(),
this.refreshPiWebStatus(), this.refreshPiWebStatus(),
this.refreshWorkspaceActivity(), this.refreshMachineActivities(),
this.loadClientConfig(), this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(), this.refreshWorkspaceDeletionRuns(),
this.refreshCurrentWorkspaceSurface(), 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 { private handleRealtimeEvent(event: RealtimeEvent): void {
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity); if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
else if (isTerminalEvent(event)) { else if (isTerminalEvent(event)) {
@@ -718,6 +767,7 @@ export class PiWebApp extends LitElement {
.machines=${this.state.machines} .machines=${this.state.machines}
.selectedMachine=${this.state.selectedMachine} .selectedMachine=${this.state.selectedMachine}
.machineStatuses=${this.state.machineStatuses} .machineStatuses=${this.state.machineStatuses}
.machineActivities=${this.state.machineActivities}
.machinesCollapsed=${this.mobileNavigation.isCollapsed("machines")} .machinesCollapsed=${this.mobileNavigation.isCollapsed("machines")}
.onToggleMachines=${() => { this.mobileNavigation.toggle("machines"); }} .onToggleMachines=${() => { this.mobileNavigation.toggle("machines"); }}
.onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => { .onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => {
@@ -1208,6 +1258,7 @@ export class PiWebApp extends LitElement {
return html` return html`
<app-context-bar <app-context-bar
.machine=${this.state.selectedMachine} .machine=${this.state.selectedMachine}
.machineActivityKind=${selectedMachineActivityIndicator(this.state)}
.project=${this.state.selectedProject} .project=${this.state.selectedProject}
.workspace=${this.state.selectedWorkspace} .workspace=${this.state.selectedWorkspace}
.session=${this.state.selectedSession} .session=${this.state.selectedSession}
@@ -1289,6 +1340,29 @@ function createPluginRegistry(): PluginRegistry {
return registry; 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 { function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value); 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 { customElement, property, query, state } from "lit/decorators.js";
import type { Machine, Project, SessionInfo, Workspace } from "../../api"; import type { Machine, Project, SessionInfo, Workspace } from "../../api";
import type { NavigationSection } from "../../appShell/navigationState"; import type { NavigationSection } from "../../appShell/navigationState";
import { renderActivityIndicator, type ActivityIndicatorKind } from "../activityBadge";
@customElement("app-context-bar") @customElement("app-context-bar")
export class AppContextBar extends LitElement { export class AppContextBar extends LitElement {
@property({ attribute: false }) machine?: Machine; @property({ attribute: false }) machine?: Machine;
@property({ attribute: false }) machineActivityKind?: ActivityIndicatorKind;
@property({ attribute: false }) project?: Project; @property({ attribute: false }) project?: Project;
@property({ attribute: false }) workspace?: Workspace; @property({ attribute: false }) workspace?: Workspace;
@property({ attribute: false }) session?: SessionInfo; @property({ attribute: false }) session?: SessionInfo;
@@ -47,6 +49,7 @@ export class AppContextBar extends LitElement {
<li class="context-item"> <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"); }}> <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> <span class="context-kind">Machine</span>
${this.renderMachineActivity()}
<span class="context-value">${machineLabel}</span> <span class="context-value">${machineLabel}</span>
</button> </button>
</li> </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() { private renderActionsButton() {
if (this.onShowActions === undefined) return null; if (this.onShowActions === undefined) return null;
return html` return html`
@@ -152,9 +159,13 @@ export class AppContextBar extends LitElement {
.context-chip:hover { background: var(--pi-surface-hover); } .context-chip:hover { background: var(--pi-surface-hover); }
.context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; } .context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
.context-chip.empty { border-style: dashed; color: var(--pi-muted); } .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-kind { display: none; }
.context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; } .context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; }
button { cursor: pointer; } 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 }) machines: Machine[] = [];
@property({ attribute: false }) selectedMachine?: Machine; @property({ attribute: false }) selectedMachine?: Machine;
@property({ attribute: false }) machineStatuses: Record<string, MachineHealth> = {}; @property({ attribute: false }) machineStatuses: Record<string, MachineHealth> = {};
@property({ attribute: false }) machineActivities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) projects: Project[] = []; @property({ attribute: false }) projects: Project[] = [];
@property({ attribute: false }) selectedProject?: Project; @property({ attribute: false }) selectedProject?: Project;
@property({ attribute: false }) workspaces: Workspace[] = []; @property({ attribute: false }) workspaces: Workspace[] = [];
@@ -65,6 +66,7 @@ export class AppNavigationPanel extends LitElement {
.machines=${this.machines} .machines=${this.machines}
.selected=${this.selectedMachine} .selected=${this.selectedMachine}
.statuses=${this.machineStatuses} .statuses=${this.machineStatuses}
.activities=${this.machineActivities}
.collapsible=${this.collapsible} .collapsible=${this.collapsible}
.collapsed=${this.machinesCollapsed} .collapsed=${this.machinesCollapsed}
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }} .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; this.api = deps.api ?? defaultApi;
} }
async refresh(): Promise<void> { async refresh(machineId = selectedMachineId(this.getState())): Promise<void> {
const snapshot = await this.api.workspaceActivity(selectedMachineId(this.getState())); this.applyMachineActivitySnapshot(machineId, indexWorkspaceActivities(await this.api.workspaceActivity(machineId)));
this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) });
} }
applyWorkspaceActivity(activity: WorkspaceActivity): void { applyWorkspaceActivity(activity: WorkspaceActivity, machineId = selectedMachineId(this.getState())): void {
this.setState({ workspaceActivities: applyWorkspaceActivityToMap(this.getState().workspaceActivities, activity) }); 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 { try {
const machines = await api.machines(); const machines = await api.machines();
const selectedMachine = await this.selectInitialMachine(machines, routeMachineId); 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); void this.refreshMachineHealthFor(machines);
} catch (error) { } catch (error) {
this.setState({ error: String(error) }); this.setState({ error: String(error) });
@@ -70,7 +71,7 @@ export class MachineController {
await api.deleteMachine(machine.id); await api.deleteMachine(machine.id);
const machines = this.getState().machines.filter((candidate) => candidate.id !== machine.id); const machines = this.getState().machines.filter((candidate) => candidate.id !== machine.id);
const local = machines.find((candidate) => candidate.id === "local") ?? machines[0]; 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 (wasSelected && local !== undefined) {
if (options.selectFallback === false) return local; if (options.selectFallback === false) return local;
await this.selectMachine(local); await this.selectMachine(local);
@@ -135,3 +136,7 @@ export class MachineController {
function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> { function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit)); 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 { describe, expect, it } from "vitest";
import type { Project, Workspace, WorkspaceActivity } from "./api"; 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 { function project(id = "p1", path = "/repo"): Project {
return { id, name: id, path, createdAt: "now" }; 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"), [], { "/repo/packages/app": activity("/repo/packages/app") })).toBe("session");
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/other": activity("/other") })).toBeUndefined(); 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 { export function projectActivityIndicator(project: Project, knownWorkspaces: Workspace[], activities: Record<string, WorkspaceActivity>): ActivityIndicatorKind | undefined {
const matched = matchedProjectActivities(project, knownWorkspaces, activities); return workspaceActivitiesIndicator(matchedProjectActivities(project, knownWorkspaces, activities));
if (matched.some((activity) => activity.hasSessionActivity)) return "session"; }
if (matched.some((activity) => activity.hasTerminalActivity)) return "terminal";
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; return undefined;
} }