From 679008d132de579c86a851ea22e7e8de78b3025c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 24 May 2026 08:15:47 +0200 Subject: [PATCH] fix: clear stale workspace activity indicators --- .changeset/quiet-workspace-activity.md | 5 ++ .../src/controllers/sessionController.test.ts | 26 ++++++- .../src/controllers/sessionController.ts | 13 +++- src/server/sessions/piSessionService.test.ts | 67 ++++++++++++++++++- src/server/sessions/piSessionService.ts | 28 ++++++-- 5 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 .changeset/quiet-workspace-activity.md diff --git a/.changeset/quiet-workspace-activity.md b/.changeset/quiet-workspace-activity.md new file mode 100644 index 0000000..ecf562c --- /dev/null +++ b/.changeset/quiet-workspace-activity.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Fix workspace and project activity indicators so stale session activity clears instead of reappearing after idle sessions. diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 90dac63..8bfc745 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { api as defaultApi, type MessagePage, type SessionInfo, type SessionStatus, type Workspace } from "../api"; +import { api as defaultApi, type MessagePage, type SessionActivity, type SessionInfo, type SessionStatus, type Workspace } from "../api"; import { loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; import { initialAppState, type AppState } from "../appState"; import { loadDraft, saveDraft } from "../promptDraftStorage"; @@ -96,6 +96,30 @@ describe("SessionController", () => { Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true }); }); + it("clears stale active activity when an idle status arrives", () => { + const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" }; + let state: AppState = { + ...initialAppState(), + selectedSession: oldSession, + sessions: [oldSession], + activity: activeActivity, + sessionActivities: { [oldSession.id]: activeActivity }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) }); + + expect(state.activity).toBeUndefined(); + expect(state.sessionActivities[oldSession.id]).toBeUndefined(); + expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, isStreaming: false }); + }); + it("recreates missing browser-cached new sessions and moves their draft", async () => { const storage = new MemoryStorage(); Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index a3e856d..adf7843 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -5,6 +5,7 @@ import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage"; import { ChatTranscriptStore } from "../chatTranscriptStore"; import { isShellInput } from "../inputModes"; import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; +import { isSessionActive } from "../../../shared/activity"; import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection"; import type { GetState, SetState, UpdateUrl } from "./types"; @@ -443,9 +444,13 @@ export class SessionController { } private applyStatus(status: SessionStatus) { + const state = this.getState(); + const clearsStaleActivity = state.sessionActivities[status.sessionId]?.phase === "active" && !isSessionActive(status); this.setState({ - sessionStatuses: { ...this.getState().sessionStatuses, [status.sessionId]: status }, - status: this.getState().selectedSession?.id === status.sessionId ? status : this.getState().status, + sessionStatuses: { ...state.sessionStatuses, [status.sessionId]: status }, + ...(clearsStaleActivity ? { sessionActivities: omitSessionActivity(state.sessionActivities, status.sessionId) } : {}), + status: state.selectedSession?.id === status.sessionId ? status : state.status, + activity: state.selectedSession?.id === status.sessionId && clearsStaleActivity ? undefined : state.activity, }); if (this.catchupStreamSessionId === status.sessionId && !status.isStreaming) this.finishStreamCatchup(status.sessionId); } @@ -536,6 +541,10 @@ export class SessionController { } } +function omitSessionActivity(activities: Record, sessionId: string): Record { + return Object.fromEntries(Object.entries(activities).filter(([id]) => id !== sessionId)); +} + function isTranscriptEvent(event: SessionUiEvent): boolean { return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "command.output", "session.error"].includes(event.type); } diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index fd24e04..ffde6fb 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -1,5 +1,5 @@ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionRuntime, type PiSessionServiceDependencies } from "./piSessionService.js"; @@ -148,6 +148,71 @@ describe("PiSessionService", () => { expect(fake.calls.dispose).toBe(1); }); + it("clears stale active activity once a previously active session becomes idle", async () => { + vi.useFakeTimers(); + let service: PiSessionService | undefined; + try { + const hub = new CapturingSessionEventHub(); + let listener: ((event: unknown) => void) | undefined; + const fake = fakeRuntime("idle-session", { + isStreaming: true, + subscribe: (next) => { + listener = next; + return () => undefined; + }, + }); + service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("idle-session")]), + heartbeatIntervalMs: 1_000, + }); + + await service.status("idle-session"); + hub.globalEvents.length = 0; + listener?.({ type: "agent_start" }); + + const activityPhases = () => hub.globalEvents + .filter((event) => event.type === "activity.update") + .map((event) => event.activity.phase); + expect(activityPhases()).toEqual(["active"]); + + fake.session.isStreaming = false; + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(1_000); + + expect(activityPhases()).toEqual(["active", "idle"]); + } finally { + await service?.dispose(); + vi.useRealTimers(); + } + }); + + it("publishes idle activity for SDK completion events", async () => { + const hub = new CapturingSessionEventHub(); + let listener: ((event: unknown) => void) | undefined; + const fake = fakeRuntime("completion-session", { + subscribe: (next) => { + listener = next; + return () => undefined; + }, + }); + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("completion-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.status("completion-session"); + hub.globalEvents.length = 0; + listener?.({ type: "tool_execution_end", toolName: "read", isError: false }); + + expect(hub.globalEvents.filter((event) => event.type === "activity.update")).toMatchObject([ + { activity: { sessionId: "completion-session", phase: "idle", label: "tool complete", detail: "read" } }, + ]); + + await service.dispose(); + }); + it("uses injected archive and session-manager gateways for listing", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { archiveStore: { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index abf7479..c869bc3 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -674,10 +674,12 @@ export class PiSessionService { for (const active of this.active.values()) { const { session } = active.runtime; const activity = this.activities.get(session.sessionId); - const isActive = session.isStreaming || session.isBashRunning || session.isCompacting || session.pendingMessageCount > 0 || activity?.phase === "active"; - if (!isActive) continue; + if (!sessionHasActiveWork(session)) { + if (activity?.phase === "active") this.publishStatus(session); + continue; + } this.publishStatus(session); - if (activity) this.publishActivity(session, activity.label, "active", activity.detail); + if (activity?.phase === "active") this.publishActivity(session, activity.label, "active", activity.detail); else this.publishActivity(session, this.activityLabelFromStatus(session), "active"); } } @@ -702,19 +704,19 @@ export class PiSessionService { }, 250); return; } - if (eventType === "turn_end") { this.publishActivity(session, "turn complete", "active"); return; } + if (eventType === "turn_end") { this.publishActivity(session, "turn complete", "idle"); return; } if (eventType === "message_start") { this.publishActivity(session, "message started", "active"); return; } if (eventType === "message_end") { this.publishActivity(session, "message complete", "idle"); return; } if (eventType === "message_update") { this.publishActivity(session, "receiving response", "active"); return; } if (eventType === "tool_execution_start") { this.publishActivity(session, "running tool", "active", getString(event, "toolName")); return; } if (eventType === "tool_execution_end") { const isError = getBoolean(event, "isError") === true; - this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "active", getString(event, "toolName")); + this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "idle", getString(event, "toolName")); return; } if (eventType === "bash_execution_start") { this.publishActivity(session, "running bash", "active"); return; } - if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "active"); return; } - this.publishActivity(session, eventType.replaceAll("_", " "), "active"); + if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "idle"); return; } + if (sessionHasActiveWork(session)) this.publishActivity(session, eventType.replaceAll("_", " "), "active"); } private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void { @@ -729,11 +731,23 @@ export class PiSessionService { private publishStatus(session: PiAgentSession): void { const status = this.statusFromSession(session); + this.clearStaleActiveActivity(session); this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status); this.events.publish(session.sessionId, { type: "status.update", status }); this.events.publishGlobal({ type: "status.update", status }); } + private clearStaleActiveActivity(session: PiAgentSession): void { + const current = this.activities.get(session.sessionId); + if (current?.phase !== "active" || sessionHasActiveWork(session)) return; + const at = new Date().toISOString(); + const stored = { phase: "idle" as const, label: "idle", at }; + this.activities.set(session.sessionId, stored); + const activity = { sessionId: session.sessionId, ...stored }; + this.events.publish(session.sessionId, { type: "activity.update", activity }); + this.events.publishGlobal({ type: "activity.update", activity }); + } + private statusFromSession(session: PiAgentSession): ClientSessionStatus { const stats = session.getSessionStats(); const model = session.model === undefined ? undefined : modelToClientModel(session.model);