fix(sessions): stop counting session startup as work in progress

Startup progress rides the per-session activity channel with an "active"
phase, because a startup phase really is in progress. But isSessionActive()
treated any active activity as work, so a session that was merely *opening*
enabled "Stop Active Work", disabled "Reload from disk" with the misleading
"Stop current session activity before reloading" tooltip, showed the row's
active-work indicator, and — for any caller that hands a startup activity to
WorkspaceActivityService — reported the whole workspace as busy. Selecting an
archived, read-only session reported active work while it opened.

Starting is not working. publishStartupProgress now marks its reports with a
new optional SessionActivity.startup field, and isSessionActive() does not
count a marked activity. Every affected consumer — the session list, the core
actions, the app's activity-transition handling, and the server's workspace
aggregation — reads that one helper, so the correction lands in all of them at
once.

The marker is a new field rather than a new phase on purpose: six readers test
phase === "active" directly, including the pending row's "creating · " prefix,
the chat dock's active styling, and the daemon's own heartbeat re-publication.
It can only ever remove the activity-phase reason for being active, so
streaming, bash, compaction, and queued prompts still report as active through
the status even while a startup report is the latest activity. The chat dock
still shows the startup text; this changes what counts as work, not what is
shown.

The browser's own pending-create row keeps its previous appearance: it borrows
only the daemon's phase text and drops the marker, since that row stands for a
create the user is waiting on rather than a session the daemon is opening.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-26 22:54:27 +02:00
parent cd1326a8ca
commit 4940eda352
13 changed files with 214 additions and 4 deletions
+9
View File
@@ -305,6 +305,15 @@ describe("API parsers", () => {
});
});
it("carries the startup marker so an opening session is not mistaken for a working one", () => {
const activity = { sessionId: "session-1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z", startup: true };
expect(parseSessionStartupProgressEvent({ type: "session.startup", activity })).toEqual({ type: "session.startup", activity });
// A malformed marker is dropped like any other malformed field rather than
// being coerced into "this is startup" or "this is work".
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, startup: "yes" } })).toThrow("Expected optional boolean field: startup");
});
it("rejects session startup progress that cannot be routed or rendered honestly", () => {
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", at: "2026-07-20T00:00:01.000Z" };
+13
View File
@@ -300,9 +300,22 @@ function parseSessionActivity(value: unknown): SessionActivity {
label: requireNonEmptyString(record, "label"),
...optionalField("detail", optionalString(record, "detail")),
at: requireNonEmptyString(record, "at"),
...optionalField("startup", optionalActivityStartupMarker(record)),
};
}
/**
* The startup marker says the activity is a session opening rather than work in
* progress, which decides whether "Stop Active Work" is offered and whether a
* reload is blocked. A malformed marker is rejected rather than guessed at.
*/
function optionalActivityStartupMarker(record: Record<string, unknown>): boolean | undefined {
const value = record["startup"];
if (value === undefined) return undefined;
if (typeof value !== "boolean") throw new Error("Expected optional boolean field: startup");
return value;
}
function requireSessionActivityPhase(record: Record<string, unknown>, key: string): SessionActivity["phase"] {
const value = requireString(record, key);
if (value !== "active" && value !== "idle" && value !== "error") throw new Error(`Expected session activity phase field: ${key}`);
@@ -16,6 +16,14 @@ describe("sessionRowActivityKind", () => {
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false)).toBe("session");
});
it("shows no active-work indicator for a session that is only starting up", () => {
const startup = { sessionId: "s", phase: "active" as const, label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
expect(sessionRowActivityKind(session("s"), idle, startup, false)).toBeUndefined();
// Ordinary activity is work and keeps its indicator.
expect(sessionRowActivityKind(session("s"), idle, { sessionId: "s", phase: "active", label: "running tool", at: "now" }, false)).toBe("session");
});
it("reports unread only while the session is idle", () => {
expect(sessionRowActivityKind(session("s"), idle, undefined, false, true)).toBe("unread");
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false, true)).toBe("session");
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { isSessionActive } from "../../../shared/activity";
import { initialAppState } from "../appState";
import { SessionController } from "./sessionController";
import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, sessionLookupId, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
@@ -10,6 +11,7 @@ function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity
label: "Creating session",
detail: "Starting the Pi session",
at: "2026-07-20T00:00:01.000Z",
startup: true,
...patch,
};
}
@@ -76,6 +78,28 @@ describe("SessionController session startup progress", () => {
await start;
});
it("keeps a pending create row's own appearance while it borrows the daemon's phase text", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const { controller, startRequest } = pendingStartController(state);
const start = controller.startSession();
const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
const beforeStartupProgress = isSessionActive(state.current.status, state.current.activity);
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
runPendingAnimationFrames();
// This row is the browser's own pending create, not a session being opened.
// Substituting the daemon's phase text into it must not silently change what
// the row reports about itself, or its indicator would blink off mid-create.
expect(state.current.activity?.phase).toBe("active");
expect(isSessionActive(state.current.status, state.current.activity)).toBe(beforeStartupProgress);
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
await start;
});
it("restores the generic wording when the daemon has nothing left to attribute", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const { controller, startRequest } = pendingStartController(state);
@@ -136,6 +160,29 @@ describe("SessionController session startup progress", () => {
expect(state.activity).toMatchObject({ sessionId: oldSession.id, label: "Opening session", detail: "Starting the Pi session" });
});
it("shows an archived session's opening progress without reporting it as active work", () => {
const archived = { ...oldSession, id: "archived-session", archived: true, archivedAt: "2026-05-16T00:00:00.000Z" };
let state: AppState = { ...initialAppState(), selectedSession: archived, sessions: [archived] };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
controller.applyGlobalEvent({
type: "session.startup",
activity: startupActivity({ sessionId: archived.id, label: "Opening session" }),
});
runPendingAnimationFrames();
// A read-only session cannot be worked on, so opening one must not make it
// look busy — while the text a waiting user reads still arrives.
expect(state.activity).toMatchObject({ sessionId: archived.id, label: "Opening session", detail: "Starting the Pi session" });
expect(isSessionActive(state.status, state.activity)).toBe(false);
});
it("gives an existing session's startup its own row rather than a pending start in the same workspace", async () => {
const existing = { ...oldSession, id: "existing-session", cwd: workspace.path };
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [existing] } };
@@ -1328,7 +1328,7 @@ export class SessionController {
// creation request that has not returned yet.
this.queueActivityUpdate(event.activity.phase === "idle"
? creatingPendingSessionActivity(pending.tempId, pending.queuedSends.length)
: { ...event.activity, sessionId: pending.tempId });
: pendingStartActivity(event.activity, pending.tempId));
}
private schedulePendingFlush(): void {
@@ -1429,6 +1429,24 @@ function isClientPendingStartSessionInfo(session: SessionInfo | undefined): sess
return session !== undefined && "clientPendingStart" in session && session.clientPendingStart === true;
}
/**
* Re-label a startup report onto the browser's own pending create row.
*
* The daemon's startup marker is dropped: this row stands for a create the user
* asked for and is waiting on, which the browser has always reported as active
* work in progress, rather than for a session the daemon is opening. Only the
* phase text is borrowed, so the row keeps its own `creating · ` appearance.
*/
function pendingStartActivity(activity: SessionActivity, sessionId: string): SessionActivity {
return {
sessionId,
phase: activity.phase,
label: activity.label,
...(activity.detail === undefined ? {} : { detail: activity.detail }),
at: activity.at,
};
}
function creatingPendingSessionActivity(sessionId: string, queuedCount = 0): SessionActivity {
return {
sessionId,
+19
View File
@@ -249,6 +249,25 @@ describe("PluginRegistry", () => {
expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
});
it("treats a session that is only starting up as having no work to stop or block", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } };
const startupActivity = { sessionId: "s1", phase: "active" as const, label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
const opening = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: true }), activity: startupActivity, machineRuntimes: reloadRuntime }).context);
// Nothing is being worked on, so there is nothing to stop and no reason to
// block a reload with "Stop current session activity before reloading".
expect(opening.find((action) => action.id === "core:session.stop")?.enabled).toBe(false);
expect(opening.find((action) => action.id === "core:session.reload")?.enabled).toBe(true);
// Real work is still real work, whatever else the session is doing.
const working = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: true, isStreaming: true }), activity: startupActivity, machineRuntimes: reloadRuntime }).context);
expect(working.find((action) => action.id === "core:session.stop")?.enabled).toBe(true);
expect(working.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
});
it("routes session reload through the runtime context", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
+10
View File
@@ -90,6 +90,16 @@ describe("notification socket guards", () => {
})).toBeUndefined();
});
it("carries the startup marker through the socket boundary, marker and all", () => {
const activity = { sessionId: "session-1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z", startup: true };
// The marker is what stops an opening session being treated as a working
// one, so dropping it in transit would restore the defect for every frame,
// including those relayed from a remote machine.
expect(parseRealtimeSocketEvent({ type: "session.startup", activity })).toMatchObject({ type: "session.startup", activity: { startup: true } });
expect(parseRealtimeSocketEvent({ type: "session.startup", activity: { ...activity, startup: 1 } })).toBeUndefined();
});
it("accepts validated session startup progress and drops malformed frames", () => {
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };