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,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(),
};
}
}