feat: show project and workspace activity indicators

This commit is contained in:
Federico Jaramillo Martinez
2026-05-19 13:24:02 +02:00
parent fb9e524e5b
commit ebe5639399
29 changed files with 498 additions and 36 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show active session and terminal activity on project and workspace rows so background work is visible from navigation.
+2 -2
View File
@@ -1,3 +1,3 @@
export { api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+6
View File
@@ -27,6 +27,7 @@ import {
parseTerminalInfo,
parseThinkingLevelsResponse,
parseWorkspace,
parseWorkspaceActivityResponse,
} from "./parsers";
import { gitDiffUrl, messageUrl } from "./urls";
@@ -34,6 +35,10 @@ export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
};
export const activityApi = {
workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse),
};
export const projectsApi = {
projects: () => request("/api/projects", arrayOf(parseProject)),
addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
@@ -100,6 +105,7 @@ export const gitApi = {
export const api = {
...piWebApi,
...activityApi,
...projectsApi,
...workspacesApi,
...sessionsApi,
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parseSessionStatus, parseSlashCommand } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parseSessionStatus, parseSlashCommand, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("accepts legacy array message pages and paged message responses", () => {
@@ -35,6 +35,16 @@ describe("API parsers", () => {
});
});
it("parses workspace activity snapshots", () => {
expect(parseWorkspaceActivityResponse({
generatedAt: "now",
workspaces: [{ cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false, updatedAt: "later" }],
})).toEqual({
generatedAt: "now",
workspaces: [{ cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false, updatedAt: "later" }],
});
});
it("rejects invalid enum-like fields", () => {
expect(() => parseSlashCommand({ name: "bad", source: "remote" })).toThrow("Invalid command source");
expect(() => parseFileSuggestion({ path: "a", kind: "deleted" })).toThrow("Invalid file kind");
+16 -1
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -298,6 +298,21 @@ export function parseTerminalInfo(value: unknown): TerminalInfo {
return { id: requireString(record, "id"), cwd: requireString(record, "cwd"), name: requireString(record, "name"), createdAt: requireString(record, "createdAt"), exited: requireBoolean(record, "exited"), ...optionalField("exitCode", optionalNumber(record, "exitCode")) };
}
export function parseWorkspaceActivity(value: unknown): WorkspaceActivity {
const record = requireRecord(value);
return {
cwd: requireString(record, "cwd"),
hasSessionActivity: requireBoolean(record, "hasSessionActivity"),
hasTerminalActivity: requireBoolean(record, "hasTerminalActivity"),
updatedAt: requireString(record, "updatedAt"),
};
}
export function parseWorkspaceActivityResponse(value: unknown): WorkspaceActivityResponse {
const record = requireRecord(value);
return { workspaces: arrayOf(parseWorkspaceActivity)(record["workspaces"]), generatedAt: requireString(record, "generatedAt") };
}
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
const record = requireRecord(value);
return {
+5 -1
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/types";
@@ -18,6 +18,8 @@ export interface AppState {
activity: SessionActivity | undefined;
sessionStatuses: Record<string, SessionStatus>;
sessionActivities: Record<string, SessionActivity>;
workspaceActivities: Record<string, WorkspaceActivity>;
workspacesByProjectId: Record<string, Workspace[]>;
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
@@ -66,6 +68,8 @@ export function initialAppState(): AppState {
activity: undefined,
sessionStatuses: {},
sessionActivities: {},
workspaceActivities: {},
workspacesByProjectId: {},
commandDialog: undefined,
modelDialog: undefined,
thinkingDialog: undefined,
+27 -5
View File
@@ -3,6 +3,8 @@ import { customElement, query, state } from "lit/decorators.js";
import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
import { ActivityController } from "../controllers/activityController";
import { AuthController } from "../controllers/authController";
import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController";
@@ -50,6 +52,10 @@ export class PiWebApp extends LitElement {
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
);
private readonly activity = new ActivityController(
() => this.state,
(patch) => { this.setState(patch); },
);
private readonly auth = new AuthController(
() => this.state,
(patch) => { this.setState(patch); },
@@ -95,11 +101,13 @@ export class PiWebApp extends LitElement {
private readonly onFocus = () => {
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity();
};
private readonly onVisibilityChange = () => {
if (document.visibilityState === "visible") {
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity();
}
};
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
@@ -127,6 +135,7 @@ export class PiWebApp extends LitElement {
this.connectRealtime();
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity();
void this.loadExternalPlugins();
void this.loadProjectsAndRestoreRoute();
}
@@ -181,6 +190,14 @@ export class PiWebApp extends LitElement {
}
}
private async refreshWorkspaceActivity(): Promise<void> {
try {
await this.activity.refresh();
} catch (error) {
console.warn("Failed to refresh workspace activity", error);
}
}
private async restoreRoute(updateUrl: boolean) {
const route = readRoute();
const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file");
@@ -262,12 +279,14 @@ export class PiWebApp extends LitElement {
() => {
const workspace = this.state.selectedWorkspace;
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
void this.refreshWorkspaceActivity();
},
);
}
private handleRealtimeEvent(event: RealtimeEvent): void {
if (isTerminalEvent(event)) this.applyTerminalEvent(event);
if (event.type === "workspace.activity") this.activity.applyWorkspaceActivity(event.activity);
else if (isTerminalEvent(event)) this.applyTerminalEvent(event);
else this.sessions.applyGlobalEvent(event);
}
@@ -296,8 +315,8 @@ export class PiWebApp extends LitElement {
}
private handleActivityTransition(previous: AppState, next: AppState) {
const wasActive = isActive(previous.status);
const nowActive = isActive(next.status);
const wasActive = isActive(previous);
const nowActive = isActive(next);
if (wasActive && !nowActive) {
this.setState({ fileTreeStale: true, gitStale: true });
this.refreshSelectedWorkspaceTool(this.state.workspaceTool);
@@ -328,6 +347,8 @@ export class PiWebApp extends LitElement {
<project-list
.projects=${this.state.projects}
.selected=${this.state.selectedProject}
.activities=${this.state.workspaceActivities}
.workspacesByProjectId=${this.state.workspacesByProjectId}
.collapsible=${this.isMobileNavigationLayout}
.collapsed=${this.isNavigationSectionCollapsed("projects")}
.onToggleCollapsed=${() => { this.toggleNavigationSection("projects"); }}
@@ -340,6 +361,7 @@ export class PiWebApp extends LitElement {
<workspace-list
.workspaces=${this.state.workspaces}
.selected=${this.state.selectedWorkspace}
.activities=${this.state.workspaceActivities}
.collapsible=${this.isMobileNavigationLayout}
.collapsed=${this.isNavigationSectionCollapsed("workspaces")}
.workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)}
@@ -624,8 +646,8 @@ function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
}
function isActive(status: AppState["status"]): boolean {
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
function isActive(state: Pick<AppState, "status" | "activity">): boolean {
return isSessionActive(state.status, state.activity);
}
function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent {
+11 -2
View File
@@ -1,6 +1,8 @@
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { Project } from "../api";
import type { Project, Workspace, WorkspaceActivity } from "../api";
import { projectActivityIndicator } from "../workspaceActivity";
import { renderActivityIndicator } from "./activityBadge";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@@ -8,6 +10,8 @@ import { listStyles } from "./shared";
export class ProjectList extends LitElement {
@property({ attribute: false }) projects: Project[] = [];
@property({ attribute: false }) selected?: Project;
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (project: Project) => void;
@@ -48,7 +52,7 @@ export class ProjectList extends LitElement {
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }}
>
<div class="action-main">
<span>${project.name}</span><small>${project.path}</small>
<span>${project.name}</span><small>${this.renderActivity(project)}${project.path}</small>
</div>
<div class="action-menu">
<button class="action-menu-toggle" title="Project actions" aria-label=${`Actions for ${project.name}`} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(project.id, event.currentTarget); }}>⋯</button>
@@ -69,6 +73,11 @@ export class ProjectList extends LitElement {
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span>${this.collapsed ? "▸" : "▾"} Projects</span><small>${this.projects.length}</small></button>`;
}
private renderActivity(project: Project) {
const kind = projectActivityIndicator(project, this.workspacesByProjectId[project.id] ?? [], this.activities);
return renderActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active") ?? "";
}
private toggleMenu(projectId: string, target: EventTarget | null) {
if (this.openMenuProjectId === projectId) {
this.openMenuProjectId = undefined;
+3 -9
View File
@@ -2,6 +2,8 @@ import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { isCachedNewSessionInfo } from "../cachedNewSessions";
import { isSessionActive } from "../../../shared/activity";
import { renderActivityIndicator } from "./activityBadge";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@@ -144,15 +146,7 @@ export class SessionList extends LitElement {
private renderStatus(session: SessionInfo) {
if (isCachedNewSessionInfo(session)) return "new · ";
if (session.archived === true) return "read-only · ";
const status = this.statuses[session.id];
const activity = this.activities[session.id];
if (activity?.phase === "active") return `${activity.label} · `;
if (status === undefined) return "";
if (status.isStreaming) return "● streaming · ";
if (status.isBashRunning) return "● bash · ";
if (status.isCompacting) return "● compacting · ";
if (status.pendingMessageCount > 0) return `${String(status.pendingMessageCount)} pending · `;
return "";
return renderActivityIndicator(isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "session" : undefined, "Session active") ?? "";
}
static override styles = listStyles;
+10 -2
View File
@@ -1,7 +1,9 @@
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { Workspace } from "../api";
import type { Workspace, WorkspaceActivity } from "../api";
import type { WorkspaceLabelItem } from "../plugins/types";
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
import { renderActivityIndicator } from "./activityBadge";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
import { renderWorkspaceLabelItems } from "./workspaceLabel";
@@ -13,6 +15,7 @@ export class WorkspaceList extends LitElement {
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
@property({ attribute: false }) onToggleCollapsed?: () => void;
@@ -39,7 +42,7 @@ export class WorkspaceList extends LitElement {
<span class="workspace-label-base">${label}</span>
${renderWorkspaceLabelItems(this.workspaceLabelItems(workspace))}
</span>
<small>${workspace.path}</small>
<small>${this.renderActivity(workspace)}${workspace.path}</small>
</div>
</div>
`;
@@ -53,6 +56,11 @@ export class WorkspaceList extends LitElement {
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span>${this.collapsed ? "▸" : "▾"} Workspaces</span><small>${this.workspaces.length}</small></button>`;
}
private renderActivity(workspace: Workspace) {
const kind = workspaceActivityIndicator(workspaceActivityFor(workspace, this.activities));
return renderActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active") ?? "";
}
private scrollSelectedIntoView(): void {
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
}
@@ -0,0 +1,8 @@
import { html, type TemplateResult } from "lit";
export type ActivityIndicatorKind = "session" | "terminal";
export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined {
if (kind === undefined) return undefined;
return html`<span class=${`activity-indicator ${kind}`} role="img" aria-label=${label} title=${label}></span>`;
}
+4
View File
@@ -169,6 +169,9 @@ export const listStyles = css`
.workspace-row .action-main { border-radius: 8px; }
.tree-marker { color: var(--pi-dim); margin-right: 5px; }
.badge { display: inline-block; margin-left: 5px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; }
.activity-indicator { display: inline-block; width: 7px; height: 7px; margin-right: 6px; 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); }
.action-menu { position: relative; align-self: stretch; }
.action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); }
@@ -183,6 +186,7 @@ export const listStyles = css`
.workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); }
.workspace-label-link { color: var(--pi-accent); text-decoration: none; }
.workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; }
@keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } }
`;
export const chatStyles = css`
@@ -0,0 +1,41 @@
import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api";
import { isWorkspaceActivityActive } from "../../../shared/activity";
import type { GetState, SetState } from "./types";
export interface ActivityControllerDependencies {
api?: Pick<typeof defaultApi, "workspaceActivity">;
}
export class ActivityController {
private readonly api: Pick<typeof defaultApi, "workspaceActivity">;
constructor(private readonly getState: GetState, private readonly setState: SetState, deps: ActivityControllerDependencies = {}) {
this.api = deps.api ?? defaultApi;
}
async refresh(): Promise<void> {
const snapshot = await this.api.workspaceActivity();
this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) });
}
applyWorkspaceActivity(activity: WorkspaceActivity): void {
this.setState({ workspaceActivities: applyWorkspaceActivityToMap(this.getState().workspaceActivities, activity) });
}
}
export function indexWorkspaceActivities(snapshot: WorkspaceActivityResponse): Record<string, WorkspaceActivity> {
const activities: Record<string, WorkspaceActivity> = {};
for (const activity of snapshot.workspaces) {
if (isWorkspaceActivityActive(activity)) activities[activity.cwd] = activity;
}
return activities;
}
export function applyWorkspaceActivityToMap(current: Record<string, WorkspaceActivity>, activity: WorkspaceActivity): Record<string, WorkspaceActivity> {
const next = { ...current };
if (isWorkspaceActivityActive(activity)) {
next[activity.cwd] = activity;
return next;
}
return Object.fromEntries(Object.entries(next).filter(([cwd]) => cwd !== activity.cwd));
}
@@ -8,7 +8,10 @@ export class ProjectController {
async loadProjects() {
this.setState({ error: "" });
try {
this.setState({ projects: await api.projects() });
const projects = await api.projects();
const projectIds = new Set(projects.map((project) => project.id));
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([projectId]) => projectIds.has(projectId)));
this.setState({ projects, workspacesByProjectId });
} catch (error) {
this.setState({ error: String(error) });
}
@@ -21,6 +21,8 @@ export class WorkspaceController {
forgetProject(projectId: string): void {
this.workspaceSelection.forgetProject(projectId);
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([candidate]) => candidate !== projectId));
this.setState({ workspacesByProjectId });
}
async selectProject(project: Project, target?: RouteTarget) {
@@ -28,7 +30,7 @@ export class WorkspaceController {
this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" });
try {
const workspaces = await api.workspaces(project.id);
this.setState({ workspaces });
this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces } });
const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(project.id) });
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
else if (target?.updateUrl !== false) this.updateUrl();
+2 -5
View File
@@ -1,3 +1,4 @@
import { isSessionActive } from "../../../../shared/activity";
import type { AppState } from "../../appState";
import type { PluginAction } from "../types";
@@ -114,7 +115,7 @@ export function createCoreActions(): PluginAction[] {
title: "Stop Active Work",
shortcut: "mod+.",
group: "Session",
enabled: (context) => context.state.selectedSession !== undefined && isActive(context.state.status),
enabled: (context) => context.state.selectedSession !== undefined && isSessionActive(context.state.status, context.state.activity),
run: (context) => context.stopActiveWork(),
},
];
@@ -127,7 +128,3 @@ function hasWorkspace(context: { state: AppState }): boolean {
function hasGitWorkspace(context: { state: AppState }): boolean {
return context.state.selectedWorkspace?.isGitRepo === true;
}
function isActive(status: AppState["status"]): boolean {
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
}
+1 -1
View File
@@ -186,7 +186,7 @@ function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent {
function isRealtimeEvent(event: unknown): event is RealtimeEvent {
const type = eventType(event);
return isGlobalSessionEvent(event) || type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed";
return isGlobalSessionEvent(event) || type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed" || type === "workspace.activity";
}
function eventType(event: unknown): string {
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import type { Project, Workspace, WorkspaceActivity } from "./api";
import { projectActivityIndicator, workspaceActivityFor, workspaceActivityIndicator } from "./workspaceActivity";
function project(id = "p1", path = "/repo"): Project {
return { id, name: id, path, createdAt: "now" };
}
function workspace(projectId: string, path: string): Workspace {
return { id: path, projectId, path, label: path, isMain: path === "/repo", isGitRepo: true, isGitWorktree: true };
}
function activity(cwd: string, patch: Partial<WorkspaceActivity> = {}): WorkspaceActivity {
return { cwd, hasSessionActivity: true, hasTerminalActivity: false, updatedAt: "now", ...patch };
}
describe("workspace activity aggregation", () => {
it("matches activity to workspace paths", () => {
const ws = workspace("p1", "/repo");
expect(workspaceActivityFor(ws, { "/repo": activity("/repo") })?.hasSessionActivity).toBe(true);
});
it("uses a terminal indicator only when there is no session activity", () => {
expect(workspaceActivityIndicator(activity("/repo", { hasSessionActivity: false, hasTerminalActivity: true }))).toBe("terminal");
expect(workspaceActivityIndicator(activity("/repo", { hasSessionActivity: true, hasTerminalActivity: true }))).toBe("session");
expect(workspaceActivityIndicator(activity("/repo", { hasSessionActivity: false, hasTerminalActivity: false }))).toBeUndefined();
});
it("aggregates project activity from known workspaces, including external worktrees", () => {
expect(projectActivityIndicator(
project("p1", "/repo"),
[workspace("p1", "/repo"), workspace("p1", "/tmp/worktree")],
{ "/tmp/worktree": activity("/tmp/worktree") },
)).toBe("session");
});
it("returns a project terminal indicator only when matched workspaces have no session activity", () => {
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/repo": activity("/repo", { hasSessionActivity: false, hasTerminalActivity: true }) })).toBe("terminal");
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/repo": activity("/repo", { hasSessionActivity: true, hasTerminalActivity: true }) })).toBe("session");
});
it("falls back to project path matching before workspaces have been loaded", () => {
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/repo/packages/app": activity("/repo/packages/app") })).toBe("session");
expect(projectActivityIndicator(project("p1", "/repo"), [], { "/other": activity("/other") })).toBeUndefined();
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { ActivityIndicatorKind } from "./components/activityBadge";
import type { Project, Workspace, WorkspaceActivity } from "./api";
export function workspaceActivityFor(workspace: Workspace, activities: Record<string, WorkspaceActivity>): WorkspaceActivity | undefined {
return activities[workspace.path];
}
export function workspaceActivityIndicator(activity: WorkspaceActivity | undefined): ActivityIndicatorKind | undefined {
if (activity?.hasSessionActivity === true) return "session";
if (activity?.hasTerminalActivity === true) return "terminal";
return undefined;
}
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 undefined;
}
function matchedProjectActivities(project: Project, knownWorkspaces: Workspace[], activities: Record<string, WorkspaceActivity>): WorkspaceActivity[] {
const knownWorkspacePaths = new Set(knownWorkspaces.filter((workspace) => workspace.projectId === project.id).map((workspace) => workspace.path));
const matched = new Map<string, WorkspaceActivity>();
for (const path of knownWorkspacePaths) {
const activity = activities[path];
if (activity !== undefined) matched.set(activity.cwd, activity);
}
for (const activity of Object.values(activities)) {
if (activity.cwd === project.path || activity.cwd.startsWith(`${project.path}/`)) matched.set(activity.cwd, activity);
}
return [...matched.values()];
}
@@ -0,0 +1,10 @@
import type { FastifyInstance } from "fastify";
import type { WorkspaceActivityResponse } from "../../shared/apiTypes.js";
export interface WorkspaceActivityRouteService {
snapshot(): WorkspaceActivityResponse;
}
export function registerWorkspaceActivityRoutes(app: FastifyInstance, activity: WorkspaceActivityRouteService, prefix = ""): void {
app.get(`${prefix}/activity`, () => activity.snapshot());
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import type { RealtimeEvent, SessionStatus } from "../../shared/apiTypes";
import { WorkspaceActivityService } from "./workspaceActivityService";
function status(patch: Partial<SessionStatus> = {}): SessionStatus {
return {
sessionId: "s1",
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
...patch,
};
}
describe("WorkspaceActivityService", () => {
it("publishes and snapshots session activity by cwd", () => {
const events: RealtimeEvent[] = [];
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
service.applySessionStatus("/repo", status({ isStreaming: true }));
expect(service.snapshot().workspaces).toMatchObject([{ cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false }]);
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false } });
service.applySessionStatus("/repo", status({ isStreaming: false }));
expect(service.snapshot().workspaces).toEqual([]);
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } });
});
it("combines sessions and terminals and clears closed terminals", () => {
const events: RealtimeEvent[] = [];
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
service.applySessionActivity("/repo", { sessionId: "s1", phase: "active", label: "running tool", detail: "read", at: "now" });
service.updateTerminal({ id: "t1", cwd: "/repo", exited: false });
expect(service.snapshot().workspaces).toMatchObject([{ cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: true }]);
service.removeSession("s1");
service.updateTerminal({ id: "t1", cwd: "/repo", exited: true });
expect(service.snapshot().workspaces).toEqual([]);
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } });
});
});
@@ -0,0 +1,102 @@
import { isSessionActive, isWorkspaceActivityActive } from "../../shared/activity.js";
import type { RealtimeEvent, SessionActivity, SessionStatus, TerminalInfo, WorkspaceActivity, WorkspaceActivityResponse } from "../../shared/apiTypes.js";
export interface WorkspaceActivityPublisher {
publishRealtime(event: RealtimeEvent): void;
}
interface SessionRecord {
cwd: string;
status?: SessionStatus;
activity?: SessionActivity;
}
interface TerminalRecord {
cwd: string;
}
export class WorkspaceActivityService {
private readonly sessions = new Map<string, SessionRecord>();
private readonly terminals = new Map<string, TerminalRecord>();
constructor(private readonly publisher?: WorkspaceActivityPublisher) {}
applySessionStatus(cwd: string, status: SessionStatus): void {
const previousCwd = this.sessions.get(status.sessionId)?.cwd;
const record = this.sessions.get(status.sessionId) ?? { cwd };
record.cwd = cwd;
record.status = status;
this.sessions.set(status.sessionId, record);
this.pruneIdleSession(status.sessionId);
this.publishChangedCwds(previousCwd, cwd);
}
applySessionActivity(cwd: string, activity: SessionActivity): void {
const previousCwd = this.sessions.get(activity.sessionId)?.cwd;
const record = this.sessions.get(activity.sessionId) ?? { cwd };
record.cwd = cwd;
record.activity = activity;
this.sessions.set(activity.sessionId, record);
this.pruneIdleSession(activity.sessionId);
this.publishChangedCwds(previousCwd, cwd);
}
removeSession(sessionId: string): void {
const cwd = this.sessions.get(sessionId)?.cwd;
this.sessions.delete(sessionId);
this.publishCwd(cwd);
}
updateTerminal(terminal: Pick<TerminalInfo, "id" | "cwd" | "exited">): void {
const previousCwd = this.terminals.get(terminal.id)?.cwd;
if (terminal.exited) this.terminals.delete(terminal.id);
else this.terminals.set(terminal.id, { cwd: terminal.cwd });
this.publishChangedCwds(previousCwd, terminal.cwd);
}
removeTerminal(terminalId: string, cwd?: string): void {
const previousCwd = this.terminals.get(terminalId)?.cwd ?? cwd;
this.terminals.delete(terminalId);
this.publishCwd(previousCwd);
}
snapshot(): WorkspaceActivityResponse {
return {
workspaces: this.activeCwds().map((cwd) => this.summaryForCwd(cwd)).filter(isWorkspaceActivityActive),
generatedAt: new Date().toISOString(),
};
}
private pruneIdleSession(sessionId: string): void {
const record = this.sessions.get(sessionId);
if (record !== undefined && !isSessionActive(record.status, record.activity)) this.sessions.delete(sessionId);
}
private publishChangedCwds(previousCwd: string | undefined, cwd: string): void {
this.publishCwd(previousCwd);
if (previousCwd !== cwd) this.publishCwd(cwd);
}
private publishCwd(cwd: string | undefined): void {
if (cwd === undefined || cwd === "") return;
this.publisher?.publishRealtime({ type: "workspace.activity", activity: this.summaryForCwd(cwd) });
}
private activeCwds(): string[] {
const cwds = new Set<string>();
for (const record of this.sessions.values()) {
if (isSessionActive(record.status, record.activity)) cwds.add(record.cwd);
}
for (const record of this.terminals.values()) cwds.add(record.cwd);
return [...cwds].sort((a, b) => a.localeCompare(b));
}
private summaryForCwd(cwd: string): WorkspaceActivity {
return {
cwd,
hasSessionActivity: [...this.sessions.values()].some((record) => record.cwd === cwd && isSessionActive(record.status, record.activity)),
hasTerminalActivity: [...this.terminals.values()].some((terminal) => terminal.cwd === cwd),
updatedAt: new Date().toISOString(),
};
}
}
+6 -2
View File
@@ -3,6 +3,8 @@ import { mkdir, rm } from "node:fs/promises";
import { dirname } from "node:path";
import Fastify from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { WorkspaceActivityService } from "./activity/workspaceActivityService.js";
import { registerWorkspaceActivityRoutes } from "./activity/workspaceActivityRoutes.js";
import { SessionEventHub } from "./realtime/sessionEventHub.js";
import { AuthService } from "./sessions/authService.js";
import { registerAuthRoutes } from "./sessions/authRoutes.js";
@@ -17,10 +19,12 @@ const app = Fastify({ logger: true });
await app.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = new AuthService();
const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry });
const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry, workspaceActivity });
auth.subscribe((change) => { sessions.applyAuthChange(change); });
const terminals = new TerminalService(eventHub);
const terminals = new TerminalService(eventHub, workspaceActivity);
registerWorkspaceActivityRoutes(app, workspaceActivity);
registerAuthRoutes(app, auth);
registerSessionRoutes(app, sessions, eventHub);
registerTerminalRoutes(app, terminals);
@@ -30,6 +30,7 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
bridgeSockets(socket, daemon.connectWebSocket("/events"));
});
app.all("/api/activity", (request, reply) => proxy(request, reply));
app.all("/api/auth", (request, reply) => proxy(request, reply));
app.all("/api/auth/*", (request, reply) => proxy(request, reply));
app.all("/api/sessions", (request, reply) => proxy(request, reply));
+8
View File
@@ -23,6 +23,7 @@ import type { ActiveSession } from "./sessionRuntimeStore.js";
import type { AuthChange } from "./authService.js";
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
function noop(): void {
// Intentionally empty default unsubscribe callback.
@@ -163,6 +164,7 @@ export interface PiSessionServiceDependencies {
createAgentRuntime?: CreateAgentRuntime;
modelRegistry?: ModelRegistryInstance;
heartbeatIntervalMs?: number;
workspaceActivity?: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession">;
}
export class PiSessionService {
@@ -177,6 +179,7 @@ export class PiSessionService {
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
private readonly createAgentRuntime: CreateAgentRuntime;
private readonly modelRegistry: ModelRegistryInstance;
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession"> | undefined;
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
@@ -185,6 +188,7 @@ export class PiSessionService {
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
this.workspaceActivity = deps.workspaceActivity;
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
this.commandService = new SessionCommandService(
(sessionId) => this.getActive(sessionId),
@@ -215,6 +219,7 @@ export class PiSessionService {
this.authLossWarnings.clear();
await Promise.all(activeSessions.map(async (active) => {
active.unsubscribe();
this.workspaceActivity?.removeSession(active.runtime.session.sessionId);
await active.runtime.session.abort();
await active.runtime.dispose();
}));
@@ -466,6 +471,7 @@ export class PiSessionService {
if (!active) return;
this.active.delete(sessionId);
this.activities.delete(sessionId);
this.workspaceActivity?.removeSession(sessionId);
this.clearAuthLossWarningsForSession(sessionId);
clearSessionQueue(active.runtime.session);
active.unsubscribe();
@@ -638,12 +644,14 @@ export class PiSessionService {
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
this.activities.set(session.sessionId, stored);
const activity = detail === undefined ? { sessionId: session.sessionId, phase, label, at } : { sessionId: session.sessionId, phase, label, detail, at };
this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity);
this.events.publish(session.sessionId, { type: "activity.update", activity });
this.events.publishGlobal({ type: "activity.update", activity });
}
private publishStatus(session: PiAgentSession): void {
const status = this.statusFromSession(session);
this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
this.events.publish(session.sessionId, { type: "status.update", status });
this.events.publishGlobal({ type: "status.update", status });
}
+7 -2
View File
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import * as pty from "node-pty";
import type { TerminalUiEvent } from "../../shared/apiTypes.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
const MAX_REPLAY_BUFFER = 200_000;
@@ -24,7 +25,7 @@ interface TerminalRecord extends TerminalInfo {
export class TerminalService {
private readonly terminals = new Map<string, TerminalRecord>();
constructor(private readonly events?: SessionEventHub) {}
constructor(private readonly events?: SessionEventHub, private readonly workspaceActivity?: Pick<WorkspaceActivityService, "updateTerminal" | "removeTerminal">) {}
list(cwd: string): TerminalInfo[] {
return [...this.terminals.values()]
@@ -63,10 +64,13 @@ export class TerminalService {
record.exited = true;
record.exitCode = exitCode;
record.events.emit("exit", exitCode);
this.publish({ type: "terminal.exited", terminal: toInfo(record) });
const info = toInfo(record);
this.workspaceActivity?.updateTerminal(info);
this.publish({ type: "terminal.exited", terminal: info });
});
this.terminals.set(id, record);
const info = toInfo(record);
this.workspaceActivity?.updateTerminal(info);
this.publish({ type: "terminal.created", terminal: info });
return info;
}
@@ -107,6 +111,7 @@ export class TerminalService {
if (terminal === undefined) return;
this.terminals.delete(id);
terminal.events.removeAllListeners();
this.workspaceActivity?.removeTerminal(id, terminal.cwd);
if (!terminal.exited) terminal.pty.kill();
this.publish({ type: "terminal.closed", terminalId: id, cwd: terminal.cwd });
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { isSessionActive, sessionActivityLabel, isWorkspaceActivityActive } from "./activity";
import type { SessionStatus, WorkspaceActivity } from "./apiTypes";
const idleStatus: SessionStatus = {
sessionId: "s1",
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
describe("activity helpers", () => {
it("detects and labels active session states consistently", () => {
expect(isSessionActive(idleStatus)).toBe(false);
expect(sessionActivityLabel(idleStatus)).toBeUndefined();
expect(isSessionActive({ ...idleStatus, isStreaming: true })).toBe(true);
expect(sessionActivityLabel({ ...idleStatus, isStreaming: true })).toBe("streaming");
expect(isSessionActive({ ...idleStatus, pendingMessageCount: 2 })).toBe(true);
expect(sessionActivityLabel({ ...idleStatus, pendingMessageCount: 2 })).toBe("2 pending");
expect(sessionActivityLabel(idleStatus, { sessionId: "s1", phase: "active", label: "running tool", detail: "read", at: "now" })).toBe("running tool: read");
});
it("detects workspace activity presence without exposing details", () => {
const idle: WorkspaceActivity = { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false, updatedAt: "now" };
expect(isWorkspaceActivityActive(idle)).toBe(false);
expect(isWorkspaceActivityActive({ ...idle, hasSessionActivity: true })).toBe(true);
expect(isWorkspaceActivityActive({ ...idle, hasTerminalActivity: true })).toBe(true);
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { SessionActivity, SessionStatus, WorkspaceActivity } from "./apiTypes.js";
export function isSessionActive(status?: SessionStatus, activity?: SessionActivity): boolean {
return activity?.phase === "active"
|| status?.isStreaming === true
|| status?.isBashRunning === true
|| status?.isCompacting === true
|| (status?.pendingMessageCount ?? 0) > 0;
}
export function sessionActivityLabel(status?: SessionStatus, activity?: SessionActivity): string | undefined {
if (activity?.phase === "active") return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label;
if (status === undefined) return undefined;
if (status.isCompacting) return "compacting";
if (status.isBashRunning) return "bash";
if (status.isStreaming) return "streaming";
if (status.pendingMessageCount > 0) return `${String(status.pendingMessageCount)} pending`;
return undefined;
}
export function isWorkspaceActivityActive(activity: WorkspaceActivity | undefined): boolean {
return activity !== undefined && (activity.hasSessionActivity || activity.hasTerminalActivity);
}
+18 -1
View File
@@ -107,6 +107,18 @@ export interface SessionStatus {
contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null };
}
export interface WorkspaceActivity {
cwd: string;
hasSessionActivity: boolean;
hasTerminalActivity: boolean;
updatedAt: string;
}
export interface WorkspaceActivityResponse {
workspaces: WorkspaceActivity[];
generatedAt: string;
}
export interface SlashCommand {
name: string;
description?: string;
@@ -242,6 +254,11 @@ export type TerminalUiEvent =
| { type: "terminal.exited"; terminal: TerminalInfo }
| { type: "terminal.closed"; terminalId: string; cwd: string };
export interface WorkspaceActivityUiEvent {
type: "workspace.activity";
activity: WorkspaceActivity;
}
export interface CommandOption {
value: string;
label: string;
@@ -280,4 +297,4 @@ export type SessionUiEvent =
| { type: "pi.event"; eventType: string };
export type GlobalSessionEvent = Extract<SessionUiEvent, { type: "status.update" | "activity.update" | "session.name" }>;
export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent;
export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent;