Merge remote-tracking branch 'origin/main' into feat/model-questions-ux

# Conflicts:
#	src/server/sessiond.ts
#	src/server/sessions/sessionRoutes.test.ts
This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 09:44:40 +02:00
34 changed files with 1366 additions and 117 deletions
+20
View File
@@ -253,6 +253,22 @@ describe("session API compatibility", () => {
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
});
it("carries a create's correlation token in the start request body when one is supplied", async () => {
const fetchMock = stubSequenceFetch([
jsonResponse(sessionInfoResponse("s 1")),
jsonResponse(sessionInfoResponse("s 2")),
]);
await sessionsApi.startSession("/repo", "remote a", "pending-session-3-k2x9");
await sessionsApi.startSession("/repo", "remote a");
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ cwd: "/repo", startupToken: "pending-session-3-k2x9" });
// The token is optional, so a caller with no row to label sends none rather
// than an empty one.
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ cwd: "/repo" });
});
it("keeps legacy session-id calls free of cwd context", async () => {
const fetchMock = stubJsonFetch({ accepted: true });
@@ -572,6 +588,10 @@ function requestBody(init: RequestInit | undefined): string {
return init.body;
}
function sessionInfoResponse(id: string) {
return { id, path: `/tmp/${id}.jsonl`, cwd: "/repo", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
}
function piWebConfigResponse(config: PiWebConfigValues) {
return {
path: "/tmp/pi-web/config.json",
+1 -1
View File
@@ -215,7 +215,7 @@ export const sessionsApi = {
notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot),
dismissNotification: (session: SessionLookup, daemonInstanceId: string, notificationId: string, machineId = "local") => request(sessionPath(session, "notifications/dismiss", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, notificationId }) }),
dismissAllNotifications: (session: SessionLookup, daemonInstanceId: string, through: SessionNotificationDismissThrough, machineId = "local") => request(sessionPath(session, "notifications/dismiss-all", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, throughOrder: through.order, throughOverflowWatermark: through.overflowWatermark }) }),
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
startSession: (cwd: string, machineId = "local", startupToken?: string) => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify(startupToken === undefined ? { cwd } : { cwd, startupToken }) }),
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
+25 -14
View File
@@ -289,34 +289,45 @@ describe("API parsers", () => {
})).toThrow("positive safe integer");
});
it("parses session startup progress with and without a wait detail", () => {
it("parses session startup progress with and without a correlation token", () => {
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
expect(parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity })).toEqual({
expect(parseSessionStartupProgressEvent({ type: "session.startup", startupToken: "pending-session-1-abc", activity })).toEqual({
type: "session.startup",
cwd: "/repo",
startupToken: "pending-session-1-abc",
activity,
});
// An open carries no token: the activity's own session id is the only route.
const idle = { sessionId: "session-1", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
expect(parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: idle })).toEqual({
expect(parseSessionStartupProgressEvent({ type: "session.startup", activity: idle })).toEqual({
type: "session.startup",
cwd: "/repo",
activity: idle,
});
});
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" };
expect(() => parseSessionStartupProgressEvent({ type: "activity.update", cwd: "/repo", activity })).toThrow("Invalid session startup event type");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity })).toThrow("Expected string field: cwd");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "", activity })).toThrow("Expected non-empty string field: cwd");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo" })).toThrow("Expected object response");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, phase: "waiting" } })).toThrow("Expected session activity phase field: phase");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, label: 7 } })).toThrow("Expected string field: label");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, label: "" } })).toThrow("Expected non-empty string field: label");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, detail: 7 } })).toThrow("Expected optional string field: detail");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, sessionId: "" } })).toThrow("Expected non-empty string field: sessionId");
expect(() => parseSessionStartupProgressEvent({ type: "activity.update", activity })).toThrow("Invalid session startup event type");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup" })).toThrow("Expected object response");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", startupToken: 7, activity })).toThrow("Expected optional string field: startupToken");
// An empty token would match nothing but must still be rejected rather than
// silently carried, so a malformed frame never reaches the routing at all.
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", startupToken: "", activity })).toThrow("Expected non-empty string field: startupToken");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, phase: "waiting" } })).toThrow("Expected session activity phase field: phase");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, label: 7 } })).toThrow("Expected string field: label");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, label: "" } })).toThrow("Expected non-empty string field: label");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, detail: 7 } })).toThrow("Expected optional string field: detail");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, sessionId: "" } })).toThrow("Expected non-empty string field: sessionId");
});
it("parses session cleanup preview and execute responses", () => {
+20 -3
View File
@@ -402,15 +402,19 @@ export function parseSessionUnreadEvent(value: unknown): SessionUnreadEvent {
/**
* Validate a startup progress frame. The browser substitutes its own wording
* from this event, so a malformed frame must be dropped rather than rendered:
* `cwd` is the routing key, and an activity missing its phase or label could
* otherwise blank out or freeze the text a user is reading while they wait.
* `startupToken` is the routing key when present, and an activity missing its
* phase or label could otherwise blank out or freeze the text a user is reading
* while they wait. An absent token is valid — an open routes by session id — but
* a present empty one is not, since it could match no row honestly.
*/
export function parseSessionStartupProgressEvent(value: unknown): SessionStartupProgressEvent {
const record = requireRecord(value);
if (record["type"] !== "session.startup") throw new Error("Invalid session startup event type");
const startupToken = optionalString(record, "startupToken");
if (startupToken === "") throw new Error("Expected non-empty string field: startupToken");
return {
type: "session.startup",
cwd: requireNonEmptyString(record, "cwd"),
...optionalField("startupToken", startupToken),
activity: parseSessionActivity(record["activity"]),
};
}
@@ -423,9 +427,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}`);
+4
View File
@@ -435,6 +435,7 @@ export class PiWebApp extends LitElement {
this.sessions.refreshSelectedSession(),
this.refreshMachineActivities(),
this.refreshWorkspaceDeletionRuns(),
this.workspaces.refreshSelectedProjectTopology(),
]);
}
@@ -492,6 +493,7 @@ export class PiWebApp extends LitElement {
this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(),
this.refreshCurrentWorkspaceSurface(),
this.workspaces.refreshSelectedProjectTopology(),
]);
this.schedulePiWebStatusRefresh();
} finally {
@@ -1789,6 +1791,8 @@ export class PiWebApp extends LitElement {
configureAuth: () => this.auth.openLogin(),
logoutAuth: () => this.auth.openLogout(),
openThemePicker: () => { this.openThemeDialog(); },
openModelPicker: () => this.openModelDialog(),
openThinkingLevelPicker: () => this.openThinkingDialog(),
selectMainView: (view) => { this.selectMainView(view); },
selectWorkspaceTool: (tool) => { this.openWorkspaceTool(tool); },
openTerminal: (options) => { this.openTerminal(options); },
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkspaceController } from "../controllers/workspaceController";
import { PiWebApp } from "./PiWebApp";
type RefreshCallback = () => void | Promise<void>;
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("PiWebApp workspace topology refresh wiring", () => {
it("re-lists the selected project's workspaces on the browser-resume refresh", async () => {
const app = createApp();
stubBackgroundRefreshes(app);
const refreshTopology = spyOnTopologyRefresh(app);
await browserResumeRefresh(app)();
expect(refreshTopology).toHaveBeenCalledOnce();
});
it("re-lists the selected project's workspaces on the plugin-facing app-data refresh", async () => {
const app = createApp();
stubBackgroundRefreshes(app);
const refreshTopology = spyOnTopologyRefresh(app);
await refreshAppData(app);
expect(refreshTopology).toHaveBeenCalledOnce();
});
it("still re-lists workspaces when a sibling refresh in the same resume batch fails", async () => {
const app = createApp();
stubBackgroundRefreshes(app);
failBackgroundRefresh(app, "refreshMachineActivities", new Error("machine activity unavailable"));
const refreshTopology = spyOnTopologyRefresh(app);
await expect(browserResumeRefresh(app)()).rejects.toThrow("machine activity unavailable");
expect(refreshTopology).toHaveBeenCalledOnce();
});
});
function createApp(): PiWebApp {
const storage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
return new PiWebApp();
}
/**
* Replaces the sibling refreshes that already have their own coverage so this test
* observes only whether the resume/app-data paths include workspace topology.
*/
function stubBackgroundRefreshes(app: PiWebApp): void {
const result = () => Promise.resolve();
for (const name of [
"renegotiateUnreadMachines",
"refreshMachineActivities",
"refreshWorkspaceDeletionRuns",
"loadClientConfig",
"refreshCurrentWorkspaceSurface",
"schedulePiWebStatusRefresh",
]) {
if (!Reflect.set(app, name, result)) throw new Error(`Could not replace PiWebApp.${name}`);
}
const sessions: unknown = Reflect.get(app, "sessions");
if (typeof sessions !== "object" || sessions === null || !Reflect.set(sessions, "refreshSelectedSession", result)) {
throw new Error("Could not replace the selected-session refresh");
}
}
function failBackgroundRefresh(app: PiWebApp, name: string, error: Error): void {
if (!Reflect.set(app, name, () => Promise.reject(error))) throw new Error(`Could not fail PiWebApp.${name}`);
}
function spyOnTopologyRefresh(app: PiWebApp) {
const controller: unknown = Reflect.get(app, "workspaces");
if (!(controller instanceof WorkspaceController)) throw new Error("PiWebApp WorkspaceController was unavailable");
return vi.spyOn(controller, "refreshSelectedProjectTopology").mockResolvedValue(undefined);
}
/** The exact callback `BrowserResumeController` invokes after a focus/visibility signal. */
function browserResumeRefresh(app: PiWebApp): RefreshCallback {
const resume: unknown = Reflect.get(app, "browserResume");
if (typeof resume !== "object" || resume === null) throw new Error("PiWebApp BrowserResumeController was unavailable");
const callbacks: unknown = Reflect.get(resume, "callbacks");
if (typeof callbacks !== "object" || callbacks === null) throw new Error("Browser resume callbacks were unavailable");
const refresh: unknown = Reflect.get(callbacks, "refreshAfterResume");
if (!isRefreshCallback(refresh)) throw new Error("The browser resume refresh callback was unavailable");
return refresh;
}
async function refreshAppData(app: PiWebApp): Promise<void> {
const refresh: unknown = Reflect.get(app, "refreshAppData");
if (!isRefreshCallback(refresh)) throw new Error("PiWebApp.refreshAppData is not callable");
await refresh.call(app);
}
function isRefreshCallback(value: unknown): value is RefreshCallback {
return typeof value === "function";
}
@@ -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,10 +1,9 @@
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";
const REMOTE_MACHINE = { id: "remote", name: "Remote", kind: "remote" as const, createdAt: "now", updatedAt: "now" };
function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity {
return {
sessionId: "backend-session",
@@ -12,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,
};
}
@@ -21,8 +21,15 @@ function idleStartupActivity(): SessionActivity {
return { sessionId: "backend-session", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
}
interface StartCall {
cwd: string;
machineId: string | undefined;
startupToken: string | undefined;
}
function pendingStartController(state: { current: AppState }, api: Partial<typeof defaultApi> = {}) {
const startRequest = deferred<SessionInfo>();
const startCalls: StartCall[] = [];
const controller = new SessionController(
() => state.current,
(patch) => { state.current = { ...state.current, ...patch }; },
@@ -31,7 +38,10 @@ function pendingStartController(state: { current: AppState }, api: Partial<typeo
{
api: {
...defaultApi,
startSession: () => startRequest.promise,
startSession: (cwd: string, machineId?: string, startupToken?: string) => {
startCalls.push({ cwd, machineId, startupToken });
return startRequest.promise;
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
...api,
@@ -39,7 +49,7 @@ function pendingStartController(state: { current: AppState }, api: Partial<typeo
socket: new FakeSocket(),
},
);
return { controller, startRequest };
return { controller, startRequest, startCalls };
}
describe("SessionController session startup progress", () => {
@@ -51,7 +61,7 @@ describe("SessionController session startup progress", () => {
const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
runPendingAnimationFrames();
// The label changes while the user is waiting, before the start resolves,
@@ -59,7 +69,7 @@ describe("SessionController session startup progress", () => {
expect(state.current.activity).toMatchObject({ sessionId: temporaryId, phase: "active", label: "Creating session", detail: "Starting the Pi session" });
expect(state.current.sessionActivities[temporaryId]).toMatchObject({ detail: "Starting the Pi session" });
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity({ detail: "Loading session extensions" }) });
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity({ detail: "Loading session extensions" }) });
runPendingAnimationFrames();
expect(state.current.activity?.detail).toBe("Loading session extensions");
@@ -68,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);
@@ -75,10 +107,10 @@ describe("SessionController session startup progress", () => {
const start = controller.startSession();
const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
runPendingAnimationFrames();
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() });
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: idleStartupActivity() });
runPendingAnimationFrames();
expect(state.current.activity).toMatchObject({
@@ -98,7 +130,9 @@ describe("SessionController session startup progress", () => {
const start = controller.startSession();
await controller.send("queued while starting");
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() });
const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: idleStartupActivity() });
runPendingAnimationFrames();
expect(state.current.activity?.detail).toBe("1 queued message will send when the backend session is ready");
@@ -119,7 +153,6 @@ describe("SessionController session startup progress", () => {
controller.applyGlobalEvent({
type: "session.startup",
cwd: oldSession.cwd,
activity: startupActivity({ sessionId: oldSession.id, label: "Opening session" }),
});
runPendingAnimationFrames();
@@ -127,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] } };
@@ -136,12 +192,11 @@ describe("SessionController session startup progress", () => {
const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
// Opening an existing session in the same workspace publishes the same cwd as
// the pending create. The known id is the proof of which row it belongs to, so
// the pending row must keep its own wording instead of the other row's phase.
// Opening an existing session in the same workspace carries no create token,
// so the known id is the only proof of which row it belongs to and the pending
// row must keep its own wording instead of the other row's phase.
controller.applyGlobalEvent({
type: "session.startup",
cwd: workspace.path,
activity: startupActivity({ sessionId: existing.id, label: "Opening session" }),
});
runPendingAnimationFrames();
@@ -153,7 +208,7 @@ describe("SessionController session startup progress", () => {
await start;
});
it("keeps the generic wording when the startup progress cannot be attributed to one row", async () => {
it("keeps the generic wording when no pending row's token matches the startup progress", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const { controller, startRequest } = pendingStartController(state);
@@ -161,29 +216,54 @@ describe("SessionController session startup progress", () => {
const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
// Another workspace's startup.
controller.applyGlobalEvent({ type: "session.startup", cwd: "/elsewhere", activity: startupActivity() });
// The selected machine's socket is the only feed for these events, so a cwd
// that matches while another machine is selected belongs to a different row.
state.current = { ...state.current, selectedMachine: REMOTE_MACHINE };
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
state.current = { ...state.current, selectedMachine: undefined };
// Another browser tab's create, or another workspace's: its token is one this
// browser never minted, so there is no row here that it belongs to.
controller.applyGlobalEvent({ type: "session.startup", startupToken: "pending-session-9-other-tab", activity: startupActivity() });
// A session this browser has not been told about — an agent's spawned
// subsession, say, whose `session.created` a pending create suppresses — is
// opened rather than created, so it carries no token at all.
controller.applyGlobalEvent({ type: "session.startup", activity: startupActivity({ sessionId: "foreign-session", label: "Opening session", detail: "Loading session extensions" }) });
runPendingAnimationFrames();
expect(state.current.activity?.detail).toBe("Waiting for the backend session to be ready");
expect(state.current.activity?.label).toBe("Creating session");
// A second concurrent start in the same workspace makes the target ambiguous,
// so neither row is given a phase that might belong to the other.
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
await start;
});
it("gives each of two concurrent creates only the progress its own token carries", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const { controller, startRequest } = pendingStartController(state);
const start = controller.startSession();
const firstId = state.current.selectedSession?.id;
const secondStart = controller.startSession();
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
const secondId = state.current.selectedSession?.id;
if (firstId === undefined || secondId === undefined || firstId === secondId) throw new Error("Expected two distinct temporary session ids");
// Two creates in the same workspace are indistinguishable by workspace path;
// the token each request carried is what tells them apart.
controller.applyGlobalEvent({ type: "session.startup", startupToken: secondId, activity: startupActivity({ detail: "Loading session extensions" }) });
runPendingAnimationFrames();
const secondTemporaryId = state.current.selectedSession?.id;
expect(secondTemporaryId).not.toBe(temporaryId);
expect(state.current.sessionActivities[temporaryId]?.detail).toBe("Waiting for the backend session to be ready");
expect(state.current.sessionActivities[secondTemporaryId ?? ""]?.detail).toBe("Waiting for the backend session to be ready");
expect(state.current.sessionActivities[secondId]).toMatchObject({ sessionId: secondId, detail: "Loading session extensions" });
expect(state.current.sessionActivities[firstId]?.detail).toBe("Waiting for the backend session to be ready");
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
await Promise.all([start, secondStart]);
});
it("sends the pending row's own id as the create request's correlation token", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const { controller, startRequest, startCalls } = pendingStartController(state);
const start = controller.startSession();
const temporaryId = state.current.selectedSession?.id;
expect(startCalls).toEqual([{ cwd: workspace.path, machineId: "local", startupToken: temporaryId }]);
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
await start;
});
});
+29 -23
View File
@@ -186,7 +186,7 @@ export class SessionController {
this.pendingSessionStarts.set(pending.tempId, pending);
this.insertAndSelectPendingSession(pending.session);
try {
const session = await this.api.startSession(workspace.path, machineId);
const session = await this.api.startSession(workspace.path, machineId, pending.tempId);
await this.resolvePendingSessionStart(pending.tempId, session);
} catch (error) {
this.failPendingSessionStart(pending.tempId, error);
@@ -1387,38 +1387,26 @@ export class SessionController {
}
// Session startup progress arrives while the daemon is still constructing the
// session, so the target row is resolved by session id when the browser knows
// it and by workspace path when it does not: a pending start knows its cwd but
// not the session id the daemon is creating. Once the row is resolved the
// progress goes through the normal activity buffer, so it renders exactly like
// any other activity and stays batched per frame.
// session, so the target row is resolved by exact identity only: a session id
// the browser already knows (an open), else the correlation token this browser
// minted for its own create and the daemon echoed back. Matching neither means
// the row is not one this browser shows — an agent's or another tab's session is
// *deliberately* absent while a create is pending — so it is ignored rather than
// guessed at. A resolved row goes through the normal activity buffer, rendering
// like any other activity and staying batched per frame.
private queueStartupProgress(event: SessionStartupProgressEvent): void {
// A known session id is the strongest possible proof of the target, so it is
// checked first: while a create is pending in a workspace, an *existing*
// session in that same workspace can be opened too (another row selected,
// another tab, a subsession), and that open publishes the same cwd. Matching
// on cwd first would paint the pending row with another session's phase.
if (this.getState().sessions.some((session) => session.id === event.activity.sessionId)) {
this.queueActivityUpdate(event.activity);
return;
}
// The id is unknown, so this can only be a create whose id the browser has
// not been told yet. Route it by workspace path, the one key both sides share.
const pending = this.startupProgressPendingStart(event.cwd);
if (pending === undefined) return;
const pending = event.startupToken === undefined ? undefined : this.pendingSessionStarts.get(event.startupToken);
if (pending === undefined || pending.discarded) return;
// An idle startup phase means the daemon has nothing left to attribute, so
// restore this row's own generic wording rather than clearing the text of a
// creation request that has not returned yet.
this.queueActivityUpdate(event.activity.phase === "idle"
? creatingPendingSessionActivity(pending.tempId, pending.queuedSends.length)
: { ...event.activity, sessionId: pending.tempId });
}
private startupProgressPendingStart(cwd: string): PendingSessionStart | undefined {
const machineId = selectedMachineId(this.getState());
const matches = Array.from(this.pendingSessionStarts.values())
.filter((pending) => pending.cwd === cwd && pending.machineId === machineId && !pending.discarded);
return matches.length === 1 ? matches[0] : undefined;
: pendingStartActivity(event.activity, pending.tempId));
}
private schedulePendingFlush(): void {
@@ -1519,6 +1507,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,
@@ -0,0 +1,328 @@
import { describe, expect, it, vi } from "vitest";
import type { AppState } from "../appState";
import { initialAppState } from "../appState";
import type { Machine, Project, SessionInfo, Workspace } from "../api";
import type { SessionController } from "./sessionController";
import { WorkspaceController } from "./workspaceController";
function machine(id: string): Machine {
return { id, name: id, kind: id === "local" ? "local" : "remote", createdAt: "now", updatedAt: "now" };
}
function project(id: string, path: string): Project {
return { id, name: id, path, createdAt: "now" };
}
function workspace(projectId: string, path: string, options: { isMain?: boolean } = {}): Workspace {
return { id: path, projectId, path, label: path, isMain: options.isMain ?? false, isGitRepo: true, isGitWorktree: true };
}
function session(cwd: string, id = "s1"): SessionInfo {
return { id, cwd, path: `${cwd}/.sessions/${id}`, created: "now", modified: "now", messageCount: 1, firstMessage: "hello" };
}
type LoadWorkspaces = (projectId: string, machineId?: string) => Promise<Workspace[]>;
interface Harness {
controller: WorkspaceController;
state: () => AppState;
clearActiveSession: ReturnType<typeof vi.fn>;
updateUrl: ReturnType<typeof vi.fn>;
backgroundErrors: { message: string; error: unknown }[];
setState: (patch: Partial<AppState>) => void;
}
function harness(initial: Partial<AppState>, loadWorkspaces: LoadWorkspaces): Harness {
let state: AppState = { ...initialAppState(), ...initial };
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
const clearActiveSession = vi.fn();
const sessions: Pick<SessionController, "clearActiveSession" | "preferredSession" | "selectSession"> = {
clearActiveSession,
preferredSession: vi.fn(),
selectSession: vi.fn(),
};
const updateUrl = vi.fn();
const backgroundErrors: { message: string; error: unknown }[] = [];
const controller = new WorkspaceController(
() => state,
setState,
updateUrl,
sessions,
undefined,
{
api: { workspaces: loadWorkspaces, sessions: vi.fn<(path: string, machineId?: string) => Promise<SessionInfo[]>>().mockResolvedValue([]) },
onBackgroundError: (message, error) => { backgroundErrors.push({ message, error }); },
},
);
return { controller, state: () => state, clearActiveSession, updateUrl, backgroundErrors, setState };
}
describe("WorkspaceController.refreshSelectedProjectTopology", () => {
it("surfaces a worktree created outside PI WEB in both the selected list and the per-project cache", async () => {
const repo = project("p1", "/repo");
const main = workspace(repo.id, repo.path, { isMain: true });
const created = workspace(repo.id, "/repo-feature");
const loadWorkspaces = vi.fn().mockResolvedValue([main, created]);
const test = harness(
{
selectedMachine: machine("local"),
projects: [repo],
selectedProject: repo,
selectedWorkspace: main,
workspaces: [main],
workspacesByProjectId: { [repo.id]: [main] },
},
loadWorkspaces,
);
await test.controller.refreshSelectedProjectTopology();
expect(loadWorkspaces).toHaveBeenCalledWith(repo.id, "local");
expect(test.state().workspaces).toEqual([main, created]);
expect(test.state().workspacesByProjectId[repo.id]).toEqual([main, created]);
});
it("preserves the selection and workspace-scoped state when the selected workspace still exists", async () => {
const repo = project("p1", "/repo");
const main = workspace(repo.id, repo.path, { isMain: true });
const selected = workspace(repo.id, "/repo-feature");
const loadWorkspaces = vi.fn().mockResolvedValue([main, selected, workspace(repo.id, "/repo-other")]);
const test = harness(
{
selectedMachine: machine("local"),
projects: [repo],
selectedProject: repo,
selectedWorkspace: selected,
workspaces: [main, selected],
workspacesByProjectId: { [repo.id]: [main, selected] },
selectedSession: session(selected.path),
sessions: [session(selected.path)],
selectedFilePath: "src/index.ts",
expandedDirs: { src: [] },
selectedTerminalId: "t1",
},
loadWorkspaces,
);
const before = test.state();
await test.controller.refreshSelectedProjectTopology();
const after = test.state();
expect(after.selectedWorkspace).toBe(selected);
expect(after.selectedSession).toBe(before.selectedSession);
expect(after.sessions).toBe(before.sessions);
expect(after.selectedFilePath).toBe("src/index.ts");
expect(after.expandedDirs).toBe(before.expandedDirs);
expect(after.selectedTerminalId).toBe("t1");
expect(test.clearActiveSession).not.toHaveBeenCalled();
expect(test.updateUrl).not.toHaveBeenCalled();
});
it("leaves the selection alone when the selected workspace disappeared", async () => {
const repo = project("p1", "/repo");
const main = workspace(repo.id, repo.path, { isMain: true });
const removed = workspace(repo.id, "/repo-gone");
const loadWorkspaces = vi.fn().mockResolvedValue([main]);
const test = harness(
{
selectedMachine: machine("local"),
projects: [repo],
selectedProject: repo,
selectedWorkspace: removed,
workspaces: [main, removed],
workspacesByProjectId: { [repo.id]: [main, removed] },
},
loadWorkspaces,
);
await test.controller.refreshSelectedProjectTopology();
expect(test.state().selectedWorkspace).toBe(removed);
expect(test.state().workspaces).toEqual([main]);
expect(test.clearActiveSession).not.toHaveBeenCalled();
});
it("discards a response for a project the user has since left", async () => {
const repo = project("p1", "/repo");
const other = project("p2", "/other");
const main = workspace(repo.id, repo.path, { isMain: true });
const created = workspace(repo.id, "/repo-feature");
let resolveWorkspaces: ((workspaces: Workspace[]) => void) | undefined;
const loadWorkspaces = vi.fn().mockReturnValue(new Promise<Workspace[]>((resolve) => { resolveWorkspaces = resolve; }));
const test = harness(
{
selectedMachine: machine("local"),
projects: [repo, other],
selectedProject: repo,
selectedWorkspace: main,
workspaces: [main],
workspacesByProjectId: { [repo.id]: [main] },
},
loadWorkspaces,
);
const pending = test.controller.refreshSelectedProjectTopology();
test.setState({ selectedProject: other, selectedWorkspace: undefined, workspaces: [] });
resolveWorkspaces?.([main, created]);
await pending;
expect(test.state().workspaces).toEqual([]);
expect(test.state().workspacesByProjectId[repo.id]).toEqual([main]);
});
it("discards a response after the selected machine changed", async () => {
const repo = project("p1", "/repo");
const main = workspace(repo.id, repo.path, { isMain: true });
let resolveWorkspaces: ((workspaces: Workspace[]) => void) | undefined;
const loadWorkspaces = vi.fn().mockReturnValue(new Promise<Workspace[]>((resolve) => { resolveWorkspaces = resolve; }));
const test = harness(
{
selectedMachine: machine("local"),
projects: [repo],
selectedProject: repo,
selectedWorkspace: main,
workspaces: [main],
workspacesByProjectId: { [repo.id]: [main] },
},
loadWorkspaces,
);
const pending = test.controller.refreshSelectedProjectTopology();
test.setState({ selectedMachine: machine("remote") });
resolveWorkspaces?.([main, workspace(repo.id, "/repo-feature")]);
await pending;
expect(test.state().workspaces).toEqual([main]);
expect(test.state().workspacesByProjectId[repo.id]).toEqual([main]);
});
it("reports a failed refresh to the background error sink without painting an error banner", async () => {
const repo = project("p1", "/repo");
const main = workspace(repo.id, repo.path, { isMain: true });
const failure = new Error("git worktree list failed");
const loadWorkspaces = vi.fn().mockRejectedValue(failure);
const test = harness(
{
selectedMachine: machine("local"),
projects: [repo],
selectedProject: repo,
selectedWorkspace: main,
workspaces: [main],
workspacesByProjectId: { [repo.id]: [main] },
},
loadWorkspaces,
);
await test.controller.refreshSelectedProjectTopology();
expect(test.state().error).toBe("");
expect(test.state().workspaces).toEqual([main]);
expect(test.backgroundErrors).toEqual([{ message: `Failed to refresh workspaces for project ${repo.id} on local`, error: failure }]);
});
it("re-points the selected workspace at refreshed metadata when its branch changed outside PI WEB", async () => {
const repo = project("p1", "/repo");
const main = workspace(repo.id, repo.path, { isMain: true });
const selected = { ...workspace(repo.id, "/repo-feature"), label: "feature-a", branch: "feature-a" };
const switched = { ...selected, label: "feature-b", branch: "feature-b" };
const loadWorkspaces = vi.fn().mockResolvedValue([main, switched]);
const test = harness(
{
selectedMachine: machine("local"),
projects: [repo],
selectedProject: repo,
selectedWorkspace: selected,
workspaces: [main, selected],
workspacesByProjectId: { [repo.id]: [main, selected] },
selectedSession: session(selected.path),
},
loadWorkspaces,
);
await test.controller.refreshSelectedProjectTopology();
// Same workspace (same id/path), so the session must survive; only the stale label moves.
expect(test.state().selectedWorkspace).toEqual(switched);
expect(test.state().selectedWorkspace?.id).toBe(selected.id);
expect(test.state().selectedSession).toBeDefined();
expect(test.clearActiveSession).not.toHaveBeenCalled();
});
it("leaves the selected workspace object untouched when the refresh returns identical metadata", async () => {
const repo = project("p1", "/repo");
const main = workspace(repo.id, repo.path, { isMain: true });
const selected = workspace(repo.id, "/repo-feature");
// A fresh, equal object, exactly what a real HTTP response produces every resume.
const loadWorkspaces = vi.fn().mockResolvedValue([{ ...main }, { ...selected }]);
const test = harness(
{
selectedMachine: machine("local"),
projects: [repo],
selectedProject: repo,
selectedWorkspace: selected,
workspaces: [main, selected],
workspacesByProjectId: { [repo.id]: [main, selected] },
},
loadWorkspaces,
);
await test.controller.refreshSelectedProjectTopology();
// Identity preserved: an unchanged resume must not churn selected-workspace identity
// into state, or every focus would re-render surfaces keyed on this object.
expect(test.state().selectedWorkspace).toBe(selected);
});
it("serializes overlapping refreshes so an earlier response cannot overwrite a newer list", async () => {
const repo = project("p1", "/repo");
const main = workspace(repo.id, repo.path, { isMain: true });
const created = workspace(repo.id, "/repo-feature");
const gates: ((workspaces: Workspace[]) => void)[] = [];
let inFlight = 0;
let maxInFlight = 0;
const loadWorkspaces = vi.fn(() => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
return new Promise<Workspace[]>((resolve) => {
gates.push((workspaces) => { inFlight -= 1; resolve(workspaces); });
});
});
const test = harness(
{
selectedMachine: machine("local"),
projects: [repo],
selectedProject: repo,
selectedWorkspace: main,
workspaces: [main],
workspacesByProjectId: { [repo.id]: [main] },
},
loadWorkspaces,
);
const resumeRefresh = test.controller.refreshSelectedProjectTopology();
await Promise.resolve();
const appDataRefresh = test.controller.refreshSelectedProjectTopology();
await Promise.resolve();
// The second caller does not open its own request while the first is in flight; it gets
// one trailing pass afterwards. Without this, two responses race and the slower-but-older
// one can land last, making a just-created worktree disappear again.
expect(maxInFlight).toBe(1);
gates[0]?.([main]);
await vi.waitFor(() => { expect(gates).toHaveLength(2); });
gates[1]?.([main, created]);
await Promise.all([resumeRefresh, appDataRefresh]);
// The last response wins, so the newly created worktree stays visible.
expect(test.state().workspaces).toEqual([main, created]);
});
it("does not request anything when no project is selected", async () => {
const loadWorkspaces = vi.fn();
const test = harness({ selectedMachine: machine("local") }, loadWorkspaces);
await test.controller.refreshSelectedProjectTopology();
expect(loadWorkspaces).not.toHaveBeenCalled();
});
});
@@ -1,17 +1,21 @@
import { api as defaultApi, type Project, type Workspace } from "../api";
import { resetWorkspaceScopedState } from "../appState";
import { resetWorkspaceScopedState, type AppState } from "../appState";
import { mergeCachedNewSessions } from "../cachedNewSessions";
import { machineProjectKey } from "../machineKeys";
import { selectedMachineId, type GetState, type RouteTarget, type SetState, type UpdateUrl } from "./types";
import type { SessionController } from "./sessionController";
import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
export interface WorkspaceControllerDependencies {
api?: Pick<typeof defaultApi, "sessions" | "workspaces">;
onBackgroundError?: (message: string, error: unknown) => void;
}
export class WorkspaceController {
private readonly api: Pick<typeof defaultApi, "sessions" | "workspaces">;
private readonly onBackgroundError: (message: string, error: unknown) => void;
private readonly topologyRefreshes = new TrailingRefreshCoordinator<string>();
constructor(
private readonly getState: GetState,
@@ -22,6 +26,7 @@ export class WorkspaceController {
deps: WorkspaceControllerDependencies = {},
) {
this.api = deps.api ?? defaultApi;
this.onBackgroundError = deps.onBackgroundError ?? ((message, error) => { console.warn(message, error); });
}
clearSelection(options?: { updateUrl?: boolean | undefined }) {
@@ -78,6 +83,40 @@ export class WorkspaceController {
return workspaces;
}
/**
* Re-lists the selected project's workspaces so worktrees created or removed outside
* PI WEB become visible, without disturbing the current selection.
*
* Deliberately never routes through `selectWorkspace`: that has no already-selected
* guard, so re-picking the same workspace would still call `clearActiveSession()` and
* `resetWorkspaceScopedState()`, closing the session socket and blanking chat, file
* tree, git status, and terminal selection. Callers run this on every browser resume,
* so applying the list through `applyProjectWorkspaces` alone is the invariant.
*
* If the selected workspace disappeared, the selection is left alone: the user is
* working there and the existing deletion path owns recovery.
*/
async refreshSelectedProjectTopology(): Promise<void> {
const state = this.getState();
const project = state.selectedProject;
if (project === undefined) return;
const machineId = selectedMachineId(state);
// Callers are independent (browser resume and the plugin-facing app refresh), so two
// refreshes for the same machine+project can overlap. Sharing one request keeps a slow
// earlier response from landing last and overwriting a newer list, which would make a
// just-created worktree disappear again.
await this.topologyRefreshes.request(machineProjectKey(machineId, project.id), async () => {
try {
const workspaces = await this.api.workspaces(project.id, machineId);
const current = this.getState();
if (selectedMachineId(current) !== machineId || current.selectedProject?.id !== project.id) return;
this.applyProjectWorkspaces(project.id, workspaces);
} catch (error) {
this.onBackgroundError(`Failed to refresh workspaces for project ${project.id} on ${machineId}`, error);
}
});
}
async refreshAfterWorkspaceDeleted(projectId: string, workspaceId: string): Promise<void> {
const workspaces = await this.refreshProjectWorkspaces(projectId);
const state = this.getState();
@@ -91,8 +130,26 @@ export class WorkspaceController {
private applyProjectWorkspaces(projectId: string, workspaces: Workspace[]): void {
const state = this.getState();
const workspacesByProjectId = { ...state.workspacesByProjectId, [projectId]: workspaces };
if (state.selectedProject?.id === projectId) this.setState({ workspaces, workspacesByProjectId });
else this.setState({ workspacesByProjectId });
if (state.selectedProject?.id !== projectId) {
this.setState({ workspacesByProjectId });
return;
}
this.setState({ workspaces, workspacesByProjectId, ...this.refreshedSelection(state.selectedWorkspace, workspaces) });
}
/**
* Re-points `selectedWorkspace` at its refreshed entry when metadata changed outside PI WEB
* (a branch switched in the worktree, say), so the workspace list and the surfaces that read
* the selected workspace cannot disagree. Keyed by id, which is derived from the path, so
* this never changes *which* workspace is selected and never triggers the session/terminal
* teardown in `handleWorkspaceChange`. Returns nothing when the entry is gone or unchanged,
* so an unchanged refresh does not churn object identity into state.
*/
private refreshedSelection(selected: Workspace | undefined, workspaces: Workspace[]): Pick<AppState, "selectedWorkspace"> | undefined {
if (selected === undefined) return undefined;
const refreshed = workspaces.find((candidate) => candidate.id === selected.id);
if (refreshed === undefined || sameWorkspaceMetadata(selected, refreshed)) return undefined;
return { selectedWorkspace: refreshed };
}
}
@@ -104,3 +161,12 @@ function selectFallbackWorkspace(workspaces: Workspace[]): Workspace | undefined
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
}
function sameWorkspaceMetadata(left: Workspace, right: Workspace): boolean {
return left.path === right.path
&& left.label === right.label
&& left.branch === right.branch
&& left.isMain === right.isMain
&& left.isGitRepo === right.isGitRepo
&& left.isGitWorktree === right.isGitWorktree;
}
+21
View File
@@ -167,6 +167,22 @@ export function createCoreActions(): PluginAction[] {
enabled: hasWorkspace,
run: (context) => context.startSession(),
},
{
id: "model.select",
title: "Select Model",
description: "Choose the model for the selected session",
group: "Session",
enabled: hasSelectableSession,
run: (context) => context.openModelPicker(),
},
{
id: "thinking.select",
title: "Select Thinking Level",
description: "Choose the thinking level for the selected session",
group: "Session",
enabled: hasSelectableSession,
run: (context) => context.openThinkingLevelPicker(),
},
{
id: "session.archive",
title: "Archive Session",
@@ -216,6 +232,11 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean {
return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain && !isWorkspaceDeletionPending(context.state, workspace);
}
function hasSelectableSession(context: { state: AppState }): boolean {
const session = context.state.selectedSession;
return session !== undefined && session.archived !== true;
}
function hasArchivableSession(context: { state: AppState }): boolean {
return isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state));
}
+49
View File
@@ -38,6 +38,8 @@ function createContext(statePatch: Partial<AppState> = {}) {
configureAuth: vi.fn(() => { calls.push("configureAuth"); }),
logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }),
openThemePicker: vi.fn(() => { calls.push("openThemePicker"); }),
openModelPicker: vi.fn(() => { calls.push("openModelPicker"); }),
openThinkingLevelPicker: vi.fn(() => { calls.push("openThinkingLevelPicker"); }),
selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }),
selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }),
openTerminal: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`openTerminal:${options?.terminalId ?? ""}`); }),
@@ -249,6 +251,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 });
@@ -271,6 +292,34 @@ describe("PluginRegistry", () => {
expect(calls).toEqual(["deleteCachedNewSession"]);
});
it("exposes model and thinking selectors as configurable actions for writable sessions", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const unavailable = registry.getActions(createContext().context);
expect(unavailable.find((action) => action.id === "core:model.select")?.enabled).toBe(false);
expect(unavailable.find((action) => action.id === "core:thinking.select")?.enabled).toBe(false);
const archivedSession = { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" };
const archived = registry.getActions(createContext({ selectedSession: archivedSession }).context);
expect(archived.find((action) => action.id === "core:model.select")?.enabled).toBe(false);
expect(archived.find((action) => action.id === "core:thinking.select")?.enabled).toBe(false);
const { context, calls } = createContext({ selectedSession: testSession() });
const actions = registry.getActions(context);
const modelAction = actions.find((action) => action.id === "core:model.select");
const thinkingAction = actions.find((action) => action.id === "core:thinking.select");
expect(modelAction).toMatchObject({ title: "Select Model", enabled: true });
expect(modelAction?.shortcut).toBeUndefined();
expect(thinkingAction).toMatchObject({ title: "Select Thinking Level", enabled: true });
expect(thinkingAction?.shortcut).toBeUndefined();
if (modelAction !== undefined) void modelAction.run();
if (thinkingAction !== undefined) void thinkingAction.run();
expect(calls).toEqual(["openModelPicker", "openThinkingLevelPicker"]);
});
it("routes refresh current to the active core workspace panel", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
+2
View File
@@ -106,6 +106,8 @@ export interface PluginRuntimeContext {
configureAuth: () => void | Promise<void>;
logoutAuth: () => void | Promise<void>;
openThemePicker: () => void;
openModelPicker: () => void | Promise<void>;
openThinkingLevelPicker: () => void | Promise<void>;
selectMainView: (view: AppState["mainView"]) => void;
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
openTerminal: (options?: { terminalId?: string | undefined }) => void;
+17 -6
View File
@@ -90,17 +90,28 @@ 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" };
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo", activity }))
.toMatchObject({ type: "session.startup", cwd: "/repo", activity });
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "", activity })).toBeUndefined();
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo" })).toBeUndefined();
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, phase: "waiting" } })).toBeUndefined();
expect(parseRealtimeSocketEvent({ type: "session.startup", startupToken: "pending-session-1-abc", activity }))
.toMatchObject({ type: "session.startup", startupToken: "pending-session-1-abc", activity });
expect(parseRealtimeSocketEvent({ type: "session.startup", activity })).toMatchObject({ type: "session.startup", activity });
expect(parseRealtimeSocketEvent({ type: "session.startup", startupToken: "", activity })).toBeUndefined();
expect(parseRealtimeSocketEvent({ type: "session.startup" })).toBeUndefined();
expect(parseRealtimeSocketEvent({ type: "session.startup", activity: { ...activity, phase: "waiting" } })).toBeUndefined();
// Startup progress is global-only, so it must not be accepted as a
// per-session frame even when it is well formed.
expect(parseSessionSocketEvent({ type: "session.startup", cwd: "/repo", activity })).toBeUndefined();
expect(parseSessionSocketEvent({ type: "session.startup", activity })).toBeUndefined();
});
it("accepts validated ask frames and drops malformed ones", () => {
@@ -32,6 +32,19 @@ describe("WorkspaceActivityService", () => {
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } });
});
it("does not report a workspace active for a session that is only starting up", () => {
const events: RealtimeEvent[] = [];
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
// Startup progress names a phase the daemon is inside; it is not work, so the
// workspace (and the project indicators and remote machines that read it)
// must not be reported as busy because of it.
service.applySessionActivity("/repo", { sessionId: "s1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true });
expect(service.snapshot().workspaces).toEqual([]);
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false } });
});
it("clears stale active activity when an idle status arrives", () => {
const events: RealtimeEvent[] = [];
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
+4 -5
View File
@@ -27,6 +27,7 @@ import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes, offlineModeEnabled } from "../config.js";
import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js";
import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";
import { sessionServiceDependencies } from "./sessiond/sessionServiceDependencies.js";
const daemonEnvironment: NodeJS.ProcessEnv = Object.freeze({ ...process.env });
const { config } = effectivePiWebConfig({ env: daemonEnvironment });
@@ -70,25 +71,23 @@ await runSessionDaemonStartup({
const spawnTargets = config.spawnSessions
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
const sessions = new PiSessionService(eventHub, {
const sessions = new PiSessionService(eventHub, sessionServiceDependencies({
modelRuntime: auth.runtime,
agentDir: activeAgentProfile.dir,
workspaceActivity,
logger: app.log,
...(spawnTargets === undefined ? {} : { spawnTargets }),
subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
subsessionsEnabled: config.subsessions,
askUserEnabled: config.askUser,
notificationStore,
unreadStore,
// Read-only, so session startup can tell a waiting user that provider
// model lists are refreshing at the same time.
catalogRefreshStatus: catalogRefresher,
sessionManager: createPiSessionManagerGateway({
agentDir: activeAgentProfile.dir,
env: daemonEnvironment,
sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys,
}),
});
}));
auth.subscribe((change) => { sessions.applyAuthChange(change); });
const terminals = new TerminalService(eventHub, workspaceActivity);
const runtimeComponent = Object.freeze({
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { SessionNotificationStore } from "../sessions/sessionNotificationStore.js";
import { SessionUnreadStore } from "../sessions/sessionUnreadStore.js";
import { PiSessionService, type PiSessionServiceDependencies } from "../sessions/piSessionService.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, sessionGateway, testModelRuntime } from "../sessions/piSessionService.testSupport.js";
import { sessionServiceDependencies, type SessionServiceDependencyInput } from "./sessionServiceDependencies.js";
const AGENT_DIR = "/tmp/pi-web-test-agent";
/**
* The collaborators sessiond hands the assembly, with only the process-facing
* ones (agent dir, session store gateway) replaced. Everything the assembly
* decides is left to the assembly.
*/
function daemonCollaborators(patch: Partial<SessionServiceDependencyInput> = {}): SessionServiceDependencyInput {
return {
agentDir: AGENT_DIR,
modelRuntime: testModelRuntime,
sessionManager: sessionGateway([]),
workspaceActivity: new WorkspaceActivityService(),
logger: { info() { /* no-op */ } },
notificationStore: new SessionNotificationStore(),
unreadStore: new SessionUnreadStore(),
catalogRefreshStatus: { isRefreshInFlight: () => false },
subsessionsEnabled: false,
askUserEnabled: true,
...patch,
};
}
/**
* Start a session through a service built by the real assembly and collect what
* the user is told while waiting. Only test-local seams are patched onto the
* assembled dependencies, never anything the assembly is responsible for
* supplying, so a dependency the assembly stops passing cannot be masked here.
*/
async function startupDetails(deps: PiSessionServiceDependencies): Promise<string[]> {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime();
const service = new PiSessionService(hub, {
...deps,
archiveStore: emptyArchiveStore(),
createAgentRuntime: () => Promise.resolve(fake.runtime),
heartbeatIntervalMs: 60_000,
});
try {
await service.start("/workspace");
} finally {
await service.dispose();
}
return hub.globalEvents.flatMap((event) =>
event.type === "session.startup" && event.activity.detail !== undefined ? [event.activity.detail] : [],
);
}
describe("sessiond session service dependency assembly", () => {
it("reports a concurrent provider model list refresh to a waiting user", async () => {
// The note is only reachable in the product because the assembly hands the
// refresher to the session service. Asserting the note rather than the
// property means dropping that line fails here instead of passing silently.
const details = await startupDetails(sessionServiceDependencies(daemonCollaborators({
catalogRefreshStatus: { isRefreshInFlight: () => true },
})));
expect(details).toEqual([
"Starting the Pi session · provider model lists are refreshing",
"Loading session extensions · provider model lists are refreshing",
]);
});
it("states the startup phase alone when no refresh is running", async () => {
const details = await startupDetails(sessionServiceDependencies(daemonCollaborators()));
// Pins the note to the refresher's answer, so the test above cannot pass on
// wording that is always appended.
expect(details).toEqual(["Starting the Pi session", "Loading session extensions"]);
});
it("keeps tracked subsessions off unless spawning is configured as well", () => {
const spawnTargets = { resolveSpawnTarget: () => Promise.reject(new Error("not used")) };
const withoutSpawnTargets = sessionServiceDependencies(daemonCollaborators({ subsessionsEnabled: true }));
const withSpawnTargets = sessionServiceDependencies(daemonCollaborators({ subsessionsEnabled: true, spawnTargets }));
expect(withoutSpawnTargets.spawnTargets).toBeUndefined();
expect(withoutSpawnTargets.subsessionsEnabled).toBe(false);
expect(withSpawnTargets.spawnTargets).toBe(spawnTargets);
expect(withSpawnTargets.subsessionsEnabled).toBe(true);
});
it("passes the ask-user preference through to the session service", () => {
expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: true })).askUserEnabled).toBe(true);
expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: false })).askUserEnabled).toBe(false);
});
});
@@ -0,0 +1,56 @@
import type { PiSessionServiceDependencies } from "../sessions/piSessionService.js";
/**
* The collaborators sessiond constructs, in the shape the assembly needs them.
*
* Every field is required unless the capability itself is optional, so a
* collaborator the daemon stops constructing cannot silently vanish from the
* session service.
*/
export interface SessionServiceDependencyInput {
agentDir: string;
sessionManager: PiSessionServiceDependencies["sessionManager"];
modelRuntime: PiSessionServiceDependencies["modelRuntime"];
workspaceActivity: NonNullable<PiSessionServiceDependencies["workspaceActivity"]>;
logger: NonNullable<PiSessionServiceDependencies["logger"]>;
notificationStore: NonNullable<PiSessionServiceDependencies["notificationStore"]>;
unreadStore: NonNullable<PiSessionServiceDependencies["unreadStore"]>;
/** Read-only view of the background refresher; see the assembly below. */
catalogRefreshStatus: NonNullable<PiSessionServiceDependencies["catalogRefreshStatus"]>;
/** Omitted when the operator has not enabled session spawning. */
spawnTargets?: NonNullable<PiSessionServiceDependencies["spawnTargets"]>;
/** The operator's subsessions preference, which also requires spawning. */
subsessionsEnabled: boolean;
/** Whether agents may post structured question sets to the browser. */
askUserEnabled: boolean;
}
/**
* Map sessiond's constructed collaborators onto the session service's
* dependencies.
*
* Extracted from `sessiond.ts`, which starts a daemon as an import side effect
* and so cannot be loaded by a test. This function performs no side effects and
* constructs nothing, so lifting it changes no startup ordering; it exists only
* so a test can build a service the way the daemon does and assert what a user
* is actually told.
*/
export function sessionServiceDependencies(input: SessionServiceDependencyInput): PiSessionServiceDependencies {
return {
modelRuntime: input.modelRuntime,
agentDir: input.agentDir,
workspaceActivity: input.workspaceActivity,
logger: input.logger,
...(input.spawnTargets === undefined ? {} : { spawnTargets: input.spawnTargets }),
// Tracked subsessions share the spawn capability's project-scope resolver,
// so they stay off unless spawning is configured too.
subsessionsEnabled: input.spawnTargets !== undefined && input.subsessionsEnabled,
askUserEnabled: input.askUserEnabled,
notificationStore: input.notificationStore,
unreadStore: input.unreadStore,
// Read-only, so session startup can tell a waiting user that provider
// model lists are refreshing at the same time.
catalogRefreshStatus: input.catalogRefreshStatus,
sessionManager: input.sessionManager,
};
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { PiSessionService, type PiSessionRuntime } from "./piSessionService.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
import { isSessionActive } from "../../shared/activity.js";
import type { SessionActivity, SessionStartupProgressEvent } from "../../shared/apiTypes.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
@@ -77,7 +78,7 @@ describe("PiSessionService session startup progress", () => {
// The proof that matters: the user is told what is being waited on before
// the wait ends, not after it.
expect(startupText(hub)).toEqual(["Creating session: Starting the Pi session"]);
expect(startupEvents(hub).at(0)).toMatchObject({ cwd: "/workspace", activity: { sessionId: "session-1", phase: "active" } });
expect(startupEvents(hub).at(0)).toMatchObject({ activity: { sessionId: "session-1", phase: "active" } });
runtimeResult.resolve(fake.runtime);
await started;
@@ -157,11 +158,42 @@ describe("PiSessionService session startup progress", () => {
await service.start("/workspace");
expect(startupEvents(hub).at(-1)).toMatchObject({ cwd: "/workspace", activity: { sessionId: "session-1", phase: "idle", label: "idle" } });
expect(startupEvents(hub).at(-1)).toMatchObject({ activity: { sessionId: "session-1", phase: "idle", label: "idle" } });
expect(startupEvents(hub).at(-1)?.activity.detail).toBeUndefined();
await service.dispose();
});
it("echoes a create's correlation token on every startup report of that construction", async () => {
const { hub, service } = startupService();
await service.start("/workspace", { startupToken: "pending-session-3-k2x9" });
// The token labels the browser row that is waiting, so it must ride every
// report of this construction, the closing idle one included.
expect(startupEvents(hub).map((event) => event.startupToken)).toEqual([
"pending-session-3-k2x9",
"pending-session-3-k2x9",
"pending-session-3-k2x9",
]);
// The token is an opaque throwaway label, never the session's identity.
expect(startupEvents(hub).map((event) => event.activity.sessionId)).toEqual(["session-1", "session-1", "session-1"]);
await service.dispose();
});
it("publishes no correlation token when a create supplies none, and none for an open", async () => {
const created = startupService();
await created.service.start("/workspace");
const opened = startupService({ sessionRecords: [sessionRecord("session-1")] });
await opened.service.status(sessionRef("session-1"));
for (const hub of [created.hub, opened.hub]) {
expect(startupEvents(hub).length).toBeGreaterThan(0);
expect(startupEvents(hub).every((event) => event.startupToken === undefined)).toBe(true);
}
await created.service.dispose();
await opened.service.dispose();
});
it("ends the startup window when the runtime construction itself fails", async () => {
const failure = new Error("runtime unavailable");
const { hub, service } = startupService({ createAgentRuntime: () => Promise.reject(failure) });
@@ -204,6 +236,34 @@ describe("PiSessionService session startup progress", () => {
await service.dispose();
});
it("reports startup progress as starting rather than as work in progress", async () => {
const { hub, service } = startupService();
await service.start("/workspace");
// Startup phases are published with an "active" phase so the waiting user
// sees them, but opening a session is not work: nothing that decides whether
// work is in progress may count them.
const phases = startupEvents(hub).filter((event) => event.activity.phase === "active");
expect(phases).toHaveLength(2);
expect(phases.map((event) => isSessionActive(undefined, event.activity))).toEqual([false, false]);
await service.dispose();
});
it("still reports a real activity published during startup as work", async () => {
const { hub, fake, service } = startupService();
await service.start("/workspace");
fake.emit({ type: "tool_execution_start", toolName: "bash" });
// The marker belongs to the startup channel alone; an ordinary activity for
// the same session still counts, or the fix would hide real work.
const running = activityUpdates(hub).filter((activity) => activity.phase === "active");
expect(running.length).toBeGreaterThan(0);
expect(running.every((activity) => isSessionActive(undefined, activity))).toBe(true);
await service.dispose();
});
it("keeps startup reporting event-only, writing no session or workspace activity state", async () => {
const recorder = recordingWorkspaceActivity();
const failure = new Error("runtime unavailable");
+27 -16
View File
@@ -204,6 +204,11 @@ type SessionCreationProvenance = "tracked-subsession";
interface StartSessionOptions {
parentSession?: string;
initialModel?: AgentModel;
/**
* Opaque label, echoed on this construction's startup progress so a browser
* row with no session id yet can recognise its own.
*/
startupToken?: string;
}
interface InternalStartSessionOptions extends StartSessionOptions {
@@ -395,7 +400,7 @@ interface PendingSessionOpen {
promise: Promise<ActiveSession<PiSessionRuntime>>;
}
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance"> {
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance" | "startupToken"> {
notificationGeneration?: SessionNotificationGeneration;
notifications?: "enabled" | "disabled";
/**
@@ -1016,6 +1021,7 @@ export class PiSessionService implements SessionRouteService {
cwd,
{
startupIntent: "create",
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
},
@@ -2459,7 +2465,7 @@ export class PiSessionService implements SessionRouteService {
cwd: string,
options: CreateSessionRuntimeOptions = {},
): Promise<ActiveSession<PiSessionRuntime>> {
const startup = this.startupProgress(sessionManager, cwd, options.startupIntent ?? "open");
const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken);
try {
return await this.createSessionRuntime(sessionManager, cwd, options, startup);
} finally {
@@ -3105,23 +3111,23 @@ export class PiSessionService implements SessionRouteService {
/**
* Build the reporter for one session construction.
*
* The session id and cwd are both known before any await — a `SessionManager`
* has its id from construction — so the daemon can name what it is starting
* even though the `PiAgentSession` that {@link publishActivity} needs does not
* exist yet. When either is missing there is nothing honest to route on, so
* the reporter stays silent and the browser keeps its own generic wording.
* The session id is known before any await — a `SessionManager` has its id
* from construction — so the daemon can name what it is starting even though
* the `PiAgentSession` that {@link publishActivity} needs does not exist yet.
* Without an id there is nothing to report against, so the reporter stays
* silent and the browser keeps its own generic wording.
*/
private startupProgress(sessionManager: PiSessionManager, cwd: string, intent: "create" | "open"): SessionStartupProgressReporter {
private startupProgress(sessionManager: PiSessionManager, intent: "create" | "open", startupToken: string | undefined): SessionStartupProgressReporter {
const sessionId = sessionManager.getSessionId();
if (sessionId === "" || cwd === "") return { report: noop, end: noop };
if (sessionId === "") return { report: noop, end: noop };
const label = intent === "create" ? "Creating session" : "Opening session";
return {
report: (phase) => { this.publishStartupProgress(sessionId, cwd, label, "active", this.startupDetail(phase)); },
report: (phase) => { this.publishStartupProgress(sessionId, startupToken, label, "active", this.startupDetail(phase)); },
end: () => {
// A real activity published during the window (an extension error, say)
// is the truth about this session and must survive the clear.
if (this.activities.has(sessionId)) return;
this.publishStartupProgress(sessionId, cwd, "idle", "idle", undefined);
this.publishStartupProgress(sessionId, startupToken, "idle", "idle", undefined);
},
};
}
@@ -3133,17 +3139,22 @@ export class PiSessionService implements SessionRouteService {
}
/**
* Report startup progress on the global channel only, keyed by `cwd` so a
* browser row that has no session id yet can find it.
* Report startup progress on the global channel only, echoing the caller's
* correlation token so a waiting browser row recognises its own construction.
*
* Unlike {@link publishActivity} this deliberately records nothing: no
* `activities` entry, no workspace activity, no unread observation. There is
* no session to own that state, and a failed creation would leave it stranded.
*
* Every report is marked `startup`, which is what keeps a session that is
* merely opening from counting as one doing work. This is the only publisher
* that sets the marker, and because it writes no `activities` entry no later
* heartbeat re-publication can carry it.
*/
private publishStartupProgress(sessionId: string, cwd: string, label: string, phase: "active" | "idle", detail: string | undefined): void {
private publishStartupProgress(sessionId: string, startupToken: string | undefined, label: string, phase: "active" | "idle", detail: string | undefined): void {
const at = new Date().toISOString();
const activity = detail === undefined ? { sessionId, phase, label, at } : { sessionId, phase, label, detail, at };
this.events.publishGlobal({ type: "session.startup", cwd, activity });
const activity = detail === undefined ? { sessionId, phase, label, at, startup: true } : { sessionId, phase, label, detail, at, startup: true };
this.events.publishGlobal(startupToken === undefined ? { type: "session.startup", activity } : { type: "session.startup", startupToken, activity });
}
private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
+36 -1
View File
@@ -28,6 +28,7 @@ import { PiSessionService, type PiSessionManagerGateway } from "./piSessionServi
import { testModelRuntime } from "./piSessionService.testSupport.js";
import { SessionNotificationStore } from "./sessionNotificationStore.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
import type { ClientSession } from "../types.js";
import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
@@ -762,6 +763,35 @@ describe("session routes", () => {
}
});
it("forwards a create's optional correlation token alongside the normalized cwd", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const withToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd, startupToken: "pending-session-3-k2x9" } });
const withoutToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd } });
// An older browser, or any non-browser caller, sends no token; and a
// malformed one must not reach the service as a label it would echo.
const malformedToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd, startupToken: 7 } });
expect(withToken.statusCode).toBe(200);
expect(withoutToken.statusCode).toBe(200);
expect(malformedToken.statusCode).toBe(400);
expect(malformedToken.json()).toEqual({ error: "startupToken field must be a string" });
expect(routeService.startCalls).toEqual([
{ cwd: requestCwd, startupToken: "pending-session-3-k2x9" },
{ cwd: requestCwd, startupToken: undefined },
]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects malformed bulk mutation bodies before calling the service", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
@@ -804,6 +834,7 @@ class CapturingRouteSessionService implements SessionRouteService {
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
readonly submitAskCalls: { lookup: SessionRouteLookup; askId: string; submission: AskUserSubmission }[] = [];
readonly cancelAskCalls: { lookup: SessionRouteLookup; askId: string }[] = [];
readonly startCalls: { cwd: string; startupToken: string | undefined }[] = [];
askError: Error | undefined;
reloadError: Error | undefined;
clearQueueError: Error | undefined;
@@ -883,7 +914,11 @@ class CapturingRouteSessionService implements SessionRouteService {
}
list(): never { throw unusedRouteMethod("list"); }
start(): never { throw unusedRouteMethod("start"); }
start(cwd: string, options?: { startupToken?: string }): Promise<ClientSession> {
this.startCalls.push({ cwd, startupToken: options?.startupToken });
return Promise.resolve({ id: "session-1", path: "/tmp/session-1.jsonl", cwd, created: "2026-06-25T00:00:00.000Z", modified: "2026-06-25T00:00:00.000Z", messageCount: 0, firstMessage: "" });
}
dismissWarning(lookup: SessionRouteLookup, dismissId: string): Promise<SessionStatus> {
this.dismissWarningCalls.push({ lookup, dismissId });
+6 -2
View File
@@ -45,10 +45,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
}
});
app.post<{ Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => {
app.post<{ Body: { cwd?: unknown; startupToken?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => {
try {
const body = requireRecord(request.body);
return await sessions.start(normalizeRequestCwd(requireString(body, "cwd")));
// An opaque label the caller uses to recognise its own construction's
// startup reports. Optional: only a browser row waiting for a session id
// has anything to correlate.
const startupToken = body["startupToken"] === undefined ? undefined : requireNonEmptyString(body, "startupToken");
return await sessions.start(normalizeRequestCwd(requireString(body, "cwd")), optionalField("startupToken", startupToken));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
+6 -1
View File
@@ -42,7 +42,12 @@ export type SessionRouteLookup = string | SessionRouteRef;
*/
export interface SessionRouteService {
list(cwd: string): Promise<ClientSession[]>;
start(cwd: string): Promise<ClientSession>;
/**
* Create a session. `startupToken` is an opaque label the caller supplies so
* it can recognise this construction's startup progress reports; the service
* echoes it and never interprets it.
*/
start(cwd: string, options?: { startupToken?: string }): Promise<ClientSession>;
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
streamSnapshot(ref: SessionRouteLookup): Promise<SessionStreamSnapshot>;
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { parseGitWorktreeList } from "./gitWorktreeDiscovery.js";
// Fixtures below are verbatim `git worktree list --porcelain` output captured from real git.
const mainAndLinked = [
"worktree /repo",
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
"branch refs/heads/main",
"",
"worktree /repo-worktrees/feature",
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
"branch refs/heads/feat/thing",
"",
].join("\n");
const removedAndLocked = [
"worktree /repo",
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
"branch refs/heads/main",
"",
"worktree /repo-worktrees/gone",
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
"branch refs/heads/gone",
"prunable gitdir file points to non-existent location",
"",
"worktree /repo-worktrees/kept",
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
"branch refs/heads/kept",
"locked keep me",
"",
].join("\n");
describe("parseGitWorktreeList", () => {
it("reads paths and short branch names for the main and linked worktrees", () => {
expect(parseGitWorktreeList(mainAndLinked)).toEqual([
{ path: "/repo", branch: "main" },
{ path: "/repo-worktrees/feature", branch: "feat/thing" },
]);
});
it("reports prunable, and leaves a locked worktree looking like the usable checkout it is", () => {
expect(parseGitWorktreeList(removedAndLocked)).toEqual([
{ path: "/repo", branch: "main" },
{ path: "/repo-worktrees/gone", branch: "gone", prunable: true },
// `locked` is ignored: a locked worktree is a real checkout and stays a usable
// workspace, so nothing downstream needs to distinguish it.
{ path: "/repo-worktrees/kept", branch: "kept" },
]);
const bareLocked = ["worktree /repo-worktrees/kept", "HEAD abc", "detached", "locked", ""].join("\n");
expect(parseGitWorktreeList(bareLocked)).toEqual([{ path: "/repo-worktrees/kept", detached: true }]);
});
it("reads bare repositories and ignores chunks without a worktree path", () => {
const bare = ["worktree /repo.git", "bare", "", "HEAD abc", ""].join("\n");
expect(parseGitWorktreeList(bare)).toEqual([{ path: "/repo.git", bare: true }]);
});
it("returns nothing for empty output", () => {
expect(parseGitWorktreeList("\n")).toEqual([]);
});
});
@@ -9,6 +9,8 @@ export interface GitWorktreeInfo {
branch?: string;
bare?: boolean;
detached?: boolean;
/** Git reports a linked worktree as prunable when its checkout directory no longer exists. */
prunable?: boolean;
}
export async function isGitRepository(path: string): Promise<boolean> {
@@ -22,6 +24,14 @@ export async function isGitRepository(path: string): Promise<boolean> {
export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]> {
const { stdout } = await execFileAsync("git", ["-C", path, "worktree", "list", "--porcelain"], { env: sanitizedGitEnv() });
return parseGitWorktreeList(stdout);
}
/**
* Parses `git worktree list --porcelain` output into facts only. Deciding which worktrees a
* project should show (for example hiding prunable ones) is workspace policy, not parsing.
*/
export function parseGitWorktreeList(stdout: string): GitWorktreeInfo[] {
const chunks = stdout.trim().split(/\n\s*\n/).filter(Boolean);
return chunks.map((chunk) => {
@@ -33,6 +43,7 @@ export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInf
if (key === "branch") info.branch = value.replace(/^refs\/heads\//, "");
if (key === "bare") info.bare = true;
if (key === "detached") info.detached = true;
if (key === "prunable") info.prunable = true;
}
return info;
}).filter((w) => w.path);
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import type { Project } from "../types.js";
import type { GitWorktreeInfo } from "./gitWorktreeDiscovery.js";
import { WorkspaceService, type WorkspaceGitPort } from "./workspaceService.js";
const project: Project = {
id: "p1",
name: "Project",
path: "/repo",
createdAt: "2026-05-25T00:00:00.000Z",
};
function serviceFor(worktrees: GitWorktreeInfo[], isGitRepo = true): WorkspaceService {
const git: WorkspaceGitPort = {
isGitRepository: () => Promise.resolve(isGitRepo),
discoverGitWorktrees: () => Promise.resolve(worktrees),
};
return new WorkspaceService(git);
}
describe("WorkspaceService.list", () => {
it("hides a linked worktree whose checkout directory was removed outside PI WEB", async () => {
const service = serviceFor([
{ path: "/repo", branch: "main" },
{ path: "/repo-worktrees/gone", branch: "gone", prunable: true },
{ path: "/repo-worktrees/live", branch: "live" },
]);
const workspaces = await service.list(project);
expect(workspaces.map((workspace) => workspace.path)).toEqual(["/repo", "/repo-worktrees/live"]);
});
it("keeps a worktree that is present but not prunable, such as a locked one", async () => {
const service = serviceFor([
{ path: "/repo", branch: "main" },
{ path: "/repo-worktrees/kept", branch: "kept" },
]);
const workspaces = await service.list(project);
expect(workspaces.map((workspace) => workspace.path)).toEqual(["/repo", "/repo-worktrees/kept"]);
});
it("keeps the project's own worktree even if git marks it prunable, so a project is never empty", async () => {
const service = serviceFor([{ path: "/repo", branch: "main", prunable: true }]);
const workspaces = await service.list(project);
expect(workspaces).toEqual([expect.objectContaining({ path: "/repo", label: "main", isMain: true, isGitWorktree: true })]);
});
it("falls back to the project itself when every linked worktree is filtered away", async () => {
const service = serviceFor([{ path: "/repo-worktrees/gone", branch: "gone", prunable: true }]);
const workspaces = await service.list(project);
expect(workspaces).toEqual([expect.objectContaining({ path: "/repo", label: "Project", isMain: true, isGitRepo: true, isGitWorktree: false })]);
});
it("labels detached and unnamed worktrees without inventing a branch", async () => {
const service = serviceFor([
{ path: "/repo", branch: "main" },
{ path: "/repo-worktrees/detached", detached: true },
]);
const workspaces = await service.list(project);
expect(workspaces.map((workspace) => ({ label: workspace.label, branch: workspace.branch }))).toEqual([
{ label: "main", branch: "main" },
{ label: "detached", branch: undefined },
]);
});
it("returns a single non-git workspace when the project is not a repository", async () => {
const service = serviceFor([], false);
expect(await service.list(project)).toEqual([expect.objectContaining({ path: "/repo", isGitRepo: false, isGitWorktree: false })]);
});
});
+23 -3
View File
@@ -1,18 +1,28 @@
import { createHash } from "node:crypto";
import type { Project } from "../types.js";
import type { Workspace } from "../types.js";
import { discoverGitWorktrees, isGitRepository } from "./gitWorktreeDiscovery.js";
import { discoverGitWorktrees, isGitRepository, type GitWorktreeInfo } from "./gitWorktreeDiscovery.js";
const idFor = (value: string) => createHash("sha1").update(value).digest("hex").slice(0, 12);
/** The git facts this service needs, injectable so workspace policy is testable without a real repo. */
export interface WorkspaceGitPort {
isGitRepository(path: string): Promise<boolean>;
discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]>;
}
const realGit: WorkspaceGitPort = { isGitRepository, discoverGitWorktrees };
export class WorkspaceService {
constructor(private readonly git: WorkspaceGitPort = realGit) {}
async list(project: Project): Promise<Workspace[]> {
const isGitRepo = await isGitRepository(project.path);
const isGitRepo = await this.git.isGitRepository(project.path);
if (!isGitRepo) {
return [this.single(project, false)];
}
const worktrees = await discoverGitWorktrees(project.path);
const worktrees = this.selectable(await this.git.discoverGitWorktrees(project.path), project);
if (worktrees.length === 0) return [this.single(project, true)];
return worktrees.map((worktree) => {
@@ -30,6 +40,16 @@ export class WorkspaceService {
});
}
/**
* Git keeps listing a linked worktree after its checkout directory is deleted outside PI WEB,
* marking it `prunable`. Such an entry is not a usable workspace, so it is hidden rather than
* offered as a selectable ghost. Listing stays read-only: we never run `git worktree prune`.
* The project's own path is always kept so a project cannot end up with no workspace at all.
*/
private selectable(worktrees: GitWorktreeInfo[], project: Project): GitWorktreeInfo[] {
return worktrees.filter((worktree) => worktree.prunable !== true || worktree.path === project.path);
}
private single(project: Project, isGitRepo: boolean): Workspace {
return {
id: idFor(`${project.id}:${project.path}`),
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { isSessionActive, isWorkspaceActivityActive } from "./activity";
import type { SessionStatus, WorkspaceActivity } from "./apiTypes";
import type { SessionActivity, SessionStatus, WorkspaceActivity } from "./apiTypes";
const idleStatus: SessionStatus = {
sessionId: "s1",
@@ -20,6 +20,29 @@ describe("activity helpers", () => {
expect(isSessionActive({ ...idleStatus, pendingMessageCount: 2 })).toBe(true);
});
it("does not count a session that is only starting up as doing work", () => {
// Startup is reported on the activity channel with an "active" phase because
// a phase really is in progress, but opening a session is not work a user
// can stop, so the marker is what separates starting from working.
const startup: SessionActivity = { sessionId: "s1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
expect(isSessionActive(undefined, startup)).toBe(false);
expect(isSessionActive(idleStatus, startup)).toBe(false);
});
it("still reports genuine work happening while a session starts up", () => {
const startup: SessionActivity = { sessionId: "s1", phase: "active", label: "Opening session", at: "now", startup: true };
// The marker only removes the activity-phase reason for being active, so
// work the status proves is unaffected by it.
expect(isSessionActive({ ...idleStatus, isStreaming: true }, startup)).toBe(true);
expect(isSessionActive({ ...idleStatus, isBashRunning: true }, startup)).toBe(true);
expect(isSessionActive({ ...idleStatus, isCompacting: true }, startup)).toBe(true);
expect(isSessionActive({ ...idleStatus, pendingMessageCount: 1 }, startup)).toBe(true);
// An unmarked active activity is ordinary work and keeps counting.
expect(isSessionActive(idleStatus, { sessionId: "s1", phase: "active", label: "running tool", at: "now" })).toBe(true);
});
it("detects workspace activity presence without exposing details", () => {
const idle: WorkspaceActivity = { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false, updatedAt: "now" };
expect(isWorkspaceActivityActive(idle)).toBe(false);
+9 -1
View File
@@ -1,7 +1,15 @@
import type { SessionActivity, SessionStatus, WorkspaceActivity } from "./apiTypes.js";
/**
* Whether a session has work in progress a user could stop or must wait for.
*
* A startup activity is excluded: the session is being opened, not worked in.
* The exclusion can only ever remove the activity-phase reason for being
* active, so streaming, bash, compaction, and queued prompts still count even
* while a startup report is the latest activity.
*/
export function isSessionActive(status?: SessionStatus, activity?: SessionActivity): boolean {
return activity?.phase === "active"
return (activity?.phase === "active" && activity.startup !== true)
|| status?.isStreaming === true
|| status?.isBashRunning === true
|| status?.isCompacting === true
+14 -6
View File
@@ -433,6 +433,14 @@ export interface SessionActivity {
label: string;
detail?: string;
at: string;
/**
* Set only on the startup window's own reports. A startup phase is genuinely
* in progress, so it is published as `active` and rendered like any other
* activity, but *starting* a session is not *working* in it: there is nothing
* to stop, nothing that blocks reloading from disk, and no workspace-level
* work to report. `isSessionActive()` reads this to keep the two apart.
*/
startup?: boolean;
}
export interface QueuedSessionMessage {
@@ -570,18 +578,18 @@ export interface AskUserCloseResponse {
* constructing the agent session and no `PiAgentSession` exists yet, so
* `activity.update` cannot be published for it.
*
* `cwd` is the routing key for a browser row that is still waiting for a
* session id: a client-invented pending start knows its workspace path but not
* the daemon's session id. `activity.sessionId` carries the daemon's real id, so
* the same event also serves the case where the browser already knows it (an
* open of an existing session).
* `startupToken` is the opaque label a create request supplied, echoed back so a
* browser row still waiting for a session id recognises its own construction.
* The daemon never interprets it and it never becomes the session id:
* `activity.sessionId` always carries the real id, which is how an *open* of a
* session the browser already knows is routed instead.
*
* `activity.phase === "idle"` means the startup window ended with nothing left
* to report, so a browser that substituted its own text should restore it.
*/
export interface SessionStartupProgressEvent {
type: "session.startup";
cwd: string;
startupToken?: string;
activity: SessionActivity;
}