diff --git a/src/store/session/sessionAtom/__tests__/mutations.test.ts b/src/store/session/sessionAtom/__tests__/mutations.test.ts index 6eed640e01..c1493720ef 100644 --- a/src/store/session/sessionAtom/__tests__/mutations.test.ts +++ b/src/store/session/sessionAtom/__tests__/mutations.test.ts @@ -28,6 +28,7 @@ async function loadModule() { createInstrumentedStore(); const mutations = await import("../mutations"); const atoms = await import("../atoms"); + const { sessionPaginationAtom } = await import("../paginationAtoms"); const { getInstrumentedStore } = await import("@src/util/core/state/instrumentedStore"); return { @@ -35,6 +36,7 @@ async function loadModule() { updateSessionStatus: mutations.updateSessionStatus, applyImportedSessionTimestamps: mutations.applyImportedSessionTimestamps, sessionsAtom: atoms.sessionsAtom, + sessionPaginationAtom, store: getInstrumentedStore(), }; } @@ -81,6 +83,68 @@ describe("upsertSession", () => { }); }); + it("registers a new primary native session in an authoritative sidebar roster", async () => { + const { upsertSession, sessionPaginationAtom, store } = await loadModule(); + const { createSidebarRosterMatcher } = await import("../sidebarRoster"); + const pagination = store.get(sessionPaginationAtom); + store.set(sessionPaginationAtom, { + ...pagination, + standalone_agent: { + ...pagination.standalone_agent, + sessionIds: ["existing-session"], + cursor: { + updatedAt: "2026-01-01T00:00:00.000Z", + sessionId: "existing-session", + }, + phase: "ready", + generation: 1, + }, + }); + + const created = makeSession({ + session_id: "created-session", + created_at: "2026-01-02T00:00:00.000Z", + updated_at: "2026-01-02T00:00:00.000Z", + }); + upsertSession(created); + + expect( + store.get(sessionPaginationAtom).standalone_agent.sessionIds + ).toEqual(["created-session", "existing-session"]); + expect( + createSidebarRosterMatcher(store.get(sessionPaginationAtom))(created) + ).toBe(true); + }); + + it("does not register child sessions in the primary sidebar roster", async () => { + const { upsertSession, sessionPaginationAtom, store } = await loadModule(); + const pagination = store.get(sessionPaginationAtom); + store.set(sessionPaginationAtom, { + ...pagination, + standalone_agent: { + ...pagination.standalone_agent, + sessionIds: ["existing-session"], + cursor: { + updatedAt: "2026-01-01T00:00:00.000Z", + sessionId: "existing-session", + }, + phase: "ready", + generation: 1, + }, + }); + + upsertSession( + makeSession({ + session_id: "parent:subagent:child", + parentSessionId: "parent", + }) + ); + + expect( + store.get(sessionPaginationAtom).standalone_agent.sessionIds + ).toEqual(["existing-session"]); + }); + it("preserves prior updated_at on update even if caller spreads a fresh one", async () => { const { upsertSession, sessionsAtom, store } = await loadModule(); const original = makeSession({ diff --git a/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts b/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts index d46fbd330a..379a5c37b5 100644 --- a/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts +++ b/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts @@ -13,6 +13,7 @@ import { loadSidebarSessions, loadSidebarSessionsByIds, refreshRecentNativeSessions, + registerNewNativeSidebarSession, syncSidebarSessionRoster, } from "../loaders"; import { sessionPaginationAtom } from "../paginationAtoms"; @@ -240,6 +241,52 @@ describe("loadSidebarSessions", () => { await Promise.all([first, second]); }); + it("keeps a locally-created native row when an older first page resolves", async () => { + let resolveStandalone: + | ((value: { + sessions: unknown[]; + nextCursor: null; + hasMore: false; + }) => void) + | undefined; + mocks.nativeSidebarSessionPage.mockImplementation((stream: string) => { + if (stream !== "standaloneAgent") { + return Promise.resolve({ + sessions: [], + nextCursor: null, + hasMore: false, + }); + } + return new Promise((resolve) => { + resolveStandalone = resolve; + }); + }); + mocks.externalHistorySidebarList.mockResolvedValue({ sources: [] }); + + const loading = loadSessionRoster({ forceRefresh: true }); + await Promise.resolve(); + if (!resolveStandalone || !mocks.store) { + throw new Error("standalone roster request did not start"); + } + + const created = { + session_id: "created-during-load", + name: "Created during load", + status: "running", + created_at: "2026-07-30T12:00:00Z", + updated_at: "2026-07-30T12:00:00Z", + }; + mocks.store.set(sessionsAtom, [created]); + registerNewNativeSidebarSession(created); + + resolveStandalone({ sessions: [], nextCursor: null, hasMore: false }); + await loading; + + expect( + mocks.store.get(sessionPaginationAtom).standalone_agent.sessionIds + ).toEqual(["created-during-load"]); + }); + it("pages standalone agents and Agent Org roots with independent cursors", async () => { mocks.sessionAggregateList.mockResolvedValue({ sessions: [] }); mocks.externalHistorySidebarList.mockResolvedValue({ sources: [] }); diff --git a/src/store/session/sessionAtom/loaders.ts b/src/store/session/sessionAtom/loaders.ts index 439b8c4dd1..4efbc4422b 100644 --- a/src/store/session/sessionAtom/loaders.ts +++ b/src/store/session/sessionAtom/loaders.ts @@ -498,6 +498,16 @@ const performSidebarSessionLoad = async (options?: SidebarLoadOptions) => { } const generation = nextSidebarRosterGeneration(store); + // A locally-created row can arrive while this request is in flight. Keep + // the initial IDs as a boundary so `applyInitialPage` preserves only those + // registrations that happened after the read started; older rows still + // yield to the response, including legitimate remote deletions. + const nativeRosterIdsAtLoadStart = new Map( + BASE_SESSION_LIST_CATEGORIES.map((category) => [ + category, + new Set(store.get(sessionPaginationAtom)[category].sessionIds), + ]) + ); store.set(sessionLoadingAtom, true); store.set(sessionErrorAtom, null); @@ -534,8 +544,28 @@ const performSidebarSessionLoad = async (options?: SidebarLoadOptions) => { ) => { if (generation !== currentSidebarRosterGeneration(store)) return; const primarySessions = sessions.filter(isPrimarySessionListSession); + const initialSessionIds = primarySessions.map( + (session) => session.session_id + ); + const locallyRegisteredIds = isImportedHistoryListCategory(category) + ? [] + : store + .get(sessionPaginationAtom) + [category].sessionIds.filter((sessionId) => { + if (nativeRosterIdsAtLoadStart.get(category)?.has(sessionId)) { + return false; + } + const session = store + .get(sessionsAtom) + .find((candidate) => candidate.session_id === sessionId); + return ( + session !== undefined && + isPrimarySessionListSession(session) && + sidebarCategoryForSession(session) === category + ); + }); const sessionIds = [ - ...new Set(primarySessions.map((session) => session.session_id)), + ...new Set([...locallyRegisteredIds, ...initialSessionIds]), ]; if (hasMore && sessionIds.length === 0) { throw new Error( @@ -957,6 +987,21 @@ export function syncSidebarSessionRoster(session: Session): void { ); } +/** + * Register a locally-created native session before the next roster read. + * Unlike status/pin projections, creation is a membership change and must + * remain visible even while the sidebar's first page is still loading. + */ +export function registerNewNativeSidebarSession(session: Session): void { + if (!isPrimarySessionListSession(session)) return; + const store = getStore(); + store.set(sessionPaginationAtom, (previous) => + syncSessionWithNativeRosters(previous, session, { + registerBeforeInitialPage: true, + }) + ); +} + export const __TESTS_ONLY = { createSidebarLoadCoordinator, mergeSessions, diff --git a/src/store/session/sessionAtom/mutations.ts b/src/store/session/sessionAtom/mutations.ts index 646b6f7001..db10677db2 100644 --- a/src/store/session/sessionAtom/mutations.ts +++ b/src/store/session/sessionAtom/mutations.ts @@ -45,6 +45,7 @@ import { sessionsAtom, } from "./atoms"; import { removeGuestImportedSession } from "./guestImportRegistry"; +import { registerNewNativeSidebarSession } from "./loaders"; import type { Session, SessionStatus } from "./types"; const getStore = () => getInstrumentedStore(); @@ -61,6 +62,7 @@ const getStore = () => getInstrumentedStore(); */ export const upsertSession = (session: Session) => { const store = getStore(); + let inserted = false; store.set(sessionsAtom, (prev) => { const existingIndex = prev.findIndex( (existingSession) => existingSession.session_id === session.session_id @@ -91,10 +93,20 @@ export const upsertSession = (session: Session) => { }; return updated; } else { + inserted = true; const newList = [session, ...prev]; return newList; } }); + + // A native session created locally has authoritative launch data before the + // next paginated roster read completes. Register its ID with the current + // native window at the same write boundary; otherwise a fully loaded + // sidebar filters out the new entity until a later safety refresh happens. + // Child and imported sessions remain owned by their respective loaders. + if (inserted) { + registerNewNativeSidebarSession(session); + } }; /** diff --git a/src/store/session/sessionAtom/sidebarRoster.ts b/src/store/session/sessionAtom/sidebarRoster.ts index e7dad47003..05d6021c63 100644 --- a/src/store/session/sessionAtom/sidebarRoster.ts +++ b/src/store/session/sessionAtom/sidebarRoster.ts @@ -95,7 +95,8 @@ function isNativeCategory( */ export function syncSessionWithNativeRosters( pagination: SessionPaginationMap, - session: Session + session: Session, + options: { registerBeforeInitialPage?: boolean } = {} ): SessionPaginationMap { const target = sidebarCategoryForSession(session); if (!target || !isNativeCategory(target)) return pagination; @@ -103,7 +104,10 @@ export function syncSessionWithNativeRosters( const alreadyLoaded = BASE_SESSION_LIST_CATEGORIES.some((category) => pagination[category].sessionIds.includes(session.session_id) ); - if (alreadyLoaded || pagination[target].generation === 0) { + if ( + alreadyLoaded || + (pagination[target].generation === 0 && !options.registerBeforeInitialPage) + ) { return pagination; }