Archived
fix: clear stale workspace activity indicators
This commit is contained in:
@@ -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.
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string, SessionActivity>, sessionId: string): Record<string, SessionActivity> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user