diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 59ebd0cea0c..7eb32128878 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -9,6 +9,7 @@ import { ProviderInstanceId, ThreadId, TurnId, + type OrchestrationShellSnapshot, type OrchestrationEvent, } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; @@ -22,6 +23,7 @@ import { selectThreadByRef, selectThreadExistsByRef, setThreadBranch, + syncServerShellSnapshot, selectThreadsAcrossEnvironments, type AppState, type EnvironmentState, @@ -247,6 +249,61 @@ function makeEvent( } as Extract; } +function makeShellSnapshot( + threads: OrchestrationShellSnapshot["threads"], + projects: OrchestrationShellSnapshot["projects"] = [ + { + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, + scripts: [], + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + }, + ], +): OrchestrationShellSnapshot { + return { + snapshotSequence: 1, + projects, + threads, + updatedAt: "2026-02-27T00:00:00.000Z", + }; +} + +function makeShellThread( + overrides: Partial = {}, +): OrchestrationShellSnapshot["threads"][number] { + const threadId = overrides.id ?? ThreadId.make("thread-shell-1"); + const projectId = overrides.projectId ?? ProjectId.make("project-1"); + return { + id: threadId, + projectId, + title: "Shell thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + describe("environment state removal", () => { it("drops local state for removed environments", () => { const removedThread = makeThread({ @@ -402,6 +459,179 @@ describe("thread selection memoization", () => { }); }); +describe("shell snapshot sync", () => { + it("registers shell threads by project without materializing detail records", () => { + const project1 = ProjectId.make("project-1"); + const project2 = ProjectId.make("project-2"); + const thread1 = makeShellThread({ + id: ThreadId.make("thread-shell-1"), + projectId: project1, + title: "First shell thread", + }); + const thread2 = makeShellThread({ + id: ThreadId.make("thread-shell-2"), + projectId: project2, + title: "Second shell thread", + hasPendingUserInput: true, + }); + const state = makeEmptyState({ bootstrapComplete: false }); + + const next = syncServerShellSnapshot( + state, + makeShellSnapshot( + [thread1, thread2], + [ + { + id: project1, + title: "Project 1", + workspaceRoot: "/tmp/project-1", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + }, + { + id: project2, + title: "Project 2", + workspaceRoot: "/tmp/project-2", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + }, + ], + ), + localEnvironmentId, + ); + const environmentState = localEnvironmentStateOf(next); + + expect(environmentState.bootstrapComplete).toBe(true); + expect(environmentState.threadIds).toEqual([thread1.id, thread2.id]); + expect(environmentState.threadIdsByProjectId).toEqual({ + [project1]: [thread1.id], + [project2]: [thread2.id], + }); + expect(environmentState.threadShellById[thread1.id]?.title).toBe("First shell thread"); + expect(environmentState.sidebarThreadSummaryById[thread2.id]?.hasPendingUserInput).toBe(true); + expect(environmentState.messageIdsByThreadId).toEqual({}); + expect(environmentState.activityIdsByThreadId).toEqual({}); + }); + + it("retains detail slices for threads still present in the shell snapshot", () => { + const keptThread = makeThread({ + id: ThreadId.make("thread-kept"), + messages: [ + { + id: MessageId.make("message-kept"), + role: "user", + text: "keep", + createdAt: "2026-02-27T00:00:00.000Z", + streaming: false, + }, + ], + }); + const removedThread = makeThread({ id: ThreadId.make("thread-removed") }); + const baseState = makeState(keptThread); + const baseEnvironmentState = localEnvironmentStateOf(baseState); + const state = withActiveEnvironmentState(baseEnvironmentState, { + threadIds: [keptThread.id, removedThread.id], + messageIdsByThreadId: { + ...baseEnvironmentState.messageIdsByThreadId, + [removedThread.id]: [MessageId.make("message-removed")], + }, + messageByThreadId: { + ...baseEnvironmentState.messageByThreadId, + [removedThread.id]: { + [MessageId.make("message-removed")]: { + id: MessageId.make("message-removed"), + role: "assistant", + text: "drop", + createdAt: "2026-02-27T00:00:01.000Z", + streaming: false, + }, + }, + }, + }); + + const next = syncServerShellSnapshot( + state, + makeShellSnapshot([ + makeShellThread({ + id: keptThread.id, + projectId: keptThread.projectId, + title: "Kept shell", + }), + ]), + localEnvironmentId, + ); + const environmentState = localEnvironmentStateOf(next); + + expect(environmentState.messageIdsByThreadId[keptThread.id]).toEqual([ + MessageId.make("message-kept"), + ]); + expect( + environmentState.messageByThreadId[keptThread.id]?.[MessageId.make("message-kept")], + ).toMatchObject({ text: "keep" }); + expect(environmentState.messageIdsByThreadId[removedThread.id]).toBeUndefined(); + expect(environmentState.messageByThreadId[removedThread.id]).toBeUndefined(); + }); + + it("deduplicates malformed shell snapshots before building project indexes", () => { + const threadId = ThreadId.make("thread-duplicate"); + const first = makeShellThread({ id: threadId, title: "First duplicate" }); + const second = makeShellThread({ id: threadId, title: "Second duplicate" }); + + const next = syncServerShellSnapshot( + makeEmptyState(), + makeShellSnapshot([first, second]), + localEnvironmentId, + ); + const environmentState = localEnvironmentStateOf(next); + + expect(environmentState.threadIds).toEqual([threadId]); + expect(environmentState.threadIdsByProjectId[first.projectId]).toEqual([threadId]); + expect(environmentState.threadShellById[threadId]?.title).toBe("First duplicate"); + }); + + it("stores server-provided ids as data keys during bulk shell sync", () => { + const threadId = ThreadId.make("__proto__"); + const projectId = ProjectId.make("__proto__"); + const thread = makeShellThread({ + id: threadId, + projectId, + title: "Prototype-looking thread", + }); + + const next = syncServerShellSnapshot( + makeEmptyState(), + makeShellSnapshot( + [thread], + [ + { + id: projectId, + title: "Prototype-looking project", + workspaceRoot: "/tmp/proto", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", + }, + ], + ), + localEnvironmentId, + ); + const environmentState = localEnvironmentStateOf(next); + + expect(Object.hasOwn(environmentState.threadIdsByProjectId, projectId)).toBe(true); + expect(Object.hasOwn(environmentState.threadShellById, threadId)).toBe(true); + expect(Object.hasOwn(environmentState.threadSessionById, threadId)).toBe(true); + expect(Object.hasOwn(environmentState.threadTurnStateById, threadId)).toBe(true); + expect(Object.hasOwn(environmentState.sidebarThreadSummaryById, threadId)).toBe(true); + expect(environmentState.threadIdsByProjectId[projectId]).toEqual([threadId]); + expect(environmentState.threadShellById[threadId]?.title).toBe("Prototype-looking thread"); + }); +}); + describe("setThreadBranch", () => { it("updates only the scoped thread environment", () => { const sharedThreadId = ThreadId.make("thread-shared"); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index e9972f7c9a8..d257c8b85b0 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -39,6 +39,10 @@ import { sanitizeThreadErrorMessage } from "./rpc/transportError"; import { getThreadFromEnvironmentState } from "./threadDerivation"; const isProviderDriverKindValue = Schema.is(ProviderDriverKind); +function createRecord(): Record { + return Object.create(null) as Record; +} + export interface EnvironmentState { projectIds: ProjectId[]; projectById: Record; @@ -1081,16 +1085,46 @@ function syncEnvironmentShellSnapshot( environmentId: EnvironmentId, ): EnvironmentState { const nextProjects = snapshot.projects.map((project) => mapProject(project, environmentId)); - const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); - let nextState: EnvironmentState = { + const nextThreadIds = new Set(); + const threadIds: ThreadId[] = []; + const threadIdsByProjectId = createRecord(); + const threadShellById = createRecord(); + const threadSessionById = createRecord(); + const threadTurnStateById = createRecord(); + const sidebarThreadSummaryById = createRecord(); + + for (const thread of snapshot.threads) { + if (nextThreadIds.has(thread.id)) { + continue; + } + // Malformed snapshots can repeat a thread id. Keep the first shell entry so + // all derived shell maps agree with the first index position. + nextThreadIds.add(thread.id); + threadIds.push(thread.id); + + const projectThreadIds = threadIdsByProjectId[thread.projectId]; + if (projectThreadIds) { + projectThreadIds.push(thread.id); + } else { + threadIdsByProjectId[thread.projectId] = [thread.id]; + } + + const mappedThread = mapThreadShell(thread, environmentId); + threadShellById[thread.id] = mappedThread.shell; + threadSessionById[thread.id] = mappedThread.session; + threadTurnStateById[thread.id] = mappedThread.turnState; + sidebarThreadSummaryById[thread.id] = mappedThread.summary; + } + + return { ...state, ...buildProjectState(nextProjects), - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - sidebarThreadSummaryById: {}, + threadIds, + threadIdsByProjectId, + threadShellById, + threadSessionById, + threadTurnStateById, + sidebarThreadSummaryById, messageIdsByThreadId: retainThreadScopedRecord(state.messageIdsByThreadId, nextThreadIds), messageByThreadId: retainThreadScopedRecord(state.messageByThreadId, nextThreadIds), activityIdsByThreadId: retainThreadScopedRecord(state.activityIdsByThreadId, nextThreadIds), @@ -1107,12 +1141,6 @@ function syncEnvironmentShellSnapshot( ), bootstrapComplete: true, }; - - for (const thread of snapshot.threads) { - nextState = writeThreadShellState(nextState, mapThreadShell(thread, environmentId)); - } - - return nextState; } export function syncServerShellSnapshot(