diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 01435ff20..cbce50f2d 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -337,6 +337,7 @@ it.layer(testLayer)("server CLI command", (it) => { getSnapshot: () => Effect.succeed({ snapshotSequence: 0, + sidebarLayout: null, projects: [] as OrchestrationReadModel["projects"], threads: [] as OrchestrationReadModel["threads"], updatedAt: new Date(0).toISOString(), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index fb21b9deb..a3f399a27 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -5,6 +5,7 @@ import { EventId, MessageId, ProjectId, + SIDEBAR_LAYOUT_ID, ThreadId, TurnId, type ProjectIconMetadata, @@ -13,7 +14,8 @@ import { import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; +import { Effect, Layer, ManagedRuntime, Option, Queue, Stream } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { describe, expect, it } from "vitest"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; @@ -70,7 +72,7 @@ async function createOrchestrationSystem( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(projectLanguageIconResolverLayer), - Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); @@ -78,7 +80,7 @@ async function createOrchestrationSystem( const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); return { engine, - run: (effect: Effect.Effect) => runtime.runPromise(effect), + run: (effect: Effect.Effect) => runtime.runPromise(effect), dispose: () => runtime.dispose(), }; } @@ -918,6 +920,569 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("publishes an accepted persisted event before dispatch resolves", async () => { + // Given + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + + await system.run( + Effect.gen(function* () { + const eventQueue = yield* Queue.unbounded(); + yield* Effect.forkScoped( + Stream.take(engine.streamDomainEvents, 1).pipe( + Stream.runForEach((event) => Queue.offer(eventQueue, event).pipe(Effect.asVoid)), + ), + ); + yield* Effect.yieldNow; + + // When + const result = yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-publish-before-response"), + projectId: asProjectId("project-publish-before-response"), + title: "Published Project", + workspaceRoot: "/tmp/project-publish-before-response", + defaultModelSelection: null, + createdAt, + }); + const publishedEvent = yield* Queue.poll(eventQueue); + + // Then + expect(Option.isSome(publishedEvent)).toBe(true); + if (Option.isSome(publishedEvent)) { + expect(publishedEvent.value.sequence).toBe(result.sequence); + expect(publishedEvent.value.type).toBe("project.created"); + } + }).pipe(Effect.scoped), + ); + + await system.dispose(); + }); + + it("serializes every sidebar layout intent into a distinct canonical revision", async () => { + // Given + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + const projectA = asProjectId("project-layout-a"); + const projectB = asProjectId("project-layout-b"); + const threadA = ThreadId.makeUnsafe("thread-layout-a"); + const threadB = ThreadId.makeUnsafe("thread-layout-b"); + + for (const [projectId, title] of [ + [projectA, "Project A"], + [projectB, "Project B"], + ] as const) { + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe(`create-${projectId}`), + projectId, + title, + workspaceRoot: `/tmp/${projectId}`, + defaultModelSelection: null, + createdAt, + }), + ); + } + for (const [threadId, projectId] of [ + [threadA, projectA], + [threadB, projectB], + ] as const) { + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(`create-${threadId}`), + threadId, + projectId, + title: threadId, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + } + + // When + const receipts = []; + receipts.push( + await system.run( + engine.dispatch({ + type: "sidebar-layout.initialize", + commandId: CommandId.makeUnsafe("layout-initialize-first"), + projectOrder: [projectB, projectA], + pinnedThreadOrder: [threadA], + }), + ), + ); + receipts.push( + await system.run( + engine.dispatch({ + type: "sidebar-layout.initialize", + commandId: CommandId.makeUnsafe("layout-initialize-losing"), + projectOrder: [projectA, projectB], + pinnedThreadOrder: [threadB], + }), + ), + ); + receipts.push( + await system.run( + engine.dispatch({ + type: "sidebar-layout.project.move", + commandId: CommandId.makeUnsafe("layout-project-self-move"), + projectId: projectB, + beforeProjectId: projectB, + }), + ), + ); + receipts.push( + await system.run( + engine.dispatch({ + type: "sidebar-layout.thread.pin", + commandId: CommandId.makeUnsafe("layout-pin-b"), + threadId: threadB, + beforeThreadId: threadA, + }), + ), + ); + receipts.push( + await system.run( + engine.dispatch({ + type: "sidebar-layout.pinned-thread.move", + commandId: CommandId.makeUnsafe("layout-pinned-self-move"), + threadId: threadB, + beforeThreadId: threadB, + }), + ), + ); + receipts.push( + await system.run( + engine.dispatch({ + type: "sidebar-layout.thread.unpin", + commandId: CommandId.makeUnsafe("layout-unpin-a"), + threadId: threadA, + }), + ), + ); + receipts.push( + await system.run( + engine.dispatch({ + type: "sidebar-layout.thread.unpin", + commandId: CommandId.makeUnsafe("layout-unpin-a-again"), + threadId: threadA, + }), + ), + ); + + // Then + expect(receipts.map((receipt) => receipt.sequence)).toEqual([5, 6, 7, 8, 9, 10, 11]); + const events = await system.run( + Stream.runCollect(engine.readEvents(4)).pipe( + Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), + ), + ); + expect(events).toHaveLength(7); + expect(events.every((event) => event.type === "sidebar-layout.updated")).toBe(true); + + const readModel = await system.run(engine.getReadModel()); + expect(readModel.sidebarLayout).toEqual({ + projectOrder: [projectB, projectA], + pinnedThreadOrder: [threadB], + revision: 11, + updatedAt: expect.any(String), + }); + expect(readModel.threads.map((thread) => [thread.id, thread.isPinned])).toEqual([ + [threadA, false], + [threadB, true], + ]); + + const retry = await system.run( + engine.dispatch({ + type: "sidebar-layout.thread.unpin", + commandId: CommandId.makeUnsafe("layout-unpin-a-again"), + threadId: threadA, + }), + ); + const eventsAfterRetry = await system.run( + Stream.runCollect(engine.readEvents(4)).pipe( + Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), + ), + ); + expect(retry.sequence).toBe(11); + expect(eventsAfterRetry).toHaveLength(7); + + await system.dispose(); + }); + + it("repairs a corrupt layout row and stale pins by replaying the layout projector", async () => { + // Given + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = "2026-07-18T14:00:00.000Z"; + const projectId = asProjectId("project-layout-repair-success"); + const threadA = ThreadId.makeUnsafe("thread-layout-repair-success-a"); + const threadB = ThreadId.makeUnsafe("thread-layout-repair-success-b"); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-layout-repair-success-project"), + projectId, + title: "Layout repair success", + workspaceRoot: "/tmp/layout-repair-success", + defaultModelSelection: null, + createdAt, + }), + ); + for (const [threadId, suffix] of [ + [threadA, "a"], + [threadB, "b"], + ] as const) { + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(`cmd-layout-repair-success-thread-${suffix}`), + threadId, + projectId, + title: `Thread ${suffix}`, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }), + ); + } + await system.run( + engine.dispatch({ + type: "sidebar-layout.initialize", + commandId: CommandId.makeUnsafe("cmd-layout-repair-success-initialize"), + projectOrder: [projectId], + pinnedThreadOrder: [threadA], + }), + ); + await system.run( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + UPDATE projection_sidebar_layout + SET pinned_thread_order_json = ${JSON.stringify([threadB])}, revision = 777 + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `; + yield* sql` + UPDATE projection_threads + SET is_pinned = CASE WHEN thread_id = ${threadB} THEN 1 ELSE 0 END + `; + }), + ); + + // When + const repaired = await system.run(engine.repairState()); + + // Then + expect(repaired.sidebarLayout).toEqual({ + projectOrder: [projectId], + pinnedThreadOrder: [threadA], + revision: 4, + updatedAt: expect.any(String), + }); + expect(repaired.threads.map((thread) => [thread.id, thread.isPinned])).toEqual([ + [threadA, true], + [threadB, false], + ]); + const persisted = await system.run( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const layout = yield* sql<{ + readonly pinnedThreadOrderJson: string; + readonly revision: number; + }>` + SELECT pinned_thread_order_json AS "pinnedThreadOrderJson", revision + FROM projection_sidebar_layout + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `; + const pins = yield* sql<{ readonly threadId: string; readonly isPinned: number }>` + SELECT thread_id AS "threadId", is_pinned AS "isPinned" + FROM projection_threads + WHERE thread_id IN (${threadA}, ${threadB}) + ORDER BY thread_id ASC + `; + const cursor = yield* sql<{ readonly lastAppliedSequence: number }>` + SELECT last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = 'projection.sidebar-layout' + `; + return { layout, pins, cursor }; + }), + ); + expect(persisted).toEqual({ + layout: [{ pinnedThreadOrderJson: JSON.stringify([threadA]), revision: 4 }], + pins: [ + { threadId: threadA, isPinned: 1 }, + { threadId: threadB, isPinned: 0 }, + ], + cursor: [{ lastAppliedSequence: 4 }], + }); + await system.dispose(); + }); + + it("restores layout, cursor, pins, and memory when the repair layout projector fails", async () => { + // Given + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = "2026-07-18T15:00:00.000Z"; + const projectId = asProjectId("project-layout-repair-failure"); + const threadA = ThreadId.makeUnsafe("thread-layout-repair-failure-a"); + const threadB = ThreadId.makeUnsafe("thread-layout-repair-failure-b"); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-layout-repair-failure-project"), + projectId, + title: "Layout repair failure", + workspaceRoot: "/tmp/layout-repair-failure", + defaultModelSelection: null, + createdAt, + }), + ); + for (const [threadId, suffix] of [ + [threadA, "a"], + [threadB, "b"], + ] as const) { + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(`cmd-layout-repair-failure-thread-${suffix}`), + threadId, + projectId, + title: `Thread ${suffix}`, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }), + ); + } + await system.run( + engine.dispatch({ + type: "sidebar-layout.initialize", + commandId: CommandId.makeUnsafe("cmd-layout-repair-failure-initialize"), + projectOrder: [projectId], + pinnedThreadOrder: [threadA], + }), + ); + const beforeMemory = await system.run(engine.getReadModel()); + await system.run( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + UPDATE projection_sidebar_layout + SET pinned_thread_order_json = ${JSON.stringify([threadB])}, revision = 888 + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `; + yield* sql` + UPDATE projection_threads + SET is_pinned = CASE WHEN thread_id = ${threadB} THEN 1 ELSE 0 END + `; + yield* sql` + CREATE TEMP TRIGGER fail_layout_repair_projector + BEFORE INSERT ON projection_sidebar_layout + WHEN NOT EXISTS ( + SELECT 1 FROM projection_state WHERE projector = 'projection.sidebar-layout' + ) + BEGIN + SELECT RAISE(ABORT, 'injected layout repair projector failure'); + END + `; + }), + ); + + // When + const repairExit = await system.run(Effect.exit(engine.repairState())); + await system.run( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`DROP TRIGGER fail_layout_repair_projector`; + }), + ); + + // Then + expect(repairExit._tag).toBe("Failure"); + const afterMemory = await system.run(engine.getReadModel()); + expect(afterMemory).toEqual(beforeMemory); + const persisted = await system.run( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const layout = yield* sql<{ + readonly pinnedThreadOrderJson: string; + readonly revision: number; + }>` + SELECT pinned_thread_order_json AS "pinnedThreadOrderJson", revision + FROM projection_sidebar_layout + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `; + const pins = yield* sql<{ readonly threadId: string; readonly isPinned: number }>` + SELECT thread_id AS "threadId", is_pinned AS "isPinned" + FROM projection_threads + WHERE thread_id IN (${threadA}, ${threadB}) + ORDER BY thread_id ASC + `; + const cursor = yield* sql<{ readonly lastAppliedSequence: number }>` + SELECT last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = 'projection.sidebar-layout' + `; + return { layout, pins, cursor }; + }), + ); + expect(persisted).toEqual({ + layout: [{ pinnedThreadOrderJson: JSON.stringify([threadB]), revision: 888 }], + pins: [ + { threadId: threadA, isPinned: 0 }, + { threadId: threadB, isPinned: 1 }, + ], + cursor: [{ lastAppliedSequence: 4 }], + }); + await system.dispose(); + }); + + it("restores layout, cursor, pins, memory, and backups when repair refresh fails", async () => { + // Given + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = "2026-07-18T15:30:00.000Z"; + const projectId = asProjectId("project-layout-repair-refresh-failure"); + const threadA = ThreadId.makeUnsafe("thread-layout-repair-refresh-failure-a"); + const threadB = ThreadId.makeUnsafe("thread-layout-repair-refresh-failure-b"); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-layout-repair-refresh-failure-project"), + projectId, + title: "Layout repair refresh failure", + workspaceRoot: "/tmp/layout-repair-refresh-failure", + defaultModelSelection: null, + createdAt, + }), + ); + for (const [threadId, suffix] of [ + [threadA, "a"], + [threadB, "b"], + ] as const) { + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(`cmd-layout-repair-refresh-failure-thread-${suffix}`), + threadId, + projectId, + title: `Thread ${suffix}`, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }), + ); + } + await system.run( + engine.dispatch({ + type: "sidebar-layout.initialize", + commandId: CommandId.makeUnsafe("cmd-layout-repair-refresh-failure-initialize"), + projectOrder: [projectId], + pinnedThreadOrder: [threadA], + }), + ); + const beforeMemory = await system.run(engine.getReadModel()); + await system.run( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + UPDATE projection_sidebar_layout + SET pinned_thread_order_json = ${JSON.stringify([threadB])}, revision = 888 + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `; + yield* sql` + UPDATE projection_threads + SET is_pinned = CASE WHEN thread_id = ${threadB} THEN 1 ELSE 0 END + `; + yield* sql` + CREATE TEMP TRIGGER corrupt_rebuilt_layout_after_cursor_insert + AFTER INSERT ON projection_state + WHEN NEW.projector = 'projection.sidebar-layout' + BEGIN + UPDATE projection_sidebar_layout + SET pinned_thread_order_json = 'invalid-json'; + END + `; + }), + ); + + // When + const repairExit = await system.run(Effect.exit(engine.repairState())); + await system.run( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`DROP TRIGGER corrupt_rebuilt_layout_after_cursor_insert`; + }), + ); + + // Then + expect(repairExit._tag).toBe("Failure"); + const afterMemory = await system.run(engine.getReadModel()); + expect(afterMemory).toEqual(beforeMemory); + const persisted = await system.run( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const layout = yield* sql<{ + readonly pinnedThreadOrderJson: string; + readonly revision: number; + }>` + SELECT pinned_thread_order_json AS "pinnedThreadOrderJson", revision + FROM projection_sidebar_layout + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `; + const pins = yield* sql<{ readonly threadId: string; readonly isPinned: number }>` + SELECT thread_id AS "threadId", is_pinned AS "isPinned" + FROM projection_threads + WHERE thread_id IN (${threadA}, ${threadB}) + ORDER BY thread_id ASC + `; + const cursor = yield* sql<{ readonly lastAppliedSequence: number }>` + SELECT last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = 'projection.sidebar-layout' + `; + const backupTables = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_temp_master + WHERE type = 'table' AND name LIKE 'temp_repair_%' + ORDER BY name ASC + `; + return { layout, pins, cursor, backupTables }; + }), + ); + expect(persisted).toEqual({ + layout: [{ pinnedThreadOrderJson: JSON.stringify([threadB]), revision: 888 }], + pins: [ + { threadId: threadA, isPinned: 0 }, + { threadId: threadB, isPinned: 1 }, + ], + cursor: [{ lastAppliedSequence: 4 }], + backupTables: [], + }); + await system.dispose(); + }); + it("stores completed checkpoint summaries even when no files changed", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 371ab8929..ff79b2dc6 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,10 +1,12 @@ -import type { - OrchestrationEvent, - OrchestrationReadModel, +import type { OrchestrationEvent, OrchestrationReadModel, SidebarLayoutId } from "@jcode/contracts"; +import { + CommandId, + OrchestrationCommand, + ORCHESTRATION_WS_METHODS, ProjectId, + SIDEBAR_LAYOUT_ID, ThreadId, } from "@jcode/contracts"; -import { CommandId, OrchestrationCommand, ORCHESTRATION_WS_METHODS } from "@jcode/contracts"; import { Cause, Deferred, @@ -36,6 +38,7 @@ import { decideOrchestrationCommand } from "../decider.ts"; import type { ProjectMetadataOrchestrationEvent } from "../projectMetadataProjection.ts"; import { PROJECT_METADATA_SNAPSHOT_PROJECTORS } from "../projectMetadataProjection.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; +import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { OrchestrationEngineService, @@ -45,6 +48,8 @@ import { ProjectLanguageIconResolver } from "../../project/Services/ProjectLangu const ORCHESTRATION_DISPATCH_TIMEOUT_MS = 45_000; const STARTUP_PROJECT_ICON_METADATA_BACKFILL_LIMIT = 20; +const PersistedSidebarProjectOrder = Schema.fromJsonString(Schema.Array(ProjectId)); +const PersistedSidebarPinnedThreadOrder = Schema.fromJsonString(Schema.Array(ThreadId)); const AUTOMATIC_PROJECT_ICON_COMMAND_PREFIXES = [ "project-icon-detect:", "project-icon-backfill:", @@ -68,8 +73,8 @@ type CommittedCommandResult = { }; function commandToAggregateRef(command: OrchestrationCommand): { - readonly aggregateKind: "project" | "thread"; - readonly aggregateId: ProjectId | ThreadId; + readonly aggregateKind: "project" | "thread" | "sidebar-layout"; + readonly aggregateId: ProjectId | ThreadId | SidebarLayoutId; } { switch (command.type) { case "project.create": @@ -79,7 +84,17 @@ function commandToAggregateRef(command: OrchestrationCommand): { aggregateKind: "project", aggregateId: command.projectId, }; + case "sidebar-layout.initialize": + case "sidebar-layout.project.move": + case "sidebar-layout.thread.pin": + case "sidebar-layout.thread.unpin": + case "sidebar-layout.pinned-thread.move": + return { + aggregateKind: "sidebar-layout", + aggregateId: SIDEBAR_LAYOUT_ID, + }; default: + command satisfies { readonly threadId: ThreadId }; return { aggregateKind: "thread", aggregateId: command.threadId, @@ -371,6 +386,25 @@ const makeOrchestrationEngine = Effect.gen(function* () { ORDER BY created_at ASC, project_id ASC `; + const layoutRows = yield* sql<{ + readonly projectOrderJson: string; + readonly pinnedThreadOrderJson: string; + readonly revision: number; + readonly updatedAt: string; + }>` + SELECT + project_order_json AS "projectOrderJson", + pinned_thread_order_json AS "pinnedThreadOrderJson", + revision, + updated_at AS "updatedAt" + FROM projection_sidebar_layout + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `; + const pinRows = yield* sql<{ readonly threadId: string; readonly isPinned: number }>` + SELECT thread_id AS "threadId", is_pinned AS "isPinned" + FROM projection_threads + `; + const stateRows = yield* sql<{ readonly projector: string; readonly lastAppliedSequence: number; @@ -400,10 +434,41 @@ const makeOrchestrationEngine = Effect.gen(function* () { if (Number.isFinite(minSequence)) { snapshotSequence = minSequence; } + const layoutRow = layoutRows[0]; + const pinnedByThreadId = new Map( + pinRows.map((row) => [row.threadId, row.isPinned === 1] as const), + ); + const sidebarLayout = + layoutRow === undefined + ? null + : yield* Effect.all({ + projectOrder: Schema.decodeUnknownEffect(PersistedSidebarProjectOrder)( + layoutRow.projectOrderJson, + ), + pinnedThreadOrder: Schema.decodeUnknownEffect(PersistedSidebarPinnedThreadOrder)( + layoutRow.pinnedThreadOrderJson, + ), + }).pipe( + Effect.map((orders) => ({ + ...orders, + revision: layoutRow.revision, + updatedAt: layoutRow.updatedAt, + })), + Effect.mapError( + (cause) => + new OrchestrationCommandInternalError({ + commandId: "repair-local-state", + commandType: ORCHESTRATION_WS_METHODS.repairState, + detail: "The rebuilt sidebar layout could not be decoded.", + cause, + }), + ), + ); const nextReadModel: OrchestrationReadModel = { ...readModel, snapshotSequence, + sidebarLayout, projects: projectRows.map((row) => ({ id: row.projectId as ProjectId, kind: row.kind, @@ -428,6 +493,10 @@ const makeOrchestrationEngine = Effect.gen(function* () { updatedAt: row.updatedAt, deletedAt: row.deletedAt, })), + threads: readModel.threads.map((thread) => ({ + ...thread, + isPinned: pinnedByThreadId.get(thread.id) ?? false, + })), updatedAt: new Date().toISOString(), }; @@ -453,15 +522,20 @@ const makeOrchestrationEngine = Effect.gen(function* () { ), ); - // Rebuild only the project projection rows and snapshot cursors. + // Rebuild project and sidebar-layout projections while retaining thread history. // Existing thread/chat projection rows stay in place so older installs do not // lose history that is no longer fully represented in orchestration_events. const resetDerivedProjectionState = sql.withTransaction( Effect.gen(function* () { yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_sidebar_layout`; + yield* sql`UPDATE projection_threads SET is_pinned = 0`; yield* sql` DELETE FROM projection_state - WHERE projector IN ${sql.in(PROJECT_METADATA_SNAPSHOT_PROJECTORS)} + WHERE projector IN ${sql.in([ + ...PROJECT_METADATA_SNAPSHOT_PROJECTORS, + ORCHESTRATION_PROJECTOR_NAMES.sidebarLayout, + ])} `; }), ); @@ -470,8 +544,15 @@ const makeOrchestrationEngine = Effect.gen(function* () { Effect.gen(function* () { yield* sql`DROP TABLE IF EXISTS temp_repair_projection_projects`; yield* sql`DROP TABLE IF EXISTS temp_repair_projection_state`; + yield* sql`DROP TABLE IF EXISTS temp_repair_projection_sidebar_layout`; + yield* sql`DROP TABLE IF EXISTS temp_repair_projection_thread_pins`; yield* sql`CREATE TEMP TABLE temp_repair_projection_projects AS SELECT * FROM projection_projects`; yield* sql`CREATE TEMP TABLE temp_repair_projection_state AS SELECT * FROM projection_state`; + yield* sql`CREATE TEMP TABLE temp_repair_projection_sidebar_layout AS SELECT * FROM projection_sidebar_layout`; + yield* sql` + CREATE TEMP TABLE temp_repair_projection_thread_pins AS + SELECT thread_id, is_pinned FROM projection_threads + `; }), ); @@ -481,6 +562,22 @@ const makeOrchestrationEngine = Effect.gen(function* () { yield* sql`INSERT INTO projection_projects SELECT * FROM temp_repair_projection_projects`; yield* sql`DELETE FROM projection_state`; yield* sql`INSERT INTO projection_state SELECT * FROM temp_repair_projection_state`; + yield* sql`DELETE FROM projection_sidebar_layout`; + yield* sql` + INSERT INTO projection_sidebar_layout + SELECT * FROM temp_repair_projection_sidebar_layout + `; + yield* sql` + UPDATE projection_threads + SET is_pinned = COALESCE( + ( + SELECT backup.is_pinned + FROM temp_repair_projection_thread_pins AS backup + WHERE backup.thread_id = projection_threads.thread_id + ), + 0 + ) + `; }), ); @@ -488,6 +585,8 @@ const makeOrchestrationEngine = Effect.gen(function* () { Effect.gen(function* () { yield* sql`DROP TABLE IF EXISTS temp_repair_projection_projects`; yield* sql`DROP TABLE IF EXISTS temp_repair_projection_state`; + yield* sql`DROP TABLE IF EXISTS temp_repair_projection_sidebar_layout`; + yield* sql`DROP TABLE IF EXISTS temp_repair_projection_thread_pins`; }), ); @@ -959,9 +1058,30 @@ const makeOrchestrationEngine = Effect.gen(function* () { ); } - const snapshot = yield* refreshReadModelFromProjectionState; + const refreshResult = yield* Effect.exit(refreshReadModelFromProjectionState); + if (refreshResult._tag === "Failure") { + yield* restoreDerivedProjectionState.pipe( + Effect.catchCause(() => + Effect.logWarning( + "failed to restore orchestration projection backup after refresh failure", + ), + ), + ); + readModel = previousReadModel; + yield* dropProjectionRepairBackup.pipe(Effect.catchCause(() => Effect.void)); + + return yield* Effect.logError( + "failed to refresh orchestration state after projection rebuild", + ).pipe( + Effect.annotateLogs({ + cause: Cause.pretty(refreshResult.cause), + }), + Effect.flatMap(() => Effect.failCause(refreshResult.cause)), + ); + } + yield* dropProjectionRepairBackup.pipe(Effect.catchCause(() => Effect.void)); - return snapshot; + return refreshResult.value; }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 4713fc745..320b44de6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -6,6 +6,7 @@ import { EventId, MessageId, ProjectId, + SIDEBAR_LAYOUT_ID, ThreadId, TurnId, } from "@jcode/contracts"; @@ -2539,20 +2540,235 @@ it.effect("restores pending turn-start metadata across projection pipeline resta ), ); -const engineLayer = it.layer( - OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionPipelineLive), - Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(NoopProjectLanguageIconResolverLayer), - Layer.provideMerge(SqlitePersistenceMemory), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-projection-pipeline-engine-dispatch-", - }), - ), - Layer.provideMerge(NodeServices.layer), +const EngineDispatchTestLayer = OrchestrationEngineLive.pipe( + Layer.provideMerge(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(NoopProjectLanguageIconResolverLayer), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-projection-pipeline-engine-dispatch-", + }), ), + Layer.provideMerge(NodeServices.layer), +); + +const engineLayer = it.layer(EngineDispatchTestLayer); + +engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { + it.effect( + "rolls back layout, exact pins, cursor, event, and receipt when pin projection fails", + () => + Effect.gen(function* () { + // Given + const engine = yield* OrchestrationEngineService; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-07-18T12:00:00.000Z"; + const projectId = ProjectId.makeUnsafe("project-layout-atomic"); + const threadA = ThreadId.makeUnsafe("thread-layout-atomic-a"); + const threadB = ThreadId.makeUnsafe("thread-layout-atomic-b"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-layout-atomic-project"), + projectId, + title: "Layout atomicity", + workspaceRoot: "/tmp/layout-atomicity", + defaultModelSelection: null, + createdAt, + }); + for (const [threadId, suffix] of [ + [threadA, "a"], + [threadB, "b"], + ] as const) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(`cmd-layout-atomic-thread-${suffix}`), + threadId, + projectId, + title: `Thread ${suffix}`, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }); + } + yield* engine.dispatch({ + type: "sidebar-layout.initialize", + commandId: CommandId.makeUnsafe("cmd-layout-atomic-initialize"), + projectOrder: [projectId], + pinnedThreadOrder: [threadA], + }); + yield* sql` + CREATE TEMP TRIGGER fail_layout_pin_projection + BEFORE UPDATE OF is_pinned ON projection_threads + WHEN NEW.thread_id = 'thread-layout-atomic-b' AND NEW.is_pinned = 1 + BEGIN + SELECT RAISE(ABORT, 'injected layout pin projection failure'); + END + `; + + // When + const failedCommandId = CommandId.makeUnsafe("cmd-layout-atomic-pin-b"); + const failure = yield* Effect.exit( + engine.dispatch({ + type: "sidebar-layout.thread.pin", + commandId: failedCommandId, + threadId: threadB, + }), + ); + yield* sql`DROP TRIGGER fail_layout_pin_projection`; + + // Then + assert.equal(failure._tag, "Failure"); + const layoutRows = yield* sql<{ + readonly pinnedThreadOrderJson: string; + readonly revision: number; + }>` + SELECT + pinned_thread_order_json AS "pinnedThreadOrderJson", + revision + FROM projection_sidebar_layout + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `; + assert.deepEqual(layoutRows, [ + { pinnedThreadOrderJson: JSON.stringify([threadA]), revision: 4 }, + ]); + const pinRows = yield* sql<{ readonly threadId: string; readonly isPinned: number }>` + SELECT thread_id AS "threadId", is_pinned AS "isPinned" + FROM projection_threads + WHERE thread_id IN (${threadA}, ${threadB}) + ORDER BY thread_id ASC + `; + assert.deepEqual(pinRows, [ + { threadId: threadA, isPinned: 1 }, + { threadId: threadB, isPinned: 0 }, + ]); + const cursorRows = yield* sql<{ readonly lastAppliedSequence: number }>` + SELECT last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = 'projection.sidebar-layout' + `; + assert.deepEqual(cursorRows, [{ lastAppliedSequence: 4 }]); + const failedEventRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM orchestration_events + WHERE command_id = ${failedCommandId} + `; + const failedReceiptRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM orchestration_command_receipts + WHERE command_id = ${failedCommandId} + `; + assert.deepEqual(failedEventRows, [{ count: 0 }]); + assert.deepEqual(failedReceiptRows, [{ count: 0 }]); + }), + ); +}); + +it.layer(Layer.fresh(EngineDispatchTestLayer))( + "OrchestrationProjectionPipeline layout rebuild", + (it) => { + it.effect( + "rebuilds an absent layout row and cursor over stale pins including empty membership", + () => + Effect.gen(function* () { + // Given + const engine = yield* OrchestrationEngineService; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-07-18T13:00:00.000Z"; + const projectId = ProjectId.makeUnsafe("project-layout-rebuild"); + const threadA = ThreadId.makeUnsafe("thread-layout-rebuild-a"); + const threadB = ThreadId.makeUnsafe("thread-layout-rebuild-b"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-layout-rebuild-project"), + projectId, + title: "Layout rebuild", + workspaceRoot: "/tmp/layout-rebuild", + defaultModelSelection: null, + createdAt, + }); + for (const [threadId, suffix] of [ + [threadA, "a"], + [threadB, "b"], + ] as const) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(`cmd-layout-rebuild-thread-${suffix}`), + threadId, + projectId, + title: `Thread ${suffix}`, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }); + } + yield* engine.dispatch({ + type: "sidebar-layout.initialize", + commandId: CommandId.makeUnsafe("cmd-layout-rebuild-initialize"), + projectOrder: [projectId], + pinnedThreadOrder: [threadA], + }); + yield* engine.dispatch({ + type: "sidebar-layout.thread.unpin", + commandId: CommandId.makeUnsafe("cmd-layout-rebuild-empty"), + threadId: threadA, + }); + yield* sql`DELETE FROM projection_sidebar_layout`; + yield* sql`DELETE FROM projection_state WHERE projector = 'projection.sidebar-layout'`; + yield* sql`UPDATE projection_threads SET is_pinned = 1`; + + // When + yield* projectionPipeline.bootstrap; + + // Then + const layoutRows = yield* sql<{ + readonly projectOrderJson: string; + readonly pinnedThreadOrderJson: string; + readonly revision: number; + }>` + SELECT + project_order_json AS "projectOrderJson", + pinned_thread_order_json AS "pinnedThreadOrderJson", + revision + FROM projection_sidebar_layout + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `; + assert.deepEqual(layoutRows, [ + { + projectOrderJson: JSON.stringify([projectId]), + pinnedThreadOrderJson: "[]", + revision: 5, + }, + ]); + const pinRows = yield* sql<{ readonly threadId: string; readonly isPinned: number }>` + SELECT thread_id AS "threadId", is_pinned AS "isPinned" + FROM projection_threads + WHERE thread_id IN (${threadA}, ${threadB}) + ORDER BY thread_id ASC + `; + assert.deepEqual(pinRows, [ + { threadId: threadA, isPinned: 0 }, + { threadId: threadB, isPinned: 0 }, + ]); + const cursorRows = yield* sql<{ readonly lastAppliedSequence: number }>` + SELECT last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = 'projection.sidebar-layout' + `; + assert.deepEqual(cursorRows, [{ lastAppliedSequence: 5 }]); + }), + ); + }, ); engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f4c3abfbb..bf0924152 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -18,6 +18,7 @@ import { } from "../../persistence/Services/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; +import { ProjectionSidebarLayoutRepository } from "../../persistence/Services/ProjectionSidebarLayout.ts"; import { type ProjectionThreadActivity, type ProjectionThreadActivityRepositoryShape, @@ -45,6 +46,7 @@ import { import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; +import { ProjectionSidebarLayoutRepositoryLive } from "../../persistence/Layers/ProjectionSidebarLayout.ts"; import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; @@ -80,6 +82,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { threadTurns: "projection.thread-turns", checkpoints: "projection.checkpoints", pendingApprovals: "projection.pending-approvals", + sidebarLayout: "projection.sidebar-layout", } as const; type ProjectorName = @@ -110,6 +113,10 @@ const THREAD_SHELL_SUMMARY_ACTIVITY_KINDS = new Set([ "provider.user-input.respond.failed", ]); +function isSidebarLayoutEvent(event: OrchestrationEvent): boolean { + return event.type === "sidebar-layout.updated"; +} + const materializeAttachmentsForProjection = Effect.fn( (input: { readonly attachments: ReadonlyArray }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments), @@ -568,6 +575,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const eventStore = yield* OrchestrationEventStore; const projectionStateRepository = yield* ProjectionStateRepository; + const projectionSidebarLayoutRepository = yield* ProjectionSidebarLayoutRepository; const projectionProjectRepository = yield* ProjectionProjectRepository; const projectionThreadRepository = yield* ProjectionThreadRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; @@ -915,6 +923,26 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { } }); + const applySidebarLayoutProjection: ProjectorDefinition["apply"] = (event) => + event.type === "sidebar-layout.updated" + ? Effect.gen(function* () { + const currentLayout = yield* projectionSidebarLayoutRepository.get(); + yield* projectionSidebarLayoutRepository.upsert({ + layoutKey: event.aggregateId, + projectOrder: event.payload.projectOrder, + pinnedThreadOrder: event.payload.pinnedThreadOrder, + revision: event.sequence, + initializedAt: Option.isSome(currentLayout) + ? currentLayout.value.initializedAt + : event.occurredAt, + updatedAt: event.payload.updatedAt, + }); + yield* projectionThreadRepository.replacePinnedMembership({ + threadIds: event.payload.pinnedThreadOrder, + }); + }) + : Effect.void; + // Keep denormalized shell summary work out of the live transcript projector path. const applyThreadShellSummariesProjection: ProjectorDefinition["apply"] = (event) => Effect.gen(function* () { @@ -1770,6 +1798,12 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { phase: "hot", apply: applyThreadsProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.sidebarLayout, + phase: "hot", + shouldApply: isSidebarLayoutEvent, + apply: applySidebarLayoutProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadShellSummaries, phase: "deferred", @@ -1815,6 +1849,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { prunedThreadRelativePaths: new Map>(), }; + // SqlClient transaction scope is re-entrant: live projection joins the engine's + // event/receipt transaction, while standalone replay owns this transaction. yield* sql.withTransaction( projector.apply(event, attachmentSideEffects).pipe( Effect.flatMap(() => @@ -2020,4 +2056,5 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionTurnRepositoryLive), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), + Layer.provideMerge(ProjectionSidebarLayoutRepositoryLive), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index baa482488..1f772e4c9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -20,6 +20,196 @@ const projectionSnapshotLayer = it.layer( ); projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { + it.effect("fences a thread detail snapshot at the slowest required projector cursor", () => + Effect.gen(function* () { + // Given: one live thread and required projectors at different cursors. + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, default_model_selection_json, + scripts_json, created_at, updated_at, deleted_at + ) VALUES ( + 'project-fence', 'Fence Project', '/tmp/project-fence', + '{"provider":"codex","model":"gpt-5-codex"}', '[]', + '2026-07-18T00:00:00.000Z', '2026-07-18T00:00:00.000Z', NULL + ) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, created_at, updated_at, deleted_at + ) VALUES ( + 'thread-fence', 'project-fence', 'Fence Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + '2026-07-18T00:00:01.000Z', '2026-07-18T00:00:01.000Z', NULL + ) + `; + let sequence = 11; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, ${sequence}, '2026-07-18T00:00:02.000Z') + `; + sequence += 1; + } + + // When: the detail and its fence are read transactionally. + const detail = yield* snapshotQuery.getThreadDetailSnapshotById(asThreadId("thread-fence")); + + // Then: the fence is the slowest cursor rather than a latest-row sequence. + assert.equal(Option.getOrNull(detail)?.snapshotSequence, 11); + yield* sql`DELETE FROM projection_threads WHERE thread_id = 'thread-fence'`; + yield* sql`DELETE FROM projection_projects WHERE project_id = 'project-fence'`; + yield* sql`DELETE FROM projection_state`; + }), + ); + + it.effect("returns null sidebar layout before initialization in full and shell snapshots", () => + Effect.gen(function* () { + // Given: an uninitialized layout projection. + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM projection_sidebar_layout`; + + // When: both public snapshots are read. + const [full, shell] = yield* Effect.all([ + snapshotQuery.getSnapshot(), + snapshotQuery.getShellSnapshot(), + ]); + + // Then: null is the only uninitialized layout state. + assert.isNull(full.sidebarLayout); + assert.isNull(shell.sidebarLayout); + }), + ); + + it.effect( + "normalizes initialized layout against every live entity inside the snapshot fence", + () => + Effect.gen(function* () { + // Given: stored relative order with duplicates, deleted ids, unseen project kinds, and stale pins. + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM projection_sidebar_layout`; + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + yield* sql` + INSERT INTO projection_projects ( + project_id, kind, title, workspace_root, default_model_selection_json, + scripts_json, created_at, updated_at, deleted_at + ) VALUES + ('project-stored-chat', 'chat', 'Stored Chat', '/tmp/stored-chat', NULL, '[]', + '2026-07-18T00:00:03.000Z', '2026-07-18T00:00:03.000Z', NULL), + ('project-stored', 'project', 'Stored Project', '/tmp/stored', NULL, '[]', + '2026-07-18T00:00:04.000Z', '2026-07-18T00:00:04.000Z', NULL), + ('project-unseen-b', 'chat', 'Unseen Chat', '/tmp/unseen-b', NULL, '[]', + '2026-07-18T00:00:01.000Z', '2026-07-18T00:00:01.000Z', NULL), + ('project-unseen-a', 'project', 'Unseen Project', '/tmp/unseen-a', NULL, '[]', + '2026-07-18T00:00:01.000Z', '2026-07-18T00:00:01.000Z', NULL), + ('project-deleted', 'project', 'Deleted Project', '/tmp/deleted', NULL, '[]', + '2026-07-18T00:00:00.000Z', '2026-07-18T00:00:00.000Z', + '2026-07-18T00:00:05.000Z') + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, is_pinned, + created_at, updated_at, deleted_at + ) VALUES + ('thread-layout', 'project-stored', 'Stored Pin', + '{"provider":"codex","model":"gpt-5-codex"}', 0, + '2026-07-18T00:00:01.000Z', '2026-07-18T00:00:01.000Z', NULL), + ('thread-flag-only', 'project-stored', 'Flag Is Not Authority', + '{"provider":"codex","model":"gpt-5-codex"}', 1, + '2026-07-18T00:00:02.000Z', '2026-07-18T00:00:02.000Z', NULL), + ('thread-deleted', 'project-stored', 'Deleted Pin', + '{"provider":"codex","model":"gpt-5-codex"}', 1, + '2026-07-18T00:00:00.000Z', '2026-07-18T00:00:00.000Z', + '2026-07-18T00:00:05.000Z') + `; + yield* sql` + INSERT INTO projection_sidebar_layout ( + layout_key, project_order_json, pinned_thread_order_json, + revision, initialized_at, updated_at + ) VALUES ( + 'sidebar-layout', + '["project-stored-chat","project-deleted","project-stored","project-stored-chat"]', + '["thread-deleted","thread-layout","thread-layout"]', + 42, + '2026-07-18T00:00:00.000Z', + '2026-07-18T00:00:06.000Z' + ) + `; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + const sequence = projector === ORCHESTRATION_PROJECTOR_NAMES.sidebarLayout ? 7 : 12; + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, ${sequence}, '2026-07-18T00:00:07.000Z') + `; + } + + // When: full and shell snapshots are read from one fenced transaction each. + const [full, shell] = yield* Effect.all([ + snapshotQuery.getSnapshot(), + snapshotQuery.getShellSnapshot(), + ]); + + // Then: both expose identical canonical state and the layout cursor participates in fencing. + const expectedLayout = { + projectOrder: [ + asProjectId("project-stored-chat"), + asProjectId("project-stored"), + asProjectId("project-unseen-a"), + asProjectId("project-unseen-b"), + ], + pinnedThreadOrder: [asThreadId("thread-layout")], + revision: 42, + updatedAt: "2026-07-18T00:00:06.000Z", + }; + assert.deepEqual(full.sidebarLayout, expectedLayout); + assert.deepEqual(shell.sidebarLayout, expectedLayout); + assert.equal(full.snapshotSequence, 7); + assert.equal(shell.snapshotSequence, 7); + + yield* sql`DELETE FROM projection_sidebar_layout`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_state`; + }), + ); + + it.effect("fails with a typed decode error when persisted layout JSON is corrupt", () => + Effect.gen(function* () { + // Given: a malformed persisted layout row. + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM projection_sidebar_layout`; + yield* sql` + INSERT INTO projection_sidebar_layout ( + layout_key, project_order_json, pinned_thread_order_json, + revision, initialized_at, updated_at + ) VALUES ( + 'sidebar-layout', '{malformed', '[]', 9, + '2026-07-18T00:00:00.000Z', '2026-07-18T00:00:01.000Z' + ) + `; + + // When: a snapshot boundary decodes the row. + const error = yield* snapshotQuery.getShellSnapshot().pipe(Effect.flip); + + // Then: corruption is loud and typed rather than treated as uninitialized state. + assert.equal(error._tag, "PersistenceDecodeError"); + assert.equal( + error.operation, + "ProjectionSnapshotQuery.getShellSnapshot:getSidebarLayout:decodeRow", + ); + yield* sql`DELETE FROM projection_sidebar_layout`; + }), + ); + it.effect("hydrates read model from projection tables and computes snapshot sequence", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5a84e033a..2a9b1b77b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -13,6 +13,7 @@ import { OrchestrationThreadPullRequest, ProjectScript, ProjectId, + SIDEBAR_LAYOUT_ID, ProviderMentionReference, ProviderSkillReference, ThreadId, @@ -47,6 +48,7 @@ import { normalizePersistedModelSelection } from "../../persistence/modelSelecti import { deriveThreadSummaryMetadata } from "@jcode/shared/threadSummary"; import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; +import { ProjectionSidebarLayout } from "../../persistence/Services/ProjectionSidebarLayout.ts"; import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; @@ -54,6 +56,7 @@ import { ProjectionThreadProposedPlan } from "../../persistence/Services/Project import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; +import { normalizeSidebarLayout } from "../sidebarLayout.ts"; import { ProjectionSnapshotQuery, type ProjectionSnapshotCounts, @@ -122,6 +125,12 @@ const ProjectionLatestTurnDbRowSchema = Schema.Struct({ sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), }); const ProjectionStateDbRowSchema = ProjectionState; +const ProjectionSidebarLayoutDbRowSchema = ProjectionSidebarLayout.mapFields( + Struct.assign({ + projectOrder: Schema.fromJsonString(Schema.Array(ProjectId)), + pinnedThreadOrder: Schema.fromJsonString(Schema.Array(ThreadId)), + }), +); const ProjectionCountsRowSchema = Schema.Struct({ projectCount: Schema.Number, threadCount: Schema.Number, @@ -170,6 +179,7 @@ type ProjectionThreadActivityDbRow = Schema.Schema.Type; type ProjectionLatestTurnDbRow = Schema.Schema.Type; type ProjectionThreadSessionDbRow = Schema.Schema.Type; +type ProjectionSidebarLayoutDbRow = Schema.Schema.Type; function decodeProjectionProjectRow( row: ProjectionProjectDbRowRaw, @@ -243,8 +253,38 @@ const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.threadActivities, ORCHESTRATION_PROJECTOR_NAMES.threadSessions, ORCHESTRATION_PROJECTOR_NAMES.checkpoints, + ORCHESTRATION_PROJECTOR_NAMES.sidebarLayout, ] as const; +function toNormalizedSidebarLayout( + row: Option.Option, + projects: readonly ProjectionProjectDbRow[], + threads: readonly ProjectionThreadDbRow[], +) { + if (Option.isNone(row)) { + return null; + } + return { + ...normalizeSidebarLayout({ + projectOrder: row.value.projectOrder, + pinnedThreadOrder: row.value.pinnedThreadOrder, + projects: projects.map((project) => ({ + id: project.projectId, + createdAt: project.createdAt, + deletedAt: project.deletedAt, + })), + threads: threads.map((thread) => ({ + id: thread.threadId, + createdAt: thread.createdAt, + deletedAt: thread.deletedAt, + isPinned: thread.isPinned === 1, + })), + }), + revision: row.value.revision, + updatedAt: row.value.updatedAt, + }; +} + function maxIso(left: string | null, right: string): string { if (left === null) { return right; @@ -836,6 +876,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getSidebarLayoutRow = SqlSchema.findOneOption({ + Request: Schema.Void, + Result: ProjectionSidebarLayoutDbRowSchema, + execute: () => + sql` + SELECT + layout_key AS "layoutKey", + project_order_json AS "projectOrder", + pinned_thread_order_json AS "pinnedThreadOrder", + revision, + initialized_at AS "initializedAt", + updated_at AS "updatedAt" + FROM projection_sidebar_layout + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `, + }); + // Cheap targeted reads avoid hydrating the full snapshot for startup and diff lookups. const readProjectionCounts = SqlSchema.findOne({ Request: Schema.Void, @@ -1252,6 +1309,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointRows, latestTurnRows, stateRows, + sidebarLayoutRow, ] = yield* Effect.all([ listProjectRows(undefined).pipe( Effect.mapError( @@ -1337,6 +1395,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + getSidebarLayoutRow(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:getSidebarLayout:query", + "ProjectionSnapshotQuery.getSnapshot:getSidebarLayout:decodeRow", + ), + ), + ), ]); const messagesByThread = new Map>(); @@ -1357,6 +1423,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { for (const row of stateRows) { updatedAt = maxIso(updatedAt, row.updatedAt); } + if (Option.isSome(sidebarLayoutRow)) { + updatedAt = maxIso(updatedAt, sidebarLayoutRow.value.updatedAt); + } for (const row of messageRows) { updatedAt = maxIso(updatedAt, row.updatedAt); @@ -1432,6 +1501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const snapshot = { snapshotSequence: computeSnapshotSequence(stateRows), + sidebarLayout: toNormalizedSidebarLayout(sidebarLayoutRow, projectRows, threadRows), projects, threads, updatedAt: updatedAt ?? new Date(0).toISOString(), @@ -1457,61 +1527,75 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql .withTransaction( Effect.gen(function* () { - const [projectRows, threadRows, sessionRows, latestTurnRows, stateRows] = - yield* Effect.all([ - listProjectRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:listProjects:query", - "ProjectionSnapshotQuery.getShellSnapshot:listProjects:decodeRows", - ), + const [ + projectRows, + threadRows, + sessionRows, + latestTurnRows, + stateRows, + sidebarLayoutRow, + ] = yield* Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:decodeRows", ), - Effect.flatMap((rows) => - decodeProjectionProjectRows( - rows, - "ProjectionSnapshotQuery.getShellSnapshot:listProjects:decodeModelSelections", - ), + ), + Effect.flatMap((rows) => + decodeProjectionProjectRows( + rows, + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:decodeModelSelections", + ), + ), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:decodeRows", ), ), - listThreadRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", - "ProjectionSnapshotQuery.getShellSnapshot:listThreads:decodeRows", - ), + Effect.flatMap((rows) => + decodeProjectionThreadRows( + rows, + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:decodeModelSelections", ), - Effect.flatMap((rows) => - decodeProjectionThreadRows( - rows, - "ProjectionSnapshotQuery.getShellSnapshot:listThreads:decodeModelSelections", - ), + ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:decodeRows", ), ), - listThreadSessionRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", - "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:decodeRows", - ), + ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:decodeRows", ), ), - listLatestTurnRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query", - "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:decodeRows", - ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:decodeRows", ), ), - listProjectionStateRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:query", - "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:decodeRows", - ), + ), + getSidebarLayoutRow(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:getSidebarLayout:query", + "ProjectionSnapshotQuery.getShellSnapshot:getSidebarLayout:decodeRow", ), ), - ]); + ), + ]); const sessionsByThread = new Map(); const latestTurnByThread = new Map(); @@ -1527,6 +1611,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { for (const row of stateRows) { updatedAt = maxIso(updatedAt, row.updatedAt); } + if (Option.isSome(sidebarLayoutRow)) { + updatedAt = maxIso(updatedAt, sidebarLayoutRow.value.updatedAt); + } for (const row of latestTurnRows) { updatedAt = maxIso(updatedAt, row.requestedAt); if (row.startedAt !== null) { @@ -1547,6 +1634,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const snapshot = { snapshotSequence: computeSnapshotSequence(stateRows), + sidebarLayout: toNormalizedSidebarLayout(sidebarLayoutRow, projectRows, threadRows), projects: projectRows .filter((row) => row.deletedAt === null) .map((row) => toProjectedProjectShell(row)), diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 2859b2df8..33ea6a134 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -2,6 +2,7 @@ import { ProjectCreatedPayload as ContractsProjectCreatedPayloadSchema, ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, + SidebarLayoutUpdatedPayload as ContractsSidebarLayoutUpdatedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, @@ -34,6 +35,7 @@ import { export const ProjectCreatedPayload = ContractsProjectCreatedPayloadSchema; export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchema; export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; +export const SidebarLayoutUpdatedPayload = ContractsSidebarLayoutUpdatedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; diff --git a/apps/server/src/orchestration/Services/ProjectionPipeline.ts b/apps/server/src/orchestration/Services/ProjectionPipeline.ts index 6ebdb7e75..a752d7d66 100644 --- a/apps/server/src/orchestration/Services/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Services/ProjectionPipeline.ts @@ -35,7 +35,8 @@ export interface OrchestrationProjectionPipelineShape { /** * Project only the hot-path repositories required for live transcript and - * session updates during streaming. + * session updates during streaming. When invoked by the orchestration engine, + * these writes and their cursors join the caller-owned event/receipt transaction. */ readonly projectHotEvent: ( event: OrchestrationEvent, diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 4166ef72d..b2d23cfde 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -25,6 +25,7 @@ const now = new Date().toISOString(); const readModel: OrchestrationReadModel = { snapshotSequence: 2, + sidebarLayout: null, updatedAt: now, projects: [ { diff --git a/apps/server/src/orchestration/decider.sidebarLayout.test.ts b/apps/server/src/orchestration/decider.sidebarLayout.test.ts new file mode 100644 index 000000000..bd273609e --- /dev/null +++ b/apps/server/src/orchestration/decider.sidebarLayout.test.ts @@ -0,0 +1,234 @@ +import { + CommandId, + OrchestrationReadModel, + ProjectId, + SIDEBAR_LAYOUT_ID, + ThreadId, + type OrchestrationCommand, +} from "@jcode/contracts"; +import { Effect, Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const occurredAt = "2026-07-18T12:00:00.000Z"; +const projectId = ProjectId.makeUnsafe; +const threadId = ThreadId.makeUnsafe; +const commandId = CommandId.makeUnsafe; + +function makeReadModel(input: { + readonly initialized?: boolean; + readonly projectOrder?: readonly string[]; + readonly pinnedThreadOrder?: readonly string[]; +}) { + return Schema.decodeUnknownSync(OrchestrationReadModel)({ + snapshotSequence: input.initialized === true ? 20 : 4, + sidebarLayout: + input.initialized === true + ? { + projectOrder: (input.projectOrder ?? ["project-a", "project-b"]).map((id) => + projectId(id), + ), + pinnedThreadOrder: (input.pinnedThreadOrder ?? ["thread-a"]).map((id) => threadId(id)), + revision: 20, + updatedAt: occurredAt, + } + : null, + projects: [ + { + id: projectId("project-a"), + kind: "project", + title: "Project A", + workspaceRoot: "/tmp/project-a", + defaultModelSelection: null, + scripts: [], + iconMetadata: null, + createdAt: "2026-07-18T10:00:00.000Z", + updatedAt: occurredAt, + deletedAt: null, + }, + { + id: projectId("project-b"), + kind: "project", + title: "Project B", + workspaceRoot: "/tmp/project-b", + defaultModelSelection: null, + scripts: [], + iconMetadata: null, + createdAt: "2026-07-18T11:00:00.000Z", + updatedAt: occurredAt, + deletedAt: null, + }, + ], + threads: [ + { + id: threadId("thread-a"), + projectId: projectId("project-a"), + title: "Thread A", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + isPinned: true, + latestTurn: null, + createdAt: "2026-07-18T10:00:00.000Z", + updatedAt: occurredAt, + deletedAt: null, + messages: [], + activities: [], + checkpoints: [], + session: null, + }, + { + id: threadId("thread-b"), + projectId: projectId("project-b"), + title: "Thread B", + modelSelection: { provider: "codex", model: "gpt-5" }, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + isPinned: false, + latestTurn: null, + createdAt: "2026-07-18T11:00:00.000Z", + updatedAt: occurredAt, + deletedAt: null, + messages: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: occurredAt, + }); +} + +async function decide(command: OrchestrationCommand, initialized = true) { + return Effect.runPromise( + decideOrchestrationCommand({ command, readModel: makeReadModel({ initialized }) }), + ); +} + +describe("sidebar layout decider", () => { + it("initializes canonical order once and preserves existing server pins", async () => { + // Given + const command: OrchestrationCommand = { + type: "sidebar-layout.initialize", + commandId: commandId("layout-initialize"), + projectOrder: [projectId("project-b")], + pinnedThreadOrder: [threadId("thread-b")], + }; + + // When + const event = await decide(command, false); + + // Then + expect(event).toEqual( + expect.objectContaining({ + type: "sidebar-layout.updated", + aggregateKind: "sidebar-layout", + aggregateId: SIDEBAR_LAYOUT_ID, + commandId: command.commandId, + payload: { + projectOrder: [projectId("project-b"), projectId("project-a")], + pinnedThreadOrder: [threadId("thread-b"), threadId("thread-a")], + updatedAt: expect.any(String), + }, + }), + ); + }); + + it("emits the existing canonical order for a losing initialization", async () => { + // Given + const command: OrchestrationCommand = { + type: "sidebar-layout.initialize", + commandId: commandId("layout-initialize-losing"), + projectOrder: [projectId("project-b")], + pinnedThreadOrder: [threadId("thread-b")], + }; + + // When + const event = await decide(command); + + // Then + expect(event).toEqual( + expect.objectContaining({ + type: "sidebar-layout.updated", + payload: { + projectOrder: [projectId("project-a"), projectId("project-b")], + pinnedThreadOrder: [threadId("thread-a")], + updatedAt: expect.any(String), + }, + }), + ); + }); + + it.each([ + { + name: "moves a project", + command: { + type: "sidebar-layout.project.move", + commandId: commandId("layout-project-move"), + projectId: projectId("project-b"), + beforeProjectId: projectId("project-a"), + } satisfies OrchestrationCommand, + projectOrder: [projectId("project-b"), projectId("project-a")], + pinnedThreadOrder: [threadId("thread-a")], + }, + { + name: "pins a thread", + command: { + type: "sidebar-layout.thread.pin", + commandId: commandId("layout-thread-pin"), + threadId: threadId("thread-b"), + beforeThreadId: threadId("thread-a"), + } satisfies OrchestrationCommand, + projectOrder: [projectId("project-a"), projectId("project-b")], + pinnedThreadOrder: [threadId("thread-b"), threadId("thread-a")], + }, + { + name: "unpins a thread", + command: { + type: "sidebar-layout.thread.unpin", + commandId: commandId("layout-thread-unpin"), + threadId: threadId("thread-a"), + } satisfies OrchestrationCommand, + projectOrder: [projectId("project-a"), projectId("project-b")], + pinnedThreadOrder: [], + }, + { + name: "moves a pinned thread", + command: { + type: "sidebar-layout.pinned-thread.move", + commandId: commandId("layout-pinned-thread-move"), + threadId: threadId("thread-b"), + beforeThreadId: threadId("thread-a"), + } satisfies OrchestrationCommand, + projectOrder: [projectId("project-a"), projectId("project-b")], + pinnedThreadOrder: [threadId("thread-b"), threadId("thread-a")], + readModel: makeReadModel({ + initialized: true, + pinnedThreadOrder: ["thread-a", "thread-b"], + }), + }, + ])("$name and emits the full canonical layout", async (testCase) => { + // Given + const readModel = testCase.readModel ?? makeReadModel({ initialized: true }); + + // When + const event = await Effect.runPromise( + decideOrchestrationCommand({ command: testCase.command, readModel }), + ); + + // Then + expect(event).toEqual( + expect.objectContaining({ + type: "sidebar-layout.updated", + payload: { + projectOrder: testCase.projectOrder, + pinnedThreadOrder: testCase.pinnedThreadOrder, + updatedAt: expect.any(String), + }, + }), + ); + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 07031761e..783980e1f 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -4,7 +4,10 @@ import type { OrchestrationGoalStatus, OrchestrationReadModel, OrchestrationThread, + ProjectId, + ThreadId, } from "@jcode/contracts"; +import { SIDEBAR_LAYOUT_ID } from "@jcode/contracts"; import { deriveAssociatedWorktreeMetadata, deriveAssociatedWorktreeMetadataPatch, @@ -29,6 +32,7 @@ import { requireThreadArchived, requireThreadNotArchived, } from "./commandInvariants.ts"; +import { decideSidebarLayoutCommand } from "./sidebarLayoutDecider.ts"; const nowIso = () => new Date().toISOString(); const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; @@ -151,6 +155,32 @@ function deriveConversationRollbackTarget( }; } +function makeSidebarLayoutUpdatedEvent( + command: OrchestrationCommand, + layout: { + readonly projectOrder: readonly ProjectId[]; + readonly pinnedThreadOrder: readonly ThreadId[]; + }, +) { + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "sidebar-layout", + aggregateId: SIDEBAR_LAYOUT_ID, + occurredAt, + commandId: command.commandId, + }), + aggregateKind: "sidebar-layout" as const, + aggregateId: SIDEBAR_LAYOUT_ID, + type: "sidebar-layout.updated" as const, + payload: { + projectOrder: layout.projectOrder, + pinnedThreadOrder: layout.pinnedThreadOrder, + updatedAt: occurredAt, + }, + }; +} + export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ command, readModel, @@ -162,6 +192,16 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" OrchestrationCommandInvariantError > { switch (command.type) { + case "sidebar-layout.initialize": + case "sidebar-layout.project.move": + case "sidebar-layout.thread.pin": + case "sidebar-layout.thread.unpin": + case "sidebar-layout.pinned-thread.move": + return makeSidebarLayoutUpdatedEvent( + command, + yield* decideSidebarLayoutCommand({ command, readModel }), + ); + case "project.create": { yield* requireProjectAbsent({ readModel, @@ -338,7 +378,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), }), createBranchFlowCompleted: command.createBranchFlowCompleted, - isPinned: command.isPinned, + isPinned: false, parentThreadId: command.parentThreadId, subagentAgentId: command.subagentAgentId, subagentNickname: command.subagentNickname, @@ -669,7 +709,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.createBranchFlowCompleted !== undefined ? { createBranchFlowCompleted: command.createBranchFlowCompleted } : {}), - ...(command.isPinned !== undefined ? { isPinned: command.isPinned } : {}), ...(command.parentThreadId !== undefined ? { parentThreadId: command.parentThreadId } : {}), diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index edd4db042..b0c9895f8 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -1,4 +1,11 @@ -import { CommandId, EventId, ProjectId, ThreadId, type OrchestrationEvent } from "@jcode/contracts"; +import { + CommandId, + EventId, + ProjectId, + SIDEBAR_LAYOUT_ID, + ThreadId, + type OrchestrationEvent, +} from "@jcode/contracts"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; @@ -21,7 +28,9 @@ function makeEvent(input: { aggregateId: input.aggregateKind === "project" ? ProjectId.makeUnsafe(input.aggregateId) - : ThreadId.makeUnsafe(input.aggregateId), + : input.aggregateKind === "thread" + ? ThreadId.makeUnsafe(input.aggregateId) + : SIDEBAR_LAYOUT_ID, occurredAt: input.occurredAt, commandId: input.commandId === null ? null : CommandId.makeUnsafe(input.commandId), causationEventId: null, @@ -31,7 +40,133 @@ function makeEvent(input: { } as OrchestrationEvent; } +async function makeModelWithThreads( + occurredAt: string, + threads: readonly { readonly id: string; readonly isPinned: boolean }[], +) { + let model = createEmptyReadModel(occurredAt); + for (const [index, thread] of threads.entries()) { + model = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: index + 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: thread.id, + occurredAt, + commandId: `create-${thread.id}`, + payload: { + threadId: thread.id, + projectId: "project-a", + title: thread.id, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + isPinned: thread.isPinned, + createdAt: occurredAt, + updatedAt: occurredAt, + }, + }), + ), + ); + } + return model; +} + describe("orchestration projector", () => { + it("projects a canonical sidebar layout at the saved global event sequence", async () => { + // Given + const occurredAt = "2026-07-18T12:00:00.000Z"; + const model = await makeModelWithThreads(occurredAt, [ + { id: "thread-a", isPinned: false }, + { id: "thread-b", isPinned: true }, + ]); + + // When + const next = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 17, + type: "sidebar-layout.updated", + aggregateKind: "sidebar-layout", + aggregateId: SIDEBAR_LAYOUT_ID, + occurredAt, + commandId: "cmd-layout-update", + payload: { + projectOrder: ["project-b", "project-a"], + pinnedThreadOrder: ["thread-a"], + updatedAt: occurredAt, + }, + }), + ), + ); + + // Then + expect(next.sidebarLayout).toEqual({ + projectOrder: ["project-b", "project-a"], + pinnedThreadOrder: ["thread-a"], + revision: 17, + updatedAt: occurredAt, + }); + expect(next.threads.map((thread) => [thread.id, thread.isPinned])).toEqual([ + ["thread-a", true], + ["thread-b", false], + ]); + }); + + it("replays historical pin metadata but cannot override initialized canonical membership", async () => { + // Given + const occurredAt = "2026-07-18T12:00:00.000Z"; + const model = await makeModelWithThreads(occurredAt, [{ id: "thread-a", isPinned: false }]); + const historicalPin = makeEvent({ + sequence: 1, + type: "thread.meta-updated", + aggregateKind: "thread", + aggregateId: "thread-a", + occurredAt, + commandId: "legacy-pin", + payload: { threadId: "thread-a", isPinned: true, updatedAt: occurredAt }, + }); + + // When + const beforeInitialization = await Effect.runPromise(projectEvent(model, historicalPin)); + const initialized = await Effect.runPromise( + projectEvent( + beforeInitialization, + makeEvent({ + sequence: 2, + type: "sidebar-layout.updated", + aggregateKind: "sidebar-layout", + aggregateId: SIDEBAR_LAYOUT_ID, + occurredAt, + commandId: "initialize-empty-pins", + payload: { projectOrder: [], pinnedThreadOrder: [], updatedAt: occurredAt }, + }), + ), + ); + const afterInitialization = await Effect.runPromise( + projectEvent( + initialized, + makeEvent({ + sequence: 3, + type: "thread.meta-updated", + aggregateKind: "thread", + aggregateId: "thread-a", + occurredAt, + commandId: "legacy-pin-after-layout", + payload: { threadId: "thread-a", isPinned: true, updatedAt: occurredAt }, + }), + ), + ); + + // Then + expect(beforeInitialization.threads[0]?.isPinned).toBe(true); + expect(afterInitialization.threads[0]?.isPinned).toBe(false); + }); + it("applies thread.created events", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 2382d3b23..0975d8b2c 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -13,6 +13,7 @@ import { ProjectCreatedPayload, ProjectDeletedPayload, ProjectMetaUpdatedPayload, + SidebarLayoutUpdatedPayload, ThreadArchivedPayload, ThreadActivityAppendedPayload, ThreadCreatedPayload, @@ -200,6 +201,7 @@ function compareThreadActivities( export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { return { snapshotSequence: 0, + sidebarLayout: null, projects: [], threads: [], updatedAt: nowIso, @@ -217,6 +219,26 @@ export function projectEvent( }; switch (event.type) { + case "sidebar-layout.updated": + return decodeForEvent(SidebarLayoutUpdatedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => { + const pinnedThreadIds = new Set(payload.pinnedThreadOrder); + return { + ...nextBase, + sidebarLayout: { + projectOrder: payload.projectOrder, + pinnedThreadOrder: payload.pinnedThreadOrder, + revision: event.sequence, + updatedAt: payload.updatedAt, + }, + threads: nextBase.threads.map((thread) => ({ + ...thread, + isPinned: pinnedThreadIds.has(thread.id), + })), + }; + }), + ); + case "project.created": return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => { @@ -312,7 +334,10 @@ export function projectEvent( associatedWorktreeBranch: payload.associatedWorktreeBranch, associatedWorktreeRef: payload.associatedWorktreeRef, createBranchFlowCompleted: payload.createBranchFlowCompleted, - isPinned: payload.isPinned, + isPinned: + nextBase.sidebarLayout === null + ? payload.isPinned + : nextBase.sidebarLayout.pinnedThreadOrder.includes(payload.threadId), parentThreadId: payload.parentThreadId, subagentAgentId: payload.subagentAgentId, subagentNickname: payload.subagentNickname, @@ -417,7 +442,9 @@ export function projectEvent( ...(nextCreateBranchFlowCompleted !== undefined ? { createBranchFlowCompleted: nextCreateBranchFlowCompleted } : {}), - ...(payload.isPinned !== undefined ? { isPinned: payload.isPinned } : {}), + ...(nextBase.sidebarLayout === null && payload.isPinned !== undefined + ? { isPinned: payload.isPinned } + : {}), ...(payload.parentThreadId !== undefined ? { parentThreadId: payload.parentThreadId } : {}), diff --git a/apps/server/src/orchestration/sidebarLayout.test.ts b/apps/server/src/orchestration/sidebarLayout.test.ts new file mode 100644 index 000000000..f8643a3e7 --- /dev/null +++ b/apps/server/src/orchestration/sidebarLayout.test.ts @@ -0,0 +1,342 @@ +import { ProjectId, ThreadId } from "@jcode/contracts"; +import { describe, expect, it } from "vitest"; + +import { + initializeSidebarLayout, + movePinnedThreadBefore, + moveProjectBefore, + normalizeSidebarLayout, + pinThreadBefore, + pinnedMembershipEquals, + unpinThread, +} from "./sidebarLayout.ts"; + +const projectId = ProjectId.makeUnsafe; +const threadId = ThreadId.makeUnsafe; +const createdAt = (day: number) => `2026-01-${day.toString().padStart(2, "0")}`; + +function project(id: string, day: number, deletedAt: string | null = null) { + return { id: projectId(id), createdAt: createdAt(day), deletedAt }; +} + +function thread(id: string, day: number, isPinned = false, deletedAt: string | null = null) { + return { id: threadId(id), createdAt: createdAt(day), deletedAt, isPinned }; +} + +const projectsABC = () => [ + project("project-a", 1), + project("project-b", 2), + project("project-c", 3), +]; + +type ProjectRows = Parameters[0]["projects"]; + +function normalizedProjectOrder(projects: ProjectRows, projectOrder: readonly ProjectId[] = []) { + return normalizeSidebarLayout({ projectOrder, pinnedThreadOrder: [], projects, threads: [] }) + .projectOrder; +} + +const liveThreadsAB = [thread("thread-a", 1), thread("thread-b", 2)]; +const pinB = (pinnedThreadOrder: readonly ThreadId[], beforeThreadId: ThreadId) => + pinThreadBefore({ + pinnedThreadOrder, + threadId: threadId("thread-b"), + beforeThreadId, + threads: liveThreadsAB, + }); + +describe("sidebar layout", () => { + it("normalizes project order when candidates are duplicated, stale, or partial", () => { + // Given + const projectOrder = [ + projectId("project-chat"), + projectId("project-chat"), + projectId("project-unknown"), + projectId("project-deleted"), + ]; + const projects = [ + project("project-z", 3), + { ...project("project-chat", 2), kind: "chat" as const }, + project("project-a", 3), + project("project-deleted", 1, "2026-02-01"), + ]; + + // When + const result = normalizedProjectOrder(projects, projectOrder); + + // Then + expect(result).toEqual([ + projectId("project-chat"), + projectId("project-a"), + projectId("project-z"), + ]); + }); + + it("appends a duplicated live project row only once", () => { + // Given + const projects = [ + project("project-duplicate", 1), + project("project-other", 2), + project("project-duplicate", 3), + ]; + + // When + const result = normalizedProjectOrder(projects); + + // Then + expect(result).toEqual([projectId("project-duplicate"), projectId("project-other")]); + }); + + it("normalizes pinned order when ids are duplicated, unknown, or deleted", () => { + // Given + const pinnedThreadOrder = [ + threadId("thread-live"), + threadId("thread-live"), + threadId("thread-unknown"), + threadId("thread-deleted"), + ]; + const threads = [thread("thread-live", 1), thread("thread-deleted", 2, true, "2026-02-01")]; + + // When + const result = normalizeSidebarLayout({ + projectOrder: [], + pinnedThreadOrder, + projects: [], + threads, + }); + + // Then + expect(result.pinnedThreadOrder).toEqual([threadId("thread-live")]); + }); + + it("initializes empty project and partial pin candidates while preserving server pins", () => { + // Given + const projects = [project("project-later", 2), project("project-first", 1)]; + const threads = [ + thread("thread-client", 4), + thread("thread-z", 3, true), + thread("thread-a", 3, true), + thread("thread-unpinned", 1), + thread("thread-deleted-pin", 2, true, "2026-02-01"), + ]; + + // When + const result = initializeSidebarLayout({ + projectOrderCandidates: [], + pinnedThreadOrderCandidates: [ + threadId("thread-client"), + threadId("thread-client"), + threadId("thread-unknown"), + threadId("thread-deleted-pin"), + ], + projects, + threads, + }); + + // Then + expect(result).toEqual({ + projectOrder: [projectId("project-first"), projectId("project-later")], + pinnedThreadOrder: [threadId("thread-client"), threadId("thread-a"), threadId("thread-z")], + }); + }); + + it.each([ + ["missing", projectId("project-missing")], + ["deleted", projectId("project-deleted")], + ])("returns an explicit failure for a %s project subject", (_case, subjectId) => { + // Given + const projects = [project("project-a", 1), project("project-deleted", 2, "2026-02-01")]; + + // When + const result = moveProjectBefore({ + projectOrder: projects.map((project) => project.id), + projectId: subjectId, + beforeProjectId: projectId("project-a"), + projects, + }); + + // Then + expect(result).toEqual({ + kind: "subject-not-found", + subject: "project", + subjectId, + }); + }); + + it.each([ + ["upward", "project-c", "project-a", ["project-c", "project-a", "project-b"]], + ["downward", "project-a", "project-c", ["project-b", "project-a", "project-c"]], + ["to the end", "project-b", null, ["project-a", "project-c", "project-b"]], + [ + "past a missing anchor", + "project-b", + "project-missing", + ["project-a", "project-c", "project-b"], + ], + ["before itself", "project-b", "project-b", ["project-a", "project-b", "project-c"]], + [ + "past a deleted anchor", + "project-b", + "project-deleted", + ["project-a", "project-c", "project-b"], + ], + ])("moves a project %s", (_case, subject, before, expected) => { + // Given + const projects = [...projectsABC(), project("project-deleted", 4, "2026-02-01")]; + + // When + const result = moveProjectBefore({ + projectOrder: projects.map((project) => project.id), + projectId: projectId(subject), + beforeProjectId: before === null ? null : projectId(before), + projects, + }); + + // Then + expect(result).toEqual({ + kind: "applied", + projectOrder: expected.map((id) => projectId(id)), + }); + }); + + it.each([ + ["upward", "thread-c", "thread-a", ["thread-c", "thread-a", "thread-b"]], + ["downward", "thread-a", "thread-c", ["thread-b", "thread-a", "thread-c"]], + ["to the end", "thread-b", null, ["thread-a", "thread-c", "thread-b"]], + ["past a missing anchor", "thread-b", "thread-missing", ["thread-a", "thread-c", "thread-b"]], + ["before itself", "thread-b", "thread-b", ["thread-a", "thread-b", "thread-c"]], + ["past a deleted anchor", "thread-b", "thread-deleted", ["thread-a", "thread-c", "thread-b"]], + ])("moves a pinned thread %s", (_case, subject, before, expected) => { + // Given + const threads = [ + thread("thread-a", 1), + thread("thread-b", 2), + thread("thread-c", 3), + thread("thread-deleted", 4, true, "2026-02-01"), + ]; + + // When + const result = movePinnedThreadBefore({ + pinnedThreadOrder: threads.map((item) => item.id), + threadId: threadId(subject), + beforeThreadId: before === null ? null : threadId(before), + threads, + }); + + // Then + expect(result).toEqual({ + kind: "applied", + pinnedThreadOrder: expected.map((id) => threadId(id)), + }); + }); + + it.each([ + ["missing", threadId("thread-missing")], + ["deleted", threadId("thread-deleted")], + ])("returns an explicit failure for a %s pinned-thread subject", (_case, subjectId) => { + // Given + const threads = [thread("thread-live", 1), thread("thread-deleted", 2, true, "2026-02-01")]; + + // When + const result = movePinnedThreadBefore({ + pinnedThreadOrder: threads.map((item) => item.id), + threadId: subjectId, + beforeThreadId: threadId("thread-live"), + threads, + }); + + // Then + expect(result).toEqual({ kind: "subject-not-found", subject: "thread", subjectId }); + }); + + it("returns an explicit failure when a live thread is not pinned", () => { + // Given + const threads = [thread("thread-live", 1)]; + + // When + const result = movePinnedThreadBefore({ + pinnedThreadOrder: [], + threadId: threadId("thread-live"), + beforeThreadId: null, + threads, + }); + + // Then + expect(result).toEqual({ kind: "subject-not-pinned", threadId: threadId("thread-live") }); + }); + + it("pins idempotently and repositions an already-pinned thread", () => { + // Given + const initialOrder = [threadId("thread-a")]; + + // When + const once = pinB(initialOrder, threadId("thread-a")); + const twice = + once.kind === "applied" ? pinB(once.pinnedThreadOrder, threadId("thread-a")) : once; + + // Then + expect(once).toEqual({ + kind: "applied", + pinnedThreadOrder: [threadId("thread-b"), threadId("thread-a")], + }); + expect(twice).toEqual(once); + }); + + it("appends an absent live thread when its pin anchor is itself", () => { + // Given + const initialOrder = [threadId("thread-a")]; + + // When + const result = pinB(initialOrder, threadId("thread-b")); + + // Then + expect(result).toEqual({ + kind: "applied", + pinnedThreadOrder: [threadId("thread-a"), threadId("thread-b")], + }); + }); + + it("unpins idempotently", () => { + // Given + const threads = [thread("thread-a", 1)]; + + // When + const unpin = (pinnedThreadOrder: readonly ThreadId[]) => + unpinThread({ pinnedThreadOrder, threadId: threadId("thread-a"), threads }); + const once = unpin([threadId("thread-a")]); + const twice = once.kind === "applied" ? unpin(once.pinnedThreadOrder) : once; + + // Then + expect(once).toEqual({ kind: "applied", pinnedThreadOrder: [] }); + expect(twice).toEqual(once); + }); + + it("compares exact pinned membership independently of order", () => { + // Given + const canonical = [threadId("thread-a"), threadId("thread-b")]; + + // When + const reordered = pinnedMembershipEquals(canonical, [ + threadId("thread-b"), + threadId("thread-a"), + ]); + const missing = pinnedMembershipEquals(canonical, [threadId("thread-a")]); + + // Then + expect(reordered).toBe(true); + expect(missing).toBe(false); + }); + + it("does not mutate input arrays or records", () => { + // Given + const projectOrder = [projectId("project-b")]; + const projects = projectsABC(); + const before = structuredClone({ projectOrder, projects }); + + // When + normalizeSidebarLayout({ projectOrder, pinnedThreadOrder: [], projects, threads: [] }); + + // Then + expect({ projectOrder, projects }).toEqual(before); + }); +}); diff --git a/apps/server/src/orchestration/sidebarLayout.ts b/apps/server/src/orchestration/sidebarLayout.ts new file mode 100644 index 000000000..ac12ff1d1 --- /dev/null +++ b/apps/server/src/orchestration/sidebarLayout.ts @@ -0,0 +1,254 @@ +import type { + OrchestrationProject, + OrchestrationThread, + ProjectId, + ThreadId, +} from "@jcode/contracts"; + +type SidebarLayoutProject = Pick; + +type SidebarLayoutThread = Pick; + +export type SidebarLayoutOrder = { + readonly projectOrder: readonly ProjectId[]; + readonly pinnedThreadOrder: readonly ThreadId[]; +}; + +export type SidebarLayoutSubjectNotFound = + | { + readonly kind: "subject-not-found"; + readonly subject: "project"; + readonly subjectId: ProjectId; + } + | { + readonly kind: "subject-not-found"; + readonly subject: "thread"; + readonly subjectId: ThreadId; + }; + +export type ProjectMoveResult = + | { readonly kind: "applied"; readonly projectOrder: readonly ProjectId[] } + | Extract; + +export type PinnedThreadMoveResult = + | { readonly kind: "applied"; readonly pinnedThreadOrder: readonly ThreadId[] } + | Extract + | { readonly kind: "subject-not-pinned"; readonly threadId: ThreadId }; + +export type PinnedThreadIntentResult = + | { readonly kind: "applied"; readonly pinnedThreadOrder: readonly ThreadId[] } + | Extract; + +export function normalizeSidebarLayout(input: { + readonly projectOrder: readonly ProjectId[]; + readonly pinnedThreadOrder: readonly ThreadId[]; + readonly projects: readonly SidebarLayoutProject[]; + readonly threads: readonly SidebarLayoutThread[]; +}): SidebarLayoutOrder { + const activeProjects = input.projects.filter((project) => project.deletedAt === null); + const activeProjectById = new Map(activeProjects.map((project) => [project.id, project])); + const seenProjectIds = new Set(); + const projectOrder = input.projectOrder.filter((projectId) => { + if (seenProjectIds.has(projectId) || !activeProjectById.has(projectId)) { + return false; + } + seenProjectIds.add(projectId); + return true; + }); + const missingProjectIds = activeProjects + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .filter((project) => { + if (seenProjectIds.has(project.id)) { + return false; + } + seenProjectIds.add(project.id); + return true; + }) + .map((project) => project.id); + const liveThreadIds = new Set( + input.threads.filter((thread) => thread.deletedAt === null).map((thread) => thread.id), + ); + const seenThreadIds = new Set(); + const pinnedThreadOrder = input.pinnedThreadOrder.filter((threadId) => { + if (seenThreadIds.has(threadId) || !liveThreadIds.has(threadId)) { + return false; + } + seenThreadIds.add(threadId); + return true; + }); + + return { + projectOrder: [...projectOrder, ...missingProjectIds], + pinnedThreadOrder, + }; +} + +export function initializeSidebarLayout(input: { + readonly projectOrderCandidates: readonly ProjectId[]; + readonly pinnedThreadOrderCandidates: readonly ThreadId[]; + readonly projects: readonly SidebarLayoutProject[]; + readonly threads: readonly SidebarLayoutThread[]; +}): SidebarLayoutOrder { + const serverPinnedThreadIds = input.threads + .filter((thread) => thread.deletedAt === null && thread.isPinned) + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ) + .map((thread) => thread.id); + + return normalizeSidebarLayout({ + projectOrder: input.projectOrderCandidates, + pinnedThreadOrder: [...input.pinnedThreadOrderCandidates, ...serverPinnedThreadIds], + projects: input.projects, + threads: input.threads, + }); +} + +function moveBeforeOrAppend( + order: readonly Id[], + subjectId: Id, + beforeId: Id | null, +): readonly Id[] { + if (beforeId === subjectId && order.includes(subjectId)) { + return [...order]; + } + const withoutSubject = order.filter((id) => id !== subjectId); + const anchorIndex = beforeId === null ? -1 : withoutSubject.indexOf(beforeId); + if (anchorIndex < 0) { + return [...withoutSubject, subjectId]; + } + return [...withoutSubject.slice(0, anchorIndex), subjectId, ...withoutSubject.slice(anchorIndex)]; +} + +export function moveProjectBefore(input: { + readonly projectOrder: readonly ProjectId[]; + readonly projectId: ProjectId; + readonly beforeProjectId: ProjectId | null; + readonly projects: readonly SidebarLayoutProject[]; +}): ProjectMoveResult { + const activeProjectIds = new Set( + input.projects.filter((project) => project.deletedAt === null).map((project) => project.id), + ); + if (!activeProjectIds.has(input.projectId)) { + return { + kind: "subject-not-found", + subject: "project", + subjectId: input.projectId, + }; + } + const normalized = normalizeSidebarLayout({ + projectOrder: input.projectOrder, + pinnedThreadOrder: [], + projects: input.projects, + threads: [], + }); + return { + kind: "applied" as const, + projectOrder: moveBeforeOrAppend( + normalized.projectOrder, + input.projectId, + input.beforeProjectId, + ), + }; +} + +export function movePinnedThreadBefore(input: { + readonly pinnedThreadOrder: readonly ThreadId[]; + readonly threadId: ThreadId; + readonly beforeThreadId: ThreadId | null; + readonly threads: readonly SidebarLayoutThread[]; +}): PinnedThreadMoveResult { + const liveThreadIds = new Set( + input.threads.filter((thread) => thread.deletedAt === null).map((thread) => thread.id), + ); + if (!liveThreadIds.has(input.threadId)) { + return { + kind: "subject-not-found", + subject: "thread", + subjectId: input.threadId, + }; + } + const normalized = normalizeSidebarLayout({ + projectOrder: [], + pinnedThreadOrder: input.pinnedThreadOrder, + projects: [], + threads: input.threads, + }); + if (!normalized.pinnedThreadOrder.includes(input.threadId)) { + return { kind: "subject-not-pinned", threadId: input.threadId }; + } + return { + kind: "applied" as const, + pinnedThreadOrder: moveBeforeOrAppend( + normalized.pinnedThreadOrder, + input.threadId, + input.beforeThreadId, + ), + }; +} + +export function pinThreadBefore(input: { + readonly pinnedThreadOrder: readonly ThreadId[]; + readonly threadId: ThreadId; + readonly beforeThreadId: ThreadId | null; + readonly threads: readonly SidebarLayoutThread[]; +}): PinnedThreadIntentResult { + const liveThreadIds = new Set( + input.threads.filter((thread) => thread.deletedAt === null).map((thread) => thread.id), + ); + if (!liveThreadIds.has(input.threadId)) { + return { kind: "subject-not-found", subject: "thread", subjectId: input.threadId }; + } + const normalized = normalizeSidebarLayout({ + projectOrder: [], + pinnedThreadOrder: input.pinnedThreadOrder, + projects: [], + threads: input.threads, + }); + return { + kind: "applied", + pinnedThreadOrder: moveBeforeOrAppend( + normalized.pinnedThreadOrder, + input.threadId, + input.beforeThreadId, + ), + }; +} + +export function unpinThread(input: { + readonly pinnedThreadOrder: readonly ThreadId[]; + readonly threadId: ThreadId; + readonly threads: readonly SidebarLayoutThread[]; +}): PinnedThreadIntentResult { + const liveThreadIds = new Set( + input.threads.filter((thread) => thread.deletedAt === null).map((thread) => thread.id), + ); + if (!liveThreadIds.has(input.threadId)) { + return { kind: "subject-not-found", subject: "thread", subjectId: input.threadId }; + } + const normalized = normalizeSidebarLayout({ + projectOrder: [], + pinnedThreadOrder: input.pinnedThreadOrder, + projects: [], + threads: input.threads, + }); + return { + kind: "applied", + pinnedThreadOrder: normalized.pinnedThreadOrder.filter( + (threadId) => threadId !== input.threadId, + ), + }; +} + +export function pinnedMembershipEquals( + left: readonly ThreadId[], + right: readonly ThreadId[], +): boolean { + const leftIds = new Set(left); + const rightIds = new Set(right); + return leftIds.size === rightIds.size && [...leftIds].every((threadId) => rightIds.has(threadId)); +} diff --git a/apps/server/src/orchestration/sidebarLayoutDecider.ts b/apps/server/src/orchestration/sidebarLayoutDecider.ts new file mode 100644 index 000000000..e67b550bc --- /dev/null +++ b/apps/server/src/orchestration/sidebarLayoutDecider.ts @@ -0,0 +1,178 @@ +import type { OrchestrationCommand, OrchestrationReadModel } from "@jcode/contracts"; +import { Effect } from "effect"; + +import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + initializeSidebarLayout, + movePinnedThreadBefore, + moveProjectBefore, + normalizeSidebarLayout, + pinThreadBefore, + type SidebarLayoutOrder, + unpinThread, +} from "./sidebarLayout.ts"; + +type SidebarLayoutCommand = Extract< + OrchestrationCommand, + { + readonly type: + | "sidebar-layout.initialize" + | "sidebar-layout.project.move" + | "sidebar-layout.thread.pin" + | "sidebar-layout.thread.unpin" + | "sidebar-layout.pinned-thread.move"; + } +>; + +function requireInitializedSidebarLayout( + command: SidebarLayoutCommand, + readModel: OrchestrationReadModel, +): Effect.Effect< + Exclude, + OrchestrationCommandInvariantError +> { + if (readModel.sidebarLayout !== null) { + return Effect.succeed(readModel.sidebarLayout); + } + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Sidebar layout has not been initialized.", + }), + ); +} + +function normalizeCurrentLayout( + layout: Exclude, + readModel: OrchestrationReadModel, +): SidebarLayoutOrder { + return normalizeSidebarLayout({ + projectOrder: layout.projectOrder, + pinnedThreadOrder: layout.pinnedThreadOrder, + projects: readModel.projects, + threads: readModel.threads, + }); +} + +export const decideSidebarLayoutCommand = Effect.fn("decideSidebarLayoutCommand")( + function* (input: { + readonly command: SidebarLayoutCommand; + readonly readModel: OrchestrationReadModel; + }): Effect.fn.Return { + const { command, readModel } = input; + switch (command.type) { + case "sidebar-layout.initialize": + return readModel.sidebarLayout === null + ? initializeSidebarLayout({ + projectOrderCandidates: command.projectOrder, + pinnedThreadOrderCandidates: command.pinnedThreadOrder, + projects: readModel.projects, + threads: readModel.threads, + }) + : normalizeCurrentLayout(readModel.sidebarLayout, readModel); + + case "sidebar-layout.project.move": { + const current = normalizeCurrentLayout( + yield* requireInitializedSidebarLayout(command, readModel), + readModel, + ); + const result = moveProjectBefore({ + projectOrder: current.projectOrder, + projectId: command.projectId, + beforeProjectId: command.beforeProjectId ?? null, + projects: readModel.projects, + }); + switch (result.kind) { + case "applied": + return { ...current, projectOrder: result.projectOrder }; + case "subject-not-found": + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Project '${result.subjectId}' does not exist.`, + }); + default: + return result satisfies never; + } + } + + case "sidebar-layout.thread.pin": { + const current = normalizeCurrentLayout( + yield* requireInitializedSidebarLayout(command, readModel), + readModel, + ); + const result = pinThreadBefore({ + pinnedThreadOrder: current.pinnedThreadOrder, + threadId: command.threadId, + beforeThreadId: command.beforeThreadId ?? null, + threads: readModel.threads, + }); + switch (result.kind) { + case "applied": + return { ...current, pinnedThreadOrder: result.pinnedThreadOrder }; + case "subject-not-found": + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${result.subjectId}' does not exist.`, + }); + default: + return result satisfies never; + } + } + + case "sidebar-layout.thread.unpin": { + const current = normalizeCurrentLayout( + yield* requireInitializedSidebarLayout(command, readModel), + readModel, + ); + const result = unpinThread({ + pinnedThreadOrder: current.pinnedThreadOrder, + threadId: command.threadId, + threads: readModel.threads, + }); + switch (result.kind) { + case "applied": + return { ...current, pinnedThreadOrder: result.pinnedThreadOrder }; + case "subject-not-found": + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${result.subjectId}' does not exist.`, + }); + default: + return result satisfies never; + } + } + + case "sidebar-layout.pinned-thread.move": { + const current = normalizeCurrentLayout( + yield* requireInitializedSidebarLayout(command, readModel), + readModel, + ); + const result = movePinnedThreadBefore({ + pinnedThreadOrder: current.pinnedThreadOrder, + threadId: command.threadId, + beforeThreadId: command.beforeThreadId ?? null, + threads: readModel.threads, + }); + switch (result.kind) { + case "applied": + return { ...current, pinnedThreadOrder: result.pinnedThreadOrder }; + case "subject-not-found": + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${result.subjectId}' does not exist.`, + }); + case "subject-not-pinned": + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${result.threadId}' is not pinned.`, + }); + default: + return result satisfies never; + } + } + + default: + return command satisfies never; + } + }, +); diff --git a/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.test.ts b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.test.ts new file mode 100644 index 000000000..2d85461e1 --- /dev/null +++ b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.test.ts @@ -0,0 +1,37 @@ +import { CommandId, SIDEBAR_LAYOUT_ID } from "@jcode/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { OrchestrationCommandReceiptRepository } from "../Services/OrchestrationCommandReceipts.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "./OrchestrationCommandReceipts.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + OrchestrationCommandReceiptRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("OrchestrationCommandReceiptRepository", (it) => { + it.effect("round-trips sidebar-layout aggregate receipts", () => + Effect.gen(function* () { + // Given: a receipt for the singleton sidebar-layout aggregate. + const receipts = yield* OrchestrationCommandReceiptRepository; + const commandId = CommandId.makeUnsafe("cmd-sidebar-layout-receipt"); + + // When: the receipt is stored and read through SQLite. + yield* receipts.upsert({ + commandId, + aggregateKind: "sidebar-layout", + aggregateId: SIDEBAR_LAYOUT_ID, + acceptedAt: "2026-07-18T00:00:00.000Z", + resultSequence: 41, + status: "accepted", + error: null, + }); + const persisted = yield* receipts.getByCommandId({ commandId }); + + // Then: the singleton aggregate identity survives typed decoding. + assert.strictEqual(Option.getOrNull(persisted)?.aggregateId, SIDEBAR_LAYOUT_ID); + assert.strictEqual(Option.getOrNull(persisted)?.aggregateKind, "sidebar-layout"); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 99818d6fb..53a9088b6 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -1,4 +1,4 @@ -import { CommandId, EventId, ProjectId, ThreadId } from "@jcode/contracts"; +import { CommandId, EventId, ProjectId, SIDEBAR_LAYOUT_ID, ThreadId } from "@jcode/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, Layer, Schema, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -13,6 +13,53 @@ const layer = it.layer( ); layer("OrchestrationEventStore", (it) => { + it.effect("round-trips the singleton sidebar-layout aggregate stream", () => + Effect.gen(function* () { + // Given: a canonical sidebar-layout event using the singleton aggregate id. + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-07-18T00:00:01.000Z"; + + yield* Effect.gen(function* () { + // When: the event is appended and replayed through SQLite. + yield* eventStore.append({ + type: "sidebar-layout.updated", + eventId: EventId.makeUnsafe("evt-sidebar-layout-roundtrip"), + aggregateKind: "sidebar-layout", + aggregateId: SIDEBAR_LAYOUT_ID, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-sidebar-layout-roundtrip"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-sidebar-layout-roundtrip"), + metadata: {}, + payload: { + projectOrder: [ProjectId.makeUnsafe("project-sidebar-layout")], + pinnedThreadOrder: [ThreadId.makeUnsafe("thread-sidebar-layout")], + updatedAt: now, + }, + }); + const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0, 10)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + + // Then: aggregate identity and canonical ordered payload remain typed and intact. + assert.equal(replayed[0]?.aggregateId, SIDEBAR_LAYOUT_ID); + assert.deepStrictEqual(replayed[0]?.payload, { + projectOrder: [ProjectId.makeUnsafe("project-sidebar-layout")], + pinnedThreadOrder: [ThreadId.makeUnsafe("thread-sidebar-layout")], + updatedAt: now, + }); + }).pipe( + Effect.ensuring( + sql` + DELETE FROM orchestration_events + WHERE event_id = 'evt-sidebar-layout-roundtrip' + `.pipe(Effect.orDie), + ), + ); + }), + ); + it.effect("stores json columns as strings and replays decoded events", () => Effect.gen(function* () { const eventStore = yield* OrchestrationEventStore; diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index cb11d906f..374dd6eca 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -9,6 +9,7 @@ import { OrchestrationEventMetadata, OrchestrationEventType, ProjectId, + SidebarLayoutId, ThreadId, } from "@jcode/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -36,7 +37,7 @@ const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMeta const AppendEventRequestSchema = Schema.Struct({ eventId: EventId, aggregateKind: OrchestrationAggregateKind, - streamId: Schema.Union([ProjectId, ThreadId]), + streamId: Schema.Union([SidebarLayoutId, ProjectId, ThreadId]), type: OrchestrationEventType, causationEventId: Schema.NullOr(EventId), correlationId: Schema.NullOr(CommandId), @@ -52,7 +53,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ eventId: EventId, type: OrchestrationEventType, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([SidebarLayoutId, ProjectId, ThreadId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), @@ -347,9 +348,13 @@ const makeEventStore = Effect.gen(function* () { if (nextRemaining <= 0) { return Stream.fromIterable(events); } + const lastEvent = events.at(-1); + if (lastEvent === undefined) { + return Stream.empty; + } return Stream.concat( Stream.fromIterable(events), - readPage(events[events.length - 1]!.sequence, nextRemaining), + readPage(lastEvent.sequence, nextRemaining), ); }), ); diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 0ba8e8b05..a542e760f 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -7,7 +7,40 @@ import { SqlitePersistenceMemory } from "./Sqlite.ts"; import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts"; import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; -import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +import { + ProjectionThreadRepository, + type ProjectionThread, +} from "../Services/ProjectionThreads.ts"; + +const makePinnedThread = ( + threadId: ThreadId, + deletedAt: ProjectionThread["deletedAt"] = null, +): ProjectionThread => ({ + threadId, + projectId: ProjectId.makeUnsafe("project-pinned-membership"), + title: `Pinned membership ${threadId}`, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + envMode: "local", + branch: null, + worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + isPinned: true, + lastKnownPr: null, + latestTurnId: null, + handoff: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + deletedAt, +}); const projectionRepositoriesLayer = it.layer( Layer.mergeAll( @@ -18,6 +51,112 @@ const projectionRepositoriesLayer = it.layer( ); projectionRepositoriesLayer("Projection repositories", (it) => { + it.effect("preserves migration 036 pinned defaults through repository round-trips", () => + Effect.gen(function* () { + // Given: migration 036 is applied and a thread omits the optional pinned field. + const threads = yield* ProjectionThreadRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.makeUnsafe("thread-pinned-default"); + + // When: the thread is stored through the existing repository. + yield* threads.upsert({ + threadId, + projectId: ProjectId.makeUnsafe("project-pinned-default"), + title: "Pinned default thread", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + envMode: "local", + branch: null, + worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + lastKnownPr: null, + latestTurnId: null, + handoff: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + createdAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + deletedAt: null, + }); + + // Then: the schema default and decoded repository value remain false/zero. + const columns = yield* sql<{ readonly dfltValue: string | null }>` + SELECT dflt_value AS "dfltValue" + FROM pragma_table_info('projection_threads') + WHERE name = 'is_pinned' + `; + const stored = yield* sql<{ readonly isPinned: number }>` + SELECT is_pinned AS "isPinned" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + const persisted = yield* threads.getById({ threadId }); + + assert.strictEqual(columns[0]?.dfltValue, "0"); + assert.strictEqual(stored[0]?.isPinned, 0); + assert.strictEqual(Option.getOrNull(persisted)?.isPinned, false); + }), + ); + + it.effect("replaces stale pin flags with exactly the live canonical membership", () => + Effect.gen(function* () { + // Given: three stale pinned flags, including a deleted thread. + const threads = yield* ProjectionThreadRepository; + const sql = yield* SqlClient.SqlClient; + const staleId = ThreadId.makeUnsafe("thread-pin-stale"); + const liveId = ThreadId.makeUnsafe("thread-pin-live"); + const deletedId = ThreadId.makeUnsafe("thread-pin-deleted"); + yield* threads.upsert(makePinnedThread(staleId)); + yield* threads.upsert(makePinnedThread(liveId)); + yield* threads.upsert(makePinnedThread(deletedId, "2026-07-18T00:01:00.000Z")); + + // When: canonical membership contains one live and one deleted thread. + yield* threads.replacePinnedMembership({ threadIds: [liveId, deletedId] }); + + // Then: only the live member remains pinned and every stale flag is cleared. + const rows = yield* sql<{ readonly threadId: string; readonly isPinned: number }>` + SELECT thread_id AS "threadId", is_pinned AS "isPinned" + FROM projection_threads + WHERE thread_id IN (${staleId}, ${liveId}, ${deletedId}) + ORDER BY thread_id + `; + assert.deepStrictEqual(rows, [ + { threadId: "thread-pin-deleted", isPinned: 0 }, + { threadId: "thread-pin-live", isPinned: 1 }, + { threadId: "thread-pin-stale", isPinned: 0 }, + ]); + }), + ); + + it.effect("clears every pin flag when canonical membership is empty", () => + Effect.gen(function* () { + // Given: two live threads have stale pinned flags. + const threads = yield* ProjectionThreadRepository; + const sql = yield* SqlClient.SqlClient; + const firstId = ThreadId.makeUnsafe("thread-pin-empty-a"); + const secondId = ThreadId.makeUnsafe("thread-pin-empty-b"); + yield* threads.upsert(makePinnedThread(firstId)); + yield* threads.upsert(makePinnedThread(secondId)); + + // When: canonical membership is empty. + yield* threads.replacePinnedMembership({ threadIds: [] }); + + // Then: no projected thread remains pinned. + const rows = yield* sql<{ readonly pinnedCount: number }>` + SELECT COUNT(*) AS "pinnedCount" + FROM projection_threads + WHERE is_pinned <> 0 + `; + assert.strictEqual(rows[0]?.pinnedCount, 0); + }), + ); + it.effect("stores SQL NULL for missing project model options", () => Effect.gen(function* () { const projects = yield* ProjectionProjectRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionSidebarLayout.test.ts b/apps/server/src/persistence/Layers/ProjectionSidebarLayout.test.ts new file mode 100644 index 000000000..394206b8d --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionSidebarLayout.test.ts @@ -0,0 +1,98 @@ +import { ProjectId, SIDEBAR_LAYOUT_ID, ThreadId } from "@jcode/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Option, Schema } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { PersistenceDecodeError } from "../Errors.ts"; +import { ProjectionSidebarLayoutRepository } from "../Services/ProjectionSidebarLayout.ts"; +import { ProjectionSidebarLayoutRepositoryLive } from "./ProjectionSidebarLayout.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const projectionSidebarLayoutLayer = it.layer( + ProjectionSidebarLayoutRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +projectionSidebarLayoutLayer("ProjectionSidebarLayoutRepository", (it) => { + it.effect("round-trips and replaces the singleton layout row", () => + Effect.gen(function* () { + // Given: an initialized canonical sidebar layout. + const layouts = yield* ProjectionSidebarLayoutRepository; + const initial = { + layoutKey: SIDEBAR_LAYOUT_ID, + projectOrder: [ProjectId.makeUnsafe("project-a"), ProjectId.makeUnsafe("project-b")], + pinnedThreadOrder: [ThreadId.makeUnsafe("thread-a")], + revision: 12, + initializedAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:12.000Z", + }; + yield* layouts.upsert(initial); + + // When: the same singleton is upserted with a later canonical layout. + const replacement = { + ...initial, + projectOrder: [ProjectId.makeUnsafe("project-b")], + pinnedThreadOrder: [ThreadId.makeUnsafe("thread-b"), ThreadId.makeUnsafe("thread-a")], + revision: 13, + updatedAt: "2026-07-18T00:00:13.000Z", + }; + yield* layouts.upsert(replacement); + + // Then: read returns the typed replacement with the original initialization time. + const persisted = yield* layouts.get(); + assert.deepStrictEqual(Option.getOrNull(persisted), replacement); + }), + ); + + it.effect("resets the singleton layout to the uninitialized state", () => + Effect.gen(function* () { + // Given: a persisted singleton layout. + const layouts = yield* ProjectionSidebarLayoutRepository; + yield* layouts.upsert({ + layoutKey: SIDEBAR_LAYOUT_ID, + projectOrder: [], + pinnedThreadOrder: [], + revision: 1, + initializedAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:01.000Z", + }); + + // When: the rebuild reset operation runs. + yield* layouts.reset(); + + // Then: the layout is explicitly uninitialized again. + assert.isTrue(Option.isNone(yield* layouts.get())); + }), + ); + + it.effect("returns a typed decode failure for malformed persisted JSON", () => + Effect.gen(function* () { + // Given: corrupted JSON exists at the SQLite trust boundary. + const layouts = yield* ProjectionSidebarLayoutRepository; + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_sidebar_layout ( + layout_key, + project_order_json, + pinned_thread_order_json, + revision, + initialized_at, + updated_at + ) VALUES ( + 'sidebar-layout', + '{', + '[]', + 1, + '2026-07-18T00:00:00.000Z', + '2026-07-18T00:00:01.000Z' + ) + `; + + // When: the repository decodes the row. + const error = yield* Effect.flip(layouts.get()); + + // Then: corruption is reported as the typed persistence decode error. + assert.isTrue(Schema.is(PersistenceDecodeError)(error)); + assert.strictEqual(error.operation, "ProjectionSidebarLayoutRepository.get:decodeRow"); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionSidebarLayout.ts b/apps/server/src/persistence/Layers/ProjectionSidebarLayout.ts new file mode 100644 index 000000000..87ed34de2 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionSidebarLayout.ts @@ -0,0 +1,114 @@ +import { ProjectId, SIDEBAR_LAYOUT_ID, ThreadId } from "@jcode/contracts"; +import { Effect, Layer, Schema, Struct } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; +import { + ProjectionSidebarLayout, + ProjectionSidebarLayoutRepository, + type ProjectionSidebarLayoutRepositoryShape, +} from "../Services/ProjectionSidebarLayout.ts"; + +const ProjectionSidebarLayoutDbRow = ProjectionSidebarLayout.mapFields( + Struct.assign({ + projectOrder: Schema.fromJsonString(Schema.Array(ProjectId)), + pinnedThreadOrder: Schema.fromJsonString(Schema.Array(ThreadId)), + }), +); + +const toSqlOrDecodeError = (sqlOperation: string, decodeOperation: string) => (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); + +const makeProjectionSidebarLayoutRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: ProjectionSidebarLayout, + execute: (row) => + sql` + INSERT INTO projection_sidebar_layout ( + layout_key, + project_order_json, + pinned_thread_order_json, + revision, + initialized_at, + updated_at + ) VALUES ( + ${row.layoutKey}, + ${JSON.stringify(row.projectOrder)}, + ${JSON.stringify(row.pinnedThreadOrder)}, + ${row.revision}, + ${row.initializedAt}, + ${row.updatedAt} + ) + ON CONFLICT (layout_key) + DO UPDATE SET + project_order_json = excluded.project_order_json, + pinned_thread_order_json = excluded.pinned_thread_order_json, + revision = excluded.revision, + initialized_at = excluded.initialized_at, + updated_at = excluded.updated_at + `, + }); + + const getRow = SqlSchema.findOneOption({ + Request: Schema.Void, + Result: ProjectionSidebarLayoutDbRow, + execute: () => + sql` + SELECT + layout_key AS "layoutKey", + project_order_json AS "projectOrder", + pinned_thread_order_json AS "pinnedThreadOrder", + revision, + initialized_at AS "initializedAt", + updated_at AS "updatedAt" + FROM projection_sidebar_layout + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `, + }); + + const resetRow = SqlSchema.void({ + Request: Schema.Void, + execute: () => + sql` + DELETE FROM projection_sidebar_layout + WHERE layout_key = ${SIDEBAR_LAYOUT_ID} + `, + }); + + const upsert: ProjectionSidebarLayoutRepositoryShape["upsert"] = (row) => + upsertRow(row).pipe( + Effect.mapError( + toSqlOrDecodeError( + "ProjectionSidebarLayoutRepository.upsert:query", + "ProjectionSidebarLayoutRepository.upsert:encodeRequest", + ), + ), + ); + + const get: ProjectionSidebarLayoutRepositoryShape["get"] = () => + getRow(undefined).pipe( + Effect.mapError( + toSqlOrDecodeError( + "ProjectionSidebarLayoutRepository.get:query", + "ProjectionSidebarLayoutRepository.get:decodeRow", + ), + ), + ); + + const reset: ProjectionSidebarLayoutRepositoryShape["reset"] = () => + resetRow(undefined).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionSidebarLayoutRepository.reset:query")), + ); + + return { upsert, get, reset } satisfies ProjectionSidebarLayoutRepositoryShape; +}); + +export const ProjectionSidebarLayoutRepositoryLive = Layer.effect( + ProjectionSidebarLayoutRepository, + makeProjectionSidebarLayoutRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 67b005715..412e8ae5b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -10,6 +10,7 @@ import { ListProjectionThreadsByProjectInput, ProjectionThread, ProjectionThreadRepository, + ReplacePinnedMembershipInput, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; import { @@ -252,6 +253,20 @@ const makeProjectionThreadRepository = Effect.gen(function* () { `, }); + const replacePinnedMembershipRows = SqlSchema.void({ + Request: ReplacePinnedMembershipInput, + execute: ({ threadIds }) => + threadIds.length === 0 + ? sql`UPDATE projection_threads SET is_pinned = 0` + : sql` + UPDATE projection_threads + SET is_pinned = CASE + WHEN deleted_at IS NULL AND thread_id IN ${sql.in(threadIds)} THEN 1 + ELSE 0 + END + `, + }); + const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) => upsertProjectionThreadRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), @@ -272,10 +287,20 @@ const makeProjectionThreadRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), ); + const replacePinnedMembership: ProjectionThreadRepositoryShape["replacePinnedMembership"] = ( + input, + ) => + replacePinnedMembershipRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadRepository.replacePinnedMembership:query"), + ), + ); + return { upsert, getById, listByProjectId, + replacePinnedMembership, deleteById, } satisfies ProjectionThreadRepositoryShape; }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 5f23f1f0a..accacb6eb 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionSnapshotCapIndexes.ts"; import Migration0038 from "./Migrations/038_ProjectionProjectsIconMetadata.ts"; import Migration0039 from "./Migrations/039_ProjectionThreadsRecap.ts"; import Migration0040 from "./Migrations/040_ProjectionThreadsGoal.ts"; +import Migration0041 from "./Migrations/041_ProjectionSidebarLayout.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionProjectsIconMetadata", Migration0038], [39, "ProjectionThreadsRecap", Migration0039], [40, "ProjectionThreadsGoal", Migration0040], + [41, "ProjectionSidebarLayout", Migration0041], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/041_ProjectionSidebarLayout.test.ts b/apps/server/src/persistence/Migrations/041_ProjectionSidebarLayout.test.ts new file mode 100644 index 000000000..5b659559e --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionSidebarLayout.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "vitest"; +import { Effect, Exit } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +test("041_ProjectionSidebarLayout registers an idempotent singleton projection table", async () => { + await Effect.runPromise( + Effect.gen(function* () { + // Given: a fresh SQLite database. + const sql = yield* SqlClient.SqlClient; + + // When: all migrations are run twice. + const firstRun = yield* runMigrations(); + const secondRun = yield* runMigrations(); + + // Then: migration 041 runs once with the expected constrained schema and index. + expect(firstRun).toContainEqual([41, "ProjectionSidebarLayout"]); + expect(secondRun).toEqual([]); + + const columns = yield* sql<{ + readonly name: string; + readonly notnull: number; + readonly pk: number; + }>` + SELECT name, "notnull", pk + FROM pragma_table_info('projection_sidebar_layout') + ORDER BY cid + `; + expect(columns).toEqual([ + { name: "layout_key", notnull: 1, pk: 1 }, + { name: "project_order_json", notnull: 1, pk: 0 }, + { name: "pinned_thread_order_json", notnull: 1, pk: 0 }, + { name: "revision", notnull: 1, pk: 0 }, + { name: "initialized_at", notnull: 1, pk: 0 }, + { name: "updated_at", notnull: 1, pk: 0 }, + ]); + + const indexes = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'index' + AND tbl_name = 'projection_sidebar_layout' + AND name = 'idx_projection_sidebar_layout_updated_at' + `; + expect(indexes).toEqual([{ name: "idx_projection_sidebar_layout_updated_at" }]); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), + ); +}); + +test("041_ProjectionSidebarLayout rejects invalid singleton keys and negative revisions", async () => { + await Effect.runPromise( + Effect.gen(function* () { + // Given: migration 041 has created the singleton table. + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + // When: invalid singleton data is inserted. + const invalidKey = yield* Effect.exit(sql` + INSERT INTO projection_sidebar_layout ( + layout_key, + project_order_json, + pinned_thread_order_json, + revision, + initialized_at, + updated_at + ) VALUES ('other-layout', '[]', '[]', 0, '2026-07-18T00:00:00.000Z', '2026-07-18T00:00:00.000Z') + `); + const invalidRevision = yield* Effect.exit(sql` + INSERT INTO projection_sidebar_layout ( + layout_key, + project_order_json, + pinned_thread_order_json, + revision, + initialized_at, + updated_at + ) VALUES ('sidebar-layout', '[]', '[]', -1, '2026-07-18T00:00:00.000Z', '2026-07-18T00:00:00.000Z') + `); + + // Then: both constraints reject malformed singleton rows. + expect(Exit.isFailure(invalidKey)).toBe(true); + expect(Exit.isFailure(invalidRevision)).toBe(true); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionSidebarLayout.ts b/apps/server/src/persistence/Migrations/041_ProjectionSidebarLayout.ts new file mode 100644 index 000000000..009ced192 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionSidebarLayout.ts @@ -0,0 +1,22 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_sidebar_layout ( + layout_key TEXT PRIMARY KEY NOT NULL CHECK (layout_key = 'sidebar-layout'), + project_order_json TEXT NOT NULL, + pinned_thread_order_json TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 0), + initialized_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_sidebar_layout_updated_at + ON projection_sidebar_layout(updated_at) + `; +}); diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts index edc5d15fb..62048393a 100644 --- a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts @@ -13,6 +13,7 @@ import { OrchestrationAggregateKind, OrchestrationCommandReceiptStatus, ProjectId, + SidebarLayoutId, ThreadId, } from "@jcode/contracts"; import { Option, Schema, ServiceMap } from "effect"; @@ -23,7 +24,7 @@ import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; export const OrchestrationCommandReceipt = Schema.Struct({ commandId: CommandId, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([SidebarLayoutId, ProjectId, ThreadId]), acceptedAt: IsoDateTime, resultSequence: NonNegativeInt, status: OrchestrationCommandReceiptStatus, diff --git a/apps/server/src/persistence/Services/ProjectionSidebarLayout.ts b/apps/server/src/persistence/Services/ProjectionSidebarLayout.ts new file mode 100644 index 000000000..8c6c12aa7 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionSidebarLayout.ts @@ -0,0 +1,40 @@ +/** + * Singleton sidebar-layout projection persistence contract. + * + * The event store remains authoritative; this row is a rebuildable read model. + */ +import { + IsoDateTime, + NonNegativeInt, + ProjectId, + SidebarLayoutId, + ThreadId, +} from "@jcode/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionSidebarLayout = Schema.Struct({ + layoutKey: SidebarLayoutId, + projectOrder: Schema.Array(ProjectId), + pinnedThreadOrder: Schema.Array(ThreadId), + revision: NonNegativeInt, + initializedAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ProjectionSidebarLayout = typeof ProjectionSidebarLayout.Type; + +export interface ProjectionSidebarLayoutRepositoryShape { + readonly upsert: (row: ProjectionSidebarLayout) => Effect.Effect; + readonly get: () => Effect.Effect< + Option.Option, + ProjectionRepositoryError + >; + readonly reset: () => Effect.Effect; +} + +export class ProjectionSidebarLayoutRepository extends ServiceMap.Service< + ProjectionSidebarLayoutRepository, + ProjectionSidebarLayoutRepositoryShape +>()("jcode/persistence/Services/ProjectionSidebarLayout/ProjectionSidebarLayoutRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index bfc8dfa74..1bef988ba 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -82,6 +82,11 @@ export const ListProjectionThreadsByProjectInput = Schema.Struct({ }); export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; +export const ReplacePinnedMembershipInput = Schema.Struct({ + threadIds: Schema.Array(ThreadId), +}); +export type ReplacePinnedMembershipInput = typeof ReplacePinnedMembershipInput.Type; + /** * ProjectionThreadRepositoryShape - Service API for projected thread records. */ @@ -109,6 +114,11 @@ export interface ProjectionThreadRepositoryShape { input: ListProjectionThreadsByProjectInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** Replace denormalized pin flags with exactly the live canonical membership. */ + readonly replacePinnedMembership: ( + input: ReplacePinnedMembershipInput, + ) => Effect.Effect; + /** * Soft-delete a projected thread row by id. */ diff --git a/apps/server/src/threadRetention.test.ts b/apps/server/src/threadRetention.test.ts index 47a97fdd9..44bdeacc7 100644 --- a/apps/server/src/threadRetention.test.ts +++ b/apps/server/src/threadRetention.test.ts @@ -3,9 +3,17 @@ // Layer: Server maintenance tests // Exports: Vitest coverage for threadRetention helpers. -import { ProjectId, ThreadId, type OrchestrationReadModel } from "@jcode/contracts"; +import { + CommandId, + EventId, + OrchestrationEvent, + ProjectId, + SIDEBAR_LAYOUT_ID, + ThreadId, + type OrchestrationReadModel, +} from "@jcode/contracts"; import { it as effectIt } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { describe, expect, it } from "vitest"; @@ -16,6 +24,7 @@ import { THREAD_RETENTION_UNUSED_MS, } from "./threadRetention"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; +import { projectEvent } from "./orchestration/projector.ts"; function makeReadModelThread( overrides: Partial = {}, @@ -44,6 +53,7 @@ function makeReadModelThread( function makeReadModel(threads: OrchestrationReadModel["threads"]): OrchestrationReadModel { return { snapshotSequence: 0, + sidebarLayout: null, projects: [], threads, updatedAt: "2026-04-20T00:00:00.000Z", @@ -116,6 +126,46 @@ describe("thread retention", () => { ).toEqual([unpinnedThread.id]); }); + it("protects exactly the canonical sidebar pinned membership", async () => { + // Given + const nowMs = Date.parse("2026-04-20T00:00:00.000Z"); + const oldActivityAt = new Date(nowMs - THREAD_RETENTION_UNUSED_MS - 1).toISOString(); + const canonicalPinned = makeReadModelThread({ + id: ThreadId.makeUnsafe("thread-canonical-pinned"), + isPinned: false, + latestUserMessageAt: oldActivityAt, + }); + const staleLegacyPinned = makeReadModelThread({ + id: ThreadId.makeUnsafe("thread-stale-legacy-pinned"), + isPinned: true, + latestUserMessageAt: oldActivityAt, + }); + const model = makeReadModel([canonicalPinned, staleLegacyPinned]); + const event = Schema.decodeUnknownSync(OrchestrationEvent)({ + sequence: 9, + eventId: EventId.makeUnsafe("event-layout-retention"), + aggregateKind: "sidebar-layout", + aggregateId: SIDEBAR_LAYOUT_ID, + type: "sidebar-layout.updated", + occurredAt: oldActivityAt, + commandId: CommandId.makeUnsafe("command-layout-retention"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("command-layout-retention"), + metadata: {}, + payload: { + projectOrder: [], + pinnedThreadOrder: [canonicalPinned.id], + updatedAt: oldActivityAt, + }, + }); + + // When + const projected = await Effect.runPromise(projectEvent(model, event)); + + // Then + expect(getInactiveThreadIdsForRetention(projected, nowMs)).toEqual([staleLegacyPinned.id]); + }); + it("selects already deleted threads for physical purge retry", () => { const deletedThread = makeReadModelThread({ id: ThreadId.makeUnsafe("thread-deleted"), diff --git a/apps/server/src/wsRpc.test.ts b/apps/server/src/wsRpc.test.ts index 0045cf34b..52ff8c0ef 100644 --- a/apps/server/src/wsRpc.test.ts +++ b/apps/server/src/wsRpc.test.ts @@ -3,6 +3,11 @@ import { readFile } from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { AuthSessionId, + CommandId, + EventId, + ProjectId, + SIDEBAR_LAYOUT_ID, + ThreadId, type AuthCapabilityScope, type ManagedSidecarSnapshot, type ManagedSidecarStartRequest, @@ -21,11 +26,13 @@ import { requireManagedSidecarHealthRpcAccess, requireManagedSidecarDiagnosticsRpcAccess, requireManagedSidecarRepairRpcAccess, + requireOrchestrationCommandRpcAccess, requireOwnerWsRpcAccess, requireProviderStatusRpcAccess, repairManagedSidecarFromLifecycle, resolveLocalLegacyWsAuthSession, skipFirstRunWizardFromRpc, + toSidebarLayoutShellStreamEvent, } from "./wsRpc.ts"; vi.mock("node:fs", () => ({ @@ -170,6 +177,74 @@ describe("managed sidecar wsRpc adapters", () => { ).rejects.toThrow("requires owner role"); }); + it("keeps non-response orchestration commands behind the existing owner guard", async () => { + // Given: the production dispatch handler source. + const source = await readFile(new URL("./wsRpc.ts", import.meta.url), "utf8"); + + // When: the command-aware authorization boundary is inspected. + const dispatchGuardStart = source.indexOf("const withCommandScope"); + const dispatchGuardEnd = source.indexOf("const noManagedWorktrees", dispatchGuardStart); + const dispatchGuard = source.slice(dispatchGuardStart, dispatchGuardEnd); + + // Then: only the two response capabilities are exceptions; the default is owner-only. + expect(dispatchGuard).toContain("requireOrchestrationCommandRpcAccess(session, command)"); + }); + + it.each([ + "sidebar-layout.initialize", + "sidebar-layout.project.move", + "sidebar-layout.thread.pin", + "sidebar-layout.thread.unpin", + "sidebar-layout.pinned-thread.move", + ])("rejects %s from non-owner sessions", async (type) => { + // Given: a scoped non-owner session and one shared-layout mutation. + const session = makeAuthSession({ + role: "client", + scopes: ["thread:read", "approval:respond", "user_input:respond"], + }); + + // When/Then: response scopes cannot authorize server-wide layout mutation. + await expect( + Effect.runPromise(requireOrchestrationCommandRpcAccess(session, { type })), + ).rejects.toThrow("requires owner role"); + }); + + it("maps layout domain events to shell updates acknowledged by the event sequence", () => { + // Given: a canonical layout event whose global sequence differs from list contents. + const event = { + type: "sidebar-layout.updated" as const, + sequence: 37, + eventId: EventId.makeUnsafe("event-layout-stream"), + aggregateKind: "sidebar-layout" as const, + aggregateId: SIDEBAR_LAYOUT_ID, + occurredAt: "2026-07-18T00:00:01.000Z", + commandId: CommandId.makeUnsafe("command-layout-stream"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("command-layout-stream"), + metadata: {}, + payload: { + projectOrder: [ProjectId.makeUnsafe("project-layout-stream")], + pinnedThreadOrder: [ThreadId.makeUnsafe("thread-layout-stream")], + updatedAt: "2026-07-18T00:00:01.000Z", + }, + }; + + // When: the domain event is mapped for shell subscribers. + const shellEvent = toSidebarLayoutShellStreamEvent(event); + + // Then: the layout revision acknowledges exactly the event sequence. + expect(shellEvent).toEqual({ + kind: "sidebar-layout-updated", + sequence: 37, + sidebarLayout: { + projectOrder: [ProjectId.makeUnsafe("project-layout-stream")], + pinnedThreadOrder: [ThreadId.makeUnsafe("thread-layout-stream")], + revision: 37, + updatedAt: "2026-07-18T00:00:01.000Z", + }, + }); + }); + it("allows provider runtime health for provider-status scoped clients", async () => { await expect( Effect.runPromise( @@ -351,6 +426,12 @@ describe("managed sidecar wsRpc adapters", () => { }); it("keeps observable WS RPC handlers limited to explicit scopes", async () => { + await expectWsRpcHandlerScopeGuarded("ORCHESTRATION_WS_METHODS.getSnapshot", "thread:read"); + await expectWsRpcHandlerScopeGuarded( + "ORCHESTRATION_WS_METHODS.getShellSnapshot", + "thread:read", + ); + await expectWsRpcHandlerScopeGuarded("ORCHESTRATION_WS_METHODS.subscribeShell", "thread:read"); await expectWsRpcHandlerScopeGuarded( "WS_METHODS.subscribeOrchestrationDomainEvents", "thread:read", diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 76baab647..0482f2871 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -383,6 +383,19 @@ export const exportManagedSidecarDiagnosticsFromLifecycle = (input: { export const skipFirstRunWizardFromRpc = () => skipFirstRunWizard(); +export const toSidebarLayoutShellStreamEvent = ( + event: Extract, +): Extract => ({ + kind: "sidebar-layout-updated", + sequence: event.sequence, + sidebarLayout: { + projectOrder: event.payload.projectOrder, + pinnedThreadOrder: event.payload.pinnedThreadOrder, + revision: event.sequence, + updatedAt: event.payload.updatedAt, + }, +}); + export const requireProviderStatusRpcAccess = ( session: AuthenticatedSession, ): Effect.Effect => @@ -403,6 +416,33 @@ export const requireOwnerWsRpcAccess = ( ? Effect.void : Effect.fail(new WsRpcError({ message: `${operation} requires owner role` })); +export const requireOrchestrationCommandRpcAccess = ( + session: AuthenticatedSession, + command: { readonly type: string }, +): Effect.Effect => { + if (session.role === "owner") { + return Effect.void; + } + switch (command.type) { + case "thread.approval.respond": + return requireScope(session, "approval:respond").pipe( + Effect.asVoid, + Effect.mapError((error) => new WsRpcError({ message: error.message, cause: error })), + ); + case "thread.user-input.respond": + return requireScope(session, "user_input:respond").pipe( + Effect.asVoid, + Effect.mapError((error) => new WsRpcError({ message: error.message, cause: error })), + ); + default: + return Effect.fail( + new WsRpcError({ + message: "Insufficient permissions: this command requires owner role", + }), + ); + } +}; + const requireManagedSidecarOwnerRpcAccess = ( session: AuthenticatedSession, operation: "repair" | "diagnostics", @@ -775,6 +815,8 @@ export const makeWsRpcLayer = () => threadId: event.payload.threadId, }), ); + case "sidebar-layout.updated": + return Effect.succeed(Option.some(toSidebarLayoutShellStreamEvent(event))); default: if (event.aggregateKind !== "thread") return Effect.succeed(Option.none()); return projectionReadModelQuery @@ -895,27 +937,9 @@ export const makeWsRpcLayer = () => if (!session) { return Effect.fail(new WsRpcError({ message: "Authentication required" })); } - if (session.role === "owner") { - return effect; - } - switch (command.type) { - case "thread.approval.respond": - return requireScope(session, "approval:respond").pipe( - Effect.mapError((err) => new WsRpcError({ message: err.message, cause: err })), - Effect.flatMap(() => effect), - ); - case "thread.user-input.respond": - return requireScope(session, "user_input:respond").pipe( - Effect.mapError((err) => new WsRpcError({ message: err.message, cause: err })), - Effect.flatMap(() => effect), - ); - default: - return Effect.fail( - new WsRpcError({ - message: "Insufficient permissions: this command requires owner role", - }), - ); - } + return requireOrchestrationCommandRpcAccess(session, command).pipe( + Effect.flatMap(() => effect), + ); }), ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index dbdb172fe..303eb4f9f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -320,6 +320,7 @@ function createSnapshotForTargetUser(options: { return { snapshotSequence: 1, + sidebarLayout: null, projects: [ { id: PROJECT_ID, @@ -522,6 +523,7 @@ function createShellSnapshotFromFixtureSnapshot( ): OrchestrationShellSnapshot { return { snapshotSequence: snapshot.snapshotSequence, + sidebarLayout: snapshot.sidebarLayout, projects: snapshot.projects .filter((project) => project.deletedAt === null) .map((project) => ({ diff --git a/apps/web/src/components/EventRouter.browser.tsx b/apps/web/src/components/EventRouter.browser.tsx index 2f03783ac..4fe38f920 100644 --- a/apps/web/src/components/EventRouter.browser.tsx +++ b/apps/web/src/components/EventRouter.browser.tsx @@ -15,6 +15,7 @@ import { type OrchestrationReadModel, type OrchestrationShellStreamEvent, type OrchestrationShellSnapshot, + type OrchestrationShellStreamItem, type OrchestrationThread, type OrchestrationThreadStreamItem, type ServerConfig, @@ -25,12 +26,13 @@ import { import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http } from "msw"; import { setupWorker } from "msw/browser"; -import { page } from "vitest/browser"; +import { commands, page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; import { getRouter } from "../router"; +import { sidebarLayoutStore } from "../sidebarLayoutStore"; import { useStore } from "../store"; import { getThreadFromState } from "../threadDerivation"; import { useWorkspaceStore } from "../workspaceStore"; @@ -38,7 +40,9 @@ import { __resetWsNativeApiForTests } from "../wsNativeApi"; const THREAD_ID = ThreadId.makeUnsafe("thread-root-browser-test"); const OTHER_THREAD_ID = ThreadId.makeUnsafe("thread-other-browser-test"); +const THIRD_THREAD_ID = ThreadId.makeUnsafe("thread-third-browser-test"); const PROJECT_ID = ProjectId.makeUnsafe("project-root-browser-test"); +const CHAT_PROJECT_ID = ProjectId.makeUnsafe("project-chat-browser-test"); const NOW_ISO = "2026-03-04T12:00:00.000Z"; interface TestFixture { @@ -51,13 +55,21 @@ interface TestFixture { let fixture: TestFixture; let delayNextThreadSnapshot = false; +let autoShellSnapshot = true; +let autoWelcome = true; let subscribeShellRequestCount = 0; const subscribeThreadRequestCountById = new Map(); let subscribeThreadRequests: ThreadId[] = []; let replayEvents: OrchestrationEvent[] = []; let replayRequestCursors: number[] = []; -let emitShellStreamEvent: ((event: OrchestrationShellStreamEvent) => void) | null = null; +let holdSidebarLayoutDispatchResponses = false; +let rejectNextSidebarLayoutDispatch = false; +let sidebarLayoutDispatchRequests: unknown[] = []; +let sidebarLayoutDispatchResolvers: Array<(result: { readonly sequence: number }) => void> = []; +let emitWelcomeStreamEvent: ((payload: WsWelcomePayload) => void) | null = null; +let emitShellStreamEvent: ((event: OrchestrationShellStreamItem) => void) | null = null; let emitThreadStreamEvent: ((event: OrchestrationThreadStreamItem) => void) | null = null; +const activeMountCleanups = new Set<() => Promise>(); function createBaseServerConfig(): ServerConfig { return { @@ -117,6 +129,7 @@ function createAuthenticatedSession(): AuthSessionState { function createSnapshot(overrides?: Partial) { return { snapshotSequence: 1, + sidebarLayout: null, projects: [ { id: PROJECT_ID, @@ -206,6 +219,7 @@ function createShellSnapshotFromFixtureSnapshot( ): OrchestrationShellSnapshot { return { snapshotSequence: snapshot.snapshotSequence, + sidebarLayout: snapshot.sidebarLayout, projects: snapshot.projects .filter((project) => project.deletedAt === null) .map((project) => ({ @@ -269,6 +283,19 @@ function resolveWsRpc(tag: string, body?: unknown): unknown { if (tag === ORCHESTRATION_WS_METHODS.getShellSnapshot) { return createShellSnapshotFromFixtureSnapshot(fixture.snapshot); } + if (tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + sidebarLayoutDispatchRequests.push(body); + if (rejectNextSidebarLayoutDispatch) { + rejectNextSidebarLayoutDispatch = false; + return Promise.reject(new Error("sidebar_layout_rejected")); + } + if (holdSidebarLayoutDispatchResponses) { + return new Promise<{ readonly sequence: number }>((resolve) => { + sidebarLayoutDispatchResolvers.push(resolve); + }); + } + return { sequence: fixture.snapshot.snapshotSequence + 1 }; + } if (tag === ORCHESTRATION_WS_METHODS.replayEvents) { const request = body as { readonly fromSequenceExclusive?: unknown } | null; const fromSequenceExclusive = @@ -282,6 +309,9 @@ function resolveWsRpc(tag: string, body?: unknown): unknown { if (tag === WS_METHODS.serverGetSettings) { return DEFAULT_SERVER_SETTINGS; } + if (tag === WS_METHODS.serverUpdateSettings) { + return { ...DEFAULT_SERVER_SETTINGS, ...(recordValue(body) ?? {}) }; + } if (tag === WS_METHODS.serverGetFirstRunWizardData) { return fixture.firstRunWizardData; } @@ -363,19 +393,24 @@ function installTransportDriver(): void { request: (method, params) => resolveWsRpc(method, params), subscribeChannel: (channel, emit) => { if (channel === WS_CHANNELS.serverWelcome) { - queueMicrotask(() => emit(fixture.welcome)); + emitWelcomeStreamEvent = (payload) => emit(payload); + if (autoWelcome) { + queueMicrotask(() => emit(fixture.welcome)); + } } return undefined; }, subscribeShell: (emit) => { subscribeShellRequestCount += 1; emitShellStreamEvent = emit; - queueMicrotask(() => - emit({ - kind: "snapshot", - snapshot: createShellSnapshotFromFixtureSnapshot(fixture.snapshot), - }), - ); + if (autoShellSnapshot) { + queueMicrotask(() => + emit({ + kind: "snapshot", + snapshot: createShellSnapshotFromFixtureSnapshot(fixture.snapshot), + }), + ); + } return () => { if (emitShellStreamEvent === emit) { emitShellStreamEvent = null; @@ -394,12 +429,14 @@ function installTransportDriver(): void { delayNextThreadSnapshot = false; return undefined; } + const thread = getThreadDetailFromFixtureSnapshot(threadId); + const snapshotSequence = fixture.snapshot.snapshotSequence; queueMicrotask(() => emit({ kind: "snapshot", snapshot: { - snapshotSequence: fixture.snapshot.snapshotSequence, - thread: getThreadDetailFromFixtureSnapshot(threadId), + snapshotSequence, + thread, }, }), ); @@ -416,6 +453,7 @@ async function mountApp(options?: { initialPath?: string; routeThreadId?: ThreadId; waitForThreadId?: ThreadId | null; + waitForHydration?: boolean; }): Promise<{ cleanup: () => Promise }> { const host = document.createElement("div"); host.style.position = "fixed"; @@ -431,25 +469,39 @@ async function mountApp(options?: { const router = getRouter(createMemoryHistory({ initialEntries: [initialPath] })); const screen = await render(, { container: host }); - await vi.waitFor( - () => { - if (options?.waitForThreadId === null) { - expect(useStore.getState().threadsHydrated).toBe(true); - return; - } - const expectedThreadId = options?.waitForThreadId ?? THREAD_ID; - expect(useStore.getState().threads.some((thread) => thread.id === expectedThreadId)).toBe( - true, - ); - }, - { timeout: 8_000, interval: 16 }, - ); + if (options?.waitForHydration !== false) { + await vi.waitFor( + () => { + if (options?.waitForThreadId === null) { + expect(useStore.getState().threadsHydrated).toBe(true); + return; + } + const expectedThreadId = options?.waitForThreadId ?? THREAD_ID; + expect(useStore.getState().threads.some((thread) => thread.id === expectedThreadId)).toBe( + true, + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } - return { - cleanup: async () => { + let cleanedUp = false; + const cleanup = async (): Promise => { + if (cleanedUp) { + return; + } + cleanedUp = true; + activeMountCleanups.delete(cleanup); + try { await screen.unmount(); + } finally { host.remove(); - }, + } + }; + activeMountCleanups.add(cleanup); + + return { + cleanup, }; } @@ -474,6 +526,98 @@ function sendShellEventPush(event: OrchestrationShellStreamEvent) { emitShellStreamEvent(event); } +function sendShellSnapshotPush(snapshot: OrchestrationShellSnapshot) { + if (!emitShellStreamEvent) throw new Error("Shell stream not connected"); + emitShellStreamEvent({ kind: "snapshot", snapshot }); +} + +function sendWelcomePush(payload: WsWelcomePayload) { + if (!emitWelcomeStreamEvent) throw new Error("Welcome stream not connected"); + emitWelcomeStreamEvent(payload); +} + +function recordValue(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value)) + : null; +} + +function dispatchedCommand(request: unknown): Record | null { + const body = recordValue(request); + return recordValue(body?.["command"]); +} + +function sidebarProjectIds(): string[] { + return Array.from(document.querySelectorAll("[data-sidebar-project-id]")).map( + (element) => element.dataset["sidebarProjectId"] ?? "", + ); +} + +function pinnedThreadIds(): string[] { + return Array.from(document.querySelectorAll("[data-pinned-thread-id]")).map( + (element) => element.dataset["pinnedThreadId"] ?? "", + ); +} + +function sidebarProjectButton(projectId: ProjectId): HTMLButtonElement { + const button = Array.from( + document.querySelectorAll("button[data-sidebar-project-id]"), + ).find((candidate) => candidate.dataset["sidebarProjectId"] === projectId); + if (button === undefined) { + throw new Error(`Missing sidebar project button: ${projectId}`); + } + return button; +} + +async function dragSidebarProject(movedProjectId: ProjectId, overProjectId: ProjectId) { + sidebarProjectButton(movedProjectId); + sidebarProjectButton(overProjectId); + await commands.dragSidebarProject(movedProjectId, overProjectId); +} + +function clickVisibleSidebarTrigger(): void { + const trigger = Array.from( + document.querySelectorAll('[data-sidebar="trigger"]'), + ).find((candidate) => { + const bounds = candidate.getBoundingClientRect(); + const style = getComputedStyle(candidate); + return ( + bounds.width > 0 && + bounds.height > 0 && + bounds.right > 0 && + bounds.left < window.innerWidth && + style.visibility !== "hidden" + ); + }); + if (trigger === undefined) { + throw new Error("Missing visible sidebar trigger"); + } + trigger.click(); +} + +async function selectProjectSortOption(name: string): Promise { + await commands.selectProjectSortOption(name); +} + +async function waitForMobileSidebarOpen(): Promise { + await vi.waitFor(() => { + const sidebar = document.querySelector( + '[data-mobile="true"][data-sidebar="sidebar"]', + ); + if (sidebar === null) { + throw new Error("Missing open mobile sidebar"); + } + const bounds = sidebar.getBoundingClientRect(); + expect(bounds.left).toBeGreaterThanOrEqual(0); + expect(bounds.right).toBeLessThanOrEqual(window.innerWidth); + expect( + sidebar + .getAnimations({ subtree: true }) + .every((animation) => animation.playState !== "running"), + ).toBe(true); + }); +} + describe("EventRouter scoped orchestration sync", () => { beforeAll(async () => { fixture = buildFixture(); @@ -492,8 +636,15 @@ describe("EventRouter scoped orchestration sync", () => { fixture = buildFixture(); __resetWsNativeApiForTests(); installTransportDriver(); - document.body.innerHTML = ""; + document.body.replaceChildren(); delayNextThreadSnapshot = false; + autoShellSnapshot = true; + autoWelcome = true; + holdSidebarLayoutDispatchResponses = false; + rejectNextSidebarLayoutDispatch = false; + sidebarLayoutDispatchRequests = []; + sidebarLayoutDispatchResolvers = []; + emitWelcomeStreamEvent = null; localStorage.clear(); useComposerDraftStore.setState({ draftsByThreadId: {}, @@ -518,6 +669,12 @@ describe("EventRouter scoped orchestration sync", () => { sidebarThreadSummaryById: {}, threadsHydrated: false, }); + sidebarLayoutStore.setState({ + confirmedLayout: null, + pendingIntents: [], + lifecycle: { projects: [], threads: [] }, + inFlightCommandId: null, + }); useWorkspaceStore.setState({ homeDir: null, workspacePages: [ @@ -537,10 +694,20 @@ describe("EventRouter scoped orchestration sync", () => { replayRequestCursors = []; }); - afterEach(() => { - __resetWsNativeApiForTests(); - delete window.__T3_WS_TRANSPORT_TEST_DRIVER__; - document.body.innerHTML = ""; + afterEach(async () => { + const acceptedSequence = + fixture.snapshot.sidebarLayout?.revision ?? fixture.snapshot.snapshotSequence + 1; + for (const resolve of sidebarLayoutDispatchResolvers.splice(0)) { + resolve({ sequence: acceptedSequence }); + } + await Promise.resolve(); + try { + await Promise.all([...activeMountCleanups].map((cleanup) => cleanup())); + } finally { + __resetWsNativeApiForTests(); + delete window.__T3_WS_TRANSPORT_TEST_DRIVER__; + document.body.replaceChildren(); + } }); it("shows the first-run wizard instead of the routed workspace when authenticated setup is incomplete", async () => { @@ -572,6 +739,938 @@ describe("EventRouter scoped orchestration sync", () => { } }); + it("applies a buffered shell event after the initial shell snapshot", async () => { + // Given: the shell stream is connected but its initial snapshot is delayed. + autoShellSnapshot = false; + autoWelcome = false; + const mounted = await mountApp({ waitForHydration: false }); + + try { + await vi.waitFor(() => expect(emitShellStreamEvent).not.toBeNull()); + const bufferedProjectId = ProjectId.makeUnsafe("project-buffered-before-snapshot"); + + // When: a live project event arrives before the older snapshot. + sendShellEventPush({ + kind: "project-upserted", + sequence: 2, + project: { + ...createShellSnapshotFromFixtureSnapshot(fixture.snapshot).projects[0]!, + id: bufferedProjectId, + title: "Buffered project", + workspaceRoot: "/repo/buffered-project", + }, + }); + expect(useStore.getState().projects).toEqual([]); + sendShellSnapshotPush(createShellSnapshotFromFixtureSnapshot(fixture.snapshot)); + + // Then: snapshot hydration occurs first and the buffered event is replayed after it. + await vi.waitFor(() => { + expect(useStore.getState().projects.map((project) => project.id)).toEqual([ + PROJECT_ID, + bufferedProjectId, + ]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("preserves the first server layout when two initialization attempts race and a response is lost", async () => { + // Given: hydrated legacy candidates and a delayed null shell snapshot. + autoShellSnapshot = false; + autoWelcome = false; + holdSidebarLayoutDispatchResponses = true; + const otherProjectId = ProjectId.makeUnsafe("project-other-initializer"); + const baseProject = fixture.snapshot.projects[0]; + if (baseProject === undefined) { + throw new Error("Missing base project fixture"); + } + fixture.snapshot = { + ...fixture.snapshot, + projects: [ + ...fixture.snapshot.projects, + { + ...baseProject, + id: otherProjectId, + title: "Other project", + workspaceRoot: "/repo/other-project", + }, + ], + }; + await page.viewport(1280, 800); + const mounted = await mountApp({ waitForHydration: false }); + + try { + await vi.waitFor(() => expect(emitShellStreamEvent).not.toBeNull()); + + // When: the null snapshot arrives after candidates are ready. + sendShellSnapshotPush(createShellSnapshotFromFixtureSnapshot(fixture.snapshot)); + + // Then: one logical initializer is dispatched and the routed DOM is hydrated. + await vi.waitFor(() => expect(sidebarLayoutDispatchRequests).toHaveLength(1)); + await expect.element(page.getByText("hello")).toBeVisible(); + const firstCommand = dispatchedCommand(sidebarLayoutDispatchRequests[0]); + expect(firstCommand?.["type"]).toBe("sidebar-layout.initialize"); + expect(firstCommand?.["projectOrder"]).toEqual([]); + expect(firstCommand?.["pinnedThreadOrder"]).toEqual([]); + const firstCommandId = firstCommand?.["commandId"]; + expect(typeof firstCommandId).toBe("string"); + + // Given: another client wins while this client's first response is lost. + const winningLayout = { + projectOrder: [otherProjectId, PROJECT_ID], + pinnedThreadOrder: [], + revision: 9, + updatedAt: "2026-07-18T00:00:09.000Z", + } as const; + fixture.snapshot = { + ...fixture.snapshot, + snapshotSequence: 9, + sidebarLayout: winningLayout, + }; + + // When: reconnect retries receipt recovery and fetches the canonical snapshot. + await vi.waitFor(() => expect(emitWelcomeStreamEvent).not.toBeNull()); + sendWelcomePush(fixture.welcome); + + // Then: the retry reuses the command ID and the canonical server order is rendered. + await vi.waitFor(() => { + expect(sidebarLayoutDispatchRequests).toHaveLength(2); + expect(sidebarProjectIds()).toEqual([otherProjectId, PROJECT_ID]); + }); + const retryCommand = dispatchedCommand(sidebarLayoutDispatchRequests[1]); + expect(retryCommand?.["commandId"]).toBe(firstCommandId); + + // Given: the reconnect stream establishes the winning snapshot fence. + sendShellSnapshotPush(createShellSnapshotFromFixtureSnapshot(fixture.snapshot)); + + // When: a newer live layout is applied. + const newerLayout = { + ...winningLayout, + projectOrder: [PROJECT_ID, otherProjectId], + revision: 10, + updatedAt: "2026-07-18T00:00:10.000Z", + } as const; + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 10, + sidebarLayout: newerLayout, + }); + await vi.waitFor(() => { + expect(sidebarProjectIds()).toEqual([PROJECT_ID, otherProjectId]); + }); + + // When: a later shell snapshot is observable but carries the older layout revision. + const staleSnapshot = createShellSnapshotFromFixtureSnapshot(fixture.snapshot); + sendShellSnapshotPush({ + ...staleSnapshot, + snapshotSequence: 100, + sidebarLayout: winningLayout, + projects: staleSnapshot.projects.map((project) => + project.id === otherProjectId + ? { ...project, title: "Other project from stale snapshot" } + : project, + ), + }); + + // Then: rendered order remains at the newer revision and no second logical init is created. + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Other project from stale snapshot"); + expect(sidebarProjectIds()).toEqual([PROJECT_ID, otherProjectId]); + expect(sidebarLayoutDispatchRequests).toHaveLength(2); + expect( + sidebarLayoutDispatchRequests.map((request) => dispatchedCommand(request)?.["commandId"]), + ).toEqual([firstCommandId, firstCommandId]); + }); + } finally { + for (const resolve of sidebarLayoutDispatchResolvers) { + resolve({ sequence: 9 }); + } + await mounted.cleanup(); + await page.viewport(414, 896); + } + }); + + it("migrates legacy sidebar authority once and a marked reload never initializes again", async () => { + // Given: an old profile has project order, pin membership, expansion, and a local alias. + autoShellSnapshot = false; + autoWelcome = false; + const otherProjectId = ProjectId.makeUnsafe("project-legacy-other"); + const baseProject = fixture.snapshot.projects[0]; + if (baseProject === undefined) { + throw new Error("Missing base project fixture"); + } + fixture.snapshot = { + ...fixture.snapshot, + projects: [ + ...fixture.snapshot.projects, + { + ...baseProject, + id: otherProjectId, + title: "Legacy other project", + workspaceRoot: "/repo/legacy-other", + }, + ], + }; + localStorage.setItem( + "jcode:renderer-state:v8", + JSON.stringify({ + expandedProjectCwds: ["/repo/project"], + projectOrderCwds: ["/repo/legacy-other", "/repo/project"], + projectNamesByCwd: { "/repo/project": "Local project" }, + }), + ); + localStorage.setItem( + "dpcode:pinned-threads:v1", + JSON.stringify({ state: { pinnedThreadIds: [THREAD_ID] }, version: 0 }), + ); + await page.viewport(1280, 800); + const mounted = await mountApp({ waitForHydration: false }); + + try { + await vi.waitFor(() => expect(emitShellStreamEvent).not.toBeNull()); + + // When: the uninitialized snapshot is hydrated and another client wins initialization. + sendShellSnapshotPush(createShellSnapshotFromFixtureSnapshot(fixture.snapshot)); + await vi.waitFor(() => expect(sidebarLayoutDispatchRequests).toHaveLength(1)); + expect(dispatchedCommand(sidebarLayoutDispatchRequests[0])).toMatchObject({ + type: "sidebar-layout.initialize", + projectOrder: [otherProjectId, PROJECT_ID], + pinnedThreadOrder: [THREAD_ID], + }); + const winningLayout = { + projectOrder: [PROJECT_ID, otherProjectId], + pinnedThreadOrder: [], + revision: 8, + updatedAt: "2026-07-18T00:00:08.000Z", + } as const; + sendShellSnapshotPush({ + ...createShellSnapshotFromFixtureSnapshot(fixture.snapshot), + snapshotSequence: 8, + sidebarLayout: winningLayout, + }); + + // Then: canonical state wins visibly and only authority fields are retired. + await vi.waitFor(() => { + expect(sidebarProjectIds()).toEqual([PROJECT_ID, otherProjectId]); + expect(pinnedThreadIds()).toEqual([]); + expect(localStorage.getItem("jcode:sidebar-layout-migrated:v1")).toBe("1"); + }); + expect(localStorage.getItem("dpcode:pinned-threads:v1")).toBeNull(); + const cleanedRendererState: unknown = JSON.parse( + localStorage.getItem("jcode:renderer-state:v8") ?? "null", + ); + expect(cleanedRendererState).not.toHaveProperty("projectOrderCwds"); + } finally { + await mounted.cleanup(); + } + + // Given: stale legacy values reappear after the durable marker on an old-profile reload. + localStorage.setItem( + "jcode:renderer-state:v8", + JSON.stringify({ projectOrderCwds: ["/repo/legacy-other"] }), + ); + localStorage.setItem("t3code:pinned-threads:v1", JSON.stringify([THREAD_ID])); + fixture.snapshot = { ...fixture.snapshot, sidebarLayout: null, snapshotSequence: 1 }; + sidebarLayoutDispatchRequests = []; + sidebarLayoutStore.setState({ + confirmedLayout: null, + pendingIntents: [], + lifecycle: { projects: [], threads: [] }, + inFlightCommandId: null, + }); + const reloaded = await mountApp({ waitForHydration: false }); + + try { + await vi.waitFor(() => expect(emitShellStreamEvent).not.toBeNull()); + + // When: the reloaded client receives the old null snapshot. + sendShellSnapshotPush(createShellSnapshotFromFixtureSnapshot(fixture.snapshot)); + await expect.element(page.getByText("hello")).toBeVisible(); + + // Then: the marker blocks replay and no second initialize command is submitted. + expect(sidebarLayoutDispatchRequests).toEqual([]); + } finally { + await reloaded.cleanup(); + await page.viewport(414, 896); + } + }); + + it("waits through a transient empty shell snapshot before collecting legacy candidates", async () => { + // Given: legacy authority refers to subjects omitted by a transient desktop startup snapshot. + autoShellSnapshot = false; + autoWelcome = false; + localStorage.setItem( + "jcode:renderer-state:v8", + JSON.stringify({ projectOrderCwds: ["/repo/project"] }), + ); + localStorage.setItem("jcode:pinned-threads:v1", JSON.stringify([THREAD_ID])); + const hydratedSnapshot = createShellSnapshotFromFixtureSnapshot(fixture.snapshot); + const mounted = await mountApp({ waitForHydration: false }); + + try { + await vi.waitFor(() => expect(emitShellStreamEvent).not.toBeNull()); + + // When: the first pushed snapshot is empty but still marks shell hydration complete. + sendShellSnapshotPush({ ...hydratedSnapshot, projects: [], threads: [] }); + await vi.waitFor(() => expect(useStore.getState().threadsHydrated).toBe(true)); + + // Then: provisional emptiness cannot consume the one-time initializer. + expect(sidebarLayoutDispatchRequests).toEqual([]); + + // When: the later pushed snapshot contains the hydrated subjects. + sendShellSnapshotPush({ ...hydratedSnapshot, snapshotSequence: 2 }); + + // Then: the first initializer includes the mapped legacy project and pin candidates. + await vi.waitFor(() => expect(sidebarLayoutDispatchRequests).toHaveLength(1)); + expect(dispatchedCommand(sidebarLayoutDispatchRequests[0])).toMatchObject({ + type: "sidebar-layout.initialize", + projectOrder: [PROJECT_ID], + pinnedThreadOrder: [THREAD_ID], + }); + } finally { + await mounted.cleanup(); + } + }); + + it("moves manual projects by final next-sibling intent and preserves canonical order across automatic sorting", async () => { + const projectB = ProjectId.makeUnsafe("project-browser-b"); + const projectC = ProjectId.makeUnsafe("project-browser-c"); + const chatProject = ProjectId.makeUnsafe("chat-browser-home"); + const projectARecord = fixture.snapshot.projects[0]; + if (projectARecord === undefined) { + throw new Error("Missing base project fixture"); + } + fixture.snapshot = { + ...fixture.snapshot, + sidebarLayout: { + projectOrder: [projectB, chatProject, PROJECT_ID, projectC], + pinnedThreadOrder: [], + revision: 1, + updatedAt: NOW_ISO, + }, + projects: [ + { ...projectARecord, title: "Project A", updatedAt: "2026-03-04T10:00:00.000Z" }, + { + ...projectARecord, + id: projectB, + title: "Project B", + workspaceRoot: "/repo/project-b", + createdAt: "2026-03-04T09:00:00.000Z", + updatedAt: "2026-03-04T11:00:00.000Z", + }, + { + ...projectARecord, + id: chatProject, + kind: "chat", + title: "Chat home", + workspaceRoot: "/repo/chat-home", + createdAt: "2026-03-04T08:00:00.000Z", + updatedAt: "2026-03-04T08:00:00.000Z", + }, + { + ...projectARecord, + id: projectC, + title: "Project C", + workspaceRoot: "/repo/project-c", + createdAt: "2026-03-04T07:00:00.000Z", + updatedAt: "2026-03-04T12:00:00.000Z", + }, + ], + }; + holdSidebarLayoutDispatchResponses = true; + await page.viewport(1280, 800); + const mounted = await mountApp(); + + try { + await vi.waitFor(() => { + expect(sidebarProjectIds()).toEqual([projectB, PROJECT_ID, projectC]); + }); + await page.screenshot({ + path: "../../../../.omo/evidence/task-11-project-order-1280-resting.png", + }); + + rejectNextSidebarLayoutDispatch = true; + await dragSidebarProject(PROJECT_ID, projectC); + + await expect.element(page.getByText("Unable to reorder projects")).toBeVisible(); + await vi.waitFor(() => { + expect(sidebarLayoutDispatchRequests).toHaveLength(1); + expect(sidebarProjectIds()).toEqual([projectB, PROJECT_ID, projectC]); + }); + expect(dispatchedCommand(sidebarLayoutDispatchRequests[0])).toMatchObject({ + type: "sidebar-layout.project.move", + projectId: PROJECT_ID, + beforeProjectId: null, + }); + + await dragSidebarProject(PROJECT_ID, projectC); + + await vi.waitFor(() => { + expect(sidebarLayoutDispatchRequests).toHaveLength(2); + expect(sidebarProjectIds()).toEqual([projectB, projectC, PROJECT_ID]); + }); + expect(dispatchedCommand(sidebarLayoutDispatchRequests[1])).toMatchObject({ + type: "sidebar-layout.project.move", + projectId: PROJECT_ID, + beforeProjectId: null, + }); + + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 2, + sidebarLayout: { + projectOrder: [projectB, chatProject, projectC, PROJECT_ID], + pinnedThreadOrder: [], + revision: 2, + updatedAt: "2026-03-04T12:00:01.000Z", + }, + }); + sidebarLayoutDispatchResolvers.shift()?.({ sequence: 2 }); + + await selectProjectSortOption("Last user message"); + await vi.waitFor(() => { + expect(sidebarProjectIds()).toEqual([PROJECT_ID, projectC, projectB]); + }); + await dragSidebarProject(PROJECT_ID, projectC); + expect(sidebarLayoutDispatchRequests).toHaveLength(2); + + await selectProjectSortOption("Manual"); + await vi.waitFor(() => { + expect(sidebarProjectIds()).toEqual([projectB, projectC, PROJECT_ID]); + }); + + await dragSidebarProject(PROJECT_ID, projectB); + await vi.waitFor(() => { + expect(sidebarLayoutDispatchRequests).toHaveLength(3); + expect(sidebarProjectIds()).toEqual([PROJECT_ID, projectB, projectC]); + }); + expect(document.activeElement).toBe(sidebarProjectButton(PROJECT_ID)); + expect(dispatchedCommand(sidebarLayoutDispatchRequests[2])).toMatchObject({ + type: "sidebar-layout.project.move", + projectId: PROJECT_ID, + beforeProjectId: projectB, + }); + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 3, + sidebarLayout: { + projectOrder: [PROJECT_ID, projectB, chatProject, projectC], + pinnedThreadOrder: [], + revision: 3, + updatedAt: "2026-03-04T12:00:02.000Z", + }, + }); + sidebarLayoutDispatchResolvers.shift()?.({ sequence: 3 }); + await page.screenshot({ + path: "../../../../.omo/evidence/task-11-project-order-1280-post-drag.png", + }); + + await page.viewport(768, 800); + await vi.waitFor(() => expect(sidebarProjectIds()).toEqual([PROJECT_ID, projectB, projectC])); + clickVisibleSidebarTrigger(); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + clickVisibleSidebarTrigger(); + await vi.waitFor(() => expect(sidebarProjectIds()).toEqual([PROJECT_ID, projectB, projectC])); + await page.screenshot({ + path: "../../../../.omo/evidence/task-11-project-order-768-post-drag.png", + }); + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 4, + sidebarLayout: { + projectOrder: [projectB, chatProject, projectC, PROJECT_ID], + pinnedThreadOrder: [], + revision: 4, + updatedAt: "2026-03-04T12:00:03.000Z", + }, + }); + await vi.waitFor(() => expect(sidebarProjectIds()).toEqual([projectB, projectC, PROJECT_ID])); + await page.screenshot({ + path: "../../../../.omo/evidence/task-11-project-order-768-resting.png", + }); + + await page.viewport(375, 800); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + clickVisibleSidebarTrigger(); + await waitForMobileSidebarOpen(); + await vi.waitFor(() => expect(sidebarProjectIds()).toEqual([projectB, projectC, PROJECT_ID])); + await page.screenshot({ + path: "../../../../.omo/evidence/task-11-project-order-375-resting.png", + }); + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 5, + sidebarLayout: { + projectOrder: [PROJECT_ID, projectB, chatProject, projectC], + pinnedThreadOrder: [], + revision: 5, + updatedAt: "2026-03-04T12:00:04.000Z", + }, + }); + await vi.waitFor(() => expect(sidebarProjectIds()).toEqual([PROJECT_ID, projectB, projectC])); + await page.screenshot({ + path: "../../../../.omo/evidence/task-11-project-order-375-post-drag.png", + }); + } finally { + for (const resolve of sidebarLayoutDispatchResolvers) { + resolve({ sequence: 3 }); + } + await mounted.cleanup(); + await page.viewport(414, 896); + } + }); + + it("converges remote project and pin order after entity lifecycle changes", async () => { + const projectB = ProjectId.makeUnsafe("project-remote-browser-b"); + const projectC = ProjectId.makeUnsafe("project-remote-browser-c"); + const threadB = ThreadId.makeUnsafe("thread-remote-browser-b"); + const threadC = ThreadId.makeUnsafe("thread-remote-browser-c"); + const projectARecord = fixture.snapshot.projects[0]; + const rootThread = fixture.snapshot.threads[0]; + if (projectARecord === undefined || rootThread === undefined || rootThread.session === null) { + throw new Error("Missing remote convergence fixture records"); + } + const projectBRecord = { + ...projectARecord, + id: projectB, + title: "Remote project B", + workspaceRoot: "/repo/remote-project-b", + createdAt: "2026-03-04T11:00:00.000Z", + updatedAt: "2026-03-04T11:00:00.000Z", + }; + const threadBRecord = { + ...rootThread, + id: threadB, + projectId: projectB, + title: "Remote pinned B", + messages: [], + session: { ...rootThread.session, threadId: threadB }, + }; + fixture.snapshot = { + ...fixture.snapshot, + sidebarLayout: { + projectOrder: [PROJECT_ID, projectB], + pinnedThreadOrder: [THREAD_ID, threadB], + revision: 1, + updatedAt: NOW_ISO, + }, + projects: [projectARecord, projectBRecord], + threads: [rootThread, threadBRecord], + }; + await page.viewport(1280, 800); + const mounted = await mountApp(); + + try { + await vi.waitFor( + () => { + expect(sidebarProjectIds()).toEqual([PROJECT_ID, projectB]); + expect(pinnedThreadIds()).toEqual([THREAD_ID, threadB]); + }, + { timeout: 5_000, interval: 16 }, + ); + + // When: another client publishes a new canonical order. + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 2, + sidebarLayout: { + projectOrder: [projectB, PROJECT_ID], + pinnedThreadOrder: [threadB, THREAD_ID], + revision: 2, + updatedAt: "2026-07-18T00:00:02.000Z", + }, + }); + + // Then: both project and pin order converge in the rendered sidebar. + await vi.waitFor(() => { + expect(sidebarProjectIds()).toEqual([projectB, PROJECT_ID]); + expect(pinnedThreadIds()).toEqual([threadB, THREAD_ID]); + }); + + const projectCRecord = { + ...projectARecord, + id: projectC, + title: "Remote project C", + workspaceRoot: "/repo/remote-project-c", + createdAt: "2026-03-04T12:00:00.000Z", + updatedAt: "2026-03-04T12:00:00.000Z", + }; + const threadCRecord = { + ...rootThread, + id: threadC, + projectId: projectC, + title: "Remote pinned C", + messages: [], + session: { ...rootThread.session, threadId: threadC }, + }; + fixture.snapshot = { + ...fixture.snapshot, + projects: [...fixture.snapshot.projects, projectCRecord], + threads: [...fixture.snapshot.threads, threadCRecord], + }; + + // When: lifecycle upserts arrive before a canonical layout that places the new entities first. + const shellSnapshotWithNewEntities = createShellSnapshotFromFixtureSnapshot(fixture.snapshot); + const projectCShell = shellSnapshotWithNewEntities.projects.find( + (project) => project.id === projectC, + ); + const projectBShell = shellSnapshotWithNewEntities.projects.find( + (project) => project.id === projectB, + ); + const threadCShell = shellSnapshotWithNewEntities.threads.find( + (thread) => thread.id === threadC, + ); + const threadBShell = shellSnapshotWithNewEntities.threads.find( + (thread) => thread.id === threadB, + ); + if ( + projectBShell === undefined || + projectCShell === undefined || + threadBShell === undefined || + threadCShell === undefined + ) { + throw new Error("Missing remote convergence shell records"); + } + sendShellEventPush({ + kind: "project-upserted", + sequence: 3, + project: projectCShell, + }); + sendShellEventPush({ + kind: "thread-upserted", + sequence: 4, + thread: threadCShell, + }); + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 5, + sidebarLayout: { + projectOrder: [projectC, projectB, PROJECT_ID], + pinnedThreadOrder: [threadC, threadB, THREAD_ID], + revision: 5, + updatedAt: "2026-07-18T00:00:05.000Z", + }, + }); + + // Then: normalization includes the new live entities in the remote canonical positions. + await vi.waitFor(() => { + expect(sidebarProjectIds()).toEqual([projectC, projectB, PROJECT_ID]); + expect(pinnedThreadIds()).toEqual([threadC, threadB, THREAD_ID]); + }); + + // When: lower/equal lifecycle removals arrive after the newer canonical event. + sendShellEventPush({ kind: "project-removed", sequence: 4, projectId: projectB }); + sendShellEventPush({ kind: "thread-removed", sequence: 5, threadId: threadB }); + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 6, + sidebarLayout: { + projectOrder: [projectB, projectC, PROJECT_ID], + pinnedThreadOrder: [threadB, threadC, THREAD_ID], + revision: 6, + updatedAt: "2026-07-18T00:00:06.000Z", + }, + }); + + // Then: stale entity events cannot alter lifecycle normalization or rendered order. + await vi.waitFor(() => { + expect(sidebarProjectIds()).toEqual([projectB, projectC, PROJECT_ID]); + expect(pinnedThreadIds()).toEqual([threadB, threadC, THREAD_ID]); + }); + + // When: the shell removes a project and a pinned thread still named by canonical state. + sendShellEventPush({ kind: "project-removed", sequence: 7, projectId: projectB }); + sendShellEventPush({ kind: "thread-removed", sequence: 8, threadId: threadB }); + + // Then: removed entities disappear without waiting for a replacement layout snapshot. + await vi.waitFor(() => { + expect(sidebarProjectIds()).toEqual([projectC, PROJECT_ID]); + expect(pinnedThreadIds()).toEqual([threadC, THREAD_ID]); + }); + + const dispatchCountAfterValidRemovals = sidebarLayoutDispatchRequests.length; + + // When: saved lower/equal upserts arrive after their entities were validly removed. + sendShellEventPush({ kind: "project-upserted", sequence: 7, project: projectBShell }); + sendShellEventPush({ kind: "thread-upserted", sequence: 8, thread: threadBShell }); + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 9, + sidebarLayout: { + projectOrder: [PROJECT_ID, projectC, projectB], + pinnedThreadOrder: [THREAD_ID, threadC, threadB], + revision: 9, + updatedAt: "2026-07-18T00:00:09.000Z", + }, + }); + + // Then: stale upserts cannot resurrect removed rows or dispatch a layout command. + await vi.waitFor(() => { + expect(sidebarProjectIds()).toEqual([PROJECT_ID, projectC]); + expect(pinnedThreadIds()).toEqual([THREAD_ID, threadC]); + expect(sidebarLayoutDispatchRequests).toHaveLength(dispatchCountAfterValidRemovals); + }); + await page.screenshot({ + path: "../../../../.omo/evidence/sidebar-layout/browser-harness.png", + }); + } finally { + await mounted.cleanup(); + await page.viewport(414, 896); + } + }); + + it("keeps the original unpin race at one unpin and zero compensating pins while reordering accessibly", async () => { + const rootThread = fixture.snapshot.threads[0]; + const rootProject = fixture.snapshot.projects[0]; + if (rootThread === undefined || rootThread.session === null || rootProject === undefined) { + throw new Error("Missing root pin fixture"); + } + const rootSession = rootThread.session; + const makeSibling = ( + id: ThreadId, + title: string, + projectId: ProjectId = rootThread.projectId, + ): OrchestrationThread => ({ + ...rootThread, + id, + projectId, + title, + isPinned: true, + messages: [], + session: { ...rootSession, threadId: id }, + }); + fixture.snapshot = { + ...fixture.snapshot, + sidebarLayout: { + projectOrder: [PROJECT_ID, CHAT_PROJECT_ID], + pinnedThreadOrder: [OTHER_THREAD_ID, THIRD_THREAD_ID], + revision: 1, + updatedAt: NOW_ISO, + }, + projects: [ + rootProject, + { + ...rootProject, + id: CHAT_PROJECT_ID, + kind: "chat", + title: "Home", + workspaceRoot: "/home/tester", + }, + ], + threads: [ + { ...rootThread, isPinned: true }, + makeSibling(OTHER_THREAD_ID, "T3Code Integration Ideas", CHAT_PROJECT_ID), + makeSibling(THIRD_THREAD_ID, "Third pinned test thread"), + ], + }; + fixture.serverConfig = { ...fixture.serverConfig, homeDir: "/home/tester" }; + fixture.welcome = { ...fixture.welcome, homeDir: "/home/tester" }; + localStorage.setItem( + "jcode:sidebar-ui:v1", + JSON.stringify({ + chatSectionExpanded: true, + expandedProjectThreadListCwds: ["/repo/project"], + }), + ); + await page.viewport(1280, 800); + const mounted = await mountApp(); + + try { + await vi.waitFor(() => { + expect(pinnedThreadIds()).toEqual([OTHER_THREAD_ID, THIRD_THREAD_ID]); + }); + expect(document.querySelectorAll(`[data-sidebar-thread-id="${THREAD_ID}"]`)).toHaveLength(1); + expect(document.querySelector(`[data-pinned-thread-id="${THREAD_ID}"]`)).toBeNull(); + expect( + document.querySelectorAll(`[data-testid="thread-title-${OTHER_THREAD_ID}"]`), + ).toHaveLength(1); + expect( + document.querySelectorAll(`[data-sidebar-thread-id="${OTHER_THREAD_ID}"]`), + ).toHaveLength(1); + await expect.element(page.getByText("No chats yet")).toBeVisible(); + + await commands.clickProjectThreadPin(THREAD_ID); + await vi.waitFor(() => { + expect(pinnedThreadIds()).toEqual([OTHER_THREAD_ID, THIRD_THREAD_ID, THREAD_ID]); + }); + expect(dispatchedCommand(sidebarLayoutDispatchRequests.at(-1))).toMatchObject({ + type: "sidebar-layout.thread.pin", + threadId: THREAD_ID, + beforeThreadId: null, + }); + expect(document.querySelectorAll(`[data-testid="thread-title-${THREAD_ID}"]`)).toHaveLength( + 1, + ); + expect(document.querySelector(`[data-pinned-thread-id="${THREAD_ID}"]`)).not.toBeNull(); + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 2, + sidebarLayout: { + projectOrder: [PROJECT_ID, CHAT_PROJECT_ID], + pinnedThreadOrder: [OTHER_THREAD_ID, THIRD_THREAD_ID, THREAD_ID], + revision: 2, + updatedAt: "2026-07-18T00:00:02.000Z", + }, + }); + fixture.snapshot = { + ...fixture.snapshot, + snapshotSequence: 2, + sidebarLayout: { + projectOrder: [PROJECT_ID, CHAT_PROJECT_ID], + pinnedThreadOrder: [OTHER_THREAD_ID, THIRD_THREAD_ID, THREAD_ID], + revision: 2, + updatedAt: "2026-07-18T00:00:02.000Z", + }, + }; + + const rootHandle = document.querySelector( + `button[data-pinned-thread-drag-handle="${THREAD_ID}"]`, + ); + expect(rootHandle?.getAttribute("aria-label")).toBe("Reorder pinned thread Root test thread"); + expect(rootHandle?.getAttribute("aria-pressed")).toBe("false"); + expect(rootHandle?.tabIndex).toBe(0); + rootHandle?.focus(); + expect(document.activeElement).toBe(rootHandle); + await page.screenshot({ + path: "../../../../.omo/evidence/task-12-pinned-1280-handle-focus.png", + }); + + const commandCountBeforeOutOfBoundsDrag = sidebarLayoutDispatchRequests.length; + expect(await commands.dragPinnedThreadOutOfBounds(THREAD_ID)).toBe(true); + expect(sidebarLayoutDispatchRequests).toHaveLength(commandCountBeforeOutOfBoundsDrag); + expect(pinnedThreadIds()).toEqual([OTHER_THREAD_ID, THIRD_THREAD_ID, THREAD_ID]); + + await commands.keyboardMovePinnedThread(THREAD_ID, "ArrowUp"); + await vi.waitFor(() => { + expect(pinnedThreadIds()).toEqual([OTHER_THREAD_ID, THREAD_ID, THIRD_THREAD_ID]); + }); + expect(dispatchedCommand(sidebarLayoutDispatchRequests.at(-1))).toMatchObject({ + type: "sidebar-layout.pinned-thread.move", + threadId: THREAD_ID, + beforeThreadId: THIRD_THREAD_ID, + }); + const rootHandleAfterKeyboard = document.querySelector( + `button[data-pinned-thread-drag-handle="${THREAD_ID}"]`, + ); + expect(document.activeElement).toBe(rootHandleAfterKeyboard); + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 4, + sidebarLayout: { + projectOrder: [PROJECT_ID, CHAT_PROJECT_ID], + pinnedThreadOrder: [OTHER_THREAD_ID, THREAD_ID, THIRD_THREAD_ID], + revision: 4, + updatedAt: "2026-07-18T00:00:04.000Z", + }, + }); + fixture.snapshot = { + ...fixture.snapshot, + snapshotSequence: 4, + sidebarLayout: { + projectOrder: [PROJECT_ID, CHAT_PROJECT_ID], + pinnedThreadOrder: [OTHER_THREAD_ID, THREAD_ID, THIRD_THREAD_ID], + revision: 4, + updatedAt: "2026-07-18T00:00:04.000Z", + }, + }; + await vi.waitFor(() => { + const list = document.querySelector("[data-pinned-thread-list]"); + expect(list).not.toBeNull(); + expect( + list + ?.getAnimations({ subtree: true }) + .every((animation) => animation.playState !== "running"), + ).toBe(true); + }); + + rejectNextSidebarLayoutDispatch = true; + await commands.dragPinnedThread(THREAD_ID, THIRD_THREAD_ID); + await expect.element(page.getByText("Unable to reorder pinned threads")).toBeVisible(); + await vi.waitFor(() => { + expect(pinnedThreadIds()).toEqual([OTHER_THREAD_ID, THREAD_ID, THIRD_THREAD_ID]); + }); + + await commands.dragPinnedThread(THREAD_ID, THIRD_THREAD_ID); + await vi.waitFor(() => { + expect(pinnedThreadIds()).toEqual([OTHER_THREAD_ID, THIRD_THREAD_ID, THREAD_ID]); + }); + expect(dispatchedCommand(sidebarLayoutDispatchRequests.at(-1))).toMatchObject({ + type: "sidebar-layout.pinned-thread.move", + threadId: THREAD_ID, + beforeThreadId: null, + }); + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 5, + sidebarLayout: { + projectOrder: [PROJECT_ID, CHAT_PROJECT_ID], + pinnedThreadOrder: [OTHER_THREAD_ID, THIRD_THREAD_ID, THREAD_ID], + revision: 5, + updatedAt: "2026-07-18T00:00:05.000Z", + }, + }); + + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 10, + sidebarLayout: { + projectOrder: [PROJECT_ID, CHAT_PROJECT_ID], + pinnedThreadOrder: [OTHER_THREAD_ID, THIRD_THREAD_ID, THREAD_ID], + revision: 10, + updatedAt: "2026-07-18T00:00:10.000Z", + }, + }); + holdSidebarLayoutDispatchResponses = true; + await commands.clickPinnedThreadUnpin(OTHER_THREAD_ID); + await vi.waitFor(() => { + expect(dispatchedCommand(sidebarLayoutDispatchRequests.at(-1))).toMatchObject({ + type: "sidebar-layout.thread.unpin", + threadId: OTHER_THREAD_ID, + }); + expect(pinnedThreadIds()).toEqual([THIRD_THREAD_ID, THREAD_ID]); + }); + sendShellEventPush({ + kind: "sidebar-layout-updated", + sequence: 11, + sidebarLayout: { + projectOrder: [PROJECT_ID, CHAT_PROJECT_ID], + pinnedThreadOrder: [THREAD_ID, OTHER_THREAD_ID, THIRD_THREAD_ID], + revision: 11, + updatedAt: "2026-07-18T00:00:11.000Z", + }, + }); + await vi.waitFor(() => { + expect(pinnedThreadIds()).toEqual([THREAD_ID, THIRD_THREAD_ID]); + }); + const commandsForRaceThread = sidebarLayoutDispatchRequests + .map(dispatchedCommand) + .filter((command) => command?.["threadId"] === OTHER_THREAD_ID); + expect( + commandsForRaceThread.filter( + (command) => command?.["type"] === "sidebar-layout.thread.unpin", + ), + ).toHaveLength(1); + expect( + commandsForRaceThread.filter( + (command) => command?.["type"] === "sidebar-layout.thread.pin", + ), + ).toHaveLength(0); + expect( + document.querySelectorAll(`[data-sidebar-thread-id="${OTHER_THREAD_ID}"]`), + ).toHaveLength(1); + await page.screenshot({ + path: "../../../../.omo/evidence/task-12-pinned-1280-post-reorder-unpin.png", + }); + } finally { + for (const resolve of sidebarLayoutDispatchResolvers) { + resolve({ sequence: 12 }); + } + await mounted.cleanup(); + await page.viewport(414, 896); + } + }); + it("does not block the pair route with the first-run wizard", async () => { fixture.firstRunWizardData = createFirstRunWizardData(); const mounted = await mountApp({ initialPath: "/pair", waitForThreadId: null }); diff --git a/apps/web/src/components/FirstRunWizard.browser.tsx b/apps/web/src/components/FirstRunWizard.browser.tsx index 3004daa25..33f033e24 100644 --- a/apps/web/src/components/FirstRunWizard.browser.tsx +++ b/apps/web/src/components/FirstRunWizard.browser.tsx @@ -92,6 +92,7 @@ const testServerConfig: ServerConfig = { const emptyShellSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 1, + sidebarLayout: null, projects: [], threads: [], updatedAt: "2026-06-12T12:03:00.000Z", diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index c10f06260..7a93cf8d8 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -24,6 +24,7 @@ import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; import { getRouter } from "../router"; +import { sidebarLayoutStore } from "../sidebarLayoutStore"; import { useStore } from "../store"; import { __resetWsNativeApiForTests } from "../wsNativeApi"; @@ -88,6 +89,7 @@ function createCompletedFirstRunWizardData(): FirstRunWizardData { function createMinimalSnapshot(): OrchestrationReadModel { return { snapshotSequence: 1, + sidebarLayout: null, projects: [ { id: PROJECT_ID, @@ -172,6 +174,7 @@ function createShellSnapshotFromFixtureSnapshot( ): OrchestrationShellSnapshot { return { snapshotSequence: snapshot.snapshotSequence, + sidebarLayout: snapshot.sidebarLayout, projects: snapshot.projects.map((project) => ({ id: project.id, kind: project.kind, @@ -231,6 +234,9 @@ function resolveWsRpc(tag: string): unknown { if (tag === ORCHESTRATION_WS_METHODS.getShellSnapshot) { return createShellSnapshotFromFixtureSnapshot(fixture.snapshot); } + if (tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { sequence: fixture.snapshot.snapshotSequence + 1 }; + } if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } @@ -461,6 +467,12 @@ describe("Keybindings update toast", () => { sidebarThreadSummaryById: {}, threadsHydrated: false, }); + sidebarLayoutStore.setState({ + confirmedLayout: null, + pendingIntents: [], + lifecycle: { projects: [], threads: [] }, + inFlightCommandId: null, + }); }); afterEach(() => { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 7489fb1f4..1f91a9256 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -9,6 +9,7 @@ import { getFallbackThreadIdAfterDelete, getVisibleSidebarEntriesForPreview, getPinnedThreadsForSidebar, + getUnpinnedSectionThreadsForSidebar, getNextVisibleSidebarThreadId, getSidebarThreadIdForJumpCommand, getSidebarThreadIdsToPrewarm, @@ -22,16 +23,18 @@ import { installDebugFeatureFlagConsoleCommands, isLoopbackHostname, isDuplicateProjectCreateError, + orderProjectsByCanonicalOrder, pruneDismissedThreadStatusKeys, pruneExpandedProjectThreadListsForCollapsedProjects, recoverExistingAddProjectTarget, resolveProjectEmptyState, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, + resolveSidebarPinnedThreadMoveIntent, + resolveSidebarProjectMoveIntent, resolveThreadRowClassName, resolveThreadStatusPill, shouldShowDebugFeatureFlagsMenu, - shouldPrunePinnedThreads, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, @@ -378,9 +381,29 @@ describe("pin helpers", () => { ).toEqual([threads[0]]); }); - it("waits for thread hydration before pruning persisted pins", () => { - expect(shouldPrunePinnedThreads({ threadsHydrated: false })).toBe(false); - expect(shouldPrunePinnedThreads({ threadsHydrated: true })).toBe(true); + it("filters canonical pins from a chat/Home section while preserving thread order", () => { + const chatProjectId = ProjectId.makeUnsafe("chat-home"); + const chatProject = makeProject({ id: chatProjectId, kind: "chat", name: "Home" }); + const first = makeSidebarThreadSummary({ + id: ThreadId.makeUnsafe("chat-thread-first"), + projectId: chatProjectId, + }); + const pinned = makeSidebarThreadSummary({ + id: ThreadId.makeUnsafe("chat-thread-pinned"), + projectId: chatProjectId, + }); + const last = makeSidebarThreadSummary({ + id: ThreadId.makeUnsafe("chat-thread-last"), + projectId: chatProjectId, + }); + + expect( + getUnpinnedSectionThreadsForSidebar({ + projects: [chatProject], + sortedThreadsByProjectId: new Map([[chatProjectId, [first, pinned, last]]]), + pinnedThreadIds: [pinned.id], + }), + ).toEqual([first, last]); }); it("shows loading before the first project snapshot can prove the list is empty", () => { @@ -1529,6 +1552,36 @@ describe("sortProjectsForSidebar", () => { ]); }); + it("restores canonical manual order after rendering an automatic sort", () => { + const canonicalProjects = orderProjectsByCanonicalOrder( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-1"), + name: "Older", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-2"), + name: "Newer", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + ], + [ProjectId.makeUnsafe("project-1"), ProjectId.makeUnsafe("project-2")], + ); + + const automatic = sortProjectsForSidebar(canonicalProjects, [], "updated_at"); + const manualAgain = sortProjectsForSidebar(canonicalProjects, [], "manual"); + + expect(automatic.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-1"), + ]); + expect(manualAgain.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-2"), + ]); + }); + it("returns the project timestamp when no threads are present", () => { const timestamp = getProjectSortTimestamp( makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }), @@ -1539,3 +1592,169 @@ describe("sortProjectsForSidebar", () => { expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z")); }); }); + +describe("canonical sidebar project ordering", () => { + it("orders every project kind canonically before sections filter in relative order", () => { + const standardA = makeProject({ id: ProjectId.makeUnsafe("project-a"), name: "A" }); + const chatA = { + ...makeProject({ id: ProjectId.makeUnsafe("chat-a"), name: "Chat A" }), + kind: "chat" as const, + }; + const standardB = makeProject({ id: ProjectId.makeUnsafe("project-b"), name: "B" }); + const chatB = { + ...makeProject({ id: ProjectId.makeUnsafe("chat-b"), name: "Chat B" }), + kind: "chat" as const, + }; + + const ordered = orderProjectsByCanonicalOrder( + [standardA, chatA, standardB, chatB], + [chatB.id, standardB.id, chatA.id, standardA.id], + ); + + expect(ordered.map((project) => project.id)).toEqual([ + chatB.id, + standardB.id, + chatA.id, + standardA.id, + ]); + expect( + ordered.filter((project) => project.kind === "chat").map((project) => project.id), + ).toEqual([chatB.id, chatA.id]); + expect( + ordered.filter((project) => project.kind === "project").map((project) => project.id), + ).toEqual([standardB.id, standardA.id]); + }); + + it("appends a project absent from the displayed canonical order", () => { + const projectA = makeProject({ id: ProjectId.makeUnsafe("project-a"), name: "A" }); + const projectB = makeProject({ id: ProjectId.makeUnsafe("project-b"), name: "B" }); + + const ordered = orderProjectsByCanonicalOrder([projectA, projectB], [projectB.id]); + + expect(ordered.map((project) => project.id)).toEqual([projectB.id, projectA.id]); + }); +}); + +describe("resolveSidebarProjectMoveIntent", () => { + const projectA = ProjectId.makeUnsafe("project-a"); + const projectB = ProjectId.makeUnsafe("project-b"); + const projectC = ProjectId.makeUnsafe("project-c"); + const projectD = ProjectId.makeUnsafe("project-d"); + const canonicalOrder = [projectA, projectB, projectC, projectD]; + + it.each([ + { + name: "upward", + movedProjectId: projectC, + overProjectId: projectB, + beforeProjectId: projectB, + }, + { + name: "downward", + movedProjectId: projectA, + overProjectId: projectC, + beforeProjectId: projectD, + }, + { + name: "first", + movedProjectId: projectD, + overProjectId: projectA, + beforeProjectId: projectA, + }, + { + name: "last", + movedProjectId: projectA, + overProjectId: projectD, + beforeProjectId: null, + }, + ])("uses the final optimistic next sibling for a $name move", (testCase) => { + const intent = resolveSidebarProjectMoveIntent({ + sortOrder: "manual", + projectOrder: canonicalOrder, + movedProjectId: testCase.movedProjectId, + overProjectId: testCase.overProjectId, + }); + + expect(intent).toEqual({ + type: "sidebar-layout.project.move", + projectId: testCase.movedProjectId, + beforeProjectId: testCase.beforeProjectId, + }); + }); + + it("does not create a canonical move in an automatic sort mode", () => { + const intent = resolveSidebarProjectMoveIntent({ + sortOrder: "updated_at", + projectOrder: canonicalOrder, + movedProjectId: projectA, + overProjectId: projectC, + }); + + expect(intent).toBeNull(); + }); + + it("does not create a move after the hovered project disappears", () => { + const intent = resolveSidebarProjectMoveIntent({ + sortOrder: "manual", + projectOrder: canonicalOrder, + movedProjectId: projectA, + overProjectId: ProjectId.makeUnsafe("deleted-project"), + }); + + expect(intent).toBeNull(); + }); + + it("does not create a move for a cancelled or repeated position", () => { + const intent = resolveSidebarProjectMoveIntent({ + sortOrder: "manual", + projectOrder: canonicalOrder, + movedProjectId: projectB, + overProjectId: projectB, + }); + + expect(intent).toBeNull(); + }); +}); + +describe("resolveSidebarPinnedThreadMoveIntent", () => { + const threadA = ThreadId.makeUnsafe("thread-a"); + const threadB = ThreadId.makeUnsafe("thread-b"); + const threadC = ThreadId.makeUnsafe("thread-c"); + const threadD = ThreadId.makeUnsafe("thread-d"); + const canonicalOrder = [threadA, threadB, threadC, threadD]; + + it.each([ + ["first", threadD, threadA, threadA], + ["middle", threadA, threadC, threadD], + ["end", threadA, threadD, null], + ])("uses the final optimistic next sibling for a %s move", (_name, moved, over, before) => { + expect( + resolveSidebarPinnedThreadMoveIntent({ + pinnedThreadOrder: canonicalOrder, + movedThreadId: moved, + overThreadId: over, + }), + ).toEqual({ + type: "sidebar-layout.pinned-thread.move", + threadId: moved, + beforeThreadId: before, + }); + }); + + it("ignores a missing target or repeated position", () => { + expect( + resolveSidebarPinnedThreadMoveIntent({ + pinnedThreadOrder: canonicalOrder, + movedThreadId: threadA, + overThreadId: ThreadId.makeUnsafe("missing-thread"), + }), + ).toBeNull(); + expect( + resolveSidebarPinnedThreadMoveIntent({ + pinnedThreadOrder: canonicalOrder, + movedThreadId: threadB, + overThreadId: threadB, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f7c7ee419..162a45619 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -4,6 +4,8 @@ import type { KeybindingCommand, ProjectId, ThreadId } from "@jcode/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; +import type { SidebarLayoutIntent } from "../sidebarLayout.logic"; +import { getDndNextSiblingAnchor } from "../sidebarLayout.logic"; import type { ChatMessage, Project, SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isDuplicateProjectCreateError } from "../lib/projectCreateRecovery"; @@ -654,9 +656,18 @@ export function getUnpinnedThreadsForSidebar>( return threads.filter((thread) => !pinnedThreadIdSet.has(thread.id)); } -// Only prune persisted pins after the thread snapshot has hydrated. -export function shouldPrunePinnedThreads(input: { threadsHydrated: boolean }): boolean { - return input.threadsHydrated; +export function getUnpinnedSectionThreadsForSidebar< + TProject extends Pick, + TThread extends Pick, +>(input: { + readonly projects: readonly TProject[]; + readonly sortedThreadsByProjectId: ReadonlyMap; + readonly pinnedThreadIds: readonly ThreadId[]; +}): TThread[] { + return getUnpinnedThreadsForSidebar( + input.projects.flatMap((project) => input.sortedThreadsByProjectId.get(project.id) ?? []), + input.pinnedThreadIds, + ); } export type ProjectEmptyState = "loading" | "empty" | null; @@ -992,6 +1003,82 @@ export function sortProjectsForSidebar< }); } +export function orderProjectsByCanonicalOrder( + projects: readonly TProject[], + projectOrder: readonly ProjectId[], +): TProject[] { + const projectsById = new Map(projects.map((project) => [project.id, project])); + const orderedProjects = projectOrder.flatMap((projectId) => { + const project = projectsById.get(projectId); + if (project === undefined) { + return []; + } + projectsById.delete(projectId); + return [project]; + }); + return [...orderedProjects, ...projects.filter((project) => projectsById.has(project.id))]; +} + +type SidebarProjectMoveIntent = Extract< + SidebarLayoutIntent, + { readonly type: "sidebar-layout.project.move" } +>; + +export function resolveSidebarProjectMoveIntent(input: { + readonly sortOrder: SidebarProjectSortOrder; + readonly projectOrder: readonly ProjectId[]; + readonly movedProjectId: ProjectId; + readonly overProjectId: ProjectId; +}): SidebarProjectMoveIntent | null { + if (input.sortOrder !== "manual") { + return null; + } + const anchor = getDndNextSiblingAnchor( + input.projectOrder, + input.movedProjectId, + input.overProjectId, + ); + const orderChanged = anchor.finalOrder.some( + (projectId, index) => projectId !== input.projectOrder[index], + ); + if (!orderChanged) { + return null; + } + return { + type: "sidebar-layout.project.move", + projectId: input.movedProjectId, + beforeProjectId: anchor.beforeId, + }; +} + +type SidebarPinnedThreadMoveIntent = Extract< + SidebarLayoutIntent, + { readonly type: "sidebar-layout.pinned-thread.move" } +>; + +export function resolveSidebarPinnedThreadMoveIntent(input: { + readonly pinnedThreadOrder: readonly ThreadId[]; + readonly movedThreadId: ThreadId; + readonly overThreadId: ThreadId; +}): SidebarPinnedThreadMoveIntent | null { + const anchor = getDndNextSiblingAnchor( + input.pinnedThreadOrder, + input.movedThreadId, + input.overThreadId, + ); + const orderChanged = anchor.finalOrder.some( + (threadId, index) => threadId !== input.pinnedThreadOrder[index], + ); + if (!orderChanged) { + return null; + } + return { + type: "sidebar-layout.pinned-thread.move", + threadId: input.movedThreadId, + beforeThreadId: anchor.beforeId, + }; +} + // Groups thread summaries once so project-specific sidebar derivations can reuse the same slices. export function groupSidebarThreadsByProjectId( threads: readonly SidebarThreadSummary[], @@ -1026,7 +1113,11 @@ export function deriveSidebarProjectData(input: { for (const project of input.projects) { const allProjectThreads = input.sortedSidebarThreadsByProjectId.get(project.id) ?? []; - const projectThreads = getUnpinnedThreadsForSidebar(allProjectThreads, input.pinnedThreadIds); + const projectThreads = getUnpinnedSectionThreadsForSidebar({ + projects: [project], + sortedThreadsByProjectId: input.sortedSidebarThreadsByProjectId, + pinnedThreadIds: input.pinnedThreadIds, + }); const projectStatus = resolveProjectStatusIndicator( allProjectThreads.map((thread) => input.resolveThreadStatus diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c9d439bec..6a2507ce7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -21,7 +21,12 @@ import { FiGitBranch, FiPlus } from "react-icons/fi"; import { GoRepoForked } from "react-icons/go"; import { HiOutlineArchiveBox, HiOutlineCheckCircle, HiOutlineFolderOpen } from "react-icons/hi2"; import { BsChat } from "react-icons/bs"; -import { TbArrowsDiagonal, TbArrowsDiagonalMinimize2, TbCursorText } from "react-icons/tb"; +import { + TbArrowsDiagonal, + TbArrowsDiagonalMinimize2, + TbCursorText, + TbGripVertical, +} from "react-icons/tb"; import { IoFilter } from "react-icons/io5"; import { LuMessageSquareDashed, LuSplit } from "react-icons/lu"; import { @@ -41,6 +46,7 @@ import { DndContext, type DragCancelEvent, type CollisionDetection, + KeyboardSensor, PointerSensor, type DragStartEvent, closestCorners, @@ -48,10 +54,17 @@ import { useSensor, useSensors, type DragEndEvent, + closestCenter, } from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; +import { useStore as useZustandStore } from "zustand"; import { type DesktopUpdateState, type OrchestrationShellSnapshot, @@ -78,6 +91,11 @@ import { APP_BASE_NAME, APP_VERSION, APP_WORDMARK_SUFFIX } from "../branding"; import { showConfirmDialogFallback } from "../confirmDialogFallback"; import { isMacPlatform, newCommandId, newProjectId, newThreadId, randomUUID } from "../lib/utils"; import { persistAppStateNow, useStore } from "../store"; +import { + selectDisplayedPinnedThreadOrder, + selectDisplayedProjectOrder, + sidebarLayoutStore, +} from "../sidebarLayoutStore"; import { getThreadFromState, getThreadsFromState } from "../threadDerivation"; import { resolveShortcutCommand, @@ -198,23 +216,26 @@ import { findWorkspaceRootMatch, getFallbackThreadIdAfterDelete, getPinnedThreadsForSidebar, + getUnpinnedSectionThreadsForSidebar, getNextVisibleSidebarThreadId, getSidebarThreadIdsToPrewarm, getVisibleSidebarEntriesForPreview, groupSidebarThreadsByProjectId, installDebugFeatureFlagConsoleCommands, + orderProjectsByCanonicalOrder, pruneDismissedThreadStatusKeys, pruneExpandedProjectThreadListsForCollapsedProjects, recoverExistingAddProjectTarget, DEBUG_FEATURE_FLAGS_MENU_STORAGE_KEY, resolveProjectEmptyState, + resolveSidebarProjectMoveIntent, resolveSidebarNewThreadEnvMode, + resolveSidebarPinnedThreadMoveIntent, resolveThreadRowClassName, resolveThreadStatusPill, isDuplicateProjectCreateError, type SidebarDerivedProjectData, shouldShowDebugFeatureFlagsMenu, - shouldPrunePinnedThreads, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, @@ -246,7 +267,6 @@ import { import { THREAD_DRAG_MIME } from "./chat-drop-overlay/ChatPaneDropOverlay"; import { useTemporaryThreadStore } from "../temporaryThreadStore"; import { useThreadActivationController } from "../hooks/useThreadActivationController"; -import { usePinnedThreadsStore } from "../pinnedThreadsStore"; import { retainThreadDetailSubscription } from "../threadDetailSubscriptionRetention"; import { useWorkspaceStore, workspaceThreadId } from "../workspaceStore"; import type { @@ -894,6 +914,7 @@ function ProjectSortMenu({ } + aria-label="Sort projects" > @@ -1079,6 +1100,44 @@ function SortableProjectItem({ ); } +type SortablePinnedThreadHandleProps = SortableProjectHandleProps & { + readonly isDragging: boolean; +}; + +function SortablePinnedThreadItem({ + threadId, + children, +}: { + threadId: ThreadId; + children: (handleProps: SortablePinnedThreadHandleProps) => React.ReactNode; +}) { + const { + attributes, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ id: threadId }); + + return ( +
+ {children({ attributes, listeners, setActivatorNodeRef, isDragging })} +
+ ); +} + function SidebarSegmentedPicker({ activeView, onSelectView, @@ -1197,7 +1256,9 @@ export default function Sidebar() { const setProjectExpanded = useStore((store) => store.setProjectExpanded); const setAllProjectsExpanded = useStore((store) => store.setAllProjectsExpanded); const collapseProjectsExcept = useStore((store) => store.collapseProjectsExcept); - const reorderProjects = useStore((store) => store.reorderProjects); + const canonicalProjectOrder = useZustandStore(sidebarLayoutStore, selectDisplayedProjectOrder); + const pinnedThreadIds = useZustandStore(sidebarLayoutStore, selectDisplayedPinnedThreadOrder); + const enqueueSidebarLayoutIntent = useZustandStore(sidebarLayoutStore, (state) => state.enqueue); const renameProjectLocally = useStore((store) => store.renameProjectLocally); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); @@ -1212,10 +1273,6 @@ export default function Sidebar() { const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); const temporaryThreadIds = useTemporaryThreadStore((store) => store.temporaryThreadIds); const clearTemporaryThread = useTemporaryThreadStore((store) => store.clearTemporaryThread); - const persistedPinnedThreadIds = usePinnedThreadsStore((store) => store.pinnedThreadIds); - const pinThreadLocally = usePinnedThreadsStore((store) => store.pinThread); - const unpinThread = usePinnedThreadsStore((store) => store.unpinThread); - const prunePinnedThreads = usePinnedThreadsStore((store) => store.prunePinnedThreads); const workspacePages = useWorkspaceStore((store) => store.workspacePages); const createWorkspace = useWorkspaceStore((store) => store.createWorkspace); const renameWorkspace = useWorkspaceStore((store) => store.renameWorkspace); @@ -1523,53 +1580,35 @@ export default function Sidebar() { presentationMode: routeTerminalState?.presentationMode ?? "drawer", terminalOpen, }); - const pinnedThreadIds = useMemo(() => { - const next = new Set(); - for (const thread of sidebarDisplayThreads) { - if (thread.isPinned === true) { - next.add(thread.id); - } - } - for (const threadId of persistedPinnedThreadIds) { - next.add(threadId); - } - return [...next]; - }, [persistedPinnedThreadIds, sidebarDisplayThreads]); const pinnedThreadIdSet = useMemo(() => new Set(pinnedThreadIds), [pinnedThreadIds]); const pinnedThreads = useMemo( () => getPinnedThreadsForSidebar(sidebarDisplayThreads, pinnedThreadIds), [pinnedThreadIds, sidebarDisplayThreads], ); - const setThreadPinned = useCallback( - async (threadId: ThreadId, isPinned: boolean) => { - const api = readNativeApi(); - if (!api) return; - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId, - isPinned, - }); - if (isPinned) { - pinThreadLocally(threadId); - } else { - unpinThread(threadId); - } - }, - [pinThreadLocally, unpinThread], - ); const toggleThreadPinned = useCallback( (threadId: ThreadId) => { const isPinned = pinnedThreadIdSet.has(threadId); - void setThreadPinned(threadId, !isPinned).catch((error) => { - console.error("Failed to update pinned thread state", { threadId, error }); - toastManager.add({ - type: "error", - title: isPinned ? "Unable to unpin thread" : "Unable to pin thread", - }); - }); + enqueueSidebarLayoutIntent( + isPinned + ? { type: "sidebar-layout.thread.unpin", threadId } + : { + type: "sidebar-layout.thread.pin", + threadId, + beforeThreadId: null, + }, + { + onRejected: (error) => { + console.error("Failed to update pinned thread state", { threadId, error }); + toastManager.add({ + type: "error", + title: isPinned ? "Unable to unpin thread" : "Unable to pin thread", + description: "The sidebar returned to the server's pinned order.", + }); + }, + }, + ); }, - [pinnedThreadIdSet, setThreadPinned], + [enqueueSidebarLayoutIntent, pinnedThreadIdSet], ); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), @@ -2443,7 +2482,6 @@ export default function Sidebar() { commandId: newCommandId(), threadId, }); - unpinThread(threadId); clearComposerDraftForThread(threadId); clearProjectDraftThreadById(thread.projectId, thread.id); clearTerminalState(threadId); @@ -2524,7 +2562,6 @@ export default function Sidebar() { removeThreadFromSplitViews, clearTemporaryThread, sidebarThreads, - unpinThread, ], ); @@ -3420,6 +3457,14 @@ export default function Sidebar() { activationConstraint: { distance: 6 }, }), ); + const pinnedThreadDnDSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); const projectCollisionDetection = useCallback((args) => { const pointerCollisions = pointerWithin(args); if (pointerCollisions.length > 0) { @@ -3441,9 +3486,34 @@ export default function Sidebar() { const activeProject = projects.find((project) => project.id === active.id); const overProject = projects.find((project) => project.id === over.id); if (!activeProject || !overProject) return; - reorderProjects(activeProject.id, overProject.id); + const intent = resolveSidebarProjectMoveIntent({ + sortOrder: appSettings.sidebarProjectSortOrder, + projectOrder: canonicalProjectOrder, + movedProjectId: activeProject.id, + overProjectId: overProject.id, + }); + if (intent !== null) { + enqueueSidebarLayoutIntent(intent, { + onRejected: (error) => { + console.error("Failed to reorder project", { + movedProjectId: activeProject.id, + error, + }); + toastManager.add({ + type: "error", + title: "Unable to reorder projects", + description: "The sidebar returned to the server's project order.", + }); + }, + }); + } }, - [appSettings.sidebarProjectSortOrder, projects, reorderProjects], + [ + appSettings.sidebarProjectSortOrder, + canonicalProjectOrder, + enqueueSidebarLayoutIntent, + projects, + ], ); const handleProjectDragStart = useCallback( @@ -3461,6 +3531,39 @@ export default function Sidebar() { dragInProgressRef.current = false; }, []); + const handlePinnedThreadDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const movedThreadId = pinnedThreadIds.find((threadId) => threadId === active.id); + const overThreadId = pinnedThreadIds.find((threadId) => threadId === over.id); + if (movedThreadId === undefined || overThreadId === undefined) { + return; + } + const intent = resolveSidebarPinnedThreadMoveIntent({ + pinnedThreadOrder: pinnedThreadIds, + movedThreadId, + overThreadId, + }); + if (intent === null) { + return; + } + enqueueSidebarLayoutIntent(intent, { + onRejected: (error) => { + console.error("Failed to reorder pinned thread", { movedThreadId, error }); + toastManager.add({ + type: "error", + title: "Unable to reorder pinned threads", + description: "The sidebar returned to the server's pinned order.", + }); + }, + }); + }, + [enqueueSidebarLayoutIntent, pinnedThreadIds], + ); + const animatedProjectListsRef = useRef(new WeakSet()); const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { if (!node || animatedProjectListsRef.current.has(node)) { @@ -3548,9 +3651,18 @@ export default function Sidebar() { [renameProjectLocally], ); + const canonicalProjects = useMemo( + () => orderProjectsByCanonicalOrder(projects, canonicalProjectOrder), + [canonicalProjectOrder, projects], + ); const sortedProjects = useMemo( - () => sortProjectsForSidebar(projects, sidebarThreads, appSettings.sidebarProjectSortOrder), - [appSettings.sidebarProjectSortOrder, projects, sidebarThreads], + () => + sortProjectsForSidebar( + canonicalProjects, + sidebarThreads, + appSettings.sidebarProjectSortOrder, + ), + [appSettings.sidebarProjectSortOrder, canonicalProjects, sidebarThreads], ); const chatProjects = useMemo( () => sortedProjects.filter((project) => isHomeChatContainerProject(project, homeDir)), @@ -3562,7 +3674,11 @@ export default function Sidebar() { } return buildProjectThreadTree({ threads: sortThreadsForSidebar( - chatProjects.flatMap((project) => sortedSidebarThreadsByProjectId.get(project.id) ?? []), + getUnpinnedSectionThreadsForSidebar({ + projects: chatProjects, + sortedThreadsByProjectId: sortedSidebarThreadsByProjectId, + pinnedThreadIds, + }), appSettings.sidebarThreadSortOrder, ), expandedParentThreadIds: expandedSubagentParentIds, @@ -3572,6 +3688,7 @@ export default function Sidebar() { chatSectionExpanded, chatProjects, expandedSubagentParentIds, + pinnedThreadIds, sortedSidebarThreadsByProjectId, ]); const visibleChatThreadIds = useMemo( @@ -3671,32 +3788,6 @@ export default function Sidebar() { ); }, [standardProjects]); - useEffect(() => { - if (!shouldPrunePinnedThreads({ threadsHydrated })) { - return; - } - prunePinnedThreads(sidebarThreads.map((thread) => thread.id)); - }, [prunePinnedThreads, sidebarThreads, threadsHydrated]); - - useEffect(() => { - if (!threadsHydrated || persistedPinnedThreadIds.length === 0) { - return; - } - - // Older builds stored pins only in localStorage; mirror them to the server - // projection so the retention job can protect those threads too. - const threadsById = new Map(sidebarThreads.map((thread) => [thread.id, thread] as const)); - for (const threadId of persistedPinnedThreadIds) { - const thread = threadsById.get(threadId); - if (!thread || thread.isPinned === true) { - continue; - } - void setThreadPinned(threadId, true).catch((error) => { - console.error("Failed to migrate pinned thread state", { threadId, error }); - }); - } - }, [persistedPinnedThreadIds, setThreadPinned, sidebarThreads, threadsHydrated]); - useEffect(() => { const retainedThreadIds = new Set(sidebarThreads.map((thread) => thread.id)); const nextDismissedThreadStatusKeyByThreadId = pruneDismissedThreadStatusKeys({ @@ -4053,7 +4144,10 @@ export default function Sidebar() { ); } - function renderPinnedThreadRow(thread: SidebarThreadSummary) { + function renderPinnedThreadRow( + thread: SidebarThreadSummary, + dragHandleProps: SortablePinnedThreadHandleProps, + ) { const threadTerminalState = selectThreadTerminalState(terminalStateByThreadId, thread.id); const threadEntryPoint = threadTerminalState.entryPoint; const terminalStatus = terminalStatusFromThreadState({ @@ -4088,11 +4182,12 @@ export default function Sidebar() { : "mr-1 w-[1.625rem] text-right text-[length:var(--app-font-size-ui-meta,11px)] leading-none tabular-nums text-muted-foreground/38 transition-opacity group-hover/thread-row:opacity-0 group-focus-within/thread-row:opacity-0"; return ( -
+
primeThreadActivation(event, thread.id)} - onClick={() => activateThreadFromSidebarIntent(thread.id)} + onClick={(event) => { + if ( + event.target instanceof Element && + event.target.closest("[data-pinned-thread-drag-handle]") !== null + ) { + return; + } + activateThreadFromSidebarIntent(thread.id); + }} onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); @@ -4111,6 +4214,12 @@ export default function Sidebar() { }} onPointerUp={(event) => handleThreadRenamePointerUp(event, thread.id)} onKeyDown={(event) => { + if ( + event.target instanceof Element && + event.target.closest("[data-pinned-thread-drag-handle]") !== null + ) { + return; + } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activateThreadFromSidebarIntent(thread.id); @@ -4125,6 +4234,19 @@ export default function Sidebar() { }} >
+ +
} /> -
- {pinnedThreads.map((thread) => renderPinnedThreadRow(thread))} -
+ + thread.id)} + strategy={verticalListSortingStrategy} + > +
+ {pinnedThreads.map((thread) => ( + + {(dragHandleProps) => renderPinnedThreadRow(thread, dragHandleProps)} + + ))} +
+
+
) : ( @@ -5920,7 +6064,7 @@ export default function Sidebar() { > project.id)} + items={standardProjects.map((project) => project.id)} strategy={verticalListSortingStrategy} > {standardProjects.map((project) => ( diff --git a/apps/web/src/lib/desktopProjectRecovery.test.ts b/apps/web/src/lib/desktopProjectRecovery.test.ts index e66c5e716..da73eab5e 100644 --- a/apps/web/src/lib/desktopProjectRecovery.test.ts +++ b/apps/web/src/lib/desktopProjectRecovery.test.ts @@ -82,6 +82,7 @@ function makeThread( function makeSnapshot(overrides: Partial = {}): OrchestrationReadModel { return { snapshotSequence: 1, + sidebarLayout: null, updatedAt: "2026-04-20T08:00:00.000Z", projects: [makeProject()], threads: [makeThread()], @@ -96,6 +97,7 @@ function makeShellSnapshot( const thread = makeThread(); return { snapshotSequence: 1, + sidebarLayout: null, updatedAt: "2026-04-20T08:00:00.000Z", projects: [ { diff --git a/apps/web/src/lib/threadCreatePromotion.test.ts b/apps/web/src/lib/threadCreatePromotion.test.ts index c562a34c6..758a18dac 100644 --- a/apps/web/src/lib/threadCreatePromotion.test.ts +++ b/apps/web/src/lib/threadCreatePromotion.test.ts @@ -94,6 +94,7 @@ describe("threadCreatePromotion", () => { useComposerDraftStore.getState().setProjectDraftThreadId(projectId, threadId); useStore.getState().syncServerShellSnapshot({ snapshotSequence: 1, + sidebarLayout: null, projects: [ { id: projectId, diff --git a/apps/web/src/pinnedThreadsStore.test.ts b/apps/web/src/pinnedThreadsStore.test.ts deleted file mode 100644 index 794eac825..000000000 --- a/apps/web/src/pinnedThreadsStore.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// FILE: pinnedThreadsStore.test.ts -// Purpose: Verifies the global pinned-thread store mutates ids predictably. -// Layer: UI state store test - -import { beforeEach, describe, expect, it } from "vitest"; -import { ThreadId } from "@jcode/contracts"; -import { usePinnedThreadsStore } from "./pinnedThreadsStore"; - -describe("usePinnedThreadsStore", () => { - beforeEach(() => { - usePinnedThreadsStore.setState({ pinnedThreadIds: [] }); - }); - - it("toggles a pinned thread id on and off", () => { - usePinnedThreadsStore.getState().togglePinnedThread("thread-1" as ThreadId); - expect(usePinnedThreadsStore.getState().pinnedThreadIds).toEqual(["thread-1"]); - - usePinnedThreadsStore.getState().togglePinnedThread("thread-1" as ThreadId); - expect(usePinnedThreadsStore.getState().pinnedThreadIds).toEqual([]); - }); - - it("prunes thread ids that are no longer present", () => { - usePinnedThreadsStore.setState({ - pinnedThreadIds: ["thread-2" as ThreadId, "thread-1" as ThreadId], - }); - - usePinnedThreadsStore.getState().prunePinnedThreads(["thread-1" as ThreadId]); - expect(usePinnedThreadsStore.getState().pinnedThreadIds).toEqual(["thread-1"]); - }); -}); diff --git a/apps/web/src/pinnedThreadsStore.ts b/apps/web/src/pinnedThreadsStore.ts deleted file mode 100644 index 6f0198a1e..000000000 --- a/apps/web/src/pinnedThreadsStore.ts +++ /dev/null @@ -1,104 +0,0 @@ -// FILE: pinnedThreadsStore.ts -// Purpose: Persists the globally pinned chat thread ids used by the sidebar. -// Layer: UI state store -// Exports: usePinnedThreadsStore - -import { type ThreadId } from "@jcode/contracts"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; -import { getLocalStorage } from "./lib/storage"; - -interface PinnedThreadsStoreState { - pinnedThreadIds: ThreadId[]; - pinThread: (threadId: ThreadId) => void; - unpinThread: (threadId: ThreadId) => void; - togglePinnedThread: (threadId: ThreadId) => void; - prunePinnedThreads: (threadIds: readonly ThreadId[]) => void; -} - -const PINNED_THREADS_STORAGE_KEY = "jcode:pinned-threads:v1"; - -function normalizePinnedThreadIds(threadIds: readonly ThreadId[]): ThreadId[] { - const seen = new Set(); - const normalized: ThreadId[] = []; - - for (const threadId of threadIds) { - if (threadId.length === 0 || seen.has(threadId)) { - continue; - } - seen.add(threadId); - normalized.push(threadId); - } - - return normalized; -} - -export const usePinnedThreadsStore = create()( - persist( - (set) => ({ - pinnedThreadIds: [], - pinThread: (threadId) => { - if (threadId.length === 0) return; - set((state) => { - if (state.pinnedThreadIds.includes(threadId)) { - return state; - } - return { - pinnedThreadIds: [threadId, ...state.pinnedThreadIds], - }; - }); - }, - unpinThread: (threadId) => { - if (threadId.length === 0) return; - set((state) => { - if (!state.pinnedThreadIds.includes(threadId)) { - return state; - } - return { - pinnedThreadIds: state.pinnedThreadIds.filter((candidate) => candidate !== threadId), - }; - }); - }, - togglePinnedThread: (threadId) => { - if (threadId.length === 0) return; - set((state) => { - if (state.pinnedThreadIds.includes(threadId)) { - return { - pinnedThreadIds: state.pinnedThreadIds.filter((candidate) => candidate !== threadId), - }; - } - return { - pinnedThreadIds: [threadId, ...state.pinnedThreadIds], - }; - }); - }, - prunePinnedThreads: (threadIds) => { - const allowedThreadIds = new Set(threadIds); - set((state) => { - const nextPinnedThreadIds = state.pinnedThreadIds.filter((threadId) => - allowedThreadIds.has(threadId), - ); - return nextPinnedThreadIds.length === state.pinnedThreadIds.length - ? state - : { pinnedThreadIds: nextPinnedThreadIds }; - }); - }, - }), - { - name: PINNED_THREADS_STORAGE_KEY, - storage: createJSONStorage(() => getLocalStorage()), - partialize: (state) => ({ - pinnedThreadIds: normalizePinnedThreadIds(state.pinnedThreadIds), - }), - merge: (persistedState, currentState) => { - const candidate = - (persistedState as Partial> | undefined) - ?.pinnedThreadIds ?? []; - return { - ...currentState, - pinnedThreadIds: normalizePinnedThreadIds(candidate), - }; - }, - }, - ), -); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8b5e01c20..eefc81bc1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -77,6 +77,18 @@ import { shouldRepairDesktopProjectBootstrapSnapshot } from "../lib/desktopProje import { parseDiffRouteSearch } from "../diffRouteSearch"; import { resolveSplitViewThreadIds, selectSplitView, useSplitViewStore } from "../splitViewStore"; import { providerDiscoveryQueryKeys } from "../lib/providerDiscoveryReactQuery"; +import { + createSidebarLayoutRouter, + sidebarLayoutLegacySubjectsReady, + type SidebarLayoutSnapshotSource, +} from "../sidebarLayoutRouter"; +import { + confirmSidebarLayoutLegacyMigration, + readSidebarLayoutLegacyCandidates, + type SidebarLayoutLegacyCandidates, +} from "../sidebarLayoutLegacyMigration"; +import { sidebarLayoutStore } from "../sidebarLayoutStore"; +import { classifyShellStreamEvent } from "../shellEventOrdering"; const SHELL_SNAPSHOT_BOOTSTRAP_FALLBACK_DELAY_MS = 1_500; const THREAD_DETAIL_CATCHUP_INTERVAL_MS = 1_500; @@ -700,6 +712,35 @@ function EventRouter() { const reconcileThreadSubscriptionsRef = useRef< ((threadIds: readonly ThreadId[]) => Promise) | null >(null); + const sidebarLayoutRouterRef = useRef | null>(null); + const sidebarLayoutRouter = + sidebarLayoutRouterRef.current ?? + createSidebarLayoutRouter({ + store: sidebarLayoutStore, + confirmLegacyMigration: (layout) => + confirmSidebarLayoutLegacyMigration(window.localStorage, layout), + onInitializationRejected: (failure) => { + toastManager.add({ + type: "error", + title: "Sidebar layout did not sync", + description: + failure.error instanceof Error + ? failure.error.message + : "The server rejected sidebar setup. Retry to use this profile's saved order.", + actionProps: { + children: "Retry", + onClick: () => { + failure.retry(); + }, + }, + }); + }, + }); + sidebarLayoutRouterRef.current = sidebarLayoutRouter; + const sidebarLayoutLegacyCandidatesRef = useRef< + | { readonly collected: false } + | { readonly collected: true; readonly candidates: SidebarLayoutLegacyCandidates | null } + >({ collected: false }); workspacePagesRef.current = workspacePages; pathnameRef.current = pathname; @@ -722,6 +763,47 @@ function EventRouter() { const threadSnapshotRequestInFlight = new Set(); const threadReplayRequestInFlight = new Set(); let reconcileThreadSubscriptionsChain = Promise.resolve(); + const acceptSidebarLayoutSnapshot = ( + snapshot: OrchestrationShellSnapshot, + source: SidebarLayoutSnapshotSource, + ): void => { + const lifecycle = { + projects: snapshot.projects.map((project) => ({ + id: project.id, + kind: project.kind, + createdAt: project.createdAt, + deletedAt: null, + })), + threads: snapshot.threads.map((thread) => ({ id: thread.id, deletedAt: null })), + }; + if ( + !sidebarLayoutLegacyCandidatesRef.current.collected && + sidebarLayoutLegacySubjectsReady(source, lifecycle) + ) { + sidebarLayoutLegacyCandidatesRef.current = { + collected: true, + candidates: readSidebarLayoutLegacyCandidates( + window.localStorage, + snapshot.sidebarLayout, + { + projects: snapshot.projects.map((project) => ({ + id: project.id, + workspaceRoot: project.workspaceRoot, + })), + threadIds: snapshot.threads.map((thread) => thread.id), + }, + ), + }; + } + const legacyCandidateState = sidebarLayoutLegacyCandidatesRef.current; + sidebarLayoutRouter.acceptSnapshot({ + sidebarLayout: snapshot.sidebarLayout, + lifecycle, + ...(legacyCandidateState.collected + ? { legacyCandidates: legacyCandidateState.candidates } + : {}), + }); + }; const beginThreadSubscription = (threadId: ThreadId) => { threadSnapshotSequenceById.delete(threadId); @@ -766,6 +848,7 @@ function EventRouter() { pendingShellEvents = []; for (const event of nextPending) { shellSnapshotSequence = Math.max(shellSnapshotSequence, event.sequence); + sidebarLayoutRouter.acceptShellEvent(event); applyShellEvent(event); } }; @@ -837,16 +920,26 @@ function EventRouter() { const loadShellSnapshotOnce = async () => { const snapshot = await api.orchestration.getShellSnapshot(); if (!shouldApplyBootstrapShellSnapshot(snapshot)) { + if ( + snapshot.sidebarLayout === null && + !sidebarLayoutLegacyCandidatesRef.current.collected + ) { + acceptSidebarLayoutSnapshot(snapshot, "query"); + } else { + sidebarLayoutRouter.acceptConfirmedLayout(snapshot.sidebarLayout); + } return; } shellSnapshotSequence = snapshot.snapshotSequence; syncServerShellSnapshot(snapshot); + acceptSidebarLayoutSnapshot(snapshot, "query"); reconcilePromotedDraftsFromShellThreads(snapshot.threads); removeOrphanedTerminalsForCurrentState(); flushShellBuffer(snapshot.snapshotSequence); }; const ensureScopedSubscriptions = async () => { + sidebarLayoutRouter.reconnect(); shellSnapshotSequence = -1; pendingShellEvents = []; subscribedThreadIds.clear(); @@ -974,20 +1067,25 @@ function EventRouter() { if (item.kind === "snapshot") { shellSnapshotSequence = item.snapshot.snapshotSequence; syncServerShellSnapshot(item.snapshot); + acceptSidebarLayoutSnapshot(item.snapshot, "stream"); reconcilePromotedDraftsFromShellThreads(item.snapshot.threads); removeOrphanedTerminalsForCurrentState(); flushShellBuffer(item.snapshot.snapshotSequence); return; } - if (shellSnapshotSequence < 0) { - pendingShellEvents.push(item); - return; - } - if (item.sequence <= shellSnapshotSequence) { - return; + const disposition = classifyShellStreamEvent(shellSnapshotSequence, item.sequence); + switch (disposition) { + case "buffer": + pendingShellEvents.push(item); + return; + case "ignore": + return; + case "apply": + break; } shellSnapshotSequence = item.sequence; + sidebarLayoutRouter.acceptShellEvent(item); applyShellEvent(item); if (item.kind === "thread-upserted") { reconcilePromotedDraftsFromShellThreads([item.thread]); @@ -1203,6 +1301,7 @@ function EventRouter() { setWorkspaceHomeDir, syncServerShellSnapshot, syncServerThreadDetailHotPath, + sidebarLayoutRouter, ]); useLayoutEffect(() => { diff --git a/apps/web/src/shellEventOrdering.test.ts b/apps/web/src/shellEventOrdering.test.ts new file mode 100644 index 000000000..059dc30bc --- /dev/null +++ b/apps/web/src/shellEventOrdering.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { classifyShellStreamEvent } from "./shellEventOrdering"; + +describe("shell event ordering", () => { + it("buffers every entity event before the first shell snapshot", () => { + // Given: no shell snapshot has established an event fence. + const shellSnapshotSequence = -1; + + // When: an entity upsert arrives. + const disposition = classifyShellStreamEvent(shellSnapshotSequence, 4); + + // Then: the event is buffered for the snapshot flush instead of routed immediately. + expect(disposition).toBe("buffer"); + }); + + it.each([ + ["stale project remove", 9], + ["equal project upsert", 10], + ["stale thread remove", 8], + ["equal thread upsert", 10], + ])("ignores %s behind the latest shell event fence", (_caseName, eventSequence) => { + // Given: a snapshot or newer event established sequence 10. + const shellSnapshotSequence = 10; + + // When: a lower or equal entity event arrives. + const disposition = classifyShellStreamEvent(shellSnapshotSequence, eventSequence); + + // Then: it cannot reach either lifecycle routing or the rendered store. + expect(disposition).toBe("ignore"); + }); + + it("applies an event strictly newer than the latest shell event fence", () => { + // Given: sequence 10 is the latest applied shell item. + const shellSnapshotSequence = 10; + + // When: sequence 11 arrives. + const disposition = classifyShellStreamEvent(shellSnapshotSequence, 11); + + // Then: it is routed and advances the fence once. + expect(disposition).toBe("apply"); + }); +}); diff --git a/apps/web/src/shellEventOrdering.ts b/apps/web/src/shellEventOrdering.ts new file mode 100644 index 000000000..09d6a949e --- /dev/null +++ b/apps/web/src/shellEventOrdering.ts @@ -0,0 +1,11 @@ +export type ShellStreamEventDisposition = "buffer" | "ignore" | "apply"; + +export function classifyShellStreamEvent( + shellSnapshotSequence: number, + eventSequence: number, +): ShellStreamEventDisposition { + if (shellSnapshotSequence < 0) { + return "buffer"; + } + return eventSequence > shellSnapshotSequence ? "apply" : "ignore"; +} diff --git a/apps/web/src/sidebarLayout.logic.test.ts b/apps/web/src/sidebarLayout.logic.test.ts new file mode 100644 index 000000000..bce1cc21a --- /dev/null +++ b/apps/web/src/sidebarLayout.logic.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it } from "vitest"; +import { CommandId, ProjectId, ThreadId, type SidebarLayout } from "@jcode/contracts"; +import * as sidebarLayout from "./sidebarLayout.logic"; + +const acceptConfirmed = sidebarLayout.acceptConfirmedSidebarLayout; +const deriveDisplayed = sidebarLayout.deriveDisplayedSidebarLayout; +const selectPins = sidebarLayout.selectDisplayedPinnedThreadOrder; +const selectProjects = sidebarLayout.selectDisplayedProjectOrder; + +const projectA = ProjectId.makeUnsafe("project-a"); +const projectB = ProjectId.makeUnsafe("project-b"); +const projectC = ProjectId.makeUnsafe("project-c"); +const projectD = ProjectId.makeUnsafe("project-d"); +const threadA = ThreadId.makeUnsafe("thread-a"); +const threadB = ThreadId.makeUnsafe("thread-b"); +const threadC = ThreadId.makeUnsafe("thread-c"); +const commandA = CommandId.makeUnsafe("command-a"); +const commandB = CommandId.makeUnsafe("command-b"); + +const layout = ( + revision: number, + projectOrder: readonly ProjectId[] = [projectA, projectB, projectC], + pinnedThreadOrder: readonly ThreadId[] = [threadA, threadB, threadC], +): SidebarLayout => ({ + projectOrder, + pinnedThreadOrder, + revision, + updatedAt: `2026-07-18T00:00:${String(revision).padStart(2, "0")}.000Z`, +}); + +const lifecycle: sidebarLayout.SidebarLayoutLifecycle = { + projects: [ + { id: projectA, kind: "project", createdAt: "2026-01-01T00:00:00Z", deletedAt: null }, + { id: projectB, kind: "project", createdAt: "2026-01-02T00:00:00Z", deletedAt: null }, + { id: projectC, kind: "project", createdAt: "2026-01-03T00:00:00Z", deletedAt: null }, + ], + threads: [ + { id: threadA, deletedAt: null }, + { id: threadB, deletedAt: null }, + { id: threadC, deletedAt: null }, + ], +}; + +describe("confirmed sidebar layout", () => { + it.each([ + ["older", layout(4, [projectC, projectB, projectA])], + ["equal conflicting", layout(5, [projectC, projectB, projectA])], + ])("rejects an %s revision", (_caseName, incoming) => { + // Given + const confirmed = layout(5); + // When + const accepted = acceptConfirmed(confirmed, incoming); + // Then + expect(accepted).toBe(confirmed); + }); + + it("accepts a newer revision", () => { + // Given + const confirmed = layout(5); + const incoming = layout(6, [projectB, projectA, projectC]); + // When + const accepted = acceptConfirmed(confirmed, incoming); + // Then + expect(accepted).toBe(incoming); + }); + + it("treats an identical equal revision as idempotent", () => { + // Given + const confirmed = layout(5); + const duplicate = { ...confirmed }; + // When + const accepted = acceptConfirmed(confirmed, duplicate); + // Then + expect(accepted).toBe(confirmed); + }); +}); + +describe("pending intent reconciliation", () => { + const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [ + { + commandId: commandA, + intent: { type: "sidebar-layout.project.move", projectId: projectA, beforeProjectId: null }, + }, + ]; + + it("keeps an intent when its layout event arrives before its RPC receipt", () => { + // Given + const confirmedRevision = 8; + // When + const reconciled = sidebarLayout.reconcilePendingSidebarLayoutIntents( + pending, + confirmedRevision, + ); + // Then + expect(reconciled).toEqual(pending); + }); + + it("clears event-before-RPC intent after the receipt sequence is recorded", () => { + // Given + const accepted = sidebarLayout.recordPendingSidebarLayoutAcceptance(pending, commandA, 8); + // When + const reconciled = sidebarLayout.reconcilePendingSidebarLayoutIntents(accepted, 8); + // Then + expect(reconciled).toEqual([]); + }); + + it.each(["RPC-before-event", "an unrelated newer snapshotSequence"])( + "keeps an intent for %s below its accepted layout revision", + () => { + // Given: snapshotSequence is deliberately absent from this API. + const accepted = sidebarLayout.recordPendingSidebarLayoutAcceptance(pending, commandA, 8); + // When + const reconciled = sidebarLayout.reconcilePendingSidebarLayoutIntents(accepted, 7); + // Then + expect(reconciled).toEqual(accepted); + }, + ); + + it("records an idempotent retry receipt on the existing command", () => { + // Given + const firstReceipt = sidebarLayout.recordPendingSidebarLayoutAcceptance(pending, commandA, 8); + // When + const retriedReceipt = sidebarLayout.recordPendingSidebarLayoutAcceptance( + firstReceipt, + commandA, + 8, + ); + // Then + expect(retriedReceipt).toEqual(firstReceipt); + }); + + it("removes only a rejected intent so the displayed state rolls back", () => { + // Given + const twoPending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [ + ...pending, + { + commandId: commandB, + intent: { type: "sidebar-layout.thread.unpin", threadId: threadA }, + }, + ]; + // When + const remaining = sidebarLayout.rejectPendingSidebarLayoutIntent(twoPending, commandA); + // Then + expect(remaining.map((item) => item.commandId)).toEqual([commandB]); + }); +}); + +describe("optimistic replay and lifecycle normalization", () => { + it("rebases two local intents over a newer remote layout", () => { + // Given + const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [ + { + commandId: commandA, + intent: { + type: "sidebar-layout.project.move", + projectId: projectB, + beforeProjectId: projectA, + }, + }, + { + commandId: commandB, + intent: { + type: "sidebar-layout.project.move", + projectId: projectC, + beforeProjectId: null, + }, + }, + ]; + // When + const displayed = deriveDisplayed( + layout(9, [projectC, projectA, projectB]), + pending, + lifecycle, + ); + // Then + expect(selectProjects(displayed)).toEqual([projectB, projectA, projectC]); + }); + + it("replays pin, pinned move, and unpin without mutating confirmed state", () => { + // Given + const confirmed = layout(3, [projectA, projectB, projectC], [threadA]); + const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [ + { + commandId: commandA, + intent: { + type: "sidebar-layout.thread.pin", + threadId: threadB, + beforeThreadId: threadA, + }, + }, + { + commandId: commandB, + intent: { + type: "sidebar-layout.pinned-thread.move", + threadId: threadA, + beforeThreadId: threadB, + }, + }, + { + commandId: CommandId.makeUnsafe("command-c"), + intent: { type: "sidebar-layout.thread.unpin", threadId: threadB }, + }, + ]; + // When + const displayed = deriveDisplayed(confirmed, pending, lifecycle); + // Then + expect(selectPins(displayed)).toEqual([threadA]); + expect(confirmed.pinnedThreadOrder).toEqual([threadA]); + }); + + it("uses initialization while canonical layout is absent", () => { + // Given + const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [ + { + commandId: commandA, + intent: { + type: "sidebar-layout.initialize", + projectOrder: [projectC, projectA], + pinnedThreadOrder: [threadB], + }, + }, + ]; + // When + const displayed = deriveDisplayed(null, pending, lifecycle); + // Then + expect(selectProjects(displayed)).toEqual([projectC, projectA, projectB]); + }); + + it("does not replay initialization over a reconnect snapshot", () => { + // Given + const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [ + { + commandId: commandA, + intent: { + type: "sidebar-layout.initialize", + projectOrder: [projectC, projectA], + pinnedThreadOrder: [threadB], + }, + }, + ]; + // When + const displayed = deriveDisplayed(layout(10), pending, lifecycle); + // Then + expect(selectProjects(displayed)).toEqual([projectA, projectB, projectC]); + }); + + it("appends every unseen active project kind by createdAt then id and omits deleted IDs", () => { + // Given + const lifecycleWithChanges: sidebarLayout.SidebarLayoutLifecycle = { + projects: [ + { id: projectA, kind: "project", createdAt: "2026-01-01", deletedAt: "2026-06-01" }, + { id: projectB, kind: "chat", createdAt: "2026-01-03", deletedAt: null }, + { id: projectC, kind: "project", createdAt: "2026-01-02", deletedAt: null }, + { id: projectD, kind: "chat", createdAt: "2026-01-02", deletedAt: null }, + ], + threads: [ + { id: threadA, deletedAt: "2026-06-01" }, + { id: threadB, deletedAt: null }, + ], + }; + // When + const displayed = deriveDisplayed( + layout(2, [projectA, projectB, projectB], [threadA, threadB, threadB]), + [], + lifecycleWithChanges, + ); + // Then + expect(selectProjects(displayed)).toEqual([projectB, projectC, projectD]); + expect(selectPins(displayed)).toEqual([threadB]); + }); + + it("ignores unknown subjects and appends on a missing anchor", () => { + // Given + const pending: readonly sidebarLayout.PendingSidebarLayoutIntent[] = [ + { + commandId: commandA, + intent: { + type: "sidebar-layout.project.move", + projectId: ProjectId.makeUnsafe("unknown-project"), + beforeProjectId: projectA, + }, + }, + { + commandId: commandB, + intent: { + type: "sidebar-layout.project.move", + projectId: projectA, + beforeProjectId: ProjectId.makeUnsafe("missing-anchor"), + }, + }, + ]; + // When + const displayed = deriveDisplayed(layout(4), pending, lifecycle); + // Then + expect(selectProjects(displayed)).toEqual([projectB, projectC, projectA]); + }); +}); + +describe("DnD next-sibling anchors", () => { + it.each([ + ["first to middle", projectA, projectC, [projectB, projectC, projectA, projectD], projectD], + ["last to first", projectD, projectA, [projectD, projectA, projectB, projectC], projectA], + ["upward", projectC, projectA, [projectC, projectA, projectB, projectD], projectA], + ["downward", projectB, projectD, [projectA, projectC, projectD, projectB], null], + ["self", projectB, projectB, [projectA, projectB, projectC, projectD], projectC], + ])("derives the %s anchor from the final project list", (_name, moved, over, final, anchor) => { + // Given + const order = [projectA, projectB, projectC, projectD]; + // When + const result = sidebarLayout.getDndNextSiblingAnchor(order, moved, over); + // Then + expect(result).toEqual({ finalOrder: final, beforeId: anchor }); + }); + + it.each([ + ["upward", threadC, threadA, [threadC, threadA, threadB], threadA], + ["downward to end", threadA, threadC, [threadB, threadC, threadA], null], + ])("derives the %s pinned-thread anchor", (_name, moved, over, final, anchor) => { + // Given + const order = [threadA, threadB, threadC]; + // When + const result = sidebarLayout.getDndNextSiblingAnchor(order, moved, over); + // Then + expect(result).toEqual({ finalOrder: final, beforeId: anchor }); + }); +}); diff --git a/apps/web/src/sidebarLayout.logic.ts b/apps/web/src/sidebarLayout.logic.ts new file mode 100644 index 000000000..88739c815 --- /dev/null +++ b/apps/web/src/sidebarLayout.logic.ts @@ -0,0 +1,267 @@ +import type { + CommandId, + DispatchableClientOrchestrationCommand, + OrchestrationProject, + OrchestrationThread, + ProjectId, + SidebarLayout, + ThreadId, +} from "@jcode/contracts"; + +type LayoutCommand = Extract< + DispatchableClientOrchestrationCommand, + { + readonly type: + | "sidebar-layout.initialize" + | "sidebar-layout.project.move" + | "sidebar-layout.thread.pin" + | "sidebar-layout.thread.unpin" + | "sidebar-layout.pinned-thread.move"; + } +>; + +type IntentOf = Omit; + +export type SidebarLayoutIntent = LayoutCommand extends infer Command + ? Command extends LayoutCommand + ? IntentOf + : never + : never; + +export type PendingSidebarLayoutIntent = { + readonly commandId: CommandId; + readonly intent: SidebarLayoutIntent; + readonly acceptedSequence?: number; +}; + +type ProjectLifecycle = Pick; +type ThreadLifecycle = Pick; + +export type SidebarLayoutLifecycle = { + readonly projects: readonly ProjectLifecycle[]; + readonly threads: readonly ThreadLifecycle[]; +}; + +export type DisplayedSidebarLayout = Pick; + +export type DndNextSiblingAnchor = { + readonly finalOrder: readonly Id[]; + readonly beforeId: Id | null; +}; + +class UnexpectedSidebarLayoutIntentError extends Error { + constructor() { + super("Unexpected sidebar layout intent"); + this.name = "UnexpectedSidebarLayoutIntentError"; + } +} + +function assertNever(_value: never): never { + throw new UnexpectedSidebarLayoutIntentError(); +} + +export function acceptConfirmedSidebarLayout( + current: SidebarLayout | null, + incoming: SidebarLayout, +): SidebarLayout { + if (current === null || incoming.revision > current.revision) { + return incoming; + } + return current; +} + +export function recordPendingSidebarLayoutAcceptance( + pending: readonly PendingSidebarLayoutIntent[], + commandId: CommandId, + acceptedSequence: number, +): readonly PendingSidebarLayoutIntent[] { + return pending.map((item) => + item.commandId === commandId ? { ...item, acceptedSequence } : item, + ); +} + +export function reconcilePendingSidebarLayoutIntents( + pending: readonly PendingSidebarLayoutIntent[], + confirmedRevision: number | null, +): readonly PendingSidebarLayoutIntent[] { + if (confirmedRevision === null) { + return pending; + } + return pending.filter( + (item) => item.acceptedSequence === undefined || item.acceptedSequence > confirmedRevision, + ); +} + +export function rejectPendingSidebarLayoutIntent( + pending: readonly PendingSidebarLayoutIntent[], + commandId: CommandId, +): readonly PendingSidebarLayoutIntent[] { + return pending.filter((item) => item.commandId !== commandId); +} + +function uniqueLiveIds(ids: readonly Id[], activeIds: ReadonlySet): readonly Id[] { + const seen = new Set(); + return ids.filter((id) => { + if (!activeIds.has(id) || seen.has(id)) { + return false; + } + seen.add(id); + return true; + }); +} + +function normalizeLayout( + layout: DisplayedSidebarLayout, + lifecycle: SidebarLayoutLifecycle, +): DisplayedSidebarLayout { + const activeProjects = lifecycle.projects + .filter((project) => project.deletedAt === null) + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ); + const activeProjectIds = new Set(activeProjects.map((project) => project.id)); + const projectOrder = [...uniqueLiveIds(layout.projectOrder, activeProjectIds)]; + const seenProjectIds = new Set(projectOrder); + for (const project of activeProjects) { + if (!seenProjectIds.has(project.id)) { + projectOrder.push(project.id); + seenProjectIds.add(project.id); + } + } + + const activeThreadIds = new Set( + lifecycle.threads.filter((thread) => thread.deletedAt === null).map((thread) => thread.id), + ); + return { + projectOrder, + pinnedThreadOrder: uniqueLiveIds(layout.pinnedThreadOrder, activeThreadIds), + }; +} + +function moveBefore(items: readonly Id[], subject: Id, anchor: Id | null): readonly Id[] { + if (!items.includes(subject) || anchor === subject) { + return items; + } + const remaining = items.filter((item) => item !== subject); + if (anchor === null) { + return [...remaining, subject]; + } + const anchorIndex = remaining.indexOf(anchor); + if (anchorIndex < 0) { + return [...remaining, subject]; + } + return [...remaining.slice(0, anchorIndex), subject, ...remaining.slice(anchorIndex)]; +} + +function applyIntent( + layout: DisplayedSidebarLayout, + intent: SidebarLayoutIntent, + context: { + readonly activeThreadIds: ReadonlySet; + readonly canonicalExists: boolean; + }, +): DisplayedSidebarLayout { + switch (intent.type) { + case "sidebar-layout.initialize": + return context.canonicalExists + ? layout + : { + projectOrder: intent.projectOrder, + pinnedThreadOrder: intent.pinnedThreadOrder, + }; + case "sidebar-layout.project.move": + return { + ...layout, + projectOrder: moveBefore( + layout.projectOrder, + intent.projectId, + intent.beforeProjectId ?? null, + ), + }; + case "sidebar-layout.thread.pin": + return context.activeThreadIds.has(intent.threadId) + ? { + ...layout, + pinnedThreadOrder: moveBefore( + layout.pinnedThreadOrder.includes(intent.threadId) + ? layout.pinnedThreadOrder + : [...layout.pinnedThreadOrder, intent.threadId], + intent.threadId, + intent.beforeThreadId ?? null, + ), + } + : layout; + case "sidebar-layout.thread.unpin": + return { + ...layout, + pinnedThreadOrder: layout.pinnedThreadOrder.filter( + (threadId) => threadId !== intent.threadId, + ), + }; + case "sidebar-layout.pinned-thread.move": + return { + ...layout, + pinnedThreadOrder: moveBefore( + layout.pinnedThreadOrder, + intent.threadId, + intent.beforeThreadId ?? null, + ), + }; + default: + return assertNever(intent); + } +} + +export function deriveDisplayedSidebarLayout( + confirmed: SidebarLayout | null, + pending: readonly PendingSidebarLayoutIntent[], + lifecycle: SidebarLayoutLifecycle, +): DisplayedSidebarLayout { + const emptyLayout: DisplayedSidebarLayout = { + projectOrder: [], + pinnedThreadOrder: [], + }; + const normalized = normalizeLayout(confirmed ?? emptyLayout, lifecycle); + const context = { + activeThreadIds: new Set( + lifecycle.threads.filter((thread) => thread.deletedAt === null).map((thread) => thread.id), + ), + canonicalExists: confirmed !== null, + }; + const replayed = pending.reduce( + (current, item) => applyIntent(current, item.intent, context), + normalized, + ); + return normalizeLayout(replayed, lifecycle); +} + +export function selectDisplayedProjectOrder(layout: DisplayedSidebarLayout): readonly ProjectId[] { + return layout.projectOrder; +} + +export function selectDisplayedPinnedThreadOrder( + layout: DisplayedSidebarLayout, +): readonly ThreadId[] { + return layout.pinnedThreadOrder; +} + +export function getDndNextSiblingAnchor( + order: readonly Id[], + movedId: Id, + overId: Id, +): DndNextSiblingAnchor { + const normalized = [...new Set(order)]; + const movedIndex = normalized.indexOf(movedId); + const overIndex = normalized.indexOf(overId); + const remaining = normalized.filter((id) => id !== movedId); + const finalOrder = + movedIndex < 0 || overIndex < 0 || movedId === overId + ? normalized + : [...remaining.slice(0, overIndex), movedId, ...remaining.slice(overIndex)]; + const finalMovedIndex = finalOrder.indexOf(movedId); + return { + finalOrder, + beforeId: finalMovedIndex < 0 ? null : (finalOrder.at(finalMovedIndex + 1) ?? null), + }; +} diff --git a/apps/web/src/sidebarLayoutLegacyMigration.test.ts b/apps/web/src/sidebarLayoutLegacyMigration.test.ts new file mode 100644 index 000000000..dbdae48c6 --- /dev/null +++ b/apps/web/src/sidebarLayoutLegacyMigration.test.ts @@ -0,0 +1,281 @@ +import { ProjectId, ThreadId, type SidebarLayout } from "@jcode/contracts"; +import { describe, expect, it } from "vitest"; +import { + SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY, + confirmSidebarLayoutLegacyMigration, + readSidebarLayoutLegacyCandidates, +} from "./sidebarLayoutLegacyMigration"; + +class MemoryStorage implements Storage { + readonly #values = new Map(); + + get length(): number { + return this.#values.size; + } + + clear(): void { + this.#values.clear(); + } + + getItem(key: string): string | null { + return this.#values.get(key) ?? null; + } + + key(index: number): string | null { + return [...this.#values.keys()][index] ?? null; + } + + removeItem(key: string): void { + this.#values.delete(key); + } + + setItem(key: string, value: string): void { + this.#values.set(key, value); + } +} + +class FailingReadStorage extends MemoryStorage { + override getItem(_key: string): string | null { + throw new DOMException("Storage denied", "SecurityError"); + } +} + +class FailOnceRemoveStorage implements Storage { + #failed = false; + + constructor(private readonly storage: Storage) {} + + get length(): number { + return this.storage.length; + } + + clear(): void { + this.storage.clear(); + } + + getItem(key: string): string | null { + return this.storage.getItem(key); + } + + key(index: number): string | null { + return this.storage.key(index); + } + + removeItem(key: string): void { + if (!this.#failed && key === "dpcode:pinned-threads:v1") { + this.#failed = true; + throw new DOMException("Interrupted", "QuotaExceededError"); + } + this.storage.removeItem(key); + } + + setItem(key: string, value: string): void { + this.storage.setItem(key, value); + } +} + +const projectA = ProjectId.makeUnsafe("project-a"); +const projectB = ProjectId.makeUnsafe("project-b"); +const threadA = ThreadId.makeUnsafe("thread-a"); +const threadB = ThreadId.makeUnsafe("thread-b"); +const hydrated = { + projects: [ + { id: projectA, workspaceRoot: "/work/a" }, + { id: projectB, workspaceRoot: "C:\\Work\\B" }, + ], + threadIds: [threadA, threadB], +} as const; +const confirmedLayout: SidebarLayout = { + projectOrder: [projectA, projectB], + pinnedThreadOrder: [threadA], + revision: 1, + updatedAt: "2026-07-18T00:00:00.000Z", +}; + +describe("sidebar layout legacy candidates", () => { + it("maps mixed current, DPCode, and T3Code values to deduplicated hydrated IDs", () => { + // Given + const storage = new MemoryStorage(); + storage.setItem( + "jcode:renderer-state:v8", + JSON.stringify({ projectOrderCwds: ["/work/a/", "/unknown", "C:\\Work\\B"] }), + ); + storage.setItem( + "dpcode:renderer-state:v8", + JSON.stringify({ projectOrderCwds: ["/work/a", "C:/Work/B"] }), + ); + storage.setItem( + "t3code:renderer-state:v7", + JSON.stringify({ projectOrderCwds: ["C:/Work/B", "/unknown"] }), + ); + storage.setItem( + "dpcode:pinned-threads:v1", + JSON.stringify({ + state: { pinnedThreadIds: ["thread-b", "unknown", "thread-b"] }, + version: 0, + }), + ); + storage.setItem( + "jcode:pinned-threads:v1", + JSON.stringify({ state: { pinnedThreadIds: ["thread-a"] }, version: 0 }), + ); + storage.setItem("t3code:pinned-threads:v1", JSON.stringify(["thread-b"])); + + // When + const candidates = readSidebarLayoutLegacyCandidates(storage, null, hydrated); + + // Then + expect(candidates).toEqual({ + projectOrder: [projectA, projectB], + pinnedThreadOrder: [threadA, threadB], + }); + }); + + it("returns empty candidates when storage is missing or malformed", () => { + // Given + const storage = new MemoryStorage(); + storage.setItem("jcode:renderer-state:v8", "not-json"); + storage.setItem("dpcode:renderer-state:v8", JSON.stringify({ projectOrderCwds: [7, null] })); + storage.setItem("jcode:pinned-threads:v1", JSON.stringify({ state: { pinnedThreadIds: 4 } })); + + // When + const candidates = readSidebarLayoutLegacyCandidates(storage, null, hydrated); + + // Then + expect(candidates).toEqual({ projectOrder: [], pinnedThreadOrder: [] }); + }); + + it("does not return initialization candidates when the server layout is already initialized", () => { + // Given + const storage = new MemoryStorage(); + storage.setItem("jcode:renderer-state:v8", JSON.stringify({ projectOrderCwds: ["/work/a"] })); + + // When + const candidates = readSidebarLayoutLegacyCandidates(storage, confirmedLayout, hydrated); + + // Then + expect(candidates).toBeNull(); + }); + + it("does not return initialization candidates after a marked reload", () => { + // Given + const storage = new MemoryStorage(); + storage.setItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY, "1"); + storage.setItem("dpcode:pinned-threads:v1", JSON.stringify(["thread-a"])); + + // When + const candidates = readSidebarLayoutLegacyCandidates(storage, null, hydrated); + + // Then + expect(candidates).toBeNull(); + }); + + it("does not return initialization candidates when storage access fails", () => { + // Given + const storage = new FailingReadStorage(); + + // When + const candidates = readSidebarLayoutLegacyCandidates(storage, null, hydrated); + + // Then + expect(candidates).toBeNull(); + }); +}); + +describe("sidebar layout legacy cleanup", () => { + it("preserves expansion and local names while removing only legacy authority fields", () => { + // Given + const storage = new MemoryStorage(); + storage.setItem( + "jcode:renderer-state:v8", + JSON.stringify({ + expandedProjectCwds: ["/work/a"], + projectOrderCwds: ["/work/b", "/work/a"], + projectNamesByCwd: { "/work/a": "Local A" }, + }), + ); + storage.setItem( + "t3code:renderer-state:v7", + JSON.stringify({ expandedProjectCwds: ["/work/b"], projectOrderCwds: ["/work/b"] }), + ); + storage.setItem("jcode:pinned-threads:v1", "current pins"); + storage.setItem("dpcode:pinned-threads:v1", "dp pins"); + storage.setItem("t3code:pinned-threads:v1", "t3 pins"); + + // When + const cleaned = confirmSidebarLayoutLegacyMigration(storage, confirmedLayout); + + // Then + expect(cleaned).toBe(true); + expect(JSON.parse(storage.getItem("jcode:renderer-state:v8") ?? "null")).toEqual({ + expandedProjectCwds: ["/work/a"], + projectNamesByCwd: { "/work/a": "Local A" }, + }); + expect(JSON.parse(storage.getItem("t3code:renderer-state:v7") ?? "null")).toEqual({ + expandedProjectCwds: ["/work/b"], + }); + expect(storage.getItem("jcode:pinned-threads:v1")).toBeNull(); + expect(storage.getItem("dpcode:pinned-threads:v1")).toBeNull(); + expect(storage.getItem("t3code:pinned-threads:v1")).toBeNull(); + expect(storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY)).toBe("1"); + }); + + it("does not clean any field before initialized server state is confirmed", () => { + // Given + const storage = new MemoryStorage(); + const rendererValue = JSON.stringify({ projectOrderCwds: ["/work/a"], extra: true }); + storage.setItem("jcode:renderer-state:v8", rendererValue); + storage.setItem("jcode:pinned-threads:v1", "pins"); + + // When + const cleaned = confirmSidebarLayoutLegacyMigration(storage, null); + + // Then + expect(cleaned).toBe(false); + expect(storage.getItem("jcode:renderer-state:v8")).toBe(rendererValue); + expect(storage.getItem("jcode:pinned-threads:v1")).toBe("pins"); + expect(storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY)).toBeNull(); + }); + + it("retries remaining field cleanup after an interrupted marked attempt", () => { + // Given + const storage = new MemoryStorage(); + storage.setItem("jcode:renderer-state:v8", JSON.stringify({ projectOrderCwds: ["/work/a"] })); + storage.setItem("dpcode:pinned-threads:v1", "pins"); + const interruptedStorage = new FailOnceRemoveStorage(storage); + + // When + const firstAttempt = confirmSidebarLayoutLegacyMigration(interruptedStorage, confirmedLayout); + const retry = confirmSidebarLayoutLegacyMigration(interruptedStorage, confirmedLayout); + + // Then + expect(firstAttempt).toBe(false); + expect(retry).toBe(true); + expect(storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY)).toBe("1"); + expect(storage.getItem("dpcode:pinned-threads:v1")).toBeNull(); + expect(JSON.parse(storage.getItem("jcode:renderer-state:v8") ?? "null")).toEqual({}); + }); + + it("does not replay stale candidates after cleanup is interrupted once the marker is durable", () => { + // Given: cleanup can write the marker but is interrupted while removing a pin key. + const storage = new MemoryStorage(); + storage.setItem( + "jcode:renderer-state:v8", + JSON.stringify({ expandedProjectCwds: ["/work/a"], projectOrderCwds: ["/work/a"] }), + ); + storage.setItem("dpcode:pinned-threads:v1", JSON.stringify(["thread-a"])); + const interruptedStorage = new FailOnceRemoveStorage(storage); + + // When: an initialized layout is confirmed and an old profile reload sees a null snapshot. + const cleaned = confirmSidebarLayoutLegacyMigration(interruptedStorage, confirmedLayout); + const reloadedCandidates = readSidebarLayoutLegacyCandidates(storage, null, hydrated); + + // Then: stale values cannot initialize again, while presentation state remains intact. + expect(cleaned).toBe(false); + expect(storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY)).toBe("1"); + expect(reloadedCandidates).toBeNull(); + expect(JSON.parse(storage.getItem("jcode:renderer-state:v8") ?? "null")).toEqual({ + expandedProjectCwds: ["/work/a"], + }); + }); +}); diff --git a/apps/web/src/sidebarLayoutLegacyMigration.ts b/apps/web/src/sidebarLayoutLegacyMigration.ts new file mode 100644 index 000000000..81c6db89c --- /dev/null +++ b/apps/web/src/sidebarLayoutLegacyMigration.ts @@ -0,0 +1,196 @@ +// FILE: sidebarLayoutLegacyMigration.ts +// Purpose: Reads and retires one-time browser authority for sidebar order and pins. +// Layer: Web migration utility +// Exports: candidate reader, confirmed-state cleanup, durable marker key + +import type { ProjectId, SidebarLayout, ThreadId } from "@jcode/contracts"; +import { normalizeWorkspaceRootForComparison } from "@jcode/shared/threadWorkspace"; + +export const SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY = "jcode:sidebar-layout-migrated:v1"; + +const RENDERER_STATE_KEYS = [ + "jcode:renderer-state:v8", + "dpcode:renderer-state:v8", + "t3code:renderer-state:v8", + "t3code:renderer-state:v7", + "t3code:renderer-state:v6", + "t3code:renderer-state:v5", + "t3code:renderer-state:v4", + "t3code:renderer-state:v3", +] as const; + +const PINNED_THREAD_KEYS = [ + "jcode:pinned-threads:v1", + "dpcode:pinned-threads:v1", + "t3code:pinned-threads:v1", +] as const; + +export type HydratedSidebarLayoutSubjects = { + readonly projects: readonly { + readonly id: ProjectId; + readonly workspaceRoot: string; + }[]; + readonly threadIds: readonly ThreadId[]; +}; + +export type SidebarLayoutLegacyCandidates = Pick< + SidebarLayout, + "projectOrder" | "pinnedThreadOrder" +>; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJson(raw: string): unknown | null { + try { + const parsed: unknown = JSON.parse(raw); + return parsed; + } catch (error) { + if (error instanceof SyntaxError) { + return null; + } + throw error; + } +} + +function stringItems(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((candidate): candidate is string => typeof candidate === "string"); +} + +function rendererProjectOrder(raw: string): readonly string[] { + const parsed = parseJson(raw); + if (!isRecord(parsed)) { + return []; + } + if (isRecord(parsed["state"])) { + return stringItems(parsed["state"]["projectOrderCwds"]); + } + return stringItems(parsed["projectOrderCwds"]); +} + +function parsePinnedThreadOrder(raw: string): readonly string[] { + const parsed = parseJson(raw); + if (Array.isArray(parsed)) { + return stringItems(parsed); + } + if (!isRecord(parsed)) { + return []; + } + if (isRecord(parsed["state"])) { + return stringItems(parsed["state"]["pinnedThreadIds"]); + } + return stringItems(parsed["pinnedThreadIds"]); +} + +function collectStoredValues(storage: Storage, keys: readonly string[]): readonly string[] { + const values: string[] = []; + for (const key of keys) { + const value = storage.getItem(key); + if (value !== null) { + values.push(value); + } + } + return values; +} + +export function readSidebarLayoutLegacyCandidates( + storage: Storage, + serverLayout: SidebarLayout | null, + subjects: HydratedSidebarLayoutSubjects, +): SidebarLayoutLegacyCandidates | null { + if (serverLayout !== null) { + return null; + } + + try { + if (storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY) !== null) { + return null; + } + + const projectByRoot = new Map( + subjects.projects.map((project) => [ + normalizeWorkspaceRootForComparison(project.workspaceRoot), + project.id, + ]), + ); + const projectOrder: ProjectId[] = []; + for (const raw of collectStoredValues(storage, RENDERER_STATE_KEYS)) { + for (const root of rendererProjectOrder(raw)) { + const projectId = projectByRoot.get(normalizeWorkspaceRootForComparison(root)); + if (projectId !== undefined && !projectOrder.includes(projectId)) { + projectOrder.push(projectId); + } + } + } + + const hydratedThreadIds = new Map( + subjects.threadIds.map((threadId) => [threadId, threadId]), + ); + const pinnedThreadOrder: ThreadId[] = []; + for (const raw of collectStoredValues(storage, PINNED_THREAD_KEYS)) { + for (const candidate of parsePinnedThreadOrder(raw)) { + const threadId = hydratedThreadIds.get(candidate); + if (threadId !== undefined && !pinnedThreadOrder.includes(threadId)) { + pinnedThreadOrder.push(threadId); + } + } + } + + return { projectOrder, pinnedThreadOrder }; + } catch (error) { + if (error instanceof Error) { + return null; + } + throw error; + } +} + +export function removeSidebarLayoutLegacyProjectOrder(raw: string): string | null { + const parsed = parseJson(raw); + if (!isRecord(parsed)) { + return null; + } + if (isRecord(parsed["state"])) { + const { projectOrderCwds: _projectOrder, ...presentationState } = parsed["state"]; + const { state: _state, ...envelope } = parsed; + return JSON.stringify({ ...envelope, state: presentationState }); + } + const { projectOrderCwds: _projectOrder, ...presentationState } = parsed; + return JSON.stringify(presentationState); +} + +export function confirmSidebarLayoutLegacyMigration( + storage: Storage, + serverLayout: SidebarLayout | null, +): boolean { + if (serverLayout === null) { + return false; + } + + try { + storage.setItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY, "1"); + for (const key of RENDERER_STATE_KEYS) { + const raw = storage.getItem(key); + if (raw === null) { + continue; + } + const cleaned = removeSidebarLayoutLegacyProjectOrder(raw); + if (cleaned !== null) { + storage.setItem(key, cleaned); + } + } + for (const key of PINNED_THREAD_KEYS) { + storage.removeItem(key); + } + return true; + } catch (error) { + if (error instanceof Error) { + return false; + } + throw error; + } +} diff --git a/apps/web/src/sidebarLayoutRouter.test.ts b/apps/web/src/sidebarLayoutRouter.test.ts new file mode 100644 index 000000000..633375115 --- /dev/null +++ b/apps/web/src/sidebarLayoutRouter.test.ts @@ -0,0 +1,351 @@ +import { + CommandId, + ProjectId, + ThreadId, + type DispatchResult, + type OrchestrationShellStreamEvent, + type SidebarLayout, +} from "@jcode/contracts"; +import { describe, expect, it } from "vitest"; +import { createSidebarLayoutRouter } from "./sidebarLayoutRouter"; +import { sidebarLayoutLegacySubjectsReady } from "./sidebarLayoutRouter"; +import { + createSidebarLayoutStore, + type SidebarLayoutCommand, + type SidebarLayoutDispatch, +} from "./sidebarLayoutStore"; + +type PendingDispatch = { + readonly command: SidebarLayoutCommand; + readonly resolve: (result: DispatchResult) => void; + readonly reject: (error: unknown) => void; +}; + +class ControllableTransport { + readonly commands: SidebarLayoutCommand[] = []; + readonly pending: PendingDispatch[] = []; + + readonly dispatchCommand: SidebarLayoutDispatch = (command) => { + this.commands.push(command); + return new Promise((resolve, reject) => this.pending.push({ command, resolve, reject })); + }; +} + +const projectA = ProjectId.makeUnsafe("project-a"); +const projectB = ProjectId.makeUnsafe("project-b"); +const threadA = ThreadId.makeUnsafe("thread-a"); +const commandA = CommandId.makeUnsafe("command-a"); +const commandB = CommandId.makeUnsafe("command-b"); + +const lifecycle = { + projects: [ + { id: projectA, kind: "project", createdAt: "2026-01-01T00:00:00Z", deletedAt: null }, + { id: projectB, kind: "project", createdAt: "2026-01-02T00:00:00Z", deletedAt: null }, + ], + threads: [{ id: threadA, deletedAt: null }], +} as const; + +function layout(revision: number, projectOrder = [projectA, projectB]): SidebarLayout { + return { + projectOrder, + pinnedThreadOrder: [threadA], + revision, + updatedAt: `2026-07-18T00:00:${String(revision).padStart(2, "0")}.000Z`, + }; +} + +function layoutEvent(revision: number, projectOrder = [projectA, projectB]) { + return { + kind: "sidebar-layout-updated", + sequence: revision, + sidebarLayout: layout(revision, projectOrder), + } satisfies OrchestrationShellStreamEvent; +} + +async function flushDispatch(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("sidebar layout router", () => { + it("defers transient empty streams but treats an authoritative empty query as ready", () => { + // Given: both observations contain a genuinely empty lifecycle. + const emptyLifecycle = { projects: [], threads: [] } as const; + + // When/Then: stream emptiness is provisional, while the bootstrap query is authoritative. + expect(sidebarLayoutLegacySubjectsReady("stream", emptyLifecycle)).toBe(false); + expect(sidebarLayoutLegacySubjectsReady("query", emptyLifecycle)).toBe(true); + expect(sidebarLayoutLegacySubjectsReady("stream", lifecycle)).toBe(true); + expect( + sidebarLayoutLegacySubjectsReady("stream", { projects: lifecycle.projects, threads: [] }), + ).toBe(true); + expect( + sidebarLayoutLegacySubjectsReady("stream", { projects: [], threads: lifecycle.threads }), + ).toBe(true); + }); + + it("initializes only after a null snapshot and legacy candidates are ready", async () => { + // Given: a hydrated null snapshot whose candidate read has not completed. + const transport = new ControllableTransport(); + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + const router = createSidebarLayoutRouter({ store }); + router.acceptSnapshot({ sidebarLayout: null, lifecycle }); + await flushDispatch(); + expect(transport.commands).toEqual([]); + + // When: the same hydrated snapshot is delivered with ready candidates. + router.acceptSnapshot({ + sidebarLayout: null, + lifecycle, + legacyCandidates: { projectOrder: [projectB], pinnedThreadOrder: [threadA] }, + }); + await flushDispatch(); + + // Then: exactly one initialize command uses those candidates. + expect(transport.commands).toEqual([ + { + type: "sidebar-layout.initialize", + commandId: commandA, + projectOrder: [projectB], + pinnedThreadOrder: [threadA], + }, + ]); + router.acceptSnapshot({ + sidebarLayout: null, + lifecycle, + legacyCandidates: { projectOrder: [], pinnedThreadOrder: [] }, + }); + await flushDispatch(); + expect(transport.commands).toHaveLength(1); + }); + + it("adopts the newest layout for event-before-snapshot and snapshot-before-event", () => { + // Given: an event arrives before its older snapshot. + const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) }); + const router = createSidebarLayoutRouter({ store }); + router.acceptShellEvent(layoutEvent(5, [projectB, projectA])); + + // When: an older initialized snapshot and then a newer event arrive. + router.acceptSnapshot({ + sidebarLayout: layout(4), + lifecycle, + legacyCandidates: null, + }); + router.acceptShellEvent(layoutEvent(6)); + + // Then: confirmation is monotonic by layout revision only. + expect(store.getState().confirmedLayout).toEqual(layout(6)); + expect(store.getState().pendingIntents).toEqual([]); + }); + + it("normalizes displayed layout when shell lifecycle events remove entities", () => { + // Given: canonical layout references two live projects and one pinned thread. + const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) }); + const router = createSidebarLayoutRouter({ store }); + router.acceptSnapshot({ sidebarLayout: layout(1), lifecycle, legacyCandidates: null }); + + // When: the shell removes one project and the pinned thread. + router.acceptShellEvent({ kind: "project-removed", sequence: 2, projectId: projectB }); + router.acceptShellEvent({ kind: "thread-removed", sequence: 3, threadId: threadA }); + + // Then: lifecycle state immediately excludes both removed entities. + expect(store.getState().lifecycle).toEqual({ + projects: [lifecycle.projects[0]], + threads: [], + }); + }); + + it("does not initialize from a stale null snapshot after a live layout event", async () => { + // Given: another client initializes before this client's first snapshot. + const transport = new ControllableTransport(); + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + const router = createSidebarLayoutRouter({ store }); + router.acceptShellEvent(layoutEvent(7, [projectB, projectA])); + + // When: a stale null snapshot is delivered with cached-marker/no-candidate state. + router.acceptSnapshot({ + sidebarLayout: null, + lifecycle, + legacyCandidates: { projectOrder: [], pinnedThreadOrder: [] }, + }); + await flushDispatch(); + + // Then: canonical state wins and no initializer is sent. + expect(store.getState().confirmedLayout).toEqual(layout(7, [projectB, projectA])); + expect(transport.commands).toEqual([]); + }); + + it("retries a lost initializer response with the same ID then adopts reconnect state", async () => { + // Given: initialize was sent but its response was lost. + const transport = new ControllableTransport(); + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + const router = createSidebarLayoutRouter({ store }); + router.acceptSnapshot({ + sidebarLayout: null, + lifecycle, + legacyCandidates: { projectOrder: [], pinnedThreadOrder: [] }, + }); + await flushDispatch(); + + // When: reconnect retries and the server snapshot reports a winning canonical layout. + expect(router.reconnect()).toBe(true); + await flushDispatch(); + router.acceptSnapshot({ + sidebarLayout: layout(9, [projectB, projectA]), + lifecycle, + legacyCandidates: null, + }); + + // Then: the same command ID was reused and no initialization remains pending. + expect(transport.commands.map((command) => command.commandId)).toEqual([commandA, commandA]); + expect(store.getState().confirmedLayout).toEqual(layout(9, [projectB, projectA])); + expect(store.getState().pendingIntents).toEqual([]); + expect(store.getState().inFlightCommandId).toBeNull(); + }); + + it("does not initialize when a durable marker suppresses legacy candidates on reload", async () => { + // Given: candidate collection reports that this profile already completed migration. + const transport = new ControllableTransport(); + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + const router = createSidebarLayoutRouter({ store }); + + // When: an old null snapshot reaches the reloaded client. + router.acceptSnapshot({ sidebarLayout: null, lifecycle, legacyCandidates: null }); + await flushDispatch(); + + // Then: stale browser values cannot cause another initialization attempt. + expect(transport.commands).toEqual([]); + expect(store.getState().pendingIntents).toEqual([]); + }); + + it("confirms legacy cleanup after another client initializes and retries interrupted cleanup", () => { + // Given: another client wins while this profile still contains legacy candidates. + const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) }); + const cleanupAttempts: SidebarLayout[] = []; + const router = createSidebarLayoutRouter({ + store, + confirmLegacyMigration: (confirmed) => { + cleanupAttempts.push(confirmed); + return cleanupAttempts.length > 1; + }, + }); + + // When: the winning event is followed by a reconnect snapshot after cleanup was interrupted. + router.acceptShellEvent(layoutEvent(7, [projectB, projectA])); + router.acceptSnapshot({ + sidebarLayout: layout(7, [projectB, projectA]), + lifecycle, + legacyCandidates: { projectOrder: [projectA], pinnedThreadOrder: [threadA] }, + }); + + // Then: canonical server state is adopted, cleanup retries, and no initializer is submitted. + expect(store.getState().confirmedLayout).toEqual(layout(7, [projectB, projectA])); + expect(cleanupAttempts).toEqual([ + layout(7, [projectB, projectA]), + layout(7, [projectB, projectA]), + ]); + expect(store.getState().pendingIntents).toEqual([]); + }); + + it("stops legacy cleanup after it succeeds", () => { + // Given: a cleanup callback that succeeds on its first initialized observation. + const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) }); + const cleanupAttempts: SidebarLayout[] = []; + const router = createSidebarLayoutRouter({ + store, + confirmLegacyMigration: (confirmed) => { + cleanupAttempts.push(confirmed); + return true; + }, + }); + + // When: later initialized snapshots and events arrive. + router.acceptSnapshot({ sidebarLayout: layout(4), lifecycle, legacyCandidates: null }); + router.acceptShellEvent(layoutEvent(5, [projectB, projectA])); + + // Then: the durable migration cleanup is not repeated in this client session. + expect(cleanupAttempts).toEqual([layout(4)]); + }); + + it("retries interrupted cleanup from a later canonical-only snapshot without regressing layout", () => { + // Given: the first canonical event is adopted but legacy cleanup is interrupted. + const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) }); + const cleanupAttempts: SidebarLayout[] = []; + const router = createSidebarLayoutRouter({ + store, + confirmLegacyMigration: (confirmed) => { + cleanupAttempts.push(confirmed); + return cleanupAttempts.length > 1; + }, + }); + router.acceptShellEvent(layoutEvent(7, [projectB, projectA])); + + // When: a fallback/bootstrap query later observes an older canonical layout that must not + // reapply lifecycle or candidates. + router.acceptConfirmedLayout(layout(6)); + + // Then: cleanup retries against the newest accepted layout and canonical revision is monotonic. + expect(store.getState().confirmedLayout).toEqual(layout(7, [projectB, projectA])); + expect(cleanupAttempts).toEqual([ + layout(7, [projectB, projectA]), + layout(7, [projectB, projectA]), + ]); + expect(store.getState().lifecycle).toEqual({ projects: [], threads: [] }); + expect(store.getState().pendingIntents).toEqual([]); + }); + + it("offers a typed retry after initialize rejection and adopts a winning canonical layout", async () => { + // Given: the first initialization attempt is rejected. + const transport = new ControllableTransport(); + const commandIds = [commandA, commandB]; + let commandIndex = 0; + let retryInitialization: (() => boolean) | null = null; + const rejectedErrors: unknown[] = []; + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandIds[commandIndex++] ?? commandB, + }); + const router = createSidebarLayoutRouter({ + store, + onInitializationRejected: (failure) => { + rejectedErrors.push(failure.error); + retryInitialization = failure.retry; + }, + }); + router.acceptSnapshot({ + sidebarLayout: null, + lifecycle, + legacyCandidates: { projectOrder: [projectB], pinnedThreadOrder: [threadA] }, + }); + await flushDispatch(); + const rejection = new Error("initialization rejected"); + transport.pending[0]?.reject(rejection); + await flushDispatch(); + + // When: the targeted recovery action retries and another client wins before its response. + expect(retryInitialization).not.toBeNull(); + expect((retryInitialization as (() => boolean) | null)?.()).toBe(true); + await flushDispatch(); + router.acceptShellEvent(layoutEvent(9, [projectA, projectB])); + + // Then: recovery is observable, uses a fresh command, and canonical state clears the retry. + expect(rejectedErrors).toEqual([rejection]); + expect(transport.commands.map((command) => command.commandId)).toEqual([commandA, commandB]); + expect(store.getState().confirmedLayout).toEqual(layout(9, [projectA, projectB])); + expect(store.getState().pendingIntents).toEqual([]); + expect(store.getState().inFlightCommandId).toBeNull(); + expect((retryInitialization as (() => boolean) | null)?.()).toBe(false); + }); +}); diff --git a/apps/web/src/sidebarLayoutRouter.ts b/apps/web/src/sidebarLayoutRouter.ts new file mode 100644 index 000000000..c3e6ba58b --- /dev/null +++ b/apps/web/src/sidebarLayoutRouter.ts @@ -0,0 +1,163 @@ +import type { OrchestrationShellStreamEvent, SidebarLayout } from "@jcode/contracts"; +import type { StoreApi } from "zustand/vanilla"; +import type { SidebarLayoutLifecycle } from "./sidebarLayout.logic"; +import type { SidebarLayoutLegacyCandidates } from "./sidebarLayoutLegacyMigration"; +import type { SidebarLayoutStoreState } from "./sidebarLayoutStore"; + +export type SidebarLayoutRouterSnapshot = { + readonly sidebarLayout: SidebarLayout | null; + readonly lifecycle: SidebarLayoutLifecycle; + readonly legacyCandidates?: SidebarLayoutLegacyCandidates | null; +}; + +export type SidebarLayoutRouterDependencies = { + readonly store: Pick, "getState">; + readonly confirmLegacyMigration?: (layout: SidebarLayout) => boolean; + readonly onInitializationRejected?: (failure: SidebarLayoutInitializationFailure) => void; +}; + +export type SidebarLayoutInitializationFailure = { + readonly error: unknown; + readonly retry: () => boolean; +}; + +export type SidebarLayoutSnapshotSource = "stream" | "query"; + +export function sidebarLayoutLegacySubjectsReady( + source: SidebarLayoutSnapshotSource, + lifecycle: SidebarLayoutLifecycle, +): boolean { + return source === "query" || lifecycle.projects.length > 0 || lifecycle.threads.length > 0; +} + +function applyLifecycleShellEvent( + lifecycle: SidebarLayoutLifecycle, + event: Exclude, +): SidebarLayoutLifecycle { + switch (event.kind) { + case "project-upserted": + return { + ...lifecycle, + projects: [ + ...lifecycle.projects.filter((project) => project.id !== event.project.id), + { + id: event.project.id, + kind: event.project.kind, + createdAt: event.project.createdAt, + deletedAt: null, + }, + ], + }; + case "project-removed": + return { + ...lifecycle, + projects: lifecycle.projects.filter((project) => project.id !== event.projectId), + }; + case "thread-upserted": + return { + ...lifecycle, + threads: [ + ...lifecycle.threads.filter((thread) => thread.id !== event.thread.id), + { id: event.thread.id, deletedAt: null }, + ], + }; + case "thread-removed": + return { + ...lifecycle, + threads: lifecycle.threads.filter((thread) => thread.id !== event.threadId), + }; + } +} + +export function createSidebarLayoutRouter(dependencies: SidebarLayoutRouterDependencies) { + let initializationAttempted = false; + let initializationCandidates: SidebarLayoutLegacyCandidates | null = null; + let legacyCleanupComplete = false; + + const confirmLegacyMigration = (): void => { + if (legacyCleanupComplete || dependencies.confirmLegacyMigration === undefined) { + return; + } + const confirmedLayout = dependencies.store.getState().confirmedLayout; + if (confirmedLayout !== null) { + legacyCleanupComplete = dependencies.confirmLegacyMigration(confirmedLayout); + } + }; + + const acceptConfirmedLayout = (layout: SidebarLayout | null): void => { + dependencies.store.getState().acceptConfirmedLayout(layout); + confirmLegacyMigration(); + }; + + const retryInitialization = (): boolean => { + if ( + initializationAttempted || + initializationCandidates === null || + dependencies.store.getState().confirmedLayout !== null + ) { + return false; + } + enqueueInitialization(initializationCandidates); + return true; + }; + + function enqueueInitialization(candidates: SidebarLayoutLegacyCandidates): void { + initializationAttempted = true; + initializationCandidates = candidates; + dependencies.store.getState().enqueue( + { + type: "sidebar-layout.initialize", + projectOrder: candidates.projectOrder, + pinnedThreadOrder: candidates.pinnedThreadOrder, + }, + { + onRejected: (error) => { + initializationAttempted = false; + dependencies.onInitializationRejected?.({ error, retry: retryInitialization }); + }, + }, + ); + } + + return { + acceptConfirmedLayout, + acceptSnapshot: (snapshot: SidebarLayoutRouterSnapshot): void => { + const state = dependencies.store.getState(); + state.setLifecycle(snapshot.lifecycle); + acceptConfirmedLayout(snapshot.sidebarLayout); + + if ( + snapshot.sidebarLayout !== null || + dependencies.store.getState().confirmedLayout !== null + ) { + initializationAttempted = true; + return; + } + if ( + snapshot.legacyCandidates === undefined || + snapshot.legacyCandidates === null || + initializationAttempted + ) { + return; + } + + enqueueInitialization(snapshot.legacyCandidates); + }, + acceptShellEvent: (event: OrchestrationShellStreamEvent): void => { + switch (event.kind) { + case "sidebar-layout-updated": + acceptConfirmedLayout(event.sidebarLayout); + return; + case "project-upserted": + case "project-removed": + case "thread-upserted": + case "thread-removed": + dependencies.store + .getState() + .setLifecycle(applyLifecycleShellEvent(dependencies.store.getState().lifecycle, event)); + return; + } + }, + reconnect: (): boolean => dependencies.store.getState().retryInFlight(), + }; +} diff --git a/apps/web/src/sidebarLayoutStore.test.ts b/apps/web/src/sidebarLayoutStore.test.ts new file mode 100644 index 000000000..beb310e18 --- /dev/null +++ b/apps/web/src/sidebarLayoutStore.test.ts @@ -0,0 +1,503 @@ +import { + CommandId, + ProjectId, + ThreadId, + type DispatchResult, + type SidebarLayout, +} from "@jcode/contracts"; +import { describe, expect, it } from "vitest"; +import { + createSidebarLayoutStore, + selectDisplayedPinnedThreadOrder, + selectDisplayedProjectOrder, + type SidebarLayoutCommand, + type SidebarLayoutDispatch, +} from "./sidebarLayoutStore"; + +type PendingDispatch = { + readonly command: SidebarLayoutCommand; + readonly resolve: (result: DispatchResult) => void; + readonly reject: (error: Error) => void; +}; + +class ControllableTransport { + readonly commands: SidebarLayoutCommand[] = []; + readonly pending: PendingDispatch[] = []; + + readonly dispatchCommand: SidebarLayoutDispatch = (command) => { + this.commands.push(command); + return new Promise((resolve, reject) => { + this.pending.push({ command, resolve, reject }); + }); + }; + + resolveAttempt(index: number, sequence: number): void { + this.pending[index]?.resolve({ sequence }); + } + + rejectAttempt(index: number, message: string): void { + this.pending[index]?.reject(new Error(message)); + } +} + +const projectA = ProjectId.makeUnsafe("project-a"); +const projectB = ProjectId.makeUnsafe("project-b"); +const projectC = ProjectId.makeUnsafe("project-c"); +const threadA = ThreadId.makeUnsafe("thread-a"); +const threadB = ThreadId.makeUnsafe("thread-b"); +const commandA = CommandId.makeUnsafe("command-a"); +const commandB = CommandId.makeUnsafe("command-b"); + +const layout = ( + revision: number, + projectOrder: readonly ProjectId[] = [projectA, projectB], + pinnedThreadOrder: readonly ThreadId[] = [threadA], +): SidebarLayout => ({ + projectOrder, + pinnedThreadOrder, + revision, + updatedAt: `2026-07-18T00:00:${String(revision).padStart(2, "0")}.000Z`, +}); + +const lifecycle = { + projects: [ + { id: projectA, kind: "project", createdAt: "2026-01-01T00:00:00Z", deletedAt: null }, + { id: projectB, kind: "project", createdAt: "2026-01-02T00:00:00Z", deletedAt: null }, + { id: projectC, kind: "project", createdAt: "2026-01-03T00:00:00Z", deletedAt: null }, + ], + threads: [ + { id: threadA, deletedAt: null }, + { id: threadB, deletedAt: null }, + ], +} as const; + +async function flushDispatch(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("sidebar layout store", () => { + it("returns a stable displayed selector value while state is unchanged", () => { + // Given: one immutable store state object. + const state = createSidebarLayoutStore({ + dispatchCommand: async () => ({ sequence: 1 }), + }).getState(); + // When: displayed state is selected twice. + const first = selectDisplayedProjectOrder(state); + // Then: React external-store consumers receive the same snapshot reference. + expect(selectDisplayedProjectOrder(state)).toBe(first); + }); + + it("queues a session-only optimistic intent", () => { + // Given: a new store with a dispatch transport. + const store = createSidebarLayoutStore({ + dispatchCommand: async () => ({ sequence: 1 }), + }); + + // When: a layout intent is enqueued. + store.getState().enqueue({ + type: "sidebar-layout.initialize", + projectOrder: [], + pinnedThreadOrder: [], + }); + + // Then: the intent is visible in session state. + expect(store.getState().pendingIntents).toHaveLength(1); + }); + + it("dispatches rapid intents sequentially with their exact generated command IDs", async () => { + // Given: a transport whose first response is controllable and deterministic command IDs. + const transport = new ControllableTransport(); + const commandIds = [commandA, commandB]; + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandIds.shift() ?? CommandId.makeUnsafe("unexpected-command"), + }); + + // When: two actions are enqueued before the first RPC completes. + store.getState().enqueue({ + type: "sidebar-layout.project.move", + projectId: projectA, + beforeProjectId: null, + }); + store.getState().enqueue({ + type: "sidebar-layout.thread.unpin", + threadId: threadA, + }); + await flushDispatch(); + + // Then: only the first exact command is dispatched. + expect(transport.commands).toEqual([ + { + type: "sidebar-layout.project.move", + commandId: commandA, + projectId: projectA, + beforeProjectId: null, + }, + ]); + + transport.resolveAttempt(0, 7); + await flushDispatch(); + expect(transport.commands[1]).toEqual({ + type: "sidebar-layout.thread.unpin", + commandId: commandB, + threadId: threadA, + }); + }); + + it("clears an event-before-RPC intent only after its accepted sequence is known", async () => { + // Given: a dispatched optimistic move whose canonical event arrives first. + const transport = new ControllableTransport(); + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + store.getState().setLifecycle(lifecycle); + store.getState().enqueue({ + type: "sidebar-layout.project.move", + projectId: projectA, + beforeProjectId: null, + }); + await flushDispatch(); + + // When: revision 7 is confirmed before the RPC receipt. + store.getState().acceptConfirmedLayout(layout(7, [projectB, projectA, projectC])); + + // Then: the unacknowledged intent remains optimistic. + expect(store.getState().pendingIntents).toHaveLength(1); + + transport.resolveAttempt(0, 7); + await flushDispatch(); + expect(store.getState().pendingIntents).toEqual([]); + }); + + it("keeps an RPC-before-event intent until the confirmed layout reaches its sequence", async () => { + // Given: an optimistic pin accepted at sequence 9. + const transport = new ControllableTransport(); + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + store.getState().setLifecycle(lifecycle); + store.getState().enqueue({ + type: "sidebar-layout.thread.pin", + threadId: threadA, + beforeThreadId: null, + }); + await flushDispatch(); + transport.resolveAttempt(0, 9); + await flushDispatch(); + + // When: an older layout revision arrives before revision 9. + store.getState().acceptConfirmedLayout(layout(8, [], [])); + expect(store.getState().pendingIntents[0]?.acceptedSequence).toBe(9); + store.getState().acceptConfirmedLayout(layout(9, [], [threadA])); + + // Then: the accepted intent is finally cleared. + expect(store.getState().pendingIntents).toEqual([]); + }); + + it("rebases displayed selectors over a newer remote layout", async () => { + // Given: a confirmed layout plus an optimistic local move-to-end. + const transport = new ControllableTransport(); + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + store.getState().setLifecycle(lifecycle); + store.getState().acceptConfirmedLayout(layout(1, [projectA, projectB, projectC])); + store.getState().enqueue({ + type: "sidebar-layout.project.move", + projectId: projectA, + beforeProjectId: null, + }); + + // When: a remote command changes canonical order at revision 2. + store.getState().acceptConfirmedLayout(layout(2, [projectC, projectA, projectB], [threadA])); + + // Then: selectors replay the pending local intent over the remote canonical order. + expect(selectDisplayedProjectOrder(store.getState())).toEqual([projectC, projectB, projectA]); + expect(selectDisplayedPinnedThreadOrder(store.getState())).toEqual([threadA]); + }); + + it("does not regress confirmed state on stale or equal conflicting revisions", () => { + // Given: revision 5 is confirmed. + const store = createSidebarLayoutStore({ dispatchCommand: async () => ({ sequence: 1 }) }); + const confirmed = layout(5, [projectA, projectB]); + store.getState().acceptConfirmedLayout(confirmed); + + // When: stale, equal-conflicting, and null reconnect values arrive. + store.getState().acceptConfirmedLayout(layout(4, [projectB, projectA])); + store.getState().acceptConfirmedLayout(layout(5, [projectB, projectA])); + store.getState().acceptConfirmedLayout(null); + + // Then: the original canonical object remains authoritative. + expect(store.getState().confirmedLayout).toBe(confirmed); + }); + + it("starts each store session without persisted layout authority", () => { + // Given: one session with confirmed and pending layout state. + const dependencies = { dispatchCommand: async () => ({ sequence: 1 }) }; + const firstSession = createSidebarLayoutStore(dependencies); + firstSession.getState().acceptConfirmedLayout(layout(1)); + firstSession.getState().enqueue({ + type: "sidebar-layout.thread.unpin", + threadId: threadA, + }); + + // When: a new session store is constructed. + const nextSession = createSidebarLayoutStore(dependencies); + + // Then: neither confirmed nor optimistic authority is restored. + expect(nextSession.getState().confirmedLayout).toBeNull(); + expect(nextSession.getState().pendingIntents).toEqual([]); + expect("snapshotSequence" in nextSession.getState()).toBe(false); + }); + + it("retries a lost response with the same command ID and ignores the late attempt", async () => { + // Given: the first command response is lost while a second intent waits. + const transport = new ControllableTransport(); + const ids = [commandA, commandB]; + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => ids.shift() ?? CommandId.makeUnsafe("unexpected-command"), + }); + store.getState().enqueue({ + type: "sidebar-layout.project.move", + projectId: projectA, + beforeProjectId: null, + }); + store.getState().enqueue({ + type: "sidebar-layout.project.move", + projectId: projectB, + beforeProjectId: projectA, + }); + await flushDispatch(); + + // When: the unresolved dispatch is retried and its receipt arrives. + expect(store.getState().retryInFlight()).toBe(true); + await flushDispatch(); + expect(transport.commands.map((command) => command.commandId)).toEqual([commandA, commandA]); + transport.resolveAttempt(1, 10); + await flushDispatch(); + + // Then: the queue continues and a late response cannot replace the recovered receipt. + expect(transport.commands[2]?.commandId).toBe(commandB); + transport.resolveAttempt(0, 99); + await flushDispatch(); + expect(store.getState().pendingIntents[0]?.acceptedSequence).toBe(10); + }); + + it("adopts an initialized canonical layout when the initialize receipt was lost", async () => { + // Given: an initialize request remains in flight without a receipt. + const transport = new ControllableTransport(); + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + store.getState().enqueue({ + type: "sidebar-layout.initialize", + projectOrder: [projectB, projectA], + pinnedThreadOrder: [threadA], + }); + await flushDispatch(); + + // When: a reconnect snapshot proves the server has initialized canonically. + store.getState().acceptConfirmedLayout(layout(11, [projectA, projectB])); + + // Then: the canonical layout is adopted without leaving a hung initializer. + expect(store.getState().pendingIntents).toEqual([]); + expect(store.getState().inFlightCommandId).toBeNull(); + }); + + it("clears the rejection observer when adopting a lost initializer", async () => { + // Given: an initializer with a rejection observer remains in flight without a receipt. + const transport = new ControllableTransport(); + const rejectedInitializers: unknown[] = []; + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + store.getState().enqueue( + { + type: "sidebar-layout.initialize", + projectOrder: [projectB, projectA], + pinnedThreadOrder: [threadA], + }, + { onRejected: (error) => rejectedInitializers.push(error) }, + ); + await flushDispatch(); + store.getState().acceptConfirmedLayout(layout(11, [projectA, projectB])); + + // When: the same command ID is reused for a later command without an observer and rejected. + store.getState().enqueue({ type: "sidebar-layout.thread.unpin", threadId: threadA }); + await flushDispatch(); + transport.rejectAttempt(1, "later_rejection"); + await flushDispatch(); + + // Then: the removed initializer's observer cannot leak into the later command lifecycle. + expect(rejectedInitializers).toEqual([]); + }); + + it("rolls back a rejected intent and continues the sequential queue", async () => { + // Given: two optimistic moves over a confirmed canonical layout. + const transport = new ControllableTransport(); + const ids = [commandA, commandB]; + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => ids.shift() ?? CommandId.makeUnsafe("unexpected-command"), + }); + store.getState().setLifecycle(lifecycle); + store.getState().acceptConfirmedLayout(layout(1, [projectA, projectB, projectC])); + store.getState().enqueue({ + type: "sidebar-layout.project.move", + projectId: projectA, + beforeProjectId: null, + }); + store.getState().enqueue({ + type: "sidebar-layout.project.move", + projectId: projectB, + beforeProjectId: null, + }); + await flushDispatch(); + + // When: the first RPC is explicitly rejected. + transport.rejectAttempt(0, "stale_state"); + await flushDispatch(); + + // Then: its optimistic effect is gone and the second exact command is dispatched. + expect(store.getState().pendingIntents.map((pending) => pending.commandId)).toEqual([commandB]); + expect(selectDisplayedProjectOrder(store.getState())).toEqual([projectA, projectC, projectB]); + expect(transport.commands[1]?.commandId).toBe(commandB); + }); + + it("notifies a rejected project move after canonical rollback and continues without compensation", async () => { + // Given: one project move with a rejection observer and a second queued move. + const transport = new ControllableTransport(); + const ids = [commandA, commandB]; + const rejectedDisplayedOrders: Array = []; + const rejectedConfirmedOrders: Array = []; + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => ids.shift() ?? CommandId.makeUnsafe("unexpected-command"), + }); + store.getState().setLifecycle(lifecycle); + store.getState().acceptConfirmedLayout(layout(1, [projectA, projectB, projectC])); + store.getState().enqueue( + { + type: "sidebar-layout.project.move", + projectId: projectA, + beforeProjectId: null, + }, + { + onRejected: () => { + rejectedDisplayedOrders.push(selectDisplayedProjectOrder(store.getState())); + rejectedConfirmedOrders.push(store.getState().confirmedLayout?.projectOrder ?? []); + }, + }, + ); + store.getState().enqueue({ + type: "sidebar-layout.project.move", + projectId: projectB, + beforeProjectId: null, + }); + await flushDispatch(); + + // When: the first project move is rejected. + transport.rejectAttempt(0, "stale_state"); + await flushDispatch(); + + // Then: its observer sees canonical state with only the queued move replayed, which proceeds once. + expect(rejectedConfirmedOrders).toEqual([[projectA, projectB, projectC]]); + expect(rejectedDisplayedOrders).toEqual([[projectA, projectC, projectB]]); + expect(store.getState().pendingIntents.map((pending) => pending.commandId)).toEqual([commandB]); + expect(transport.commands.map((command) => command.commandId)).toEqual([commandA, commandB]); + expect( + transport.commands.every((command) => command.type === "sidebar-layout.project.move"), + ).toBe(true); + expect(selectDisplayedProjectOrder(store.getState())).toEqual([projectA, projectC, projectB]); + }); + + it("isolates a throwing rejection observer and continues the sequential queue", async () => { + // Given: a rejected first command whose caller observer throws and a second queued command. + const transport = new ControllableTransport(); + const ids = [commandA, commandB]; + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => ids.shift() ?? CommandId.makeUnsafe("unexpected-command"), + }); + store.getState().enqueue( + { type: "sidebar-layout.thread.pin", threadId: threadA, beforeThreadId: null }, + { + onRejected: () => { + throw new Error("observer_exploded"); + }, + }, + ); + store.getState().enqueue({ type: "sidebar-layout.thread.unpin", threadId: threadA }); + await flushDispatch(); + + // When: the first RPC rejects and invokes the throwing observer. + transport.rejectAttempt(0, "stale_state"); + await flushDispatch(); + + // Then: the observer is contained, no rejected promise escapes, and dispatch continues. + expect(transport.commands[1]?.commandId).toBe(commandB); + }); + + it("dispatches exactly one unpin while a stale shell layout arrives in flight", async () => { + // Given: canonical membership contains the thread and the unpin response is held. + const transport = new ControllableTransport(); + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + store.getState().setLifecycle(lifecycle); + store.getState().acceptConfirmedLayout(layout(1, [projectA, projectB], [threadA])); + + // When: unpin is optimistic and an unrelated shell update still carries the old pin. + store.getState().enqueue({ type: "sidebar-layout.thread.unpin", threadId: threadA }); + await flushDispatch(); + store.getState().acceptConfirmedLayout(layout(2, [projectB, projectA], [threadA, threadB])); + await flushDispatch(); + + // Then: replay keeps the thread unpinned and no mirror command is generated. + expect(selectDisplayedPinnedThreadOrder(store.getState())).toEqual([threadB]); + expect(transport.commands).toHaveLength(1); + expect(transport.commands[0]?.type).toBe("sidebar-layout.thread.unpin"); + expect( + transport.commands.filter((command) => command.type === "sidebar-layout.thread.pin"), + ).toEqual([]); + }); + + it("rolls back a rejected pin intent before notifying its caller", async () => { + // Given: a canonical unpinned thread and a caller-owned rejection observer. + const transport = new ControllableTransport(); + const rejectedDisplayedOrders: Array = []; + const store = createSidebarLayoutStore({ + dispatchCommand: transport.dispatchCommand, + createCommandId: () => commandA, + }); + store.getState().setLifecycle(lifecycle); + store.getState().acceptConfirmedLayout(layout(1, [projectA, projectB], [])); + + // When: the optimistic pin is rejected. + store.getState().enqueue( + { type: "sidebar-layout.thread.pin", threadId: threadA, beforeThreadId: null }, + { + onRejected: () => { + rejectedDisplayedOrders.push(selectDisplayedPinnedThreadOrder(store.getState())); + }, + }, + ); + await flushDispatch(); + transport.rejectAttempt(0, "thread_not_found"); + await flushDispatch(); + + // Then: the callback sees canonical rollback and no compensation is dispatched. + expect(rejectedDisplayedOrders).toEqual([[]]); + expect(transport.commands.map((command) => command.type)).toEqual([ + "sidebar-layout.thread.pin", + ]); + }); +}); diff --git a/apps/web/src/sidebarLayoutStore.ts b/apps/web/src/sidebarLayoutStore.ts new file mode 100644 index 000000000..f22c99c6b --- /dev/null +++ b/apps/web/src/sidebarLayoutStore.ts @@ -0,0 +1,265 @@ +import type { + CommandId, + DispatchableClientOrchestrationCommand, + DispatchResult, + SidebarLayout, +} from "@jcode/contracts"; +import { createStore } from "zustand/vanilla"; +import { newCommandId } from "./lib/utils"; +import { ensureNativeApi } from "./nativeApi"; +import type { + DisplayedSidebarLayout, + PendingSidebarLayoutIntent, + SidebarLayoutLifecycle, + SidebarLayoutIntent, +} from "./sidebarLayout.logic"; +import { + acceptConfirmedSidebarLayout, + deriveDisplayedSidebarLayout, + reconcilePendingSidebarLayoutIntents, +} from "./sidebarLayout.logic"; + +export type SidebarLayoutCommand = Extract< + DispatchableClientOrchestrationCommand, + { readonly type: SidebarLayoutIntent["type"] } +>; + +export type SidebarLayoutDispatch = (command: SidebarLayoutCommand) => Promise; + +export type SidebarLayoutStoreDependencies = { + readonly dispatchCommand: SidebarLayoutDispatch; + readonly createCommandId?: () => PendingSidebarLayoutIntent["commandId"]; +}; + +export type SidebarLayoutEnqueueOptions = { + readonly onRejected?: (error: unknown) => void; +}; + +export type SidebarLayoutStoreState = { + readonly confirmedLayout: SidebarLayout | null; + readonly pendingIntents: readonly PendingSidebarLayoutIntent[]; + readonly lifecycle: SidebarLayoutLifecycle; + readonly inFlightCommandId: CommandId | null; + readonly enqueue: ( + intent: SidebarLayoutIntent, + options?: SidebarLayoutEnqueueOptions, + ) => CommandId; + readonly acceptConfirmedLayout: (layout: SidebarLayout | null) => void; + readonly setLifecycle: (lifecycle: SidebarLayoutLifecycle) => void; + readonly retryInFlight: () => boolean; +}; + +const displayedLayoutByState = new WeakMap(); + +function sidebarLayoutLifecycleEqual( + left: SidebarLayoutLifecycle, + right: SidebarLayoutLifecycle, +): boolean { + return ( + left.projects.length === right.projects.length && + left.projects.every((project, index) => { + const candidate = right.projects[index]; + return ( + candidate !== undefined && + project.id === candidate.id && + project.kind === candidate.kind && + project.createdAt === candidate.createdAt && + project.deletedAt === candidate.deletedAt + ); + }) && + left.threads.length === right.threads.length && + left.threads.every((thread, index) => { + const candidate = right.threads[index]; + return ( + candidate !== undefined && + thread.id === candidate.id && + thread.deletedAt === candidate.deletedAt + ); + }) + ); +} + +export function selectDisplayedSidebarLayout( + state: SidebarLayoutStoreState, +): DisplayedSidebarLayout { + const cached = displayedLayoutByState.get(state); + if (cached !== undefined) { + return cached; + } + const displayed = deriveDisplayedSidebarLayout( + state.confirmedLayout, + state.pendingIntents, + state.lifecycle, + ); + displayedLayoutByState.set(state, displayed); + return displayed; +} + +export function selectDisplayedProjectOrder( + state: SidebarLayoutStoreState, +): DisplayedSidebarLayout["projectOrder"] { + return selectDisplayedSidebarLayout(state).projectOrder; +} + +export function selectDisplayedPinnedThreadOrder( + state: SidebarLayoutStoreState, +): DisplayedSidebarLayout["pinnedThreadOrder"] { + return selectDisplayedSidebarLayout(state).pinnedThreadOrder; +} + +export function createSidebarLayoutStore(dependencies: SidebarLayoutStoreDependencies) { + return createStore()((set, get) => { + let activeAttempt = 0; + const rejectionHandlers = new Map void>(); + + const dispatchAttempt = (pendingIntent: PendingSidebarLayoutIntent): void => { + const attempt = ++activeAttempt; + const command: SidebarLayoutCommand = { + ...pendingIntent.intent, + commandId: pendingIntent.commandId, + }; + void Promise.resolve() + .then(() => dependencies.dispatchCommand(command)) + .then( + (result) => { + if (attempt !== activeAttempt || get().inFlightCommandId !== pendingIntent.commandId) { + return; + } + set((state) => { + const accepted = state.pendingIntents.map((pending) => + pending.commandId === pendingIntent.commandId + ? { ...pending, acceptedSequence: result.sequence } + : pending, + ); + return { + inFlightCommandId: null, + pendingIntents: reconcilePendingSidebarLayoutIntents( + accepted, + state.confirmedLayout?.revision ?? null, + ), + }; + }); + rejectionHandlers.delete(pendingIntent.commandId); + dispatchNext(); + }, + (error: unknown) => { + if (attempt !== activeAttempt || get().inFlightCommandId !== pendingIntent.commandId) { + return; + } + set((state) => ({ + inFlightCommandId: null, + pendingIntents: state.pendingIntents.filter( + (pending) => pending.commandId !== pendingIntent.commandId, + ), + })); + const onRejected = rejectionHandlers.get(pendingIntent.commandId); + rejectionHandlers.delete(pendingIntent.commandId); + try { + onRejected?.(error); + } catch { + return; + } finally { + dispatchNext(); + } + }, + ); + }; + + const dispatchNext = (): void => { + if (get().inFlightCommandId !== null) { + return; + } + const next = get().pendingIntents.find((pending) => pending.acceptedSequence === undefined); + if (next === undefined) { + return; + } + set({ inFlightCommandId: next.commandId }); + dispatchAttempt(next); + }; + + return { + confirmedLayout: null, + pendingIntents: [], + lifecycle: { projects: [], threads: [] }, + inFlightCommandId: null, + enqueue: (intent, options) => { + const commandId = (dependencies.createCommandId ?? newCommandId)(); + if (options?.onRejected !== undefined) { + rejectionHandlers.set(commandId, options.onRejected); + } + set((state) => ({ + pendingIntents: [...state.pendingIntents, { commandId, intent }], + })); + dispatchNext(); + return commandId; + }, + acceptConfirmedLayout: (incoming) => { + if (incoming === null) { + return; + } + const current = get(); + const lostInitializer = current.pendingIntents.find( + (pending) => + pending.commandId === current.inFlightCommandId && + pending.intent.type === "sidebar-layout.initialize" && + pending.acceptedSequence === undefined, + ); + if (lostInitializer !== undefined) { + activeAttempt += 1; + rejectionHandlers.delete(lostInitializer.commandId); + } + set((state) => { + const confirmedLayout = acceptConfirmedSidebarLayout(state.confirmedLayout, incoming); + const pendingIntents = + lostInitializer === undefined + ? state.pendingIntents + : state.pendingIntents.filter( + (pending) => pending.commandId !== lostInitializer.commandId, + ); + const reconciledPendingIntents = reconcilePendingSidebarLayoutIntents( + pendingIntents, + confirmedLayout.revision, + ); + if ( + confirmedLayout === state.confirmedLayout && + reconciledPendingIntents.length === state.pendingIntents.length && + reconciledPendingIntents.every( + (pending, index) => pending === state.pendingIntents[index], + ) && + lostInitializer === undefined + ) { + return state; + } + return { + confirmedLayout, + pendingIntents: reconciledPendingIntents, + ...(lostInitializer === undefined ? {} : { inFlightCommandId: null }), + }; + }); + if (lostInitializer !== undefined) { + dispatchNext(); + } + }, + setLifecycle: (lifecycle) => + set((state) => + sidebarLayoutLifecycleEqual(state.lifecycle, lifecycle) ? state : { lifecycle }, + ), + retryInFlight: () => { + const commandId = get().inFlightCommandId; + if (commandId === null) { + return false; + } + const pending = get().pendingIntents.find((item) => item.commandId === commandId); + if (pending === undefined) { + return false; + } + dispatchAttempt(pending); + return true; + }, + }; + }); +} + +export const sidebarLayoutStore = createSidebarLayoutStore({ + dispatchCommand: (command) => ensureNativeApi().orchestration.dispatchCommand(command), +}); diff --git a/apps/web/src/storageKeyMigration.test.ts b/apps/web/src/storageKeyMigration.test.ts index dcfa2258f..736831e36 100644 --- a/apps/web/src/storageKeyMigration.test.ts +++ b/apps/web/src/storageKeyMigration.test.ts @@ -98,10 +98,68 @@ describe("storageKeyMigration", () => { await importMigrationFresh(); expect(globalThis.localStorage.getItem("jcode:composer-drafts:v1")).toBe("drafts"); - expect(globalThis.localStorage.getItem("jcode:pinned-threads:v1")).toBe("pinned"); + expect(globalThis.localStorage.getItem("jcode:pinned-threads:v1")).toBeNull(); expect(globalThis.localStorage.getItem("jcode:last-editor")).toBe("vscode"); }); + it("never copies a legacy pinned-thread payload into current storage", async () => { + // Given + const sourceKey = "dpcode:pinned-threads:v1"; + const destinationKey = "jcode:pinned-threads:v1"; + const legacyPayload = JSON.stringify({ state: { pinnedThreadIds: ["thread-2"] }, version: 0 }); + globalThis.localStorage.setItem(sourceKey, legacyPayload); + + // When + await importMigrationFresh(); + + // Then + expect(globalThis.localStorage.getItem(sourceKey)).toBe(legacyPayload); + expect(globalThis.localStorage.getItem(destinationKey)).toBeNull(); + }); + + it("does not resurrect legacy pin or project-order authority after migration is marked", async () => { + // Given + globalThis.localStorage.setItem("jcode:sidebar-layout-migrated:v1", "1"); + globalThis.localStorage.setItem("dpcode:pinned-threads:v1", "legacy pins"); + globalThis.localStorage.setItem( + "t3code:renderer-state:v8", + JSON.stringify({ projectOrderCwds: ["/stale/project"] }), + ); + + // When + await importMigrationFresh(); + + // Then + expect(globalThis.localStorage.getItem("jcode:pinned-threads:v1")).toBeNull(); + expect( + JSON.parse(globalThis.localStorage.getItem("jcode:renderer-state:v8") ?? "null"), + ).toEqual({}); + }); + + it("migrates marked legacy renderer presentation without restoring project order", async () => { + // Given: a marked old profile only has a T3Code renderer payload. + globalThis.localStorage.setItem("jcode:sidebar-layout-migrated:v1", "1"); + globalThis.localStorage.setItem( + "t3code:renderer-state:v8", + JSON.stringify({ + expandedProjectCwds: ["/repo/a"], + projectNamesByCwd: { "/repo/a": "Local A" }, + projectOrderCwds: ["/repo/a"], + }), + ); + + // When: namespace bootstrap runs. + await importMigrationFresh(); + + // Then: presentation migrates to JCode while ordering authority stays retired. + expect( + JSON.parse(globalThis.localStorage.getItem("jcode:renderer-state:v8") ?? "null"), + ).toEqual({ + expandedProjectCwds: ["/repo/a"], + projectNamesByCwd: { "/repo/a": "Local A" }, + }); + }); + it("swallows storage errors so the app can still boot", async () => { const failingStorage = { getItem: () => { diff --git a/apps/web/src/storageKeyMigration.ts b/apps/web/src/storageKeyMigration.ts index c27310634..65e793ac1 100644 --- a/apps/web/src/storageKeyMigration.ts +++ b/apps/web/src/storageKeyMigration.ts @@ -3,6 +3,11 @@ // Layer: Web bootstrap utility // Exports: migrateJCodeLocalStorageKeys +import { + removeSidebarLayoutLegacyProjectOrder, + SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY, +} from "./sidebarLayoutLegacyMigration"; + // DPCode/T3Code keys are compatibility inputs only. Leave legacy keys intact so // users can downgrade during the rebrand window without losing browser state. const STORAGE_KEY_MIGRATIONS = [ @@ -17,7 +22,6 @@ const STORAGE_KEY_MIGRATIONS = [ [["dpcode:terminal-state:v1", "t3code:terminal-state:v1"], "jcode:terminal-state:v1"], [["dpcode:latest-project:v1", "t3code:latest-project:v1"], "jcode:latest-project:v1"], [["dpcode:app-settings:v1", "t3code:app-settings:v1"], "jcode:app-settings:v1"], - [["dpcode:pinned-threads:v1", "t3code:pinned-threads:v1"], "jcode:pinned-threads:v1"], [["dpcode:browser-state:v1", "t3code:browser-state:v1"], "jcode:browser-state:v1"], [["dpcode:workspace-pages:v2", "t3code:workspace-pages:v2"], "jcode:workspace-pages:v2"], [["dpcode:theme", "t3code:theme"], "jcode:theme"], @@ -42,6 +46,7 @@ export function migrateJCodeLocalStorageKeys(): void { } try { + const sidebarLayoutMigrated = storage.getItem(SIDEBAR_LAYOUT_MIGRATION_MARKER_KEY) !== null; for (const [legacyKeys, nextKey] of STORAGE_KEY_MIGRATIONS) { if (storage.getItem(nextKey) !== null) { continue; @@ -50,7 +55,13 @@ export function migrateJCodeLocalStorageKeys(): void { .map((legacyKey) => storage.getItem(legacyKey)) .find((value): value is string => value !== null); if (legacyValue !== undefined) { - storage.setItem(nextKey, legacyValue); + const nextValue = + sidebarLayoutMigrated && nextKey === "jcode:renderer-state:v8" + ? removeSidebarLayoutLegacyProjectOrder(legacyValue) + : legacyValue; + if (nextValue !== null) { + storage.setItem(nextKey, nextValue); + } } } } catch { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index a7ae8a9db..7f86d2a35 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -21,7 +21,6 @@ import { collapseProjectsExcept, markThreadUnread, renameProjectLocally, - reorderProjects, setThreadWorkspace, setAllProjectsExpanded, syncServerReadModel, @@ -172,6 +171,7 @@ function makeReadModelThread(overrides: Partial { }, { snapshotSequence: 1, + sidebarLayout: null, updatedAt: "2026-02-27T00:00:00.000Z", projects: [ makeReadModelProject({ @@ -1518,44 +1519,6 @@ describe("store pure functions", () => { expect(next.threads[0]?.latestTurn?.turnId).toBe(TurnId.makeUnsafe("turn-1")); }); - it("reorderProjects moves a project to a target index", () => { - const project1 = ProjectId.makeUnsafe("project-1"); - const project2 = ProjectId.makeUnsafe("project-2"); - const project3 = ProjectId.makeUnsafe("project-3"); - const state: AppState = { - projects: [ - makeProject({ - id: project1, - name: "Project 1", - remoteName: "Project 1", - folderName: "project-1", - cwd: "/tmp/project-1", - }), - makeProject({ - id: project2, - name: "Project 2", - remoteName: "Project 2", - folderName: "project-2", - cwd: "/tmp/project-2", - }), - makeProject({ - id: project3, - name: "Project 3", - remoteName: "Project 3", - folderName: "project-3", - cwd: "/tmp/project-3", - }), - ], - threads: [], - sidebarThreadSummaryById: {}, - threadsHydrated: true, - }; - - const next = reorderProjects(state, project1, project3); - - expect(next.projects.map((project) => project.id)).toEqual([project2, project3, project1]); - }); - it("expands every project when toggled on", () => { const project1 = ProjectId.makeUnsafe("project-1"); const project2 = ProjectId.makeUnsafe("project-2"); @@ -2726,7 +2689,7 @@ describe("store read model sync", () => { expect(next.sidebarThreadSummaryById["thread-1"]?.archivedAt).toBeNull(); }); - it("preserves the current project order when syncing incoming read model updates", () => { + it("maps incoming projects without treating the previous local array order as authority", () => { const project1 = ProjectId.makeUnsafe("project-1"); const project2 = ProjectId.makeUnsafe("project-2"); const project3 = ProjectId.makeUnsafe("project-3"); @@ -2753,6 +2716,7 @@ describe("store read model sync", () => { }; const readModel: OrchestrationReadModel = { snapshotSequence: 2, + sidebarLayout: null, updatedAt: "2026-02-27T00:00:00.000Z", projects: [ makeReadModelProject({ @@ -2776,7 +2740,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.projects.map((project) => project.id)).toEqual([project2, project1, project3]); + expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]); }); it("preserves expanded project state when a project briefly disappears from the snapshot", () => { @@ -2806,6 +2770,7 @@ describe("store read model sync", () => { const snapshotWithoutProject2: OrchestrationReadModel = { snapshotSequence: 2, + sidebarLayout: null, updatedAt: "2026-02-27T00:00:00.000Z", projects: [ makeReadModelProject({ @@ -2818,6 +2783,7 @@ describe("store read model sync", () => { }; const snapshotWithProject2Restored: OrchestrationReadModel = { snapshotSequence: 3, + sidebarLayout: null, updatedAt: "2026-02-27T00:01:00.000Z", projects: [ makeReadModelProject({ @@ -2980,9 +2946,169 @@ describe("store read model sync", () => { } }); + it("does not create browser-owned project order while persisting presentation state", async () => { + // Given: a fresh profile with a hydrated project list. + const storage = new Map(); + const fakeWindow = { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear(), + }, + addEventListener: vi.fn(), + }; + vi.stubGlobal("window", fakeWindow); + try { + vi.resetModules(); + const freshStore = await import("./store"); + freshStore.useStore.setState((state) => ({ + ...state, + projects: [makeProject({ cwd: "/tmp/project", expanded: true })], + })); + + // When: device-local presentation state is flushed. + freshStore.persistAppStateNow(); + + // Then: expansion is retained without deriving an order from the project array. + expect(JSON.parse(storage.get("jcode:renderer-state:v8") ?? "{}")).toEqual({ + expandedProjectCwds: ["/tmp/project"], + projectNamesByCwd: {}, + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("preserves an unconfirmed legacy order without reading or updating it as authority", async () => { + // Given: migration candidates exist before the server has confirmed initialization. + const storage = new Map(); + storage.set( + "jcode:renderer-state:v8", + JSON.stringify({ + expandedProjectCwds: ["/legacy"], + projectOrderCwds: ["/legacy/b", "/legacy/a"], + projectNamesByCwd: { "/legacy/a": "Legacy A" }, + }), + ); + const fakeWindow = { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear(), + }, + addEventListener: vi.fn(), + }; + vi.stubGlobal("window", fakeWindow); + try { + vi.resetModules(); + const freshStore = await import("./store"); + freshStore.useStore.setState((state) => ({ + ...state, + projects: [makeProject({ cwd: "/tmp/current", expanded: true })], + })); + + // When: presentation changes are persisted before migration confirmation. + freshStore.persistAppStateNow(); + + // Then: the candidate field is byte-for-value preserved, not replaced by current UI order. + expect(JSON.parse(storage.get("jcode:renderer-state:v8") ?? "{}")).toMatchObject({ + projectOrderCwds: ["/legacy/b", "/legacy/a"], + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("preserves a mixed legacy order and presentation fields before project hydration", async () => { + // Given: an old flat renderer payload contains valid candidates mixed with malformed entries. + const storage = new Map(); + storage.set( + "jcode:renderer-state:v8", + JSON.stringify({ + expandedProjectCwds: ["/legacy/a"], + projectOrderCwds: ["/legacy/b", 42, "/legacy/a", null], + projectNamesByCwd: { "/legacy/a": "Legacy A" }, + }), + ); + const fakeWindow = { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear(), + }, + addEventListener: vi.fn(), + }; + vi.stubGlobal("window", fakeWindow); + try { + vi.resetModules(); + const freshStore = await import("./store"); + + // When: ordinary presentation persistence runs before the first project snapshot. + freshStore.persistAppStateNow(); + + // Then: migration input is unchanged and device-local presentation survives hydration wait. + expect(JSON.parse(storage.get("jcode:renderer-state:v8") ?? "{}")).toEqual({ + expandedProjectCwds: ["/legacy/a"], + projectNamesByCwd: { "/legacy/a": "Legacy A" }, + projectOrderCwds: ["/legacy/b", 42, "/legacy/a", null], + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("preserves Zustand-envelope migration input and presentation fields before hydration", async () => { + // Given: a supported persisted envelope contains legacy authority and presentation state. + const storage = new Map(); + storage.set( + "jcode:renderer-state:v8", + JSON.stringify({ + state: { + expandedProjectCwds: ["/legacy/a"], + projectOrderCwds: ["/legacy/b", false, "/legacy/a"], + projectNamesByCwd: { "/legacy/a": "Legacy A" }, + }, + version: 8, + }), + ); + const fakeWindow = { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear(), + }, + addEventListener: vi.fn(), + }; + vi.stubGlobal("window", fakeWindow); + try { + vi.resetModules(); + const freshStore = await import("./store"); + + // When: persistence runs before the server project snapshot arrives. + freshStore.persistAppStateNow(); + + // Then: the envelope and candidate field remain migration-only while presentation survives. + expect(JSON.parse(storage.get("jcode:renderer-state:v8") ?? "{}")).toEqual({ + state: { + expandedProjectCwds: ["/legacy/a"], + projectNamesByCwd: { "/legacy/a": "Legacy A" }, + projectOrderCwds: ["/legacy/b", false, "/legacy/a"], + }, + version: 8, + }); + } finally { + vi.unstubAllGlobals(); + } + }); + it("reuses normalized thread objects when the incoming snapshot is unchanged", () => { const readModel = { snapshotSequence: 1, + sidebarLayout: null, updatedAt: "2026-02-28T00:00:00.000Z", projects: [ makeReadModelProject({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index ee7d7051f..fc447919d 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -78,13 +78,6 @@ type ThreadUserInputResponseRequestedEvent = Extract< const PERSISTED_STATE_KEY = "jcode:renderer-state:v8"; const LEGACY_PERSISTED_STATE_KEYS = [ - "dpcode:renderer-state:v8", - "t3code:renderer-state:v8", - "t3code:renderer-state:v7", - "t3code:renderer-state:v6", - "t3code:renderer-state:v5", - "t3code:renderer-state:v4", - "t3code:renderer-state:v3", "codething:renderer-state:v4", "codething:renderer-state:v3", "codething:renderer-state:v2", @@ -139,7 +132,6 @@ const initialState: AppState = { turnDiffSummaryByThreadId: {}, }; const persistedExpandedProjectCwds = new Set(); -const persistedProjectOrderCwds: string[] = []; const persistedProjectNamesByCwd = new Map(); function projectCwdKey(cwd: string): string { @@ -151,7 +143,9 @@ function basenameOfPath(value: string): string | null { return segments.at(-1) ?? null; } -function rememberProjectUiState(projects: ReadonlyArray>): void { +function rememberProjectExpansionState( + projects: ReadonlyArray>, +): void { for (const project of projects) { const cwdKey = projectCwdKey(project.cwd); if (project.expanded) { @@ -159,10 +153,69 @@ function rememberProjectUiState(projects: ReadonlyArray; + readonly state: Record; + readonly envelope: boolean; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseRendererStorage(raw: string | null): ParsedRendererStorage | null { + if (raw === null) { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed)) { + return null; + } + const nestedState = parsed["state"]; + if (isRecord(nestedState)) { + return { root: parsed, state: nestedState, envelope: true }; } + return { root: parsed, state: parsed, envelope: false }; + } catch (error) { + if (error instanceof SyntaxError) { + return null; + } + throw error; + } +} + +function storedStringItems(value: unknown): readonly string[] { + return Array.isArray(value) + ? value.filter((candidate): candidate is string => typeof candidate === "string") + : []; +} + +function serializeRendererPresentation( + existing: ParsedRendererStorage | null, + presentation: { + readonly expandedProjectCwds: readonly string[]; + readonly projectNamesByCwd: Readonly>; + }, +): string { + if (existing === null) { + return JSON.stringify(presentation); } + + const { projectOrderCwds, ...deviceLocalState } = existing.state; + const nextState = { + ...deviceLocalState, + ...presentation, + ...(Array.isArray(projectOrderCwds) ? { projectOrderCwds } : {}), + }; + if (!existing.envelope) { + return JSON.stringify(nextState); + } + const { state: _state, ...envelope } = existing.root; + return JSON.stringify({ ...envelope, state: nextState }); } function rememberProjectLocalNames( @@ -186,26 +239,19 @@ function readPersistedState(): AppState { try { const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); if (!raw) return initialState; - const parsed = JSON.parse(raw) as { - expandedProjectCwds?: string[]; - projectOrderCwds?: string[]; - projectNamesByCwd?: Record; - }; + const parsed = parseRendererStorage(raw); + if (parsed === null) return initialState; persistedExpandedProjectCwds.clear(); - persistedProjectOrderCwds.length = 0; persistedProjectNamesByCwd.clear(); - for (const cwd of parsed.expandedProjectCwds ?? []) { - if (typeof cwd === "string" && cwd.length > 0) { + for (const cwd of storedStringItems(parsed.state["expandedProjectCwds"])) { + if (cwd.length > 0) { persistedExpandedProjectCwds.add(projectCwdKey(cwd)); } } - for (const cwd of parsed.projectOrderCwds ?? []) { - const cwdKey = typeof cwd === "string" ? projectCwdKey(cwd) : ""; - if (cwdKey.length > 0 && !persistedProjectOrderCwds.includes(cwdKey)) { - persistedProjectOrderCwds.push(cwdKey); - } - } - for (const [cwd, name] of Object.entries(parsed.projectNamesByCwd ?? {})) { + const storedProjectNames = parsed.state["projectNamesByCwd"]; + for (const [cwd, name] of Object.entries( + isRecord(storedProjectNames) ? storedProjectNames : {}, + )) { if (typeof cwd !== "string" || cwd.length === 0) continue; if (typeof name !== "string") continue; const trimmedName = name.trim(); @@ -223,15 +269,17 @@ let legacyKeysCleanedUp = false; function persistState(state: AppState): void { if (typeof window === "undefined") return; try { - rememberProjectUiState(state.projects); + rememberProjectExpansionState(state.projects); rememberProjectLocalNames(state.projects); + const existing = parseRendererStorage(window.localStorage.getItem(PERSISTED_STATE_KEY)); + const expandedProjectCwds = + state.threadsHydrated || state.projects.length > 0 + ? state.projects.filter((project) => project.expanded).map((project) => project.cwd) + : [...persistedExpandedProjectCwds]; window.localStorage.setItem( PERSISTED_STATE_KEY, - JSON.stringify({ - expandedProjectCwds: state.projects - .filter((project) => project.expanded) - .map((project) => project.cwd), - projectOrderCwds: state.projects.map((project) => project.cwd), + serializeRendererPresentation(existing, { + expandedProjectCwds, projectNamesByCwd: Object.fromEntries(persistedProjectNamesByCwd), }), ); @@ -1827,39 +1875,11 @@ function mapProjectsFromReadModel( const previousByCwd = new Map( previous.map((project) => [projectCwdKey(project.cwd), project] as const), ); - const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const)); - const previousOrderByCwd = new Map( - previous.map((project, index) => [projectCwdKey(project.cwd), index] as const), - ); - const persistedOrderByCwd = new Map( - persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), - ); - const usePersistedOrder = previous.length === 0; - - const mappedProjects = incoming - .map((project) => { - const existing = - previousById.get(project.id) ?? previousByCwd.get(projectCwdKey(project.workspaceRoot)); - return normalizeProjectFromReadModel(project, existing); - }) - .map((project, incomingIndex) => { - const previousIndex = - previousOrderById.get(project.id) ?? previousOrderByCwd.get(projectCwdKey(project.cwd)); - const persistedIndex = usePersistedOrder - ? persistedOrderByCwd.get(projectCwdKey(project.cwd)) - : undefined; - const orderIndex = - previousIndex ?? - persistedIndex ?? - (usePersistedOrder ? persistedProjectOrderCwds.length : previous.length) + incomingIndex; - return { project, incomingIndex, orderIndex }; - }) - .toSorted((a, b) => { - const byOrder = a.orderIndex - b.orderIndex; - if (byOrder !== 0) return byOrder; - return a.incomingIndex - b.incomingIndex; - }) - .map((entry) => entry.project); + const mappedProjects = incoming.map((project) => { + const existing = + previousById.get(project.id) ?? previousByCwd.get(projectCwdKey(project.workspaceRoot)); + return normalizeProjectFromReadModel(project, existing); + }); return arraysShallowEqual(previous, mappedProjects) ? previous : mappedProjects; } @@ -1872,39 +1892,11 @@ function mapProjectsFromShellSnapshot( const previousByCwd = new Map( previous.map((project) => [projectCwdKey(project.cwd), project] as const), ); - const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const)); - const previousOrderByCwd = new Map( - previous.map((project, index) => [projectCwdKey(project.cwd), index] as const), - ); - const persistedOrderByCwd = new Map( - persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), - ); - const usePersistedOrder = previous.length === 0; - - const mappedProjects = incoming - .map((project) => { - const existing = - previousById.get(project.id) ?? previousByCwd.get(projectCwdKey(project.workspaceRoot)); - return normalizeProjectFromShell(project, existing); - }) - .map((project, incomingIndex) => { - const previousIndex = - previousOrderById.get(project.id) ?? previousOrderByCwd.get(projectCwdKey(project.cwd)); - const persistedIndex = usePersistedOrder - ? persistedOrderByCwd.get(projectCwdKey(project.cwd)) - : undefined; - const orderIndex = - previousIndex ?? - persistedIndex ?? - (usePersistedOrder ? persistedProjectOrderCwds.length : previous.length) + incomingIndex; - return { project, incomingIndex, orderIndex }; - }) - .toSorted((a, b) => { - const byOrder = a.orderIndex - b.orderIndex; - if (byOrder !== 0) return byOrder; - return a.incomingIndex - b.incomingIndex; - }) - .map((entry) => entry.project); + const mappedProjects = incoming.map((project) => { + const existing = + previousById.get(project.id) ?? previousByCwd.get(projectCwdKey(project.workspaceRoot)); + return normalizeProjectFromShell(project, existing); + }); return arraysShallowEqual(previous, mappedProjects) ? previous : mappedProjects; } @@ -3622,7 +3614,7 @@ export function syncServerShellSnapshot( state: AppState, snapshot: OrchestrationShellSnapshot, ): AppState { - rememberProjectUiState(state.projects); + rememberProjectExpansionState(state.projects); rememberProjectLocalNames(state.projects); const projects = mapProjectsFromShellSnapshot(snapshot.projects, state.projects); const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); @@ -3733,11 +3725,13 @@ export function applyShellEvent(state: AppState, event: OrchestrationShellStream } case "thread-removed": return removeThreadState(state, event.threadId); + case "sidebar-layout-updated": + return state; } } export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { - rememberProjectUiState(state.projects); + rememberProjectExpansionState(state.projects); rememberProjectLocalNames(state.projects); const projects = mapProjectsFromReadModel( readModel.projects.filter((project) => project.deletedAt === null), @@ -3897,22 +3891,6 @@ export function collapseProjectsExcept( return changed ? { ...state, projects } : state; } -export function reorderProjects( - state: AppState, - draggedProjectId: Project["id"], - targetProjectId: Project["id"], -): AppState { - if (draggedProjectId === targetProjectId) return state; - const draggedIndex = state.projects.findIndex((project) => project.id === draggedProjectId); - const targetIndex = state.projects.findIndex((project) => project.id === targetProjectId); - if (draggedIndex < 0 || targetIndex < 0) return state; - const projects = [...state.projects]; - const [draggedProject] = projects.splice(draggedIndex, 1); - if (!draggedProject) return state; - projects.splice(targetIndex, 0, draggedProject); - return { ...state, projects }; -} - export function renameProjectLocally( state: AppState, projectId: Project["id"], @@ -4026,7 +4004,6 @@ interface AppStore extends AppState { setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; setAllProjectsExpanded: (expanded: boolean) => void; collapseProjectsExcept: (activeProjectId: Project["id"] | null) => void; - reorderProjects: (draggedProjectId: Project["id"], targetProjectId: Project["id"]) => void; renameProjectLocally: (projectId: Project["id"], name: string | null) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadWorkspace: (threadId: ThreadId, patch: ThreadWorkspacePatch) => void; @@ -4057,8 +4034,6 @@ export const useStore = create((set) => ({ setAllProjectsExpanded: (expanded) => set((state) => setAllProjectsExpanded(state, expanded)), collapseProjectsExcept: (activeProjectId) => set((state) => collapseProjectsExcept(state, activeProjectId)), - reorderProjects: (draggedProjectId, targetProjectId) => - set((state) => reorderProjects(state, draggedProjectId, targetProjectId)), renameProjectLocally: (projectId, name) => { set((state) => renameProjectLocally(state, projectId, name)); persistAppStateNow(); @@ -4070,7 +4045,7 @@ export const useStore = create((set) => ({ // Persist state changes with debouncing to avoid localStorage thrashing useStore.subscribe((state) => { - rememberProjectUiState(state.projects); + rememberProjectExpansionState(state.projects); rememberProjectLocalNames(state.projects); debouncedPersistState.maybeExecute(state); }); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 9063b6bce..7c967bd62 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,6 +1,21 @@ /// -import type { NativeApi, DesktopBridge } from "@jcode/contracts"; +import type { DesktopBridge, NativeApi, ProjectId, ThreadId } from "@jcode/contracts"; + +declare module "vitest/browser" { + interface BrowserCommands { + dragSidebarProject: (sourceId: ProjectId, targetId: ProjectId) => Promise; + dragPinnedThread: (sourceId: ThreadId, targetId: ThreadId) => Promise; + dragPinnedThreadOutOfBounds: (sourceId: ThreadId) => Promise; + keyboardMovePinnedThread: ( + sourceId: ThreadId, + direction: "ArrowUp" | "ArrowDown", + ) => Promise; + selectProjectSortOption: (name: string) => Promise; + clickProjectThreadPin: (threadId: ThreadId) => Promise; + clickPinnedThreadUnpin: (threadId: ThreadId) => Promise; + } +} interface ImportMetaEnv { readonly APP_VERSION: string; diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts index 2d12665ef..0b0206357 100644 --- a/apps/web/vitest.browser.config.ts +++ b/apps/web/vitest.browser.config.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from "node:url"; -import { playwright } from "@vitest/browser-playwright"; +import { defineBrowserCommand, playwright } from "@vitest/browser-playwright"; import { defineConfig, mergeConfig } from "vitest/config"; import viteConfig from "./vite.config"; @@ -35,6 +35,206 @@ export default mergeConfig( }, fileParallelism: localTestProfile ? false : undefined, provider: playwright(), + commands: { + dragSidebarProject: defineBrowserCommand(async ({ frame, page }, sourceId, targetId) => { + const testerFrame = await frame(); + const source = testerFrame.locator(`button[data-sidebar-project-id="${sourceId}"]`); + const target = testerFrame.locator(`button[data-sidebar-project-id="${targetId}"]`); + const [sourceBounds, targetBounds] = await Promise.all([ + source.boundingBox(), + target.boundingBox(), + ]); + if (sourceBounds === null || targetBounds === null) { + throw new Error("Missing visible sidebar project drag activator"); + } + const sourcePoint = { + x: sourceBounds.x + sourceBounds.width / 2, + y: sourceBounds.y + sourceBounds.height / 2, + }; + const targetPoint = { + x: targetBounds.x + targetBounds.width / 2, + y: targetBounds.y + targetBounds.height / 2, + }; + await page.mouse.move(sourcePoint.x, sourcePoint.y); + await page.mouse.down(); + try { + await page.mouse.move(sourcePoint.x, sourcePoint.y + 8, { steps: 2 }); + await page.mouse.move(targetPoint.x, targetPoint.y, { steps: 8 }); + } finally { + await page.mouse.up(); + } + }), + dragPinnedThread: defineBrowserCommand(async ({ frame, page }, sourceId, targetId) => { + const testerFrame = await frame(); + const source = testerFrame.locator( + `button[data-pinned-thread-drag-handle="${sourceId}"]`, + ); + const target = testerFrame.locator(`[data-pinned-thread-id="${targetId}"]`); + const [sourceBounds, targetBounds] = await Promise.all([ + source.boundingBox(), + target.boundingBox(), + ]); + if (sourceBounds === null || targetBounds === null) { + throw new Error("Missing visible pinned-thread drag activator"); + } + await page.mouse.move( + sourceBounds.x + sourceBounds.width / 2, + sourceBounds.y + sourceBounds.height / 2, + ); + await page.mouse.down(); + try { + await page.mouse.move(sourceBounds.x, sourceBounds.y + 8, { steps: 2 }); + await page.mouse.move( + targetBounds.x + targetBounds.width / 2, + targetBounds.y + targetBounds.height / 2, + { steps: 8 }, + ); + } finally { + await page.mouse.up(); + } + }), + dragPinnedThreadOutOfBounds: defineBrowserCommand(async ({ frame, page }, sourceId) => { + const testerFrame = await frame(); + const sourceHandle = testerFrame.locator( + `button[data-pinned-thread-drag-handle="${sourceId}"]`, + ); + const sourceRow = testerFrame.locator(`[data-pinned-thread-id="${sourceId}"]`); + const list = testerFrame.locator("[data-pinned-thread-list]"); + const [sourceBounds, listBounds] = await Promise.all([ + sourceHandle.boundingBox(), + list.boundingBox(), + ]); + if (sourceBounds === null || listBounds === null) { + throw new Error("Missing pinned-thread containment bounds"); + } + const sourcePoint = { + x: sourceBounds.x + sourceBounds.width / 2, + y: sourceBounds.y + sourceBounds.height / 2, + }; + await page.mouse.move(sourcePoint.x, sourcePoint.y); + await page.mouse.down(); + try { + await page.mouse.move(sourceBounds.x, sourceBounds.y + 8, { steps: 2 }); + await testerFrame + .locator( + `button[data-pinned-thread-drag-handle="${sourceId}"][aria-pressed="true"]`, + ) + .waitFor({ state: "attached", timeout: 5_000 }); + await page.mouse.move(listBounds.x + listBounds.width + 160, sourceBounds.y + 8, { + steps: 8, + }); + await testerFrame.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + const activeBounds = await sourceRow.boundingBox(); + if (activeBounds === null) { + throw new Error("Missing active pinned-thread bounds"); + } + return ( + activeBounds.x >= listBounds.x - 0.5 && + activeBounds.x + activeBounds.width <= listBounds.x + listBounds.width + 0.5 + ); + } finally { + await page.mouse.up(); + } + }), + keyboardMovePinnedThread: defineBrowserCommand(async ({ frame }, sourceId, direction) => { + const testerFrame = await frame(); + const source = testerFrame.locator( + `button[data-pinned-thread-drag-handle="${sourceId}"]`, + ); + await source.focus(); + await source.press("Space"); + await testerFrame + .locator(`button[data-pinned-thread-drag-handle="${sourceId}"][aria-pressed="true"]`) + .waitFor({ state: "attached", timeout: 5_000 }); + let movedOverSibling = false; + for (let attempt = 0; attempt < 4; attempt += 1) { + await source.press(String(direction)); + await testerFrame.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + const announcements = await testerFrame + .locator('[aria-live="assertive"]') + .allTextContents(); + movedOverSibling = announcements.some( + (announcement) => + announcement.includes( + `Draggable item ${sourceId} was moved over droppable area`, + ) && !announcement.includes(`droppable area ${sourceId}.`), + ); + if (movedOverSibling) { + break; + } + } + if (!movedOverSibling) { + throw new Error("Keyboard drag did not announce a sibling target"); + } + await source.press("Space"); + await testerFrame + .locator(`button[data-pinned-thread-drag-handle="${sourceId}"][aria-pressed="false"]`) + .waitFor({ state: "attached", timeout: 5_000 }); + }), + selectProjectSortOption: defineBrowserCommand(async ({ frame, page }, name) => { + const testerFrame = await frame(); + const triggerCandidates = testerFrame.getByRole("button", { + name: "Sort projects", + exact: true, + }); + const triggerCount = await triggerCandidates.count(); + let visibleTriggerIndex = -1; + for (let index = 0; index < triggerCount; index += 1) { + const candidate = triggerCandidates.nth(index); + if (await candidate.isVisible()) { + visibleTriggerIndex = index; + break; + } + } + if (visibleTriggerIndex < 0) { + throw new Error("Missing visible project sort trigger"); + } + const visibleTrigger = triggerCandidates.nth(visibleTriggerIndex); + if ((await visibleTrigger.getAttribute("aria-expanded")) !== "true") { + await testerFrame + .locator('[role="menu"]:visible') + .waitFor({ state: "hidden", timeout: 5_000 }); + await visibleTrigger.click({ timeout: 5_000 }); + } + const openMenu = testerFrame.locator('[role="menu"]:visible').last(); + await openMenu.waitFor({ state: "visible", timeout: 5_000 }); + const candidate = openMenu.getByRole("menuitemradio", { name, exact: true }).first(); + await candidate.waitFor({ state: "visible", timeout: 5_000 }); + const bounds = await candidate.boundingBox(); + if (bounds === null) { + throw new Error(`Missing bounds for visible menu radio item: ${name}`); + } + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + await page.mouse.down(); + await page.mouse.up(); + await page.keyboard.press("Escape"); + await openMenu.waitFor({ state: "hidden", timeout: 5_000 }); + }), + clickProjectThreadPin: defineBrowserCommand(async ({ frame }, threadId) => { + const testerFrame = await frame(); + await testerFrame + .locator(`[data-sidebar-thread-id="${threadId}"]`) + .getByRole("button", { name: "Pin thread", exact: true }) + .click(); + }), + clickPinnedThreadUnpin: defineBrowserCommand(async ({ frame }, threadId) => { + const testerFrame = await frame(); + await testerFrame + .locator(`[data-pinned-thread-id="${threadId}"]`) + .getByRole("button", { name: "Unpin thread", exact: true }) + .click(); + }), + }, instances: [{ browser: "chromium" }], headless: true, }, diff --git a/docs/superpowers/specs/2026-07-17-server-owned-sidebar-layout-design.md b/docs/superpowers/specs/2026-07-17-server-owned-sidebar-layout-design.md new file mode 100644 index 000000000..841cef162 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-server-owned-sidebar-layout-design.md @@ -0,0 +1,295 @@ +# Server-Owned Sidebar Layout Design + +## Summary + +JCode will make the server the sole authority for manual project order and pinned-thread membership and order. Every browser or desktop client connected to the same JCode server will render the same canonical sidebar layout. + +The layout will be modeled as a dedicated event-sourced aggregate. Clients will send semantic move, pin, and unpin intents instead of replacing complete arrays. The server will apply each intent to its latest state, publish the resulting canonical layout, and include that layout in shell snapshots and updates. + +Device-local storage will retain presentation state such as expanded sections, but it will no longer participate in project or pin ordering after a one-time migration. + +## Goals + +- Synchronize manual project order across every client connected to one JCode server. +- Synchronize pinned-thread membership and order across those clients. +- Eliminate the current local/server reconciliation race that can undo an unpin. +- Preserve responsive drag, pin, and unpin interactions through optimistic rendering. +- Resolve concurrent client operations deterministically without stale whole-list replacement. +- Preserve server-side retention protection for pinned threads. +- Migrate existing local project and pin order once without allowing legacy data to overwrite later server changes. + +## Non-Goals + +- Synchronizing layout between independent JCode server installations. +- Moving expanded/collapsed sections, transient selection, hover state, or open panels to the server. +- Changing automatic project or thread sorting modes. +- Adding accounts, cloud storage, or Tailnet-specific persistence. +- Persisting arbitrary pending client mutations across browser restarts. + +## Current Failure + +Pinned membership currently has two authorities: `projection_threads.is_pinned` on the server and a persisted browser list. During unpin, the client waits for the server command before removing the browser pin. The shell stream can publish `isPinned: false` during that interval, causing the legacy migration effect to interpret the mismatch as an unmigrated local pin and immediately send `isPinned: true`. + +The affected thread produced two accepted events 24 milliseconds apart: an unpin followed by a pin. The server correctly applied both commands; the client reconciliation policy created the second command. + +Project order has a similar ownership problem. It is persisted by workspace root in browser local storage, so each client can maintain a different order even though all clients use the same JCode server. + +## Ownership Model + +### Server-owned state + +One singleton `SidebarLayout` aggregate per JCode server owns: + +- `projectOrder`: the explicit relative order of project IDs; +- `pinnedThreadOrder`: the ordered set of pinned thread IDs; +- aggregate revision and update metadata. + +Membership in `pinnedThreadOrder` is the authoritative definition of whether a thread is pinned. The existing `projection_threads.is_pinned` value remains as a denormalized projection for retention and compatibility queries. It must only be written as a consequence of sidebar-layout events and must never drive layout commands. + +The layout contains project IDs rather than workspace roots. Workspace roots are used only to map legacy browser state during migration. + +### Device-local state + +The following remain local because they describe a view rather than shared layout: + +- project and section expansion; +- selected rows and range-selection anchors; +- transient drag and hover state; +- open panels, drawers, and routes; +- unconfirmed optimistic intents for the current session. + +## Domain Contracts + +### Read model + +The server exposes a canonical layout in the shell snapshot and shell update stream: + +```ts +interface SidebarLayout { + readonly projectOrder: readonly ProjectId[]; + readonly pinnedThreadOrder: readonly ThreadId[]; + readonly revision: number; + readonly updatedAt: string; +} +``` + +Full and shell snapshots expose `sidebarLayout: SidebarLayout | null`. `null` is the only +uninitialized state. Once initialized, every accepted layout event publishes a non-null layout +whose `revision` is that event's global orchestration sequence; `snapshotSequence` is only a +snapshot fence and is never a layout acknowledgement. + +The shell query normalizes the stored layout against live projections: + +- duplicate IDs are removed; +- deleted IDs are omitted; +- projects not yet explicitly ordered are appended by creation time and ID; +- pinned IDs that no longer resolve to live threads are omitted. + +Normalization is deterministic and read-only. The next accepted layout command persists the normalized result as part of its canonical event. + +### Commands + +Clients express intent with narrow commands: + +- `sidebar-layout.initialize` + - the first accepted command initializes the layout; + - carries legacy project and pinned-thread order candidates. +- `sidebar-layout.project.move` + - carries a project ID and an optional `beforeProjectId` anchor; + - a missing anchor means append. +- `sidebar-layout.thread.pin` + - carries a thread ID and an optional `beforeThreadId` anchor; + - an already-pinned thread is repositioned rather than duplicated. +- `sidebar-layout.thread.unpin` + - removes the thread from pinned membership and order atomically. +- `sidebar-layout.pinned-thread.move` + - repositions an already-pinned thread. + +All commands carry the existing idempotent command ID. Clients do not send full replacement arrays and do not use a client-authored revision as a last-write-wins token. + +Every accepted command returns the existing dispatch receipt sequence. A client retains its +optimistic intent until a canonical layout with `revision >= acceptedSequence` is observed. This +handles RPC-before-event, event-before-RPC, retry receipt recovery, and reconnect snapshots without +using the unrelated shell `snapshotSequence`. + +### Command semantics + +The orchestration engine serializes commands on the singleton layout stream and evaluates each command against the latest aggregate state. + +- If the subject project or thread no longer exists, the command returns a typed not-found result. +- If a move anchor disappeared concurrently, the subject appends to the relevant list. +- Moving an item before itself is a no-op. +- Pin and unpin are idempotent. +- Concurrent moves of different items are both applied in server acceptance order. +- Concurrent moves of the same item resolve to the last accepted intent. +- Initialization after the layout already exists is an accepted canonical no-op: it preserves the + existing ordered lists and emits them at a later global event sequence so the dispatch can be + acknowledged normally. + +Events contain the resulting canonical ordered lists. This keeps projection rebuilds deterministic and makes each accepted layout revision self-contained while commands remain semantic and concurrency-safe. + +## Persistence And Projection + +A migration adds a singleton `projection_sidebar_layout` table containing: + +- layout key; +- project order JSON; +- pinned-thread order JSON; +- revision; +- initialized timestamp; +- updated timestamp. + +The event store remains authoritative. The projection table is rebuildable from layout events. + +When a layout event changes pinned membership, the projection pipeline updates `projection_sidebar_layout` and the affected `projection_threads.is_pinned` values in the same projection transaction. Retention therefore continues using an indexed scalar without becoming a second writer or authority. + +Project and thread creation do not need to rewrite the layout. Newly created projects append deterministically until the next explicit project move stores the normalized list. Deleted projects and threads disappear through snapshot normalization; a later layout command compacts the stored arrays. + +## Client State And Reconciliation + +The web client stores: + +- the last confirmed server layout; +- a session-only queue of pending semantic intents. + +The displayed layout is derived by replaying pending intents over the confirmed server layout. It is not persisted as an independent membership or ordering source. + +For each interaction: + +1. Add the semantic intent to the pending queue and render it immediately. +2. Dispatch the command with a unique command ID. +3. Apply shell layout events from any client as the new confirmed layout. +4. Record the dispatch result's global event sequence as the intent's `acceptedSequence`. +5. Remove a pending intent after a canonical layout revision reaches that accepted sequence. +6. Replay any remaining pending intents over the new confirmed layout. + +This model naturally handles another client changing the layout while a local command is in flight. There is no imperative rollback copy: on rejection, the client removes the rejected intent and the displayed layout derives again from the last confirmed server state. A targeted toast explains the failure. + +Commands from one client are dispatched sequentially so rapid local drags preserve user intent. Server-side stream serialization remains the cross-client ordering authority. + +The manual project order is rendered only when the existing sidebar project sort mode is `manual`. Other sort modes remain derived views. Switching back to manual restores the canonical server order. + +## Legacy Migration + +Migration is explicit, atomic, and one-time. + +1. The server snapshot reports that no sidebar layout has been initialized. +2. A client reads the legacy local project order and pinned-thread list. +3. The client maps project workspace roots to project IDs from the hydrated server snapshot. +4. The client sends `sidebar-layout.initialize` with valid candidate IDs. +5. The server filters deleted or unknown IDs, removes duplicates, appends missing live projects deterministically, merges valid client pin candidates with existing server-pinned threads, and initializes the aggregate atomically. Existing server pins are preserved even when the winning client has no legacy pin storage. +6. If two clients race to initialize, only the first command changes the ordered lists. The later command is accepted as a canonical no-op event at a newer sequence, and the losing client adopts the already-initialized layout. +7. After observing initialized server state, each client removes ordering and pin authority from its legacy local storage. Local expansion and other presentation values remain. + +If a client has no valid legacy order, it initializes from the deterministic server default. Legacy values are never consulted again after server initialization, so an old browser profile cannot resurrect stale pins or project order. + +The final web upgrade algorithm distinguishes three candidate states. `undefined` means the hydrated +snapshot has not made candidate collection ready, a candidate object (including empty arrays) means +the client may make its one initialization attempt, and `null` means a durable migration marker or +unavailable storage forbids initialization from that profile. Candidate collection occurs at most +once per mounted EventRouter. An empty, readable profile therefore still initializes the server +default, while a marked old profile never submits another initialize command even if stale keys +later reappear. + +A fully empty pushed shell snapshot is provisional because desktop startup can publish it before the +projection query is hydrated. It keeps candidates `undefined`. Any pushed snapshot containing a +project or thread is ready, including valid project-only and thread-only lifecycles. An authoritative +`getShellSnapshot` query is ready even when both collections are genuinely empty, so an empty server +still initializes deterministically without treating elapsed time as proof of readiness. + +Confirmation is driven only by an observed non-null canonical layout, whether it arrives in a +snapshot, a non-applied fallback/bootstrap query, or another client's shell event. Canonical-only +fallback observations pass through the layout router so they retry interrupted cleanup without +reapplying stale lifecycle data or migration candidates; layout revision monotonicity still prevents +regression. The client writes +`jcode:sidebar-layout-migrated:v1` before retiring authority fields, removes only +`projectOrderCwds` from renderer-state objects, removes the three current/DPCode/T3Code pin keys, +and leaves expansion, local project names, and unrelated device-local state intact. If field cleanup +is interrupted, the marker immediately prevents stale replay and later canonical observations retry +the remaining cleanup. Bootstrap never copies legacy pin storage into a current pin store. Until +confirmation, ordinary presentation persistence preserves an existing legacy order field unchanged; +it never derives or updates that field from the rendered project array. This preservation supports +both flat renderer objects and Zustand-style `{ state, version }` envelopes, keeps mixed legacy +arrays byte-for-value equivalent until the migration parser filters them, and retains expansion and +local project names even when persistence runs before project hydration. + +The namespace bootstrap marker retires ordering authority only; it does not suppress presentation +migration. If a marked profile has only a DPCode/T3Code renderer payload, bootstrap strips +`projectOrderCwds` and copies the remaining expansion, local-name, and unrelated presentation fields +to the current JCode renderer key. + +## Authorization And Transport + +Layout reads follow existing shell snapshot authorization. Layout mutations require an owner session because they change shared server state for every connected client. The feature does not add Tailnet-specific trust; it remains behind JCode's Server Auth Boundary. + +The web bundle and server contracts ship together. The old client-only pin migration effect and persisted pin membership store are removed from active reconciliation. Any temporary compatibility decoding must be read-only and must not reintroduce a second writer. + +## Failure Handling + +- Transport failure: remove the rejected pending intent, render confirmed server state, and show a specific retryable toast. +- Initialization rejection: clear the one-attempt latch and show a targeted Retry action. Retrying + reuses the collected candidate snapshot in a fresh command only while canonical layout remains + uninitialized; if another client wins first, the retry becomes a no-op and canonical state wins. +- Domain not found: remove the intent and refresh from the next shell snapshot; do not retry automatically. +- Lost connection after server acceptance: command ID idempotency makes an explicit retry safe, while the shell snapshot eventually confirms the canonical result. +- Projection restart or rebuild: replay layout events and reconstruct both the layout row and denormalized pinned flags. +- Corrupt persisted JSON: fail startup or projection decoding loudly using the existing typed persistence error path; do not silently fall back to browser data. +- Concurrent client changes: apply accepted server revisions in stream order and rebase local pending intents by replay. + +## Testing Strategy + +### Contracts and domain + +- Decode valid layout snapshots and every command variant. +- Reject duplicate/invalid boundary data before it enters the domain. +- Cover initialize-once behavior. +- Cover project move, pin, unpin, and pinned move behavior. +- Cover missing subjects, missing anchors, self-moves, and idempotent retries. +- Apply interleaved intents from two clients and assert deterministic server order. + +### Persistence and projections + +- Migrate a database and round-trip the singleton layout row. +- Project layout events into ordered JSON and denormalized `is_pinned` flags. +- Rebuild projections from events and obtain identical layout and pin flags. +- Verify thread retention protects exactly the IDs in canonical pinned membership. +- Verify deleted IDs and newly created projects normalize deterministically. + +### Web client + +- Replay pending intents over confirmed layout without mutating confirmed state. +- Reconcile a local pending move with a remote server revision. +- Remove a failed intent and derive the original confirmed layout. +- Prove an unpin cannot emit a compensating pin while its RPC is in flight. +- Prove automatic project sorting ignores manual order without overwriting it. +- Prove legacy initialization runs only while the server is uninitialized. + +### Browser acceptance + +Use two isolated authenticated browser contexts against one loopback development server: + +1. Reorder projects in client A and observe the same order in client B. +2. Pin and reorder threads in client B and observe the same order in client A. +3. Unpin the formerly failing thread and verify it remains unpinned after stream reconciliation and reload. +4. Perform overlapping moves from both clients and verify both converge to the server's canonical order. +5. Simulate a rejected command and verify optimistic state rolls back with a targeted error. +6. Restart the server and verify both orders persist. + +## Rollout Sequence + +1. Add contracts, aggregate behavior, projection migration, and server snapshot support behind focused tests. +2. Add the web client's confirmed-layout plus pending-intent model. +3. Add atomic legacy initialization and remove continuous local reconciliation. +4. Cut project drag-and-drop and pin controls over to layout commands. +5. Verify projection rebuilds, retention, two-client convergence, reloads, and restart persistence. +6. Remove obsolete local ordering and pin-membership code once migration coverage proves it is no longer authoritative. + +## Acceptance Criteria + +- All clients connected to one JCode server converge on the same manual project order and pinned-thread order. +- Pin membership has exactly one authority: the server sidebar layout. +- Unpin cannot be undone by local migration or stream timing. +- Concurrent client operations are applied as semantic intents to the latest server state. +- Legacy browser data initializes the server at most once and cannot later overwrite it. +- Pinned-thread retention behavior remains correct after projection rebuild and server restart. +- Device-local expansion and navigation state remain independent between clients. diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index bbf2dd72a..56a070dc4 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -17,6 +17,12 @@ const makeEntityId = (brand: Brand) => export const ThreadId = makeEntityId("ThreadId"); export type ThreadId = typeof ThreadId.Type; +export const SidebarLayoutId = Schema.Literal("sidebar-layout").pipe( + Schema.brand("SidebarLayoutId"), +); +export type SidebarLayoutId = typeof SidebarLayoutId.Type; +export const SIDEBAR_LAYOUT_ID: SidebarLayoutId = + Schema.decodeSync(SidebarLayoutId)("sidebar-layout"); export const ProjectId = makeEntityId("ProjectId"); export type ProjectId = typeof ProjectId.Type; export const EnvironmentId = makeEntityId("EnvironmentId"); diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 213361898..d0c76922b 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -14,6 +14,8 @@ import { OrchestrationLatestTurn, OrchestrationMessage, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationShellStreamItem, ProviderKind, ProjectIconMetadata, ProjectCreatedPayload, @@ -50,6 +52,214 @@ const decodeModelSelection = Schema.decodeUnknownEffect(ModelSelection); const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand); const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); +const decodeOrchestrationReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); +const decodeOrchestrationShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); +const decodeOrchestrationShellStreamItem = Schema.decodeUnknownEffect(OrchestrationShellStreamItem); + +it.effect("decodes an uninitialized sidebar layout in full and shell snapshots", () => + Effect.gen(function* () { + const wire = { + snapshotSequence: 0, + sidebarLayout: null, + projects: [], + threads: [], + updatedAt: "2026-07-18T00:00:00.000Z", + } as const; + + const fullSnapshot = yield* decodeOrchestrationReadModel(wire); + const shellSnapshot = yield* decodeOrchestrationShellSnapshot(wire); + + assert.strictEqual(fullSnapshot.sidebarLayout, null); + assert.strictEqual(shellSnapshot.sidebarLayout, null); + }), +); + +it.effect("decodes an initialized sidebar layout in full and shell snapshots", () => + Effect.gen(function* () { + const wire = { + snapshotSequence: 12, + sidebarLayout: { + projectOrder: ["project-2", "project-1"], + pinnedThreadOrder: ["thread-2", "thread-1"], + revision: 11, + updatedAt: "2026-07-18T00:00:00.000Z", + }, + projects: [], + threads: [], + updatedAt: "2026-07-18T00:00:00.000Z", + } as const; + + const fullSnapshot = yield* decodeOrchestrationReadModel(wire); + const shellSnapshot = yield* decodeOrchestrationShellSnapshot(wire); + + assert.deepStrictEqual(fullSnapshot.sidebarLayout, wire.sidebarLayout); + assert.deepStrictEqual(shellSnapshot.sidebarLayout, wire.sidebarLayout); + }), +); + +it.effect("decodes all sidebar layout intent commands", () => + Effect.gen(function* () { + const commands = [ + { + type: "sidebar-layout.initialize", + commandId: "command-layout-initialize", + projectOrder: ["project-2", "project-1"], + pinnedThreadOrder: ["thread-2", "thread-1"], + }, + { + type: "sidebar-layout.project.move", + commandId: "command-layout-project-move", + projectId: "project-2", + beforeProjectId: "project-1", + }, + { + type: "sidebar-layout.thread.pin", + commandId: "command-layout-thread-pin", + threadId: "thread-2", + beforeThreadId: "thread-1", + }, + { + type: "sidebar-layout.thread.unpin", + commandId: "command-layout-thread-unpin", + threadId: "thread-2", + }, + { + type: "sidebar-layout.pinned-thread.move", + commandId: "command-layout-thread-move", + threadId: "thread-2", + beforeThreadId: null, + }, + ] as const; + + for (const command of commands) { + const parsed = yield* decodeClientOrchestrationCommand(command); + + assert.strictEqual(parsed.type, command.type); + assert.strictEqual(parsed.commandId, command.commandId); + } + }), +); + +it.effect("decodes sidebar layout domain and shell events", () => + Effect.gen(function* () { + const sidebarLayout = { + projectOrder: ["project-1"], + pinnedThreadOrder: ["thread-1"], + revision: 17, + updatedAt: "2026-07-18T00:00:00.000Z", + } as const; + const domainEvent = yield* decodeOrchestrationEvent({ + sequence: 17, + eventId: "event-layout-updated", + aggregateKind: "sidebar-layout", + aggregateId: "sidebar-layout", + type: "sidebar-layout.updated", + payload: { + projectOrder: sidebarLayout.projectOrder, + pinnedThreadOrder: sidebarLayout.pinnedThreadOrder, + updatedAt: sidebarLayout.updatedAt, + }, + occurredAt: sidebarLayout.updatedAt, + commandId: "command-layout-project-move", + causationEventId: null, + correlationId: "command-layout-project-move", + metadata: {}, + }); + const shellItem = yield* decodeOrchestrationShellStreamItem({ + kind: "sidebar-layout-updated", + sequence: 17, + sidebarLayout, + }); + + assert.strictEqual(domainEvent.type, "sidebar-layout.updated"); + assert.strictEqual(domainEvent.aggregateId, "sidebar-layout"); + assert.ok(shellItem.kind === "sidebar-layout-updated"); + assert.deepStrictEqual(shellItem.sidebarLayout, sidebarLayout); + }), +); + +it.effect("strips historical pin metadata from new client commands", () => + Effect.gen(function* () { + const metaCommand = yield* decodeClientOrchestrationCommand({ + type: "thread.meta.update", + commandId: "command-new-meta-pin", + threadId: "thread-1", + isPinned: true, + }); + + assert.strictEqual("isPinned" in metaCommand, false); + }), +); + +it.effect("strips historical pin metadata from new thread creation", () => + Effect.gen(function* () { + const createCommand = yield* decodeClientOrchestrationCommand({ + type: "thread.create", + commandId: "command-new-thread-pin", + threadId: "thread-1", + projectId: "project-1", + title: "Thread 1", + modelSelection: { provider: "codex", model: "gpt-5.5" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + isPinned: true, + createdAt: "2026-07-18T00:00:00.000Z", + }); + + assert.strictEqual("isPinned" in createCommand, false); + }), +); + +it.effect("rejects duplicate sidebar layout initialization candidates", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeClientOrchestrationCommand({ + type: "sidebar-layout.initialize", + commandId: "command-layout-duplicates", + projectOrder: ["project-1", "project-1"], + pinnedThreadOrder: ["thread-1", "thread-1"], + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), +); + +it.effect("rejects malformed sidebar layout anchors and aggregate IDs", () => + Effect.gen(function* () { + const anchorResult = yield* Effect.exit( + decodeClientOrchestrationCommand({ + type: "sidebar-layout.project.move", + commandId: "command-layout-invalid-anchor", + projectId: "project-1", + beforeProjectId: " ", + }), + ); + const aggregateResult = yield* Effect.exit( + decodeOrchestrationEvent({ + sequence: 18, + eventId: "event-layout-invalid-id", + aggregateKind: "sidebar-layout", + aggregateId: "not-the-sidebar-layout", + type: "sidebar-layout.updated", + payload: { + projectOrder: [], + pinnedThreadOrder: [], + updatedAt: "2026-07-18T00:00:00.000Z", + }, + occurredAt: "2026-07-18T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + }), + ); + + assert.strictEqual(anchorResult._tag, "Failure"); + assert.strictEqual(aggregateResult._tag, "Failure"); + }), +); it.effect("accepts OpenClaw as a provider kind", () => Effect.gen(function* () { @@ -210,6 +420,7 @@ it.effect("preserves thread activity payloads through the RPC JSON codec", () => const codec = Schema.toCodecJson(OrchestrationReadModel); const readModel = { snapshotSequence: 1, + sidebarLayout: null, updatedAt: "2026-01-01T00:00:00.000Z", projects: [], threads: [ @@ -667,6 +878,33 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => }), ); +it.effect("preserves historical thread pin metadata events", () => + Effect.gen(function* () { + const wire = { + sequence: 42, + eventId: "event-historical-pin", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.meta-updated", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "command-historical-pin", + causationEventId: null, + correlationId: "command-historical-pin", + metadata: {}, + payload: { + threadId: "thread-1", + isPinned: true, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + } as const; + + const parsed = yield* decodeOrchestrationEvent(wire); + + assert.deepStrictEqual(parsed.payload, wire.payload); + assert.strictEqual(parsed.payload.isPinned, true); + }), +); + it.effect("accepts provider-scoped model options in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cbd1b9426..ffd46d44d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -26,6 +26,7 @@ import { PositiveInt, ProjectId, ProviderItemId, + SidebarLayoutId, ThreadId, TrimmedNonEmptyString, TurnId, @@ -637,8 +638,27 @@ export const OrchestrationThreadShell = Schema.Struct({ }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; +const hasUniqueItems = (items: readonly Item[]): boolean => + new Set(items).size === items.length; + +const SidebarProjectOrder = Schema.Array(ProjectId).check( + Schema.makeFilter(hasUniqueItems, { identifier: "SidebarProjectOrder" }), +); +const SidebarPinnedThreadOrder = Schema.Array(ThreadId).check( + Schema.makeFilter(hasUniqueItems, { identifier: "SidebarPinnedThreadOrder" }), +); + +export const SidebarLayout = Schema.Struct({ + projectOrder: SidebarProjectOrder, + pinnedThreadOrder: SidebarPinnedThreadOrder, + revision: NonNegativeInt, + updatedAt: IsoDateTime, +}); +export type SidebarLayout = typeof SidebarLayout.Type; + export const OrchestrationReadModel = Schema.Struct({ snapshotSequence: NonNegativeInt, + sidebarLayout: Schema.NullOr(SidebarLayout), projects: Schema.Array(OrchestrationProject), threads: Schema.Array(OrchestrationThread), updatedAt: IsoDateTime, @@ -647,6 +667,7 @@ export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; export const OrchestrationShellSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, + sidebarLayout: Schema.NullOr(SidebarLayout), projects: Schema.Array(OrchestrationProjectShell), threads: Schema.Array(OrchestrationThreadShell), updatedAt: IsoDateTime, @@ -674,6 +695,11 @@ export const OrchestrationShellStreamEvent = Schema.Union([ sequence: NonNegativeInt, threadId: ThreadId, }), + Schema.Struct({ + kind: Schema.Literal("sidebar-layout-updated"), + sequence: NonNegativeInt, + sidebarLayout: SidebarLayout, + }), ]); export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type; @@ -739,7 +765,6 @@ const ThreadCreateCommand = Schema.Struct({ createBranchFlowCompleted: Schema.optional(Schema.Boolean).pipe( Schema.withDecodingDefault(() => false), ), - isPinned: Schema.optional(Schema.Boolean).pipe(Schema.withDecodingDefault(() => false)), parentThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe( Schema.withDecodingDefault(() => null), ), @@ -850,7 +875,6 @@ const ThreadMetaUpdateCommand = Schema.Struct({ associatedWorktreeBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), associatedWorktreeRef: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), createBranchFlowCompleted: Schema.optional(Schema.Boolean), - isPinned: Schema.optional(Schema.Boolean), parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), subagentAgentId: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), subagentNickname: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -1064,6 +1088,46 @@ const ThreadGoalClearCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const SidebarLayoutInitializeCommand = Schema.Struct({ + type: Schema.Literal("sidebar-layout.initialize"), + commandId: CommandId, + projectOrder: SidebarProjectOrder, + pinnedThreadOrder: SidebarPinnedThreadOrder, +}); + +const SidebarLayoutProjectMoveCommand = Schema.Struct({ + type: Schema.Literal("sidebar-layout.project.move"), + commandId: CommandId, + projectId: ProjectId, + beforeProjectId: Schema.optional(Schema.NullOr(ProjectId)).pipe( + Schema.withDecodingDefault(() => null), + ), +}); + +const SidebarLayoutThreadPinCommand = Schema.Struct({ + type: Schema.Literal("sidebar-layout.thread.pin"), + commandId: CommandId, + threadId: ThreadId, + beforeThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe( + Schema.withDecodingDefault(() => null), + ), +}); + +const SidebarLayoutThreadUnpinCommand = Schema.Struct({ + type: Schema.Literal("sidebar-layout.thread.unpin"), + commandId: CommandId, + threadId: ThreadId, +}); + +const SidebarLayoutPinnedThreadMoveCommand = Schema.Struct({ + type: Schema.Literal("sidebar-layout.pinned-thread.move"), + commandId: CommandId, + threadId: ThreadId, + beforeThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe( + Schema.withDecodingDefault(() => null), + ), +}); + const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, @@ -1090,6 +1154,11 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadGoalResumeCommand, ThreadGoalCompleteCommand, ThreadGoalClearCommand, + SidebarLayoutInitializeCommand, + SidebarLayoutProjectMoveCommand, + SidebarLayoutThreadPinCommand, + SidebarLayoutThreadUnpinCommand, + SidebarLayoutPinnedThreadMoveCommand, ]); export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; @@ -1120,6 +1189,11 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadGoalResumeCommand, ThreadGoalCompleteCommand, ThreadGoalClearCommand, + SidebarLayoutInitializeCommand, + SidebarLayoutProjectMoveCommand, + SidebarLayoutThreadPinCommand, + SidebarLayoutThreadUnpinCommand, + SidebarLayoutPinnedThreadMoveCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -1253,10 +1327,11 @@ export const OrchestrationEventType = Schema.Literals([ "thread.goal-resumed", "thread.goal-completed", "thread.goal-cleared", + "sidebar-layout.updated", ]); export type OrchestrationEventType = typeof OrchestrationEventType.Type; -export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]); +export const OrchestrationAggregateKind = Schema.Literals(["project", "thread", "sidebar-layout"]); export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type; export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]); @@ -1545,6 +1620,13 @@ export const ThreadGoalClearedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const SidebarLayoutUpdatedPayload = Schema.Struct({ + projectOrder: SidebarProjectOrder, + pinnedThreadOrder: SidebarPinnedThreadOrder, + updatedAt: IsoDateTime, +}); +export type SidebarLayoutUpdatedPayload = typeof SidebarLayoutUpdatedPayload.Type; + export const OrchestrationEventMetadata = Schema.Struct({ providerTurnId: Schema.optional(TrimmedNonEmptyString), providerItemId: Schema.optional(ProviderItemId), @@ -1558,7 +1640,7 @@ const EventBaseFields = { sequence: NonNegativeInt, eventId: EventId, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([ProjectId, ThreadId, SidebarLayoutId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), @@ -1722,6 +1804,13 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.goal-cleared"), payload: ThreadGoalClearedPayload, }), + Schema.Struct({ + ...EventBaseFields, + aggregateKind: Schema.Literal("sidebar-layout"), + aggregateId: SidebarLayoutId, + type: Schema.Literal("sidebar-layout.updated"), + payload: SidebarLayoutUpdatedPayload, + }), ]); export type OrchestrationEvent = typeof OrchestrationEvent.Type;