diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 19184915ac7..12af4c77b9b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,9 +1,4 @@ -import type { - OrchestrationEvent, - OrchestrationReadModel, - ProjectId, - ThreadId, -} from "@t3tools/contracts"; +import type { OrchestrationEvent, ProjectId, ThreadId } from "@t3tools/contracts"; import { OrchestrationCommand } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -13,6 +8,7 @@ import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as HashMap from "effect/HashMap"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; @@ -37,8 +33,13 @@ import { type OrchestrationDispatchError, type OrchestrationProjectorDecodeError, } from "../Errors.ts"; +import { + createEmptyCommandReadModel, + fromWireReadModel, + type CommandReadModel, +} from "../commandReadModel.ts"; import { decideOrchestrationCommand } from "../decider.ts"; -import { createEmptyReadModel, projectEvent } from "../projector.ts"; +import { projectEvent } from "../projector.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { @@ -85,15 +86,15 @@ const makeOrchestrationEngine = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - let commandReadModel = createEmptyReadModel(yield* nowIso); + let commandReadModel: CommandReadModel = createEmptyCommandReadModel(yield* nowIso); const commandQueue = yield* Queue.unbounded(); const eventPubSub = yield* PubSub.unbounded(); const projectEventsOntoReadModel = ( - baseReadModel: OrchestrationReadModel, + baseReadModel: CommandReadModel, events: ReadonlyArray, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { let nextReadModel = baseReadModel; for (const event of events) { @@ -298,12 +299,21 @@ const makeOrchestrationEngine = Effect.gen(function* () { }; yield* projectionPipeline.bootstrap; - commandReadModel = yield* projectionSnapshotQuery.getCommandReadModel(); + // Seed the in-memory command model from the DB projection. Deleted threads + // are dropped so the model starts consistent with the projector's eviction + // policy (see commandReadModel.ts / the `thread.deleted` projector branch). + commandReadModel = fromWireReadModel(yield* projectionSnapshotQuery.getCommandReadModel(), { + dropDeletedThreads: true, + }); const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); yield* Effect.forkScoped(worker); yield* Effect.logDebug("orchestration engine started").pipe( - Effect.annotateLogs({ sequence: commandReadModel.snapshotSequence }), + Effect.annotateLogs({ + sequence: commandReadModel.snapshotSequence, + threadCount: HashMap.size(commandReadModel.threads), + projectCount: HashMap.size(commandReadModel.projects), + }), ); const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9c6c8bd2a18..750e3dbcdb9 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -11,6 +11,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import { fromWireReadModel } from "./commandReadModel.ts"; import { findThreadById, listThreadsByProjectId, @@ -21,7 +22,7 @@ import { const now = "2026-01-01T00:00:00.000Z"; -const readModel: OrchestrationReadModel = { +const wireReadModel: OrchestrationReadModel = { snapshotSequence: 2, updatedAt: now, projects: [ @@ -102,6 +103,8 @@ const readModel: OrchestrationReadModel = { ], }; +const readModel = fromWireReadModel(wireReadModel, { dropDeletedThreads: false }); + const messageSendCommand: OrchestrationCommand = { type: "thread.turn.start", commandId: CommandId.make("cmd-1"), diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index b59ded77f4f..e3c532ca1ed 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -1,16 +1,25 @@ import type { OrchestrationCommand, OrchestrationProject, - OrchestrationReadModel, OrchestrationThread, ProjectId, ThreadId, } from "@t3tools/contracts"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; +import { + findProjectById, + findThreadById, + isThreadDeleted, + listThreadsByProjectId, + type CommandReadModel, +} from "./commandReadModel.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; +export { findProjectById, findThreadById, listThreadsByProjectId }; + function invariantError(commandType: string, detail: string): OrchestrationCommandInvariantError { return new OrchestrationCommandInvariantError({ commandType, @@ -18,29 +27,8 @@ function invariantError(commandType: string, detail: string): OrchestrationComma }); } -export function findThreadById( - readModel: OrchestrationReadModel, - threadId: ThreadId, -): OrchestrationThread | undefined { - return readModel.threads.find((thread) => thread.id === threadId); -} - -export function findProjectById( - readModel: OrchestrationReadModel, - projectId: ProjectId, -): OrchestrationProject | undefined { - return readModel.projects.find((project) => project.id === projectId); -} - -export function listThreadsByProjectId( - readModel: OrchestrationReadModel, - projectId: ProjectId, -): ReadonlyArray { - return readModel.threads.filter((thread) => thread.projectId === projectId); -} - export function requireProject(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly projectId: ProjectId; }): Effect.Effect { @@ -57,7 +45,7 @@ export function requireProject(input: { } export function requireProjectAbsent(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly projectId: ProjectId; }): Effect.Effect { @@ -73,18 +61,23 @@ export function requireProjectAbsent(input: { } export function requireActiveProjectWorkspaceRootAbsent(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly workspaceRoot: string; readonly exceptProjectId?: ProjectId; }): Effect.Effect { const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot); - const existingProject = input.readModel.projects.find( - (project) => + let existingProject: OrchestrationProject | undefined; + for (const project of HashMap.values(input.readModel.projects)) { + if ( project.deletedAt === null && normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot && - project.id !== input.exceptProjectId, - ); + project.id !== input.exceptProjectId + ) { + existingProject = project; + break; + } + } if (existingProject === undefined) { return Effect.void; } @@ -97,7 +90,7 @@ export function requireActiveProjectWorkspaceRootAbsent(input: { } export function requireThread(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly threadId: ThreadId; }): Effect.Effect { @@ -114,7 +107,7 @@ export function requireThread(input: { } export function requireThreadArchived(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly threadId: ThreadId; }): Effect.Effect { @@ -133,7 +126,7 @@ export function requireThreadArchived(input: { } export function requireThreadNotArchived(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly threadId: ThreadId; }): Effect.Effect { @@ -152,11 +145,16 @@ export function requireThreadNotArchived(input: { } export function requireThreadAbsent(input: { - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; readonly command: OrchestrationCommand; readonly threadId: ThreadId; }): Effect.Effect { - if (!findThreadById(input.readModel, input.threadId)) { + // A deleted thread is evicted from `threads` but its id is retained in + // `deletedThreadIds`, so reject re-using a live OR previously-deleted id. + if ( + !findThreadById(input.readModel, input.threadId) && + !isThreadDeleted(input.readModel, input.threadId) + ) { return Effect.void; } return Effect.fail( diff --git a/apps/server/src/orchestration/commandReadModel.test.ts b/apps/server/src/orchestration/commandReadModel.test.ts new file mode 100644 index 00000000000..d971e0cabae --- /dev/null +++ b/apps/server/src/orchestration/commandReadModel.test.ts @@ -0,0 +1,130 @@ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as HashMap from "effect/HashMap"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createEmptyCommandReadModel, + findProjectById, + findThreadById, + fromWireReadModel, + isThreadDeleted, + listThreadsByProjectId, +} from "./commandReadModel.ts"; + +const now = "2026-01-01T00:00:00.000Z"; + +function makeThread( + id: string, + projectId: string, + overrides?: Partial, +): OrchestrationThread { + return { + id: ThreadId.make(id), + projectId: ProjectId.make(projectId), + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + latestTurn: null, + messages: [], + session: null, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + ...overrides, + }; +} + +const wireReadModel: OrchestrationReadModel = { + snapshotSequence: 5, + updatedAt: now, + projects: [ + { + id: ProjectId.make("project-a"), + title: "Project A", + workspaceRoot: "/tmp/project-a", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: [ + makeThread("thread-live", "project-a"), + makeThread("thread-archived", "project-a", { archivedAt: now }), + makeThread("thread-deleted", "project-a", { deletedAt: now }), + makeThread("thread-other", "project-b"), + ], +}; + +describe("commandReadModel", () => { + it("creates an empty model", () => { + const model = createEmptyCommandReadModel(now); + expect(model.snapshotSequence).toBe(0); + expect(HashMap.size(model.threads)).toBe(0); + expect(HashMap.size(model.projects)).toBe(0); + expect(model.updatedAt).toBe(now); + }); + + it("seeds from the wire model and drops deleted threads by default", () => { + const model = fromWireReadModel(wireReadModel); + expect(model.snapshotSequence).toBe(5); + // deleted thread is evicted; archived + live + other retained + expect(HashMap.size(model.threads)).toBe(3); + expect(HashMap.has(model.threads, ThreadId.make("thread-deleted"))).toBe(false); + expect(HashMap.has(model.threads, ThreadId.make("thread-archived"))).toBe(true); + expect(HashMap.has(model.threads, ThreadId.make("thread-live"))).toBe(true); + expect(HashMap.size(model.projects)).toBe(1); + // The evicted deleted thread's id is retained so the create-twice invariant + // survives a restart. + expect(isThreadDeleted(model, ThreadId.make("thread-deleted"))).toBe(true); + expect(isThreadDeleted(model, ThreadId.make("thread-live"))).toBe(false); + expect(isThreadDeleted(model, ThreadId.make("thread-archived"))).toBe(false); + }); + + it("retains deleted threads when dropDeletedThreads is false", () => { + const model = fromWireReadModel(wireReadModel, { dropDeletedThreads: false }); + expect(HashMap.size(model.threads)).toBe(4); + expect(HashMap.has(model.threads, ThreadId.make("thread-deleted"))).toBe(true); + }); + + it("finds threads and projects by id with O(1) lookups", () => { + const model = fromWireReadModel(wireReadModel); + expect(findThreadById(model, ThreadId.make("thread-live"))?.projectId).toBe("project-a"); + expect(findThreadById(model, ThreadId.make("thread-deleted"))).toBeUndefined(); + expect(findThreadById(model, ThreadId.make("missing"))).toBeUndefined(); + expect(findProjectById(model, ProjectId.make("project-a"))?.title).toBe("Project A"); + expect(findProjectById(model, ProjectId.make("missing"))).toBeUndefined(); + }); + + it("lists threads by project id", () => { + const model = fromWireReadModel(wireReadModel); + const ids = listThreadsByProjectId(model, ProjectId.make("project-a")) + .map((thread) => thread.id) + .toSorted(); + expect(ids).toEqual([ThreadId.make("thread-archived"), ThreadId.make("thread-live")]); + expect(listThreadsByProjectId(model, ProjectId.make("project-b")).map((t) => t.id)).toEqual([ + ThreadId.make("thread-other"), + ]); + }); +}); diff --git a/apps/server/src/orchestration/commandReadModel.ts b/apps/server/src/orchestration/commandReadModel.ts new file mode 100644 index 00000000000..dce19fc2842 --- /dev/null +++ b/apps/server/src/orchestration/commandReadModel.ts @@ -0,0 +1,119 @@ +import type { + OrchestrationProject, + OrchestrationReadModel, + OrchestrationThread, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; +import * as HashMap from "effect/HashMap"; +import * as HashSet from "effect/HashSet"; +import * as Option from "effect/Option"; + +/** + * Server-internal representation of the orchestration read model. + * + * Unlike the wire {@link OrchestrationReadModel} (which uses arrays and is + * produced by the DB-backed `ProjectionSnapshotQuery`), this model is only ever + * touched by the single serial command-worker fiber in `OrchestrationEngine`. + * It uses persistent `HashMap`s keyed by id so the projector and command + * invariants get O(1)-ish lookups and single-key updates instead of O(N) + * array scans and full-array copies on every event. + * + * The model is never serialized or sent over the wire, so its shape can evolve + * independently of the contract. + */ +export interface CommandReadModel { + readonly snapshotSequence: number; + readonly projects: HashMap.HashMap; + readonly threads: HashMap.HashMap; + /** + * Ids of threads that have been deleted. Deleted threads are evicted from + * `threads` (freeing their message/activity/checkpoint arrays), but their id + * is retained here so `requireThreadAbsent` still rejects re-creating a + * thread with a previously-used id — the "cannot be created twice" invariant + * the DB projection also upholds (its tombstone row is never removed). + */ + readonly deletedThreadIds: HashSet.HashSet; + readonly updatedAt: string; +} + +export function createEmptyCommandReadModel(nowIso: string): CommandReadModel { + return { + snapshotSequence: 0, + projects: HashMap.empty(), + threads: HashMap.empty(), + deletedThreadIds: HashSet.empty(), + updatedAt: nowIso, + }; +} + +/** + * Whether a thread id has been used and deleted. Used by `requireThreadAbsent` + * so an evicted (deleted) thread's id cannot be re-created. + */ +export function isThreadDeleted(model: CommandReadModel, threadId: ThreadId): boolean { + return HashSet.has(model.deletedThreadIds, threadId); +} + +/** + * Seed a {@link CommandReadModel} from the array-based wire read model produced + * by `ProjectionSnapshotQuery.getCommandReadModel()` at engine boot. + * + * Deleted threads are dropped so the in-memory model starts consistent with the + * projector's eviction policy (deleted threads are removed, archived threads are + * retained). The DB projection remains the source of truth for deleted rows. + */ +export function fromWireReadModel( + model: OrchestrationReadModel, + options?: { readonly dropDeletedThreads?: boolean }, +): CommandReadModel { + const dropDeletedThreads = options?.dropDeletedThreads ?? true; + const threads = dropDeletedThreads + ? model.threads.filter((thread) => thread.deletedAt === null) + : model.threads; + // Retain the ids of deleted threads so the create-twice invariant survives + // a restart even though the (evicted) thread bodies are not loaded. + const deletedThreadIds = HashSet.fromIterable( + model.threads.filter((thread) => thread.deletedAt !== null).map((thread) => thread.id), + ); + return { + snapshotSequence: model.snapshotSequence, + updatedAt: model.updatedAt, + projects: HashMap.fromIterable(model.projects.map((project) => [project.id, project] as const)), + threads: HashMap.fromIterable(threads.map((thread) => [thread.id, thread] as const)), + deletedThreadIds, + }; +} + +export function findThreadById( + model: CommandReadModel, + threadId: ThreadId, +): OrchestrationThread | undefined { + return Option.getOrUndefined(HashMap.get(model.threads, threadId)); +} + +export function findProjectById( + model: CommandReadModel, + projectId: ProjectId, +): OrchestrationProject | undefined { + return Option.getOrUndefined(HashMap.get(model.projects, projectId)); +} + +export function listThreadsByProjectId( + model: CommandReadModel, + projectId: ProjectId, +): ReadonlyArray { + const result: OrchestrationThread[] = []; + for (const thread of HashMap.values(model.threads)) { + if (thread.projectId === projectId) { + result.push(thread); + } + } + // HashMap iteration order is not insertion order; sort by creation time (then + // id) so callers observe a stable, deterministic order — matching the prior + // array-backed behavior. Only used by the rare `project.delete` fan-out. + return result.toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ); +} diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index fea36b5717f..e9f881d1e78 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, + MessageId, ProjectId, ThreadId, type OrchestrationCommand, @@ -9,6 +10,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; @@ -214,4 +216,134 @@ it.layer(NodeServices.layer)("decider deletion flows", (it) => { expect(normalizeDeleteEvent(forcedResult)).toEqual(normalizeDeleteEvent(sequentialEvents)); }), ); + + it.effect("rejects commands targeting an already-deleted (evicted) thread", () => + Effect.gen(function* () { + const seeded = yield* seedReadModel; + const now = "2026-01-01T00:00:00.000Z"; + + // Delete thread-delete-1; the projector evicts it from the model. + const afterDelete = yield* projectEvent(seeded, { + sequence: 4, + eventId: asEventId("evt-thread-delete-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-1"), + type: "thread.deleted", + occurredAt: now, + commandId: asCommandId("cmd-thread-delete-1"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-delete-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-1"), + deletedAt: now, + }, + }); + + // A follow-up command to the deleted thread now fails cleanly. + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: asCommandId("cmd-turn-after-delete"), + threadId: asThreadId("thread-delete-1"), + message: { + messageId: MessageId.make("msg-after-delete"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }, + readModel: afterDelete, + }), + ); + expect(error.message).toContain("does not exist"); + + // Re-creating a thread with the SAME (deleted) id is rejected: the id is + // retained in deletedThreadIds even though the thread body was evicted, so + // the "cannot be created twice" invariant still holds and the durable DB + // row is not silently overwritten. + const recreateSameId = (threadId: ThreadId) => + decideOrchestrationCommand({ + command: { + type: "thread.create", + commandId: asCommandId(`cmd-recreate-${threadId}`), + threadId, + projectId: asProjectId("project-delete"), + title: "Recreate", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }, + readModel: afterDelete, + }); + + const recreateError = yield* Effect.flip(recreateSameId(asThreadId("thread-delete-1"))); + expect(recreateError.message).toContain("cannot be created twice"); + + // A fresh thread with a NEW, never-used id can still be created. + const created = yield* recreateSameId(asThreadId("thread-delete-3")); + const createdEvents = Array.isArray(created) ? created : [created]; + expect(createdEvents.map((event) => event.type)).toEqual(["thread.created"]); + }), + ); + + it.effect("projector evicts deleted threads but retains archived threads", () => + Effect.gen(function* () { + const seeded = yield* seedReadModel; + const now = "2026-01-01T00:00:00.000Z"; + expect(HashMap.size(seeded.threads)).toBe(2); + + // Archiving keeps the thread resident (unarchive/other commands need it). + const afterArchive = yield* projectEvent(seeded, { + sequence: 4, + eventId: asEventId("evt-thread-archive-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-1"), + type: "thread.archived", + occurredAt: now, + commandId: asCommandId("cmd-thread-archive-1"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-archive-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-1"), + archivedAt: now, + updatedAt: now, + }, + }); + expect(HashMap.size(afterArchive.threads)).toBe(2); + expect(HashMap.has(afterArchive.threads, asThreadId("thread-delete-1"))).toBe(true); + + // Deleting evicts the thread from the in-memory model entirely. + const afterDelete = yield* projectEvent(afterArchive, { + sequence: 5, + eventId: asEventId("evt-thread-delete-2"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-2"), + type: "thread.deleted", + occurredAt: now, + commandId: asCommandId("cmd-thread-delete-2"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-delete-2"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-2"), + deletedAt: now, + }, + }); + expect(HashMap.size(afterDelete.threads)).toBe(1); + expect(HashMap.has(afterDelete.threads, asThreadId("thread-delete-2"))).toBe(false); + expect(HashMap.has(afterDelete.threads, asThreadId("thread-delete-1"))).toBe(true); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 1730494ecc6..9a5697a439d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,14 +1,10 @@ -import { - EventId, - type OrchestrationCommand, - type OrchestrationEvent, - type OrchestrationReadModel, -} from "@t3tools/contracts"; +import { EventId, type OrchestrationCommand, type OrchestrationEvent } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import type * as PlatformError from "effect/PlatformError"; +import type { CommandReadModel } from "./commandReadModel.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, @@ -65,7 +61,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ readModel, }: { readonly commands: ReadonlyArray; - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; }): Effect.fn.Return< ReadonlyArray, OrchestrationCommandInvariantError | PlatformError.PlatformError, @@ -99,7 +95,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" readModel, }: { readonly command: OrchestrationCommand; - readonly readModel: OrchestrationReadModel; + readonly readModel: CommandReadModel; }): Effect.fn.Return< DecideOrchestrationCommandResult, OrchestrationCommandInvariantError | PlatformError.PlatformError, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd5078026..8bd4e35332a 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -5,12 +5,25 @@ import { ProviderDriverKind, ThreadId, type OrchestrationEvent, + type OrchestrationThread, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; import { describe, expect, it } from "vite-plus/test"; +import type { CommandReadModel } from "./commandReadModel.ts"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; +/** Thread list from the map, for assertions that previously used an array. */ +function threadsArray(model: CommandReadModel): ReadonlyArray { + return Array.from(HashMap.values(model.threads)); +} + +/** First thread in the map (insertion-order-independent tests use a single thread). */ +function firstThread(model: CommandReadModel): OrchestrationThread | undefined { + return threadsArray(model)[0]; +} + function makeEvent(input: { sequence: number; type: OrchestrationEvent["type"]; @@ -72,7 +85,7 @@ describe("orchestration projector", () => { ); expect(next.snapshotSequence).toBe(1); - expect(next.threads).toEqual([ + expect(threadsArray(next)).toEqual([ { id: "thread-1", projectId: "project-1", @@ -183,7 +196,7 @@ describe("orchestration projector", () => { }), ), ); - expect(archived.threads[0]?.archivedAt).toBe(later); + expect(firstThread(archived)?.archivedAt).toBe(later); const unarchived = await Effect.runPromise( projectEvent( @@ -202,7 +215,7 @@ describe("orchestration projector", () => { }), ), ); - expect(unarchived.threads[0]?.archivedAt).toBeNull(); + expect(firstThread(unarchived)?.archivedAt).toBeNull(); }); it("keeps projector forward-compatible for unhandled event types", async () => { @@ -231,7 +244,7 @@ describe("orchestration projector", () => { expect(next.snapshotSequence).toBe(7); expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); - expect(next.threads).toEqual([]); + expect(threadsArray(next)).toEqual([]); }); it("tracks latest turn id from session lifecycle events", async () => { @@ -327,13 +340,13 @@ describe("orchestration projector", () => { ), ); - const thread = afterRunning.threads[0]; + const thread = firstThread(afterRunning); expect(thread?.latestTurn?.turnId).toBe("turn-1"); expect(thread?.session?.status).toBe("running"); // Leaving the "running" session status settles the running turn with the // session timestamp as the turn end. - const settledThread = afterReady.threads[0]; + const settledThread = firstThread(afterReady); expect(settledThread?.latestTurn?.turnId).toBe("turn-1"); expect(settledThread?.latestTurn?.state).toBe("completed"); expect(settledThread?.latestTurn?.completedAt).toBe(settledAt); @@ -391,8 +404,8 @@ describe("orchestration projector", () => { ), ); - expect(afterUpdate.threads[0]?.runtimeMode).toBe("approval-required"); - expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt); + expect(firstThread(afterUpdate)?.runtimeMode).toBe("approval-required"); + expect(firstThread(afterUpdate)?.updatedAt).toBe(updatedAt); }); it("marks assistant messages completed with non-streaming updates", async () => { @@ -477,7 +490,7 @@ describe("orchestration projector", () => { ), ); - const message = afterComplete.threads[0]?.messages[0]; + const message = firstThread(afterComplete)?.messages[0]; expect(message?.id).toBe("assistant:msg-1"); expect(message?.text).toBe("hello"); expect(message?.streaming).toBe(false); @@ -685,7 +698,7 @@ describe("orchestration projector", () => { Promise.resolve(afterCreate), ); - const thread = afterRevert.threads[0]; + const thread = firstThread(afterRevert); expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual( [ { role: "user", text: "First edit" }, @@ -842,7 +855,7 @@ describe("orchestration projector", () => { Promise.resolve(afterCreate), ); - const thread = afterRevert.threads[0]; + const thread = firstThread(afterRevert); expect( thread?.messages.map((message) => ({ id: message.id, @@ -944,7 +957,7 @@ describe("orchestration projector", () => { Promise.resolve(afterMessages), ); - const thread = finalState.threads[0]; + const thread = firstThread(finalState); expect(thread?.messages).toHaveLength(2_000); expect(thread?.messages[0]?.id).toBe("msg-100"); expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..d193862dd9d 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,4 +1,4 @@ -import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; +import type { OrchestrationEvent, OrchestrationProject, ThreadId } from "@t3tools/contracts"; import { OrchestrationCheckpointSummary, OrchestrationMessage, @@ -6,8 +6,16 @@ import { OrchestrationThread, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; +import * as HashSet from "effect/HashSet"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { + createEmptyCommandReadModel, + findThreadById, + type CommandReadModel, +} from "./commandReadModel.ts"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { MessageSentPayloadSchema, @@ -61,12 +69,40 @@ function settledTurnStateForSessionStatus( } } +/** + * Apply a patch to a single thread, keyed by id. No-op if the thread is absent + * (mirrors the previous array-map behavior, which left the collection unchanged + * when no entry matched). + */ function updateThread( - threads: ReadonlyArray, + threads: HashMap.HashMap, threadId: ThreadId, patch: ThreadPatch, -): OrchestrationThread[] { - return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); +): HashMap.HashMap { + const existing = HashMap.get(threads, threadId); + if (Option.isNone(existing)) { + return threads; + } + return HashMap.set(threads, threadId, { ...existing.value, ...patch }); +} + +/** + * Apply an update function to a single project in the model, keyed by id. No-op + * if the project is absent. + */ +function updateProject( + model: CommandReadModel, + projectId: OrchestrationProject["id"], + update: (project: OrchestrationProject) => OrchestrationProject, +): CommandReadModel { + const existing = HashMap.get(model.projects, projectId); + if (Option.isNone(existing)) { + return model; + } + return { + ...model, + projects: HashMap.set(model.projects, projectId, update(existing.value)), + }; } function decodeForEvent( @@ -178,20 +214,15 @@ function compareThreadActivities( return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); } -export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { - return { - snapshotSequence: 0, - projects: [], - threads: [], - updatedAt: nowIso, - }; +export function createEmptyReadModel(nowIso: string): CommandReadModel { + return createEmptyCommandReadModel(nowIso); } export function projectEvent( - model: OrchestrationReadModel, + model: CommandReadModel, event: OrchestrationEvent, -): Effect.Effect { - const nextBase: OrchestrationReadModel = { +): Effect.Effect { + const nextBase: CommandReadModel = { ...model, snapshotSequence: event.sequence, updatedAt: event.occurredAt, @@ -201,8 +232,7 @@ export function projectEvent( case "project.created": return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => { - const existing = nextBase.projects.find((entry) => entry.id === payload.projectId); - const nextProject = { + const nextProject: OrchestrationProject = { id: payload.projectId, title: payload.title, workspaceRoot: payload.workspaceRoot, @@ -215,52 +245,38 @@ export function projectEvent( return { ...nextBase, - projects: existing - ? nextBase.projects.map((entry) => - entry.id === payload.projectId ? nextProject : entry, - ) - : [...nextBase.projects, nextProject], + projects: HashMap.set(nextBase.projects, payload.projectId, nextProject), }; }), ); case "project.meta-updated": return decodeForEvent(ProjectMetaUpdatedPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - projects: nextBase.projects.map((project) => - project.id === payload.projectId - ? { - ...project, - ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.workspaceRoot !== undefined - ? { workspaceRoot: payload.workspaceRoot } - : {}), - ...(payload.defaultModelSelection !== undefined - ? { defaultModelSelection: payload.defaultModelSelection } - : {}), - ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), - updatedAt: payload.updatedAt, - } - : project, - ), - })), + Effect.map((payload) => + updateProject(nextBase, payload.projectId, (project) => ({ + ...project, + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.workspaceRoot !== undefined + ? { workspaceRoot: payload.workspaceRoot } + : {}), + ...(payload.defaultModelSelection !== undefined + ? { defaultModelSelection: payload.defaultModelSelection } + : {}), + ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), + updatedAt: payload.updatedAt, + })), + ), ); case "project.deleted": return decodeForEvent(ProjectDeletedPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - projects: nextBase.projects.map((project) => - project.id === payload.projectId - ? { - ...project, - deletedAt: payload.deletedAt, - updatedAt: payload.deletedAt, - } - : project, - ), - })), + Effect.map((payload) => + updateProject(nextBase, payload.projectId, (project) => ({ + ...project, + deletedAt: payload.deletedAt, + updatedAt: payload.deletedAt, + })), + ), ); case "thread.created": @@ -295,23 +311,27 @@ export function projectEvent( event.type, "thread", ); - const existing = nextBase.threads.find((entry) => entry.id === thread.id); return { ...nextBase, - threads: existing - ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) - : [...nextBase.threads, thread], + threads: HashMap.set(nextBase.threads, thread.id, thread), }; }); case "thread.deleted": + // Evict deleted threads from the in-memory model entirely rather than + // tombstoning them. The DB projection retains the row (it is the source + // of truth for downstream/HTTP reads and cleanup reactors consume the + // event stream, not this model), and no command legitimately targets an + // already-deleted thread. This is the primary fix for unbounded growth: + // a deleted thread's messages/activities/checkpoints are freed and it no + // longer costs anything on every subsequent event. The id is recorded in + // `deletedThreadIds` so `requireThreadAbsent` still rejects re-creating a + // thread with a previously-used id (the invariant the DB tombstone kept). return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - deletedAt: payload.deletedAt, - updatedAt: payload.deletedAt, - }), + threads: HashMap.remove(nextBase.threads, payload.threadId), + deletedThreadIds: HashSet.add(nextBase.deletedThreadIds, payload.threadId), })), ); @@ -388,7 +408,7 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } @@ -449,7 +469,7 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } @@ -512,7 +532,7 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } @@ -544,7 +564,7 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } @@ -614,7 +634,7 @@ export function projectEvent( case "thread.reverted": return decodeForEvent(ThreadRevertedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => { - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } @@ -670,7 +690,7 @@ export function projectEvent( "payload", ).pipe( Effect.map((payload) => { - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const thread = findThreadById(nextBase, payload.threadId); if (!thread) { return nextBase; } diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 032e48e4612..1b1d783e347 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -706,6 +706,7 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.await(remoteStarted); assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localStatusCalls, 1); yield* Scope.close(firstScope, Exit.void); assert.isTrue(Option.isNone(yield* Deferred.poll(remoteInterrupted))); @@ -713,6 +714,20 @@ describe("VcsStatusBroadcaster", () => { yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); yield* Deferred.await(remoteInterrupted); assert.isTrue(Option.isSome(yield* Deferred.poll(remoteInterrupted))); + + const nextSnapshot = yield* Deferred.make(); + const nextScope = yield* Scope.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(nextSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(nextScope)); + yield* Deferred.await(nextSnapshot); + + // Releasing the final poller also evicts its cwd cache entry, so a later + // subscription reloads local status instead of retaining state forever. + assert.equal(state.localStatusCalls, 2); + yield* Scope.close(nextScope, Exit.void); }).pipe(Effect.provide(testLayer)); }); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index c238154f58c..78d91eaa29d 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -476,10 +476,10 @@ export const make = Effect.gen(function* () { const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( cwd: string, ) { - const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const pollerToInterrupt = yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { const existing = activePollers.get(cwd); if (!existing) { - return [null, activePollers] as const; + return Effect.succeed([null, activePollers] as const); } if (existing.subscriberCount > 1) { @@ -488,12 +488,24 @@ export const make = Effect.gen(function* () { ...existing, subscriberCount: existing.subscriberCount - 1, }); - return [null, nextPollers] as const; + return Effect.succeed([null, nextPollers] as const); } const nextPollers = new Map(activePollers); nextPollers.delete(cwd); - return [existing.fiber, nextPollers] as const; + // Drop the cached status for this cwd in the same critical section that + // removes the poller, so a concurrent retainRemotePoller (which reloads + // the cache and installs a fresh poller) cannot have its new entry wiped + // by this release. Otherwise the cache grows one entry per cwd for the + // broadcaster's lifetime; a future subscriber re-loads and re-seeds it. + return Ref.update(cacheRef, (cache) => { + if (!cache.has(cwd)) { + return cache; + } + const nextCache = new Map(cache); + nextCache.delete(cwd); + return nextCache; + }).pipe(Effect.as([existing.fiber, nextPollers] as const)); }); if (pollerToInterrupt) { diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index ecfce8cb432..b17467d9bb5 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -62,17 +62,15 @@ describe("browserSurfaceStore", () => { }); }); - it("hides a surface when its current lease is released", () => { + it("removes a surface entry when its current lease is released", () => { const tabId = "released-browser-surface"; const lease = acquireBrowserSurface(tabId); lease.present({ x: 10, y: 20, width: 900, height: 640 }, true); lease.release(); + // A stale present() from the released lease must not resurrect the entry. lease.present({ x: 0, y: 0, width: 1, height: 1 }, true); - expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ - visible: false, - owner: null, - }); + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toBeUndefined(); }); }); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 58012a11a30..d023896cd4f 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -142,12 +142,14 @@ export const useBrowserSurfaceStore = create()((set) = set((state) => { const current = state.byTabId[tabId]; if (current?.owner !== owner) return state; - return { - byTabId: { - ...state.byTabId, - [tabId]: { ...current, visible: false, updatedAt: Date.now(), owner: null }, - }, - }; + // Delete the entry entirely instead of leaving a released tombstone + // ({ visible: false, owner: null }). Readers only consider `visible` + // entries, so removal is behavior-preserving, and it stops byTabId from + // accumulating one dead entry per preview tab ever opened. A later + // re-claim of the same tabId starts fresh, which is correct since release + // means the surface was torn down. + const { [tabId]: _released, ...byTabId } = state.byTabId; + return { byTabId }; }), })); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index dbde5acce99..51ebd6ea8fb 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,5 +1,6 @@ import { parseScopedThreadKey, + scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; @@ -13,6 +14,10 @@ import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; +import { useDiffPanelStore } from "../diffPanelStore"; +import { removePreviewThread } from "../previewStateStore"; +import { useRightPanelStore } from "../rightPanelStore"; +import { useUiStateStore } from "../uiStateStore"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; @@ -39,6 +44,23 @@ export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass(EMPTY_THREAD_PREVIEW Atom.withLabel("preview:empty-thread"), ); +// Previously `Atom.keepAlive`, which pinned one atom node per thread key ever +// visited in the registry for the process lifetime — an unbounded leak in a +// long-lived tab. Idle-TTL lets the registry evict a thread's preview atom once +// nothing observes it, matching the pattern used for other per-resource atom +// families (see browser/previewWebviewConfigState.ts). Threads with live +// preview sessions stay resident because the observed aggregate +// `activePreviewSessionsAtom` reads them, keeping them out of the idle state; +// only closed/idle threads are collected. An evicted idle thread re-seeds from +// EMPTY and is re-populated by the next server snapshot, so eviction is safe. +const PREVIEW_STATE_IDLE_TTL_MS = 5 * 60_000; + export const previewStateAtom = Atom.family((threadKey: string) => Atom.make(EMPTY_THREAD_PREVIEW_STATE).pipe( - Atom.keepAlive, + Atom.setIdleTTL(PREVIEW_STATE_IDLE_TTL_MS), Atom.withLabel(`preview:thread:${threadKey}`), ), ); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 0fbbd79ec27..2052d24a40a 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -9,6 +9,7 @@ import { PERSISTED_STATE_KEY, type PersistedUiState, persistState, + removeThreadUiState, reorderProjects, resolveProjectExpanded, setDefaultAdvertisedEndpointKey, @@ -39,6 +40,27 @@ describe("uiStateStore pure functions", () => { expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited); }); + it("removes all per-thread ui state for a deleted thread", () => { + const threadId = ThreadId.make("thread-1"); + const otherId = ThreadId.make("thread-2"); + const state = makeUiState({ + threadLastVisitedAtById: { + [threadId]: "2026-02-25T12:30:00.000Z", + [otherId]: "2026-02-25T12:31:00.000Z", + }, + threadChangedFilesExpandedById: { + [threadId]: { "turn-1": false }, + [otherId]: { "turn-1": false }, + }, + }); + + const next = removeThreadUiState(state, threadId); + expect(next.threadLastVisitedAtById).toEqual({ [otherId]: "2026-02-25T12:31:00.000Z" }); + expect(next.threadChangedFilesExpandedById).toEqual({ [otherId]: { "turn-1": false } }); + // No-op (same reference) when the thread has no state. + expect(removeThreadUiState(next, threadId)).toBe(next); + }); + it("marks a completed thread unread using the server completion timestamp", () => { const threadId = ThreadId.make("thread-1"); const initialState = makeUiState({ diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..1a2d07b8786 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -274,6 +274,27 @@ export function markThreadUnread( }; } +/** + * Drop all per-thread UI state for a deleted thread. Without this, both + * `threadLastVisitedAtById` and `threadChangedFilesExpandedById` grew one entry + * per thread ever visited and were persisted to localStorage indefinitely. + */ +export function removeThreadUiState(state: UiState, threadId: string): UiState { + const hasVisited = threadId in state.threadLastVisitedAtById; + const hasChangedFiles = threadId in state.threadChangedFilesExpandedById; + if (!hasVisited && !hasChangedFiles) { + return state; + } + const { [threadId]: _visited, ...threadLastVisitedAtById } = state.threadLastVisitedAtById; + const { [threadId]: _changed, ...threadChangedFilesExpandedById } = + state.threadChangedFilesExpandedById; + return { + ...state, + threadLastVisitedAtById, + threadChangedFilesExpandedById, + }; +} + export function setThreadChangedFilesExpanded( state: UiState, threadId: string, @@ -414,6 +435,7 @@ export function reorderProjects( interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; + removeThread: (threadId: string) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; @@ -430,6 +452,7 @@ export const useUiStateStore = create((set) => ({ set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), + removeThread: (threadId) => set((state) => removeThreadUiState(state, threadId)), setThreadChangedFilesExpanded: (threadId, turnId, expanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) =>