Archived
feat: show machine activity indicators
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Show machine activity indicators when sessions or terminals are active on any workspace for that machine.
|
||||
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
`,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user