diff --git a/src/app/root/e2e/helpers/sessionHelpers/seeders.ts b/src/app/root/e2e/helpers/sessionHelpers/seeders.ts index 76485dc91..aaabd7127 100644 --- a/src/app/root/e2e/helpers/sessionHelpers/seeders.ts +++ b/src/app/root/e2e/helpers/sessionHelpers/seeders.ts @@ -22,8 +22,8 @@ import { } from "@src/store/session/planApprovalAtom"; import { type Session, + registerCreatedSession, sessionsAtom, - upsertSession, } from "@src/store/session/sessionAtom"; import { updateShellProcessAtom } from "@src/store/session/shellProcessAtom"; import { updateSubagentJobAtom } from "@src/store/session/subagentJobAtom"; @@ -187,7 +187,7 @@ export function createSessionSeederHelpers(store: E2EStore) { category: existing?.category ?? "rust_agent", is_active: true, }; - upsertSession(session); + registerCreatedSession(session); return { ok: true, sessionId: input.sessionId }; } catch (err) { return asError(err); @@ -235,7 +235,7 @@ export function createSessionSeederHelpers(store: E2EStore) { model: "composer-2", is_active: true, }; - upsertSession(session); + registerCreatedSession(session); store.set(stationModeAtom, "my-station"); store.set(chatPanelMaximizedAtom, true); store.set(chatWidthAtom, 560); diff --git a/src/app/root/e2e/helpers/sessions.ts b/src/app/root/e2e/helpers/sessions.ts index 01ff8bc36..b3991f0c9 100644 --- a/src/app/root/e2e/helpers/sessions.ts +++ b/src/app/root/e2e/helpers/sessions.ts @@ -46,7 +46,10 @@ import { } from "@src/store/session/planApprovalAtom"; import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; import { loadSessions } from "@src/store/session/sessionAtom/loaders"; -import { upsertSession } from "@src/store/session/sessionAtom/mutations"; +import { + registerCreatedSession, + upsertSession, +} from "@src/store/session/sessionAtom/mutations"; import { sessionPaginationAtom } from "@src/store/session/sessionAtom/paginationAtoms"; import type { Session } from "@src/store/session/sessionAtom/types"; import { @@ -417,6 +420,12 @@ export function createSessionHelpers(store: E2EStore) { ? result.session_id : null; if (sessionId) { + const launchedSession = await rpc.agentSession.getSession({ + sessionId, + }); + if (launchedSession) { + registerCreatedSession(toStoreSession(launchedSession)); + } store.set(activeSessionIdAtom, sessionId); store.set(workstationActiveSessionIdAtom, sessionId); await waitForSessionSurface(sessionId); @@ -745,7 +754,7 @@ export function createSessionHelpers(store: E2EStore) { name: input.name ?? input.userInput ?? input.sessionId, category: input.category ?? "cli_agent", }); - upsertSession(session); + registerCreatedSession(session); await saveEvents( input.sessionId, input.events as unknown as SessionEvent[] diff --git a/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts b/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts index 1ef6e6a48..545fe9bdf 100644 --- a/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts +++ b/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts @@ -522,6 +522,26 @@ describe("launchPayload", () => { expect(source).toContain("void emitOpenWorkspace("); expect(source).not.toContain("await emitOpenWorkspace("); }); + + it("registers a launched session with the Sidebar roster before navigation", () => { + const launchHookPath = fileURLToPath( + new URL( + "../useSessionCreator/useSessionLaunch/index.tsx", + import.meta.url + ) + ); + const source = readFileSync(launchHookPath, "utf8"); + const registrationIndex = source.indexOf( + "registerCreatedSession(launchedSession);" + ); + const navigationIndex = source.indexOf( + "navigateToLaunchedSession(result.sessionId, sessionUsesHostedKey);" + ); + + expect(registrationIndex).toBeGreaterThan(-1); + expect(navigationIndex).toBeGreaterThan(registrationIndex); + expect(source).not.toContain("syncSidebarSessionRoster(launchedSession)"); + }); }); function baseLaunchOptions(): Parameters[0] { diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx index c5a838c16..1f09aa17d 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx @@ -26,12 +26,12 @@ import { activeSessionIdAtom, dispatchCategoryAtom, loadSidebarSessions, + registerCreatedSession, selectedAgentDefinitionIdAtom, selectedAgentOrgIdAtom, sessionCreatorDraftAtom, sessionSourceAtom, sessionTargetKindAtom, - upsertSession, workstationActiveSessionIdAtom, } from "@src/store/session"; import { lastUserMessageAtom } from "@src/store/session/cliSessionStatusAtom"; @@ -230,16 +230,15 @@ export function useSessionLaunch( clearImages?.(); } - upsertSession( - buildSessionFromLaunchResult({ - agentExecMode, - effectiveSource, - isBackgroundLaunch, - launchCliAgentType: launchParams.platform, - launchOrgContext: resolvedWorkItemContext ?? undefined, - result, - }) - ); + const launchedSession = buildSessionFromLaunchResult({ + agentExecMode, + effectiveSource, + isBackgroundLaunch, + launchCliAgentType: launchParams.platform, + launchOrgContext: resolvedWorkItemContext ?? undefined, + result, + }); + registerCreatedSession(launchedSession); void autoTagLaunchedSessionToActiveCloudOrg({ sessionId: result.sessionId, repoPath: effectiveSource?.repoPath ?? null, diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts index 17a80c21f..745fbfa01 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts @@ -17,8 +17,8 @@ import type { WorkspaceSnapshot } from "@src/services/context/workspaceSnapshot" import { SESSION_TARGET_KIND, type Session, - type SessionStatus, type SessionTargetKind, + buildCreatedSessionRecord, } from "@src/store/session"; import type { SessionLaunchOrgContext, @@ -319,48 +319,15 @@ export function buildSessionFromLaunchResult(options: { result, } = options; - return { - session_id: result.sessionId, - status: result.status as SessionStatus, - created_at: result.createdAt, - updated_at: result.createdAt, - user_input: result.userInput || result.name, - repo_name: effectiveSource?.repoName ?? "", - name: result.name, - branch: - result.worktreeBranch || result.branch || effectiveSource?.branch || "", - is_active: !isBackgroundLaunch, - category: result.category as - | typeof DISPATCH_CATEGORY.RUST_AGENT - | typeof DISPATCH_CATEGORY.CLI_AGENT, - model: result.model ?? undefined, - cliAgentType: result.cliAgentType ?? launchCliAgentType ?? undefined, + return buildCreatedSessionRecord({ + result, + repoName: effectiveSource?.repoName, + fallbackRepoPath: effectiveSource?.repoPath, + fallbackBranch: effectiveSource?.branch, + fallbackCliAgentType: launchCliAgentType ?? undefined, + isActive: !isBackgroundLaunch, agentExecMode, - ...(result.agentOrgId - ? { agentIconId: AGENT_ORG_ICON_ID, agentOrgId: result.agentOrgId } - : {}), - ...(result.accountId ? { accountId: result.accountId } : {}), - ...((result.orgId ?? launchOrgContext?.orgId) - ? { orgId: result.orgId ?? launchOrgContext?.orgId } - : {}), - ...((result.projectId ?? launchOrgContext?.projectId) - ? { projectId: result.projectId ?? launchOrgContext?.projectId } - : {}), - ...((result.projectName ?? launchOrgContext?.projectName) - ? { projectName: result.projectName ?? launchOrgContext?.projectName } - : {}), - ...((result.projectSlug ?? launchOrgContext?.projectSlug) - ? { projectSlug: result.projectSlug ?? launchOrgContext?.projectSlug } - : {}), - ...((result.workItemId ?? launchOrgContext?.workItemId) - ? { workItemId: result.workItemId ?? launchOrgContext?.workItemId } - : {}), - ...((result.agentRole ?? launchOrgContext?.agentRole) - ? { agentRole: result.agentRole ?? launchOrgContext?.agentRole } - : {}), - ...(result.background ? { background: true } : {}), - ...(result.worktreePath ? { worktreePath: result.worktreePath } : {}), - ...(result.worktreeBranch ? { worktreeBranch: result.worktreeBranch } : {}), - ...(result.workspacePath ? { repoPath: result.workspacePath } : {}), - }; + agentIconId: result.agentOrgId ? AGENT_ORG_ICON_ID : undefined, + context: launchOrgContext, + }); } diff --git a/src/engines/SessionCore/services/SessionService.create.test.ts b/src/engines/SessionCore/services/SessionService.create.test.ts new file mode 100644 index 000000000..acc087d03 --- /dev/null +++ b/src/engines/SessionCore/services/SessionService.create.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionLaunchResult } from "@src/api/tauri/agent/session"; + +import { SessionService } from "./SessionService"; + +const mocks = vi.hoisted(() => ({ + sessionLaunch: vi.fn(), + registerCreatedSession: vi.fn(), +})); + +vi.mock("@src/api/tauri/agent", async (importOriginal) => ({ + ...(await importOriginal()), + sessionLaunch: mocks.sessionLaunch, +})); + +vi.mock("@src/store/session", async (importOriginal) => ({ + ...(await importOriginal()), + registerCreatedSession: mocks.registerCreatedSession, +})); + +vi.mock("@src/services/context/collectors", () => ({ + collectAdeContext: () => undefined, +})); + +function launchResult( + overrides: Partial = {} +): SessionLaunchResult { + return { + sessionId: "sdeagent-service-created", + category: "rust_agent", + name: "Service-created session", + status: "running", + createdAt: "2026-08-05T12:00:00.000Z", + userInput: "Do the work", + background: false, + ...overrides, + }; +} + +describe("SessionService.create", () => { + beforeEach(() => { + mocks.sessionLaunch.mockReset(); + mocks.registerCreatedSession.mockReset(); + }); + + it("registers the created entity and its Sidebar projection before returning", async () => { + mocks.sessionLaunch.mockResolvedValue( + launchResult({ + workspacePath: "/workspace/repo", + workItemId: "ORG-42", + }) + ); + + await expect( + SessionService.create({ + task: "Do the work", + repoPath: "/workspace/repo", + model: "gpt-5.6", + mode: "build", + agentDefinitionId: "builtin:sde", + workItemId: "ORG-42", + }) + ).resolves.toEqual({ sessionId: "sdeagent-service-created" }); + + expect(mocks.registerCreatedSession).toHaveBeenCalledOnce(); + expect(mocks.registerCreatedSession).toHaveBeenCalledWith( + expect.objectContaining({ + session_id: "sdeagent-service-created", + repoPath: "/workspace/repo", + agentDefinitionId: "builtin:sde", + agentExecMode: "build", + workItemId: "ORG-42", + }) + ); + }); + + it("does not register anything when the launch boundary fails", async () => { + mocks.sessionLaunch.mockRejectedValue(new Error("launch failed")); + + await expect( + SessionService.create({ task: "Do the work" }) + ).rejects.toThrow("Failed to create session: launch failed"); + expect(mocks.registerCreatedSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engines/SessionCore/services/SessionService.ts b/src/engines/SessionCore/services/SessionService.ts index 763a16260..0d16a2e34 100644 --- a/src/engines/SessionCore/services/SessionService.ts +++ b/src/engines/SessionCore/services/SessionService.ts @@ -37,8 +37,10 @@ import { type Session, type SessionStatus, activeSessionIdAtom, + buildCreatedSessionRecord, loadSessions, markSessionActive, + registerCreatedSession, sessionsAtom, workstationActiveSessionIdAtom, } from "@src/store/session"; @@ -185,6 +187,20 @@ export const SessionService = { const result = await sessionLaunch( launchParams as Parameters[0] ); + registerCreatedSession( + buildCreatedSessionRecord({ + result, + fallbackRepoPath: params.projectRepoPath || params.repoPath, + agentExecMode: params.mode, + agentDefinitionId: params.agentDefinitionId, + parentSessionId: params.parentSessionId, + context: { + projectSlug: params.projectSlug, + workItemId: params.workItemId, + agentRole: params.agentRole, + }, + }) + ); logger.info( `Created and started ${category} session: ${result.sessionId}` ); diff --git a/src/features/TeamCollaboration/engine/collabSessionFork.ts b/src/features/TeamCollaboration/engine/collabSessionFork.ts index 69e949127..56f03f4b2 100644 --- a/src/features/TeamCollaboration/engine/collabSessionFork.ts +++ b/src/features/TeamCollaboration/engine/collabSessionFork.ts @@ -20,7 +20,7 @@ import { loadSharedLocalKeys } from "@src/hooks/keyVault/sharedLocalKeyStore"; import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; import { lastModelPairMapAtom } from "@src/store/session/creatorDefaultModelAtom"; import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; -import { upsertSession } from "@src/store/session/sessionAtom/mutations"; +import { registerCreatedSession } from "@src/store/session/sessionAtom/mutations"; import { persistSessions } from "@src/store/session/sessionAtom/persistence"; import type { Session, @@ -342,7 +342,7 @@ export async function forkSession( remoteSession.forkedFrom?.rootSessionId ?? remoteSession.sourceSessionId, }; const name = buildForkedSessionName(remoteSession.title); - upsertSession({ + registerCreatedSession({ session_id: localSessionId, status: "completed", created_at: now, diff --git a/src/features/TeamCollaboration/engine/collabSessionImport.ts b/src/features/TeamCollaboration/engine/collabSessionImport.ts index 8113723f6..39bf00fb0 100644 --- a/src/features/TeamCollaboration/engine/collabSessionImport.ts +++ b/src/features/TeamCollaboration/engine/collabSessionImport.ts @@ -14,6 +14,7 @@ import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; import { recordGuestImportedSession } from "@src/store/session/sessionAtom/guestImportRegistry"; import { applyImportedSessionTimestamps, + registerCreatedSession, upsertSession, } from "@src/store/session/sessionAtom/mutations"; import { persistSessions } from "@src/store/session/sessionAtom/persistence"; @@ -1004,7 +1005,7 @@ async function importRemoteSessionInner( throwIfAborted(options.signal); // No await after the final abort check: the session row, guest registry // and persisted list commit synchronously as one local critical section. - upsertSession(importedRow); + registerCreatedSession(importedRow); // Re-import of an existing copy: upsertSession pins timestamps against // careless reconcile writes, but this row's clock belongs to the source. applyImportedSessionTimestamps(localSessionId, { diff --git a/src/hooks/ui/layout/useElementDimensions.test.ts b/src/hooks/ui/layout/useElementDimensions.test.ts new file mode 100644 index 000000000..8a4b0f999 --- /dev/null +++ b/src/hooks/ui/layout/useElementDimensions.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import React, { act, createElement, useRef } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { useElementDimensions } from "./useElementDimensions"; + +function DimensionProbe(): React.ReactNode { + const elementRef = useRef(null); + const dimensions = useElementDimensions(elementRef); + + // eslint-disable-next-line react-hooks/refs -- createElement is required because Vitest only includes `.test.ts`; this is a normal React ref prop. + return createElement("div", { + ref: elementRef, + "data-testid": "dimension-probe", + "data-dimensions": `${dimensions.width}x${dimensions.height}`, + }); +} + +describe("useElementDimensions", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("keeps the window resize fallback when ResizeObserver is unavailable", () => { + vi.stubGlobal("ResizeObserver", undefined); + const addWindowListener = vi.spyOn(window, "addEventListener"); + const removeWindowListener = vi.spyOn(window, "removeEventListener"); + + expect(() => { + act(() => root.render(createElement(DimensionProbe))); + }).not.toThrow(); + + expect(addWindowListener).toHaveBeenCalledWith( + "resize", + expect.any(Function) + ); + + act(() => root.unmount()); + root = createRoot(container); + + expect(removeWindowListener).toHaveBeenCalledWith( + "resize", + expect.any(Function) + ); + }); + + it("disconnects the observer when the measured element unmounts", () => { + const observe = vi.fn(); + const disconnect = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserverMock { + observe = observe; + unobserve = vi.fn(); + disconnect = disconnect; + } + ); + + act(() => root.render(createElement(DimensionProbe))); + + expect(observe).toHaveBeenCalledWith( + container.querySelector('[data-testid="dimension-probe"]') + ); + + act(() => root.unmount()); + root = createRoot(container); + + expect(disconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/hooks/ui/layout/useElementDimensions.ts b/src/hooks/ui/layout/useElementDimensions.ts index f65ba34a1..36d2001ce 100644 --- a/src/hooks/ui/layout/useElementDimensions.ts +++ b/src/hooks/ui/layout/useElementDimensions.ts @@ -122,7 +122,7 @@ export function useElementDimensions( // Set up ResizeObserver for accurate tracking let resizeObserver: ResizeObserver | null = null; - if (element) { + if (element && typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(measureDimensions); resizeObserver.observe(element); } diff --git a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/useAgentControlPalette.ts b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/useAgentControlPalette.ts index c5e27b0e3..7727a30a0 100644 --- a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/useAgentControlPalette.ts +++ b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/useAgentControlPalette.ts @@ -49,10 +49,10 @@ import type { import { EMPTY_ADE_MANAGER_EVENTS_ATOM, buildControlPrompt, + registerAdeManagerSession, resolveControlModel, resolveControlModelLabel, toAdeManagerActivityItem, - upsertAdeManagerSession, } from "./utils"; function useAdeManagerActivity( @@ -222,7 +222,7 @@ export function useAgentControlPalette({ }); controlSessionIdRef.current = result.sessionId; setControlSessionId(result.sessionId); - upsertAdeManagerSession(result); + registerAdeManagerSession(result); } setRunStatus("running"); diff --git a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/utils.test.ts b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/utils.test.ts new file mode 100644 index 000000000..c2ad2aea7 --- /dev/null +++ b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/utils.test.ts @@ -0,0 +1,35 @@ +import { expect, it, vi } from "vitest"; + +import { ADE_MANAGER_AGENT_NAME, ADE_MANAGER_SESSION_NAME } from "./constants"; +import { registerAdeManagerSession } from "./utils"; + +const mocks = vi.hoisted(() => ({ + registerCreatedSession: vi.fn(), +})); + +vi.mock("@src/store/session/sessionAtom", async (importOriginal) => ({ + ...(await importOriginal()), + registerCreatedSession: mocks.registerCreatedSession, +})); + +it("registers a newly launched ADE Manager session through the shared boundary", () => { + registerAdeManagerSession({ + sessionId: "sdeagent-ade-manager", + category: "rust_agent", + name: "", + status: "running", + createdAt: "2026-08-05T12:00:00.000Z", + userInput: "Manage ORGII", + background: false, + }); + + expect(mocks.registerCreatedSession).toHaveBeenCalledOnce(); + expect(mocks.registerCreatedSession).toHaveBeenCalledWith( + expect.objectContaining({ + session_id: "sdeagent-ade-manager", + name: ADE_MANAGER_SESSION_NAME, + agentDefinitionId: "builtin:agent-architect", + agentDisplayName: ADE_MANAGER_AGENT_NAME, + }) + ); +}); diff --git a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/utils.ts b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/utils.ts index ec7fada8e..467c75093 100644 --- a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/utils.ts +++ b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/utils.ts @@ -9,7 +9,10 @@ import { import { extractArgsSummary } from "@src/engines/ChatPanel/blocks/ToolCallBlock/helpers/argsSummary"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import type { LastModelSelection } from "@src/store/session/creatorDefaultModelAtom"; -import { upsertSession } from "@src/store/session/sessionAtom"; +import { + buildCreatedSessionRecord, + registerCreatedSession, +} from "@src/store/session/sessionAtom"; import { BUILTIN_ADE_MANAGER_DEF_ID } from "@src/util/session/sessionDispatch"; import { @@ -132,25 +135,19 @@ export function toAdeManagerActivityItem( return null; } -export function upsertAdeManagerSession(result: SessionLaunchResult): void { - upsertSession({ - session_id: result.sessionId, - status: result.status, - created_at: result.createdAt, - updated_at: result.createdAt, - user_input: result.userInput || result.name, - name: result.name || ADE_MANAGER_SESSION_NAME, - branch: result.branch ?? "", - is_active: true, - category: DISPATCH_CATEGORY.RUST_AGENT, - model: result.model ?? undefined, - agentExecMode: ADE_MANAGER_AGENT_EXEC_MODE, - agentDefinitionId: BUILTIN_ADE_MANAGER_DEF_ID, - agentIconId: ADE_MANAGER_AGENT_ICON_ID, - agentDisplayName: ADE_MANAGER_AGENT_NAME, - ...(result.accountId ? { accountId: result.accountId } : {}), - ...(result.background ? { background: true } : {}), - ...(result.workspacePath ? { repoPath: result.workspacePath } : {}), - ...(result.worktreePath ? { worktreePath: result.worktreePath } : {}), - }); +export function registerAdeManagerSession(result: SessionLaunchResult): void { + registerCreatedSession( + buildCreatedSessionRecord({ + result: { + ...result, + name: result.name || ADE_MANAGER_SESSION_NAME, + category: DISPATCH_CATEGORY.RUST_AGENT, + }, + isActive: true, + agentExecMode: ADE_MANAGER_AGENT_EXEC_MODE, + agentDefinitionId: BUILTIN_ADE_MANAGER_DEF_ID, + agentIconId: ADE_MANAGER_AGENT_ICON_ID, + agentDisplayName: ADE_MANAGER_AGENT_NAME, + }) + ); } diff --git a/src/scaffold/NavigationSidebar/connectors/sessionImportExport.ts b/src/scaffold/NavigationSidebar/connectors/sessionImportExport.ts index ffeadaa35..fbab41b96 100644 --- a/src/scaffold/NavigationSidebar/connectors/sessionImportExport.ts +++ b/src/scaffold/NavigationSidebar/connectors/sessionImportExport.ts @@ -10,7 +10,7 @@ import { cacheAdapter } from "@src/engines/SessionCore/storage/cacheAdapter"; import { loadOwnSessionInitialEvents } from "@src/engines/SessionCore/sync/sessionSyncUtils"; import { createLogger } from "@src/hooks/logger"; import type { Session } from "@src/store/session"; -import { sessionsAtom, upsertSession } from "@src/store/session"; +import { registerCreatedSession, sessionsAtom } from "@src/store/session"; import { persistSessions } from "@src/store/session/sessionAtom/persistence"; import type { ActivityChunk } from "@src/types/session/session"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; @@ -391,7 +391,7 @@ export async function importSessionExportFile( pinned: false, error_message: JSON.stringify(importMetadata), }; - upsertSession(importedSession); + registerCreatedSession(importedSession); persistSessions(getInstrumentedStore().get(sessionsAtom)); return { ...preview, importedEventCount: remappedEvents.length }; } diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/createdSessionVisibility.test.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/createdSessionVisibility.test.ts new file mode 100644 index 000000000..e3475e976 --- /dev/null +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/createdSessionVisibility.test.ts @@ -0,0 +1,81 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NavigationMenuLeafRow } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/NavigationMenu/NavigationMenuRow"; + +import { buildSessionMenuItem } from "../menuItemBuilders"; + +beforeEach(() => { + vi.resetModules(); +}); + +describe("newly created session visibility", () => { + it("renders a registered creation as a Sidebar row before roster refresh", async () => { + const { createInstrumentedStore } = + await import("@src/util/core/state/instrumentedStore"); + const store = createInstrumentedStore(); + const { sessionsAtom } = + await import("@src/store/session/sessionAtom/atoms"); + const { registerCreatedSession } = + await import("@src/store/session/sessionAtom/mutations"); + const { sessionPaginationAtom } = + await import("@src/store/session/sessionAtom/paginationAtoms"); + const { createSidebarRosterMatcher } = + await import("@src/store/session/sessionAtom/sidebarRoster"); + + const initialPagination = store.get(sessionPaginationAtom); + store.set(sessionPaginationAtom, { + ...initialPagination, + standalone_agent: { + ...initialPagination.standalone_agent, + sessionIds: ["sdeagent-existing"], + cursor: { + updatedAt: "2026-08-05T10:00:00Z", + sessionId: "sdeagent-existing", + }, + generation: 1, + }, + }); + + registerCreatedSession({ + session_id: "sdeagent-new-visible", + name: "New visible session", + status: "running", + category: "rust_agent", + created_at: "2026-08-05T11:00:00Z", + updated_at: "2026-08-05T11:00:00Z", + }); + + const matcher = createSidebarRosterMatcher( + store.get(sessionPaginationAtom) + ); + const [visibleSession] = store.get(sessionsAtom).filter(matcher); + if (!visibleSession) throw new Error("created session was filtered out"); + expect(visibleSession.session_id).toBe("sdeagent-new-visible"); + + const item = buildSessionMenuItem({ + session: visibleSession, + untitledSession: "Untitled", + visitedSessions: new Set(), + }); + const html = renderToStaticMarkup( + createElement(NavigationMenuLeafRow, { + item, + isChild: false, + isSelected: false, + collapsed: false, + t: (key) => key, + renderIcon: () => null, + onMenuItemClick: () => undefined, + onRowMouseEnter: () => undefined, + onRowActionClick: () => undefined, + }) + ); + + expect(html).toContain( + 'data-testid="sidebar-session-item-sdeagent-new-visible"' + ); + expect(html).toContain("New visible session"); + }); +}); diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts index 4f5bd969b..4908586d9 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts @@ -23,6 +23,7 @@ function streamState( ): CategoryPaginationState { return { sessionIds: [], + localSessionIds: [], cursor: null, phase, generation: 1, diff --git a/src/store/session/sessionAtom/__tests__/mutations.test.ts b/src/store/session/sessionAtom/__tests__/mutations.test.ts index 6eed640e0..7aa8d2e68 100644 --- a/src/store/session/sessionAtom/__tests__/mutations.test.ts +++ b/src/store/session/sessionAtom/__tests__/mutations.test.ts @@ -6,6 +6,7 @@ * lock that contract for the mutation entry points: * * - `upsertSession` (insert + update) + * - `registerCreatedSession` (entity + Sidebar roster registration) * - `updateSessionStatus` * - `applyImportedSessionTimestamps` — the one sanctioned override, * narrowed to imported replay copies whose clock is the source's @@ -28,13 +29,16 @@ async function loadModule() { createInstrumentedStore(); const mutations = await import("../mutations"); const atoms = await import("../atoms"); + const paginationAtoms = await import("../paginationAtoms"); const { getInstrumentedStore } = await import("@src/util/core/state/instrumentedStore"); return { upsertSession: mutations.upsertSession, + registerCreatedSession: mutations.registerCreatedSession, updateSessionStatus: mutations.updateSessionStatus, applyImportedSessionTimestamps: mutations.applyImportedSessionTimestamps, sessionsAtom: atoms.sessionsAtom, + sessionPaginationAtom: paginationAtoms.sessionPaginationAtom, store: getInstrumentedStore(), }; } @@ -129,6 +133,68 @@ describe("upsertSession", () => { }); }); +describe("registerCreatedSession", () => { + it("registers entity and local roster membership idempotently", async () => { + const { + registerCreatedSession, + sessionPaginationAtom, + sessionsAtom, + store, + } = await loadModule(); + const initial = store.get(sessionPaginationAtom); + store.set(sessionPaginationAtom, { + ...initial, + standalone_agent: { + ...initial.standalone_agent, + sessionIds: ["existing"], + cursor: { + updatedAt: "2026-01-01T00:00:00.000Z", + sessionId: "existing", + }, + generation: 1, + }, + }); + const created = makeSession({ session_id: "created" }); + + registerCreatedSession(created); + registerCreatedSession(created); + + expect( + store + .get(sessionsAtom) + .filter((session) => session.session_id === created.session_id) + ).toHaveLength(1); + expect( + store.get(sessionPaginationAtom).standalone_agent.localSessionIds + ).toEqual(["created"]); + expect( + store.get(sessionPaginationAtom).standalone_agent.sessionIds + ).toEqual(["existing"]); + }); + + it("caches child sessions without placing them in the top-level roster", async () => { + const { + registerCreatedSession, + sessionPaginationAtom, + sessionsAtom, + store, + } = await loadModule(); + const child = makeSession({ + session_id: "parent:subagent:child", + parentSessionId: "parent", + }); + + registerCreatedSession(child); + + expect(store.get(sessionsAtom)).toContainEqual(child); + expect( + Object.values(store.get(sessionPaginationAtom)).flatMap( + (state) => state.localSessionIds + ) + ).not.toContain(child.session_id); + }); +}); + describe("applyImportedSessionTimestamps", () => { const SOURCE_TIMES = { created_at: "2026-06-01T09:30:00.000Z", @@ -228,12 +294,17 @@ describe("updateSessionStatus", () => { describe("removeSession", () => { it("drops the session and disposes its rust-agent streaming state", async () => { - const { upsertSession, sessionsAtom, store } = await loadModule(); + const { + registerCreatedSession, + sessionPaginationAtom, + sessionsAtom, + store, + } = await loadModule(); const mutations = await import("../mutations"); const streamHelpers = await import("@src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHelpers"); - upsertSession(makeSession({ session_id: "sess-x" })); + registerCreatedSession(makeSession({ session_id: "sess-x" })); // Seed per-turn streaming-stop state that only the deletion path can free. streamHelpers.noteSessionStreamingTurn("sess-x", "turn-1"); @@ -250,5 +321,10 @@ describe("removeSession", () => { expect(streamHelpers.isSessionStreamingStopped("sess-x", "turn-1")).toBe( false ); + expect( + Object.values(store.get(sessionPaginationAtom)).flatMap( + (state) => state.localSessionIds + ) + ).not.toContain("sess-x"); }); }); diff --git a/src/store/session/sessionAtom/__tests__/paginationAtoms.test.ts b/src/store/session/sessionAtom/__tests__/paginationAtoms.test.ts index c5d18ec6a..a2d9539b2 100644 --- a/src/store/session/sessionAtom/__tests__/paginationAtoms.test.ts +++ b/src/store/session/sessionAtom/__tests__/paginationAtoms.test.ts @@ -29,30 +29,35 @@ describe("session pagination categories", () => { expect(state["external_history:codex_app"]).toEqual({ sessionIds: [], + localSessionIds: [], cursor: null, phase: "ready", generation: 0, }); expect(state["external_history:claude_code"]).toEqual({ sessionIds: [], + localSessionIds: [], cursor: null, phase: "ready", generation: 0, }); expect(state["external_history:opencode"]).toEqual({ sessionIds: [], + localSessionIds: [], cursor: null, phase: "ready", generation: 0, }); expect(state["external_history:windsurf"]).toEqual({ sessionIds: [], + localSessionIds: [], cursor: null, phase: "ready", generation: 0, }); expect(state["external_history:warp"]).toEqual({ sessionIds: [], + localSessionIds: [], cursor: null, phase: "ready", generation: 0, diff --git a/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts b/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts index d46fbd330..ea8783869 100644 --- a/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts +++ b/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts @@ -13,9 +13,10 @@ import { loadSidebarSessions, loadSidebarSessionsByIds, refreshRecentNativeSessions, - syncSidebarSessionRoster, } from "../loaders"; +import { registerCreatedSession, syncSidebarSessionRoster } from "../mutations"; import { sessionPaginationAtom } from "../paginationAtoms"; +import { createSidebarRosterMatcher } from "../sidebarRoster"; const mocks = vi.hoisted(() => ({ externalHistorySidebarList: vi.fn(), @@ -78,6 +79,174 @@ describe("loadSidebarSessions", () => { expect(loadSidebarSessions).toBe(loadSessionRoster); }); + it("makes a newly launched session visible through the loaded roster", () => { + const current = mocks.store?.get(sessionPaginationAtom); + if (!current || !mocks.store) throw new Error("missing test store"); + mocks.store.set(sessionPaginationAtom, { + ...current, + standalone_agent: { + ...current.standalone_agent, + sessionIds: ["sdeagent-existing"], + cursor: { + updatedAt: "2026-08-05T10:00:00Z", + sessionId: "sdeagent-existing", + }, + phase: "ready", + generation: 1, + }, + }); + const launchedSession = { + session_id: "sdeagent-new", + name: "New session", + status: "running", + category: "rust_agent" as const, + created_at: "2026-08-05T11:00:00Z", + updated_at: "2026-08-05T11:00:00Z", + }; + + registerCreatedSession(launchedSession); + + const pagination = mocks.store.get(sessionPaginationAtom); + const storedSession = mocks.store + .get(sessionsAtom) + .find((session) => session.session_id === launchedSession.session_id); + expect(storedSession).toEqual(launchedSession); + expect(pagination.standalone_agent.sessionIds).toEqual([ + "sdeagent-existing", + ]); + expect(pagination.standalone_agent.localSessionIds).toEqual([ + "sdeagent-new", + ]); + expect(createSidebarRosterMatcher(pagination)(launchedSession)).toBe(true); + expect(mocks.nativeSidebarSessionPage).not.toHaveBeenCalled(); + expect(mocks.sessionAggregateList).not.toHaveBeenCalled(); + }); + + it("promotes a recent local creation when the safety refresh confirms it", async () => { + if (!mocks.store) throw new Error("missing test store"); + const current = mocks.store.get(sessionPaginationAtom); + mocks.store.set(sessionPaginationAtom, { + ...current, + standalone_agent: { + ...current.standalone_agent, + sessionIds: ["sdeagent-page-tail"], + cursor: { + updatedAt: "2026-08-05T10:00:00Z", + sessionId: "sdeagent-page-tail", + }, + generation: 1, + }, + }); + const created = { + session_id: "sdeagent-created-ahead-of-cursor", + status: "running", + category: "rust_agent" as const, + created_at: "2026-08-05T11:00:00Z", + updated_at: "2026-08-05T11:00:00Z", + }; + registerCreatedSession(created); + mocks.sessionAggregateList.mockResolvedValue({ sessions: [created] }); + + await refreshRecentNativeSessions(); + + const pagination = mocks.store.get(sessionPaginationAtom); + expect(pagination.standalone_agent.localSessionIds).toEqual([]); + expect(pagination.standalone_agent.sessionIds).toEqual([ + created.session_id, + "sdeagent-page-tail", + ]); + expect(createSidebarRosterMatcher(pagination)(created)).toBe(true); + }); + + it("keeps a local creation visible until a native refresh acknowledges it", async () => { + if (!mocks.store) throw new Error("missing test store"); + const created = { + session_id: "sdeagent-created-during-refresh", + status: "running", + category: "rust_agent" as const, + created_at: "2026-08-05T12:00:00Z", + updated_at: "2026-08-05T12:00:00Z", + }; + registerCreatedSession(created); + mocks.externalHistorySidebarList.mockResolvedValue({ sources: [] }); + mocks.nativeSidebarSessionPage.mockImplementation(async (stream: string) => + stream === "standaloneAgent" + ? { + sessions: [ + { + session_id: "sdeagent-existing", + status: "completed", + category: "rust_agent", + created_at: "2026-08-05T10:00:00Z", + updated_at: "2026-08-05T10:00:00Z", + }, + ], + nextCursor: null, + hasMore: false, + } + : { sessions: [], nextCursor: null, hasMore: false } + ); + + await loadSessionRoster({ forceRefresh: true }); + + let pagination = mocks.store.get(sessionPaginationAtom); + expect(pagination.standalone_agent.localSessionIds).toEqual([ + created.session_id, + ]); + expect(createSidebarRosterMatcher(pagination)(created)).toBe(true); + + mocks.nativeSidebarSessionPage.mockImplementation(async (stream: string) => + stream === "standaloneAgent" + ? { sessions: [created], nextCursor: null, hasMore: false } + : { sessions: [], nextCursor: null, hasMore: false } + ); + await loadSessionRoster({ forceRefresh: true }); + + pagination = mocks.store.get(sessionPaginationAtom); + expect(pagination.standalone_agent.localSessionIds).toEqual([]); + expect(pagination.standalone_agent.sessionIds).toContain( + created.session_id + ); + expect(createSidebarRosterMatcher(pagination)(created)).toBe(true); + }); + + it("does not drop a local creation that remains behind the server page cursor", async () => { + if (!mocks.store) throw new Error("missing test store"); + const current = mocks.store.get(sessionPaginationAtom); + mocks.store.set(sessionPaginationAtom, { + ...current, + standalone_agent: { + ...current.standalone_agent, + sessionIds: ["sdeagent-page-tail"], + cursor: { + updatedAt: "2026-08-05T10:00:00Z", + sessionId: "sdeagent-page-tail", + }, + generation: 1, + }, + }); + const created = { + session_id: "sdeagent-created-behind-cursor", + status: "running", + category: "rust_agent" as const, + created_at: "2026-08-05T09:00:00Z", + updated_at: "2026-08-05T09:00:00Z", + }; + registerCreatedSession(created); + mocks.sessionAggregateList.mockResolvedValue({ sessions: [created] }); + + await refreshRecentNativeSessions(); + + const pagination = mocks.store.get(sessionPaginationAtom); + expect(pagination.standalone_agent.sessionIds).toEqual([ + "sdeagent-page-tail", + ]); + expect(pagination.standalone_agent.localSessionIds).toEqual([ + created.session_id, + ]); + expect(createSidebarRosterMatcher(pagination)(created)).toBe(true); + }); + it("keeps healthy imported sources listed when one source's store fails", async () => { mocks.sessionAggregateList.mockResolvedValue({ sessions: [] }); mocks.externalHistorySidebarList.mockResolvedValue({ diff --git a/src/store/session/sessionAtom/__tests__/sidebarRoster.test.ts b/src/store/session/sessionAtom/__tests__/sidebarRoster.test.ts index 68f115bdf..5d2d77a50 100644 --- a/src/store/session/sessionAtom/__tests__/sidebarRoster.test.ts +++ b/src/store/session/sessionAtom/__tests__/sidebarRoster.test.ts @@ -6,7 +6,10 @@ import { resetPaginationState, } from "../paginationAtoms"; import { + acknowledgeCreatedSessionsInNativeRoster, createSidebarRosterMatcher, + registerCreatedSessionWithNativeRoster, + removeSessionFromRosters, sidebarCategoryForSession, syncSessionWithNativeRosters, } from "../sidebarRoster"; @@ -130,4 +133,62 @@ describe("sidebar roster ownership", () => { ) ).toBe(true); }); + + it("keeps confirmed local creations separate until the backend acknowledges them", () => { + const cursor = { + updatedAt: "2026-07-30T00:00:00Z", + sessionId: "sdeagent-10", + }; + const base = withStandaloneRoster(["sdeagent-10"], 1); + const created = makeSession("sdeagent-created", { + // A known creation must stay visible even when timestamp precision puts + // it at or behind the current keyset cursor. + updated_at: "2026-07-29T23:59:59Z", + }); + + const registered = registerCreatedSessionWithNativeRoster(base, created); + const registeredAgain = registerCreatedSessionWithNativeRoster( + registered, + created + ); + + expect(registeredAgain.standalone_agent.sessionIds).toEqual([ + "sdeagent-10", + ]); + expect(registeredAgain.standalone_agent.localSessionIds).toEqual([ + created.session_id, + ]); + expect(registeredAgain.standalone_agent.cursor).toEqual(cursor); + expect(createSidebarRosterMatcher(registeredAgain)(created)).toBe(true); + + const pinnedGenerationLoaded = { + ...registeredAgain, + pinned_native: { + ...registeredAgain.pinned_native, + generation: 1, + }, + standalone_agent: { + ...registeredAgain.standalone_agent, + generation: 0, + }, + }; + expect( + createSidebarRosterMatcher(pinnedGenerationLoaded)({ + ...created, + pinned: true, + }) + ).toBe(true); + + const acknowledged = acknowledgeCreatedSessionsInNativeRoster( + registeredAgain, + [created] + ); + expect(acknowledged.standalone_agent.localSessionIds).toEqual([]); + + const deleted = removeSessionFromRosters( + registeredAgain, + created.session_id + ); + expect(deleted.standalone_agent.localSessionIds).toEqual([]); + }); }); diff --git a/src/store/session/sessionAtom/atoms.ts b/src/store/session/sessionAtom/atoms.ts index 8345efa08..4071ac39e 100644 --- a/src/store/session/sessionAtom/atoms.ts +++ b/src/store/session/sessionAtom/atoms.ts @@ -26,10 +26,10 @@ export const SESSION_CACHE_INVALIDATED_EVENT = "session-cache-invalidated"; // Core Atoms // ============================================ -// Hydrated synchronously from localStorage so the sidebar renders the -// previous list on cold start without waiting for a network round-trip. -// `loadSessions()` swaps in fresh data shortly after — see -// `loaders.ts`. +// Entity cache, not the Sidebar's authoritative roster. It is hydrated +// synchronously from localStorage so known sessions can render on cold start; +// paginated list membership lives in `sessionPaginationAtom` and is combined +// with these records by the Sidebar projection. export const sessionsAtom = atom(loadPersistedSessions()); sessionsAtom.debugLabel = "sessionsAtom"; diff --git a/src/store/session/sessionAtom/createdSession.ts b/src/store/session/sessionAtom/createdSession.ts new file mode 100644 index 000000000..a29590640 --- /dev/null +++ b/src/store/session/sessionAtom/createdSession.ts @@ -0,0 +1,91 @@ +import type { SessionLaunchResult } from "@src/api/tauri/agent/session"; + +import type { Session } from "./types"; + +export type CreatedSessionContext = Partial< + Pick< + Session, + | "orgId" + | "projectId" + | "projectName" + | "projectSlug" + | "workItemId" + | "agentRole" + > +>; + +export interface BuildCreatedSessionRecordOptions { + result: SessionLaunchResult; + repoName?: string; + fallbackRepoPath?: string; + fallbackBranch?: string; + fallbackCliAgentType?: Session["cliAgentType"]; + isActive?: boolean; + agentExecMode?: string; + agentDefinitionId?: string; + agentIconId?: string; + agentDisplayName?: string; + parentSessionId?: string; + context?: CreatedSessionContext; +} + +/** + * Convert the canonical `session_launch` response into the shared frontend + * entity shape. Backend response fields win; caller context only fills values + * that the launch response may omit. + */ +export function buildCreatedSessionRecord({ + result, + repoName = "", + fallbackRepoPath, + fallbackBranch, + fallbackCliAgentType, + isActive = !result.background, + agentExecMode, + agentDefinitionId, + agentIconId, + agentDisplayName, + parentSessionId, + context, +}: BuildCreatedSessionRecordOptions): Session { + const orgId = result.orgId ?? context?.orgId; + const projectId = result.projectId ?? context?.projectId; + const projectName = result.projectName ?? context?.projectName; + const projectSlug = result.projectSlug ?? context?.projectSlug; + const workItemId = result.workItemId ?? context?.workItemId; + const agentRole = result.agentRole ?? context?.agentRole; + + return { + session_id: result.sessionId, + status: result.status, + created_at: result.createdAt, + updated_at: result.createdAt, + user_input: result.userInput || result.name, + repo_name: repoName, + name: result.name, + branch: result.worktreeBranch || result.branch || fallbackBranch || "", + is_active: isActive, + category: result.category as Session["category"], + model: result.model ?? undefined, + cliAgentType: result.cliAgentType ?? fallbackCliAgentType ?? undefined, + ...(agentExecMode ? { agentExecMode } : {}), + ...(agentDefinitionId ? { agentDefinitionId } : {}), + ...(agentIconId ? { agentIconId } : {}), + ...(agentDisplayName ? { agentDisplayName } : {}), + ...(parentSessionId ? { parentSessionId } : {}), + ...(result.agentOrgId ? { agentOrgId: result.agentOrgId } : {}), + ...(result.accountId ? { accountId: result.accountId } : {}), + ...(orgId ? { orgId } : {}), + ...(projectId ? { projectId } : {}), + ...(projectName ? { projectName } : {}), + ...(projectSlug ? { projectSlug } : {}), + ...(workItemId ? { workItemId } : {}), + ...(agentRole ? { agentRole } : {}), + ...(result.background ? { background: true } : {}), + ...(result.worktreePath ? { worktreePath: result.worktreePath } : {}), + ...(result.worktreeBranch ? { worktreeBranch: result.worktreeBranch } : {}), + ...((result.workspacePath ?? fallbackRepoPath) + ? { repoPath: result.workspacePath ?? fallbackRepoPath } + : {}), + }; +} diff --git a/src/store/session/sessionAtom/index.ts b/src/store/session/sessionAtom/index.ts index 61b9d0e9c..cd3a4df37 100644 --- a/src/store/session/sessionAtom/index.ts +++ b/src/store/session/sessionAtom/index.ts @@ -8,6 +8,7 @@ export * from "./types"; export * from "./atoms"; +export * from "./createdSession"; export * from "./loaders"; export * from "./mutations"; export * from "./helpers"; diff --git a/src/store/session/sessionAtom/loaders.ts b/src/store/session/sessionAtom/loaders.ts index 439b8c4dd..b9117da78 100644 --- a/src/store/session/sessionAtom/loaders.ts +++ b/src/store/session/sessionAtom/loaders.ts @@ -64,6 +64,7 @@ import { } from "./paginationAtoms"; import { persistSessions } from "./persistence"; import { + acknowledgeCreatedSessionsInNativeRoster, sidebarCategoryForSession, syncSessionWithNativeRosters, } from "./sidebarRoster"; @@ -553,6 +554,11 @@ const performSidebarSessionLoad = async (options?: SidebarLoadOptions) => { generation, dateBuckets, }); + if (!isImportedHistoryListCategory(category)) { + store.set(sessionPaginationAtom, (previous) => + acknowledgeCreatedSessionsInNativeRoster(previous, primarySessions) + ); + } }; const nativeTasks = enabledCategories @@ -729,6 +735,11 @@ export function refreshRecentNativeSessions(): Promise { .get(sessionsAtom) .map((session) => [session.session_id, session] as const) ); + const locallyRegisteredIds = new Set( + BASE_SESSION_LIST_CATEGORIES.flatMap((category) => + store.get(sessionPaginationAtom)[category].localSessionIds.slice() + ) + ); const refresh = (async () => { const response = await sessionAggregateList({ includeExternalHistory: false, @@ -748,6 +759,7 @@ export function refreshRecentNativeSessions(): Promise { const previous = previousById.get(session.session_id); return ( !previous || + locallyRegisteredIds.has(session.session_id) || sidebarCategoryForSession(previous) !== sidebarCategoryForSession(session) ); @@ -756,7 +768,9 @@ export function refreshRecentNativeSessions(): Promise { store.set(sessionPaginationAtom, (previous) => membershipChanges.reduce( (pagination, session) => - syncSessionWithNativeRosters(pagination, session), + syncSessionWithNativeRosters(pagination, session, { + promoteLocalCreation: true, + }), previous ) ); @@ -926,6 +940,11 @@ export const loadMoreCategory = async ( generation, dateBuckets, }); + if (!imported) { + store.set(sessionPaginationAtom, (previous) => + acknowledgeCreatedSessionsInNativeRoster(previous, primarySessions) + ); + } persistSessions(store.get(sessionsAtom)); const newIds = new Set(newSessionIds); return { @@ -950,13 +969,6 @@ export const loadMoreCategory = async ( } }; -export function syncSidebarSessionRoster(session: Session): void { - const store = getStore(); - store.set(sessionPaginationAtom, (previous) => - syncSessionWithNativeRosters(previous, session) - ); -} - export const __TESTS_ONLY = { createSidebarLoadCoordinator, mergeSessions, diff --git a/src/store/session/sessionAtom/mutations.ts b/src/store/session/sessionAtom/mutations.ts index 4cda96054..d728aeeb1 100644 --- a/src/store/session/sessionAtom/mutations.ts +++ b/src/store/session/sessionAtom/mutations.ts @@ -11,8 +11,9 @@ * * 1. `loadSessions()` — full list replace from `session_aggregate_list` * (and the supplementary Cursor IDE row read). - * 2. Insert path of `upsertSession()` — for a brand-new session, - * whose timestamps still originate from the launch RPC response. + * 2. Insert path of `registerCreatedSession()` / `upsertSession()` — for a + * brand-new session whose timestamps still originate from its creation + * boundary. * 3. `markSessionActive()` — explicitly bumped on a real *user * action* (currently only "send a prompt"). This is NOT a * reconcile-driven write; it represents activity the user just @@ -32,11 +33,14 @@ * intentional escape hatch for "the user just did something, bump * the row". */ +import { atom } from "jotai"; + import { disposeSessionStreamingState } from "@src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHelpers"; import { cursorIdeTurnSummariesAtomFamily } from "@src/store/session/cursorIdeTurnSummariesAtom"; import { tuiModeAtom } from "@src/store/session/tuiModeAtom"; import { clearTodosForSessionAtom } from "@src/store/ui/todoAtom"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; +import { isPrimarySessionListSession } from "@src/util/session/sessionVisibility"; import { sessionFlatListLastLoadedBySignatureAtom, @@ -44,10 +48,58 @@ import { sessionsAtom, } from "./atoms"; import { removeGuestImportedSession } from "./guestImportRegistry"; +import { sessionPaginationAtom } from "./paginationAtoms"; +import { + registerCreatedSessionWithNativeRoster, + removeSessionFromRosters, + syncSessionWithNativeRosters, +} from "./sidebarRoster"; import type { Session, SessionStatus } from "./types"; const getStore = () => getInstrumentedStore(); +function upsertSessionInList(prev: Session[], session: Session): Session[] { + const existingIndex = prev.findIndex( + (existingSession) => existingSession.session_id === session.session_id + ); + + if (existingIndex < 0) return [session, ...prev]; + + const existing = prev[existingIndex]; + const updated = [...prev]; + updated[existingIndex] = { + ...existing, + ...session, + parentSessionId: session.parentSessionId ?? existing.parentSessionId, + orgMemberId: session.orgMemberId ?? existing.orgMemberId, + agentOrgId: session.agentOrgId ?? existing.agentOrgId, + agentOrgName: session.agentOrgName ?? existing.agentOrgName, + agentDefinitionId: session.agentDefinitionId ?? existing.agentDefinitionId, + agentIconId: session.agentIconId ?? existing.agentIconId, + agentDisplayName: session.agentDisplayName ?? existing.agentDisplayName, + // Backend-owned. Pin to the prior values so a careless caller spreading + // a synthesized timestamp cannot drift list ordering. + created_at: existing.created_at, + updated_at: existing.updated_at, + created_time: existing.created_time, + updated_time: existing.updated_time, + }; + return updated; +} + +const registerCreatedSessionStateAtom = atom( + null, + (_get, set, session: Session) => { + set(sessionsAtom, (prev) => upsertSessionInList(prev, session)); + if (isPrimarySessionListSession(session)) { + set(sessionPaginationAtom, (previous) => + registerCreatedSessionWithNativeRoster(previous, session) + ); + } + } +); +registerCreatedSessionStateAtom.debugLabel = "registerCreatedSessionState"; + /** * Add or update a session in the store. * @@ -60,42 +112,27 @@ const getStore = () => getInstrumentedStore(); */ export const upsertSession = (session: Session) => { const store = getStore(); - store.set(sessionsAtom, (prev) => { - const existingIndex = prev.findIndex( - (existingSession) => existingSession.session_id === session.session_id - ); - - if (existingIndex >= 0) { - const existing = prev[existingIndex]; - const updated = [...prev]; - updated[existingIndex] = { - ...existing, - ...session, - parentSessionId: session.parentSessionId ?? existing.parentSessionId, - orgMemberId: session.orgMemberId ?? existing.orgMemberId, - agentOrgId: session.agentOrgId ?? existing.agentOrgId, - agentOrgName: session.agentOrgName ?? existing.agentOrgName, - agentDefinitionId: - session.agentDefinitionId ?? existing.agentDefinitionId, - agentIconId: session.agentIconId ?? existing.agentIconId, - agentDisplayName: session.agentDisplayName ?? existing.agentDisplayName, - // Backend-owned. Pin to the prior values so a careless caller - // spreading a synthesized timestamp can't drift the field. - // `*_time` are aliases populated alongside `*_at` from the - // same RPC fields — kept in lockstep for the same reason. - created_at: existing.created_at, - updated_at: existing.updated_at, - created_time: existing.created_time, - updated_time: existing.updated_time, - }; - return updated; - } else { - const newList = [session, ...prev]; - return newList; - } - }); + store.set(sessionsAtom, (prev) => upsertSessionInList(prev, session)); +}; + +/** + * Commit a newly created/imported session to every client-side projection. + * + * Call this only after the owning persistence boundary succeeds. Cache-only + * reconciliation of an existing row must continue to use `upsertSession`. + */ +export const registerCreatedSession = (session: Session) => { + getStore().set(registerCreatedSessionStateAtom, session); }; +/** Keep an existing native row visible while its roster category changes. */ +export function syncSidebarSessionRoster(session: Session): void { + const store = getStore(); + store.set(sessionPaginationAtom, (previous) => + syncSessionWithNativeRosters(previous, session) + ); +} + /** * Bump a session's activity timestamps to "now". * @@ -178,6 +215,9 @@ export const removeSession = (sessionId: string) => { store.set(sessionsAtom, (prev) => prev.filter((session) => session.session_id !== sessionId) ); + store.set(sessionPaginationAtom, (previous) => + removeSessionFromRosters(previous, sessionId) + ); // A removed session has no live viewers, so free its per-session caches. // Without this they accumulate one entry per session for the app lifetime — // and tuiMode additionally leaves a `orgii:tuiMode:` localStorage key. diff --git a/src/store/session/sessionAtom/paginationAtoms.ts b/src/store/session/sessionAtom/paginationAtoms.ts index fb800fa6c..49b6053a1 100644 --- a/src/store/session/sessionAtom/paginationAtoms.ts +++ b/src/store/session/sessionAtom/paginationAtoms.ts @@ -59,6 +59,15 @@ export interface SidebarStreamCursor { export interface CategoryPaginationState { /** IDs that this stream has actually returned in the current generation. */ sessionIds: readonly string[]; + /** + * Backend-confirmed creations that this client must render immediately, + * before the owning stream returns them on its next page/refresh. + * + * Kept separate from `sessionIds` so local registration never pretends to + * advance or rewrite the authoritative keyset window. Existing roster loads + * remove IDs from this overlay once the backend has acknowledged them. + */ + localSessionIds: readonly string[]; /** Native keyset cursor. Imported sources keep this null. */ cursor: SidebarStreamCursor | null; phase: SidebarStreamPhase; @@ -87,6 +96,7 @@ export function emptyDateBucketPagination(): DateBucketPaginationMap { const DEFAULT_STATE: CategoryPaginationState = { sessionIds: [], + localSessionIds: [], cursor: null, phase: "ready", generation: 0, diff --git a/src/store/session/sessionAtom/sidebarRoster.ts b/src/store/session/sessionAtom/sidebarRoster.ts index e7dad4700..b12d8bdc9 100644 --- a/src/store/session/sessionAtom/sidebarRoster.ts +++ b/src/store/session/sessionAtom/sidebarRoster.ts @@ -51,6 +51,11 @@ export function createSidebarRosterMatcher( for (const [category, state] of Object.entries(pagination) as Array< [SessionListCategory, SessionPaginationMap[SessionListCategory]] >) { + if (isNativeCategory(category)) { + for (const sessionId of state.localSessionIds) { + nativeIds.add(sessionId); + } + } if (state.generation > 0) { idsByCategory.set(category, new Set(state.sessionIds)); if (isNativeCategory(category)) { @@ -77,6 +82,90 @@ export function createSidebarRosterMatcher( }; } +/** + * Register a backend-confirmed local creation without mutating the server + * page/cursor. The overlay survives an older in-flight roster response and is + * removed only after a native roster read returns the same ID. + */ +export function registerCreatedSessionWithNativeRoster( + pagination: SessionPaginationMap, + session: Session +): SessionPaginationMap { + const target = sidebarCategoryForSession(session); + if (!target || !isNativeCategory(target)) return pagination; + + const alreadyKnown = BASE_SESSION_LIST_CATEGORIES.some((category) => { + const state = pagination[category]; + return ( + state.sessionIds.includes(session.session_id) || + state.localSessionIds.includes(session.session_id) + ); + }); + if (alreadyKnown) return pagination; + + return { + ...pagination, + [target]: { + ...pagination[target], + localSessionIds: [ + session.session_id, + ...pagination[target].localSessionIds, + ], + }, + }; +} + +/** Remove locally registered IDs once a native roster response confirms them. */ +export function acknowledgeCreatedSessionsInNativeRoster( + pagination: SessionPaginationMap, + sessions: readonly Session[] +): SessionPaginationMap { + const confirmedIds = new Set(sessions.map((session) => session.session_id)); + if (confirmedIds.size === 0) return pagination; + + let next = pagination; + for (const category of BASE_SESSION_LIST_CATEGORIES) { + const state = next[category]; + const localSessionIds = state.localSessionIds.filter( + (sessionId) => !confirmedIds.has(sessionId) + ); + if (localSessionIds.length === state.localSessionIds.length) continue; + if (next === pagination) next = { ...pagination }; + next = { + ...next, + [category]: { ...state, localSessionIds }, + }; + } + return next; +} + +/** Evict a deleted session from both server-page and local overlay rosters. */ +export function removeSessionFromRosters( + pagination: SessionPaginationMap, + sessionId: string +): SessionPaginationMap { + let next = pagination; + for (const category of Object.keys(pagination) as SessionListCategory[]) { + const state = next[category]; + const sessionIds = state.sessionIds.filter((id) => id !== sessionId); + const localSessionIds = state.localSessionIds.filter( + (id) => id !== sessionId + ); + if ( + sessionIds.length === state.sessionIds.length && + localSessionIds.length === state.localSessionIds.length + ) { + continue; + } + if (next === pagination) next = { ...pagination }; + next = { + ...next, + [category]: { ...state, sessionIds, localSessionIds }, + }; + } + return next; +} + function isNativeCategory( category: SessionListCategory ): category is BaseSessionListCategory { @@ -95,15 +184,28 @@ function isNativeCategory( */ export function syncSessionWithNativeRosters( pagination: SessionPaginationMap, - session: Session + session: Session, + options: { promoteLocalCreation?: boolean } = {} ): SessionPaginationMap { const target = sidebarCategoryForSession(session); if (!target || !isNativeCategory(target)) return pagination; - const alreadyLoaded = BASE_SESSION_LIST_CATEGORIES.some((category) => + const loadedByServer = BASE_SESSION_LIST_CATEGORIES.some((category) => pagination[category].sessionIds.includes(session.session_id) ); - if (alreadyLoaded || pagination[target].generation === 0) { + const locallyRegistered = BASE_SESSION_LIST_CATEGORIES.some((category) => { + const state = pagination[category]; + return state.localSessionIds.includes(session.session_id); + }); + if (loadedByServer) { + return locallyRegistered && options.promoteLocalCreation + ? acknowledgeCreatedSessionsInNativeRoster(pagination, [session]) + : pagination; + } + if ( + (locallyRegistered && !options.promoteLocalCreation) || + pagination[target].generation === 0 + ) { return pagination; } @@ -124,11 +226,14 @@ export function syncSessionWithNativeRosters( } } - return { + const synced = { ...pagination, [target]: { ...pagination[target], sessionIds: [session.session_id, ...pagination[target].sessionIds], }, }; + return locallyRegistered + ? acknowledgeCreatedSessionsInNativeRoster(synced, [session]) + : synced; }