Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/store/session/sessionAtom/__tests__/mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ 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 {
upsertSession: mutations.upsertSession,
updateSessionStatus: mutations.updateSessionStatus,
applyImportedSessionTimestamps: mutations.applyImportedSessionTimestamps,
sessionsAtom: atoms.sessionsAtom,
sessionPaginationAtom,
store: getInstrumentedStore(),
};
}
Expand Down Expand Up @@ -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({
Expand Down
47 changes: 47 additions & 0 deletions src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
loadSidebarSessions,
loadSidebarSessionsByIds,
refreshRecentNativeSessions,
registerNewNativeSidebarSession,
syncSidebarSessionRoster,
} from "../loaders";
import { sessionPaginationAtom } from "../paginationAtoms";
Expand Down Expand Up @@ -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: [] });
Expand Down
47 changes: 46 additions & 1 deletion src/store/session/sessionAtom/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/store/session/sessionAtom/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
};

/**
Expand Down
8 changes: 6 additions & 2 deletions src/store/session/sessionAtom/sidebarRoster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,19 @@ 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;

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;
}

Expand Down
Loading