From 439520c3e4d7ad9b78488646c3eb56d909c6a764 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 2 Jul 2026 19:05:23 +0200 Subject: [PATCH] fix: reconcile pending session starts --- .changeset/session-start-persistence.md | 2 +- .../src/controllers/sessionController.test.ts | 72 +++++++++++++++++++ .../src/controllers/sessionController.ts | 39 +++++++--- 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/.changeset/session-start-persistence.md b/.changeset/session-start-persistence.md index 8f31014..33e8be4 100644 --- a/.changeset/session-start-persistence.md +++ b/.changeset/session-start-persistence.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Create editable chats immediately when starting sessions, queue sends until the backend session is ready, and use server-backed persistence signals for session archive/delete/reload actions. +Create editable chats immediately when starting sessions, open the chat right away on mobile, queue sends until the backend session is ready, reconcile concurrent session-created broadcasts, and use server-backed persistence signals for session archive/delete/reload actions. diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 00899d3..2baf0fe 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -397,6 +397,41 @@ describe("SessionController", () => { expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true); }); + it("releases unrelated created-session broadcasts after pending starts settle", async () => { + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + const otherClientSession: SessionInfo = { ...oldSession, id: "other-client-session", path: "/tmp/other-client-session.jsonl" }; + const startRequest = deferred(); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => startRequest.promise, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const start = controller.startSession(); + const temporaryId = state.selectedSession?.id; + controller.applyGlobalEvent({ type: "session.created", session: started }); + controller.applyGlobalEvent({ type: "session.created", session: otherClientSession }); + + expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]); + + startRequest.resolve(started); + await start; + + const sessionIds = state.sessions.map((session) => session.id); + expect(sessionIds).not.toContain(temporaryId); + expect(sessionIds.filter((id) => id === started.id)).toHaveLength(1); + expect(sessionIds.filter((id) => id === otherClientSession.id)).toHaveLength(1); + }); + it("preserves temporary start rows across session-list refreshes before backend resolution", async () => { const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; const startRequest = deferred(); @@ -537,6 +572,43 @@ describe("SessionController", () => { expect(state.selectedSession).toBeUndefined(); }); + it("stops the backend session if a discarded pending start resolves later", async () => { + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + const startRequest = deferred(); + const stoppedIds: string[] = []; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => startRequest.promise, + stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); }, + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket: new FakeSocket() }, + ); + + const start = controller.startSession(); + const temporaryId = state.selectedSession?.id; + if (temporaryId === undefined) throw new Error("Expected temporary session id"); + await controller.send("queued before discard"); + expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "queued before discard" }]); + + await controller.deleteCachedNewSession(state.selectedSession); + expect(state.sessions).toEqual([]); + expect(state.selectedSession).toBeUndefined(); + expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined(); + + startRequest.resolve(started); + await start; + + expect(stoppedIds).toEqual([started.id]); + expect(state.sessions).toEqual([]); + expect(state.selectedSession).toBeUndefined(); + }); + it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => { let resolvePrompt: (() => void) | undefined; let promptArgs: { attachments?: PromptAttachment[] } | undefined; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 85f171d..619687a 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -56,7 +56,7 @@ interface PendingSessionStart { } interface SuppressedCreatedSession { - cwd: string; + session: SessionInfo; machineId: string; } @@ -828,10 +828,11 @@ export class SessionController { if (pending === undefined) return; this.pendingSessionStarts.delete(tempId); const queuedSends = pending.queuedSends.splice(0); - this.clearSuppressedCreatedSessionsFor(pending.cwd, pending.machineId, session.id); + const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId, session.id); if (pending.discarded) { clearDraft(machineSessionKey(pending.machineId, tempId)); this.setState({ clientQueuedSessionMessages: omitKey(this.getState().clientQueuedSessionMessages, tempId) }); + this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); void this.api.stop(session, pending.machineId).catch(() => { // Best-effort cleanup for a backend session whose temporary UI row was discarded before creation finished. }); @@ -857,6 +858,7 @@ export class SessionController { ...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id] } : {}), error: "", }); + this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); if (wasSelected) { this.updateUrl({ replace: true }); await this.selectSession(cachedSession, { updateUrl: false }); @@ -868,8 +870,12 @@ export class SessionController { const pending = this.pendingSessionStarts.get(tempId); if (pending === undefined) return; this.pendingSessionStarts.delete(tempId); - this.clearSuppressedCreatedSessionsFor(pending.cwd, pending.machineId); - if (pending.discarded || !this.isCurrentPendingStart(pending)) return; + const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId); + const isCurrentPendingStart = this.isCurrentPendingStart(pending); + if (pending.discarded || !isCurrentPendingStart) { + if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); + return; + } const state = this.getState(); const message = errorMessage(error); const activity = failedPendingSessionActivity(tempId, message, pending.queuedSends.length); @@ -880,6 +886,7 @@ export class SessionController { activity: state.selectedSession?.id === tempId ? activity : state.activity, error: `Failed to start session: ${message}`, }); + this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); } private isCurrentPendingStart(pending: PendingSessionStart): boolean { @@ -893,15 +900,29 @@ export class SessionController { private isSuppressedCreatedSession(session: SessionInfo, machineId: string): boolean { const suppressed = this.suppressedCreatedSessions.get(session.id); - return suppressed?.cwd === session.cwd && suppressed.machineId === machineId; + return suppressed?.session.cwd === session.cwd && suppressed.machineId === machineId; } - private clearSuppressedCreatedSessionsFor(cwd: string, machineId: string, resolvedSessionId?: string): void { + private takeSuppressedCreatedSessionsFor(cwd: string, machineId: string, resolvedSessionId?: string): SessionInfo[] { if (resolvedSessionId !== undefined) this.suppressedCreatedSessions.delete(resolvedSessionId); - if (this.hasPendingStartFor(cwd, machineId)) return; + if (this.hasPendingStartFor(cwd, machineId)) return []; + const released: SessionInfo[] = []; for (const [sessionId, suppressed] of this.suppressedCreatedSessions) { - if (suppressed.cwd === cwd && suppressed.machineId === machineId) this.suppressedCreatedSessions.delete(sessionId); + if (suppressed.session.cwd !== cwd || suppressed.machineId !== machineId) continue; + this.suppressedCreatedSessions.delete(sessionId); + released.push(suppressed.session); } + return released; + } + + private applyReleasedCreatedSessions(sessions: readonly SessionInfo[], machineId: string): void { + if (sessions.length === 0 || selectedMachineId(this.getState()) !== machineId) return; + const state = this.getState(); + if (state.selectedWorkspace === undefined) return; + const existingIds = new Set(state.sessions.map((session) => session.id)); + const released = sessions.filter((session) => session.cwd === state.selectedWorkspace?.path && !existingIds.has(session.id)); + if (released.length === 0) return; + this.setState({ sessions: [...released.reverse(), ...state.sessions] }); } private mergePendingStartSessions(cwd: string, sessions: SessionInfo[], machineId: string): SessionInfo[] { @@ -958,7 +979,7 @@ export class SessionController { if (state.sessions.some((candidate) => candidate.id === session.id)) return; const machineId = selectedMachineId(state); if (this.hasPendingStartFor(session.cwd, machineId)) { - this.suppressedCreatedSessions.set(session.id, { cwd: session.cwd, machineId }); + this.suppressedCreatedSessions.set(session.id, { session, machineId }); return; } this.setState({ sessions: [session, ...state.sessions] });