diff --git a/.changeset/clear-archived-workspace-activity.md b/.changeset/clear-archived-workspace-activity.md new file mode 100644 index 0000000..c6cfbbd --- /dev/null +++ b/.changeset/clear-archived-workspace-activity.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Clear stale workspace activity indicators when sessions become idle or all remaining sessions are archived. diff --git a/src/server/activity/workspaceActivityService.test.ts b/src/server/activity/workspaceActivityService.test.ts index 46eabf7..976ec0a 100644 --- a/src/server/activity/workspaceActivityService.test.ts +++ b/src/server/activity/workspaceActivityService.test.ts @@ -32,6 +32,47 @@ describe("WorkspaceActivityService", () => { expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } }); }); + it("clears stale active activity when an idle status arrives", () => { + 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.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("publishes a clear event when removing an already-pruned session with a cwd", () => { + const events: RealtimeEvent[] = []; + const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) }); + + service.removeSession("missing-session", "/repo"); + + expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } }); + }); + + it("reconciles stale session activity for a workspace", () => { + const events: RealtimeEvent[] = []; + const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) }); + + service.applySessionActivity("/repo", { sessionId: "s1", phase: "active", label: "running tool", at: "now" }); + service.applySessionActivity("/repo", { sessionId: "s2", phase: "active", label: "running tool", at: "now" }); + service.applySessionActivity("/other", { sessionId: "s3", phase: "active", label: "running tool", at: "now" }); + + service.reconcileSessionActivity("/repo", ["s2"]); + + expect(service.snapshot().workspaces).toMatchObject([ + { cwd: "/other", hasSessionActivity: true, hasTerminalActivity: false }, + { cwd: "/repo", hasSessionActivity: true, hasTerminalActivity: false }, + ]); + + service.reconcileSessionActivity("/repo", []); + + expect(service.snapshot().workspaces).toMatchObject([{ cwd: "/other", hasSessionActivity: true, hasTerminalActivity: false }]); + 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) }); diff --git a/src/server/activity/workspaceActivityService.ts b/src/server/activity/workspaceActivityService.ts index d70282a..fd32653 100644 --- a/src/server/activity/workspaceActivityService.ts +++ b/src/server/activity/workspaceActivityService.ts @@ -26,6 +26,7 @@ export class WorkspaceActivityService { const record = this.sessions.get(status.sessionId) ?? { cwd }; record.cwd = cwd; record.status = status; + if (!isSessionActive(status) && record.activity?.phase === "active") delete record.activity; this.sessions.set(status.sessionId, record); this.pruneIdleSession(status.sessionId); this.publishChangedCwds(previousCwd, cwd); @@ -41,10 +42,21 @@ export class WorkspaceActivityService { this.publishChangedCwds(previousCwd, cwd); } - removeSession(sessionId: string): void { - const cwd = this.sessions.get(sessionId)?.cwd; + removeSession(sessionId: string, cwd?: string): void { + const previousCwd = this.sessions.get(sessionId)?.cwd ?? cwd; this.sessions.delete(sessionId); - this.publishCwd(cwd); + this.publishCwd(previousCwd); + } + + reconcileSessionActivity(cwd: string, sessionIds: Iterable): void { + const knownSessionIds = new Set(sessionIds); + let changed = false; + for (const [sessionId, record] of this.sessions.entries()) { + if (record.cwd !== cwd || knownSessionIds.has(sessionId)) continue; + this.sessions.delete(sessionId); + changed = true; + } + if (changed) this.publishCwd(cwd); } updateTerminal(terminal: Pick): void { diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 75cffa9..effa111 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -206,6 +206,40 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("reconciles workspace activity when listing only archived sessions", async () => { + const reconciliations: { cwd: string; sessionIds: string[] }[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + archiveStore: { + list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), + get: () => Promise.resolve(undefined), + archive: () => { throw new Error("archive should not be called for moved records"); }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + listAll: () => Promise.resolve([]), + open: () => fakeSessionManager(), + }, + workspaceActivity: { + applySessionStatus: () => undefined, + applySessionActivity: () => undefined, + removeSession: () => undefined, + reconcileSessionActivity: (cwd, sessionIds) => { reconciliations.push({ cwd, sessionIds: [...sessionIds] }); }, + }, + heartbeatIntervalMs: 60_000, + }); + + const sessions = await service.list("/workspace"); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ id: "archived", archived: true }); + expect(reconciliations).toEqual([{ cwd: "/workspace", sessionIds: [] }]); + + await service.dispose(); + }); + it("sends prompts to an injected runtime without touching the SDK runtime", async () => { const fake = fakeRuntime("prompt-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 97fb1d9..afb786e 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -164,7 +164,7 @@ export interface PiSessionServiceDependencies { createAgentRuntime?: CreateAgentRuntime; modelRegistry?: ModelRegistryInstance; heartbeatIntervalMs?: number; - workspaceActivity?: Pick; + workspaceActivity?: Pick; } export class PiSessionService { @@ -179,7 +179,7 @@ export class PiSessionService { private readonly createRuntime: CreateAgentSessionRuntimeFactory; private readonly createAgentRuntime: CreateAgentRuntime; private readonly modelRegistry: ModelRegistryInstance; - private readonly workspaceActivity: Pick | undefined; + private readonly workspaceActivity: Pick | undefined; constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) { this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); @@ -219,7 +219,7 @@ export class PiSessionService { this.authLossWarnings.clear(); await Promise.all(activeSessions.map(async (active) => { active.unsubscribe(); - this.workspaceActivity?.removeSession(active.runtime.session.sessionId); + this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd()); await active.runtime.session.abort(); await active.runtime.dispose(); })); @@ -234,12 +234,13 @@ export class PiSessionService { .map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))), ); const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record])); - const activeSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry); + const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry); + this.workspaceActivity?.reconcileSessionActivity(cwd, this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById)); const archivedSessions = archivedForCwd .sort(compareArchivedRecords) .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId))) .filter(isDefined); - return [...activeSessions, ...archivedSessions]; + return [...unarchivedSessions, ...archivedSessions]; } async start(cwd: string): Promise { @@ -439,6 +440,15 @@ export class PiSessionService { }); } + private reconcilableSessionIds(cwd: string, listedSessionIds: string[], archivedById: Map): string[] { + const sessionIds = new Set(listedSessionIds); + for (const active of new Set(this.active.values())) { + const session = active.runtime.session; + if (session.sessionManager.getCwd() === cwd && !archivedById.has(session.sessionId)) sessionIds.add(session.sessionId); + } + return [...sessionIds]; + } + private async ensureArchivedSessionMoved(record: ArchivedSessionRecord, session: PiSessionListEntry | undefined): Promise { if (session === undefined || this.active.has(record.sessionId)) return record; try { @@ -471,7 +481,7 @@ export class PiSessionService { if (!active) return; this.active.delete(sessionId); this.activities.delete(sessionId); - this.workspaceActivity?.removeSession(sessionId); + this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd()); this.clearAuthLossWarningsForSession(sessionId); clearSessionQueue(active.runtime.session); active.unsubscribe();