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>;
|
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,
|
||||||
|
|||||||
@@ -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); }
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user