diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index d6b1004749..7360df8511 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -116,6 +116,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, + pinned: false, defaultModelSelection: { provider, model: defaultModel, @@ -129,6 +130,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", + pinned: false, modelSelection: { provider, model: defaultModel, @@ -265,6 +267,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5.3-codex", @@ -278,6 +281,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5.3-codex", diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 9fb2500ce4..1f8b4793c1 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -31,6 +31,7 @@ function makeSnapshot(input: { id: input.projectId, title: "Project", workspaceRoot: input.workspaceRoot, + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", @@ -43,6 +44,7 @@ function makeSnapshot(input: { id: input.threadId, projectId: input.projectId, title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 075f62f889..2e18356ad9 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -282,6 +282,7 @@ describe("CheckpointReactor", () => { projectId: asProjectId("project-1"), title: "Test Project", workspaceRoot: options?.projectWorkspaceRoot ?? cwd, + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -296,6 +297,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 6aa889991e..6df6e2481b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -72,6 +72,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -86,6 +87,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -132,6 +134,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-archive"), title: "Project Archive", workspaceRoot: "/tmp/project-archive", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -146,6 +149,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-archive"), projectId: asProjectId("project-archive"), title: "Archive me", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -199,6 +203,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-replay"), title: "Replay Project", workspaceRoot: "/tmp/project-replay", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -213,6 +218,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-replay"), projectId: asProjectId("project-replay"), title: "replay", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -257,6 +263,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-stream"), title: "Stream Project", workspaceRoot: "/tmp/project-stream", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -281,6 +288,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-stream"), projectId: asProjectId("project-stream"), title: "domain-stream", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -318,6 +326,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-turn-diff"), title: "Turn Diff Project", workspaceRoot: "/tmp/project-turn-diff", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -332,6 +341,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-turn-diff"), projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -435,6 +445,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "Flaky Project", workspaceRoot: "/tmp/project-flaky", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -451,6 +462,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-flaky-fail"), projectId: asProjectId("project-flaky"), title: "flaky-fail", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -471,6 +483,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-flaky-ok"), projectId: asProjectId("project-flaky"), title: "flaky-ok", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -528,6 +541,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-atomic"), title: "Atomic Project", workspaceRoot: "/tmp/project-atomic", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -542,6 +556,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-atomic"), projectId: asProjectId("project-atomic"), title: "atomic", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -669,6 +684,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-sync"), title: "Sync Project", workspaceRoot: "/tmp/project-sync", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -683,6 +699,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-sync"), projectId: asProjectId("project-sync"), title: "sync-before", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -754,6 +771,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "Duplicate Project", workspaceRoot: "/tmp/project-duplicate", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -769,6 +787,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-duplicate"), projectId: asProjectId("project-duplicate"), title: "duplicate", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -789,6 +808,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-duplicate"), projectId: asProjectId("project-duplicate"), title: "duplicate", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 77b5d4d619..f59d7f1c94 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1867,6 +1867,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { projectId: ProjectId.makeUnsafe("project-live"), title: "Live Project", workspaceRoot: "/tmp/project-live", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1905,6 +1906,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { projectId: ProjectId.makeUnsafe("project-scripts"), title: "Scripts Project", workspaceRoot: "/tmp/project-scripts", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f0f2ccabee..5eb050269b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -383,6 +383,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti projectId: event.payload.projectId, title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, + pinnedAt: event.payload.pinnedAt, defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, createdAt: event.payload.createdAt, @@ -404,6 +405,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.workspaceRoot !== undefined ? { workspaceRoot: event.payload.workspaceRoot } : {}), + ...(event.payload.pinnedAt !== undefined ? { pinnedAt: event.payload.pinnedAt } : {}), ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), @@ -442,6 +444,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti threadId: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, + pinnedAt: event.payload.pinnedAt, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -495,6 +498,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.pinnedAt !== undefined ? { pinnedAt: event.payload.pinnedAt } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 32143d751f..9ec55d56cd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -234,6 +234,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -257,6 +258,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread 1", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f951c54b5b..f24ae6ff40 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -46,6 +46,7 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ + pinnedAt: Schema.NullOr(IsoDateTime), defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), @@ -59,6 +60,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ + pinnedAt: Schema.NullOr(IsoDateTime), modelSelection: Schema.fromJsonString(ModelSelection), }), ); @@ -147,6 +149,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + pinned_at AS "pinnedAt", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", @@ -166,6 +169,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + pinned_at AS "pinnedAt", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -542,6 +546,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + pinnedAt: row.pinnedAt, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, createdAt: row.createdAt, @@ -553,6 +558,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.threadId, projectId: row.projectId, title: row.title, + pinnedAt: row.pinnedAt, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, interactionMode: row.interactionMode, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ed7037695f..0d7e1f7123 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -245,6 +245,7 @@ describe("ProviderCommandReactor", () => { projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot: "/tmp/provider-project", + pinned: false, defaultModelSelection: modelSelection, createdAt: now, }), @@ -256,6 +257,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: modelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 529eae2444..5f8e134e7a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -225,6 +225,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Provider Project", workspaceRoot, + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -239,6 +240,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -768,6 +770,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -803,6 +806,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: targetThreadId, projectId: asProjectId("project-1"), title: "Plan Target", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -955,6 +959,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1108,6 +1113,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1143,6 +1149,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: targetThreadId, projectId: asProjectId("project-1"), title: "Plan Target", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 43d665a2c9..376e3f1480 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -28,6 +28,7 @@ const readModel: OrchestrationReadModel = { id: ProjectId.makeUnsafe("project-a"), title: "Project A", workspaceRoot: "/tmp/project-a", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -41,6 +42,7 @@ const readModel: OrchestrationReadModel = { id: ProjectId.makeUnsafe("project-b"), title: "Project B", workspaceRoot: "/tmp/project-b", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -56,6 +58,7 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -79,6 +82,7 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-2"), projectId: ProjectId.makeUnsafe("project-b"), title: "Thread B", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -158,6 +162,7 @@ describe("commandInvariants", () => { threadId: ThreadId.makeUnsafe("thread-3"), projectId: ProjectId.makeUnsafe("project-a"), title: "new", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -182,6 +187,7 @@ describe("commandInvariants", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-a"), title: "dup", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 465865549b..f25a3f2f94 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -29,6 +29,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-scripts"), title: "Scripts", workspaceRoot: "/tmp/scripts", + pinned: false, createdAt: now, }, readModel, @@ -37,7 +38,10 @@ describe("decider project scripts", () => { const event = Array.isArray(result) ? result[0] : result; expect(event.type).toBe("project.created"); - expect((event.payload as { scripts: unknown[] }).scripts).toEqual([]); + expect(event.payload).toMatchObject({ + pinnedAt: null, + scripts: [], + }); }); it("propagates scripts in project.meta.update payload", async () => { @@ -59,6 +63,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-scripts"), title: "Scripts", workspaceRoot: "/tmp/scripts", + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: now, @@ -94,6 +99,82 @@ describe("decider project scripts", () => { expect((event.payload as { scripts?: unknown[] }).scripts).toEqual(scripts); }); + it("propagates pin state through thread create and meta update", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create-pin"), + aggregateKind: "project", + aggregateId: asProjectId("project-pin"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create-pin"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create-pin"), + metadata: {}, + payload: { + projectId: asProjectId("project-pin"), + title: "Project", + workspaceRoot: "/tmp/project-pin", + pinnedAt: null, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + const created = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-pin"), + threadId: ThreadId.makeUnsafe("thread-pin"), + projectId: asProjectId("project-pin"), + title: "Pinned thread", + pinned: true, + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: now, + }, + readModel: withProject, + }), + ); + const createdEvent = Array.isArray(created) ? created[0] : created; + expect(createdEvent.type).toBe("thread.created"); + expect(createdEvent.payload).toMatchObject({ pinnedAt: now }); + + const withThread = await Effect.runPromise( + projectEvent(withProject, { + ...createdEvent, + sequence: 2, + }), + ); + const updated = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-update-pin"), + threadId: ThreadId.makeUnsafe("thread-pin"), + pinned: false, + }, + readModel: withThread, + }), + ); + const updatedEvent = Array.isArray(updated) ? updated[0] : updated; + expect(updatedEvent.type).toBe("thread.meta-updated"); + expect(updatedEvent.payload).toMatchObject({ pinnedAt: null }); + }); + it("emits user message and turn-start-requested events for thread.turn.start", async () => { const now = new Date().toISOString(); const initial = createEmptyReadModel(now); @@ -113,6 +194,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: now, @@ -136,6 +218,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -222,6 +305,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: now, @@ -245,6 +329,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -304,6 +389,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: now, @@ -327,6 +413,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 22f5bcb280..9a7dff04ca 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -77,6 +77,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, title: command.title, workspaceRoot: command.workspaceRoot, + pinnedAt: command.pinned ? command.createdAt : null, defaultModelSelection: command.defaultModelSelection ?? null, scripts: [], createdAt: command.createdAt, @@ -104,6 +105,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, ...(command.title !== undefined ? { title: command.title } : {}), ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), + ...(command.pinned !== undefined ? { pinnedAt: command.pinned ? occurredAt : null } : {}), ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), @@ -158,6 +160,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, + pinnedAt: command.pinned ? command.createdAt : null, modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, @@ -254,6 +257,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.pinned !== undefined ? { pinnedAt: command.pinned ? occurredAt : null } : {}), ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 3dcdd19250..eef819cb6c 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -76,6 +76,7 @@ describe("orchestration projector", () => { id: "thread-1", projectId: "project-1", title: "demo", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -98,6 +99,99 @@ describe("orchestration projector", () => { ]); }); + it("applies pin updates for projects and threads", async () => { + const now = new Date().toISOString(); + const later = new Date(Date.parse(now) + 1_000).toISOString(); + const withProject = await Effect.runPromise( + projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "project.created", + aggregateKind: "project", + aggregateId: "project-1", + occurredAt: now, + commandId: "cmd-project-create", + payload: { + projectId: "project-1", + title: "demo", + workspaceRoot: "/tmp/project-1", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + const withPinnedProject = await Effect.runPromise( + projectEvent( + withProject, + makeEvent({ + sequence: 2, + type: "project.meta-updated", + aggregateKind: "project", + aggregateId: "project-1", + occurredAt: later, + commandId: "cmd-project-pin", + payload: { + projectId: "project-1", + pinnedAt: later, + updatedAt: later, + }, + }), + ), + ); + expect(withPinnedProject.projects[0]?.pinnedAt).toBe(later); + + const withThread = await Effect.runPromise( + projectEvent( + withPinnedProject, + makeEvent({ + sequence: 3, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-thread-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + const withPinnedThread = await Effect.runPromise( + projectEvent( + withThread, + makeEvent({ + sequence: 4, + type: "thread.meta-updated", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: later, + commandId: "cmd-thread-pin", + payload: { + threadId: "thread-1", + pinnedAt: later, + updatedAt: later, + }, + }), + ), + ); + expect(withPinnedThread.threads[0]?.pinnedAt).toBe(later); + }); + it("fails when event payload cannot be decoded by runtime schema", 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 deb8a6d44d..dd41062266 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -183,6 +183,7 @@ export function projectEvent( id: payload.projectId, title: payload.title, workspaceRoot: payload.workspaceRoot, + pinnedAt: payload.pinnedAt, defaultModelSelection: payload.defaultModelSelection, scripts: payload.scripts, createdAt: payload.createdAt, @@ -213,6 +214,7 @@ export function projectEvent( ...(payload.workspaceRoot !== undefined ? { workspaceRoot: payload.workspaceRoot } : {}), + ...(payload.pinnedAt !== undefined ? { pinnedAt: payload.pinnedAt } : {}), ...(payload.defaultModelSelection !== undefined ? { defaultModelSelection: payload.defaultModelSelection } : {}), @@ -254,6 +256,7 @@ export function projectEvent( id: payload.threadId, projectId: payload.projectId, title: payload.title, + pinnedAt: payload.pinnedAt, modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, @@ -320,6 +323,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.pinnedAt !== undefined ? { pinnedAt: payload.pinnedAt } : {}), ...(payload.modelSelection !== undefined ? { modelSelection: payload.modelSelection } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 7ff19f55ae..b31bb5c9a5 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -14,6 +14,7 @@ import { const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ + pinnedAt: Schema.NullOr(Schema.String), defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), @@ -31,6 +32,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id, title, workspace_root, + pinned_at, default_model_selection_json, scripts_json, created_at, @@ -41,6 +43,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.projectId}, ${row.title}, ${row.workspaceRoot}, + ${row.pinnedAt}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, @@ -51,6 +54,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { DO UPDATE SET title = excluded.title, workspace_root = excluded.workspace_root, + pinned_at = excluded.pinned_at, default_model_selection_json = excluded.default_model_selection_json, scripts_json = excluded.scripts_json, created_at = excluded.created_at, @@ -68,6 +72,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + pinned_at AS "pinnedAt", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", @@ -87,6 +92,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + pinned_at AS "pinnedAt", default_model_selection_json AS "defaultModelSelection", scripts_json AS "scripts", created_at AS "createdAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index b0e1774837..a9525e5dde 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -27,6 +27,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { projectId: ProjectId.makeUnsafe("project-null-options"), title: "Null options project", workspaceRoot: "/tmp/project-null-options", + pinnedAt: "2026-03-24T00:00:00.000Z", defaultModelSelection: { provider: "codex", model: "gpt-5.4", @@ -60,6 +61,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const persisted = yield* projects.getById({ projectId: ProjectId.makeUnsafe("project-null-options"), }); + assert.strictEqual(Option.getOrNull(persisted)?.pinnedAt, "2026-03-24T00:00:00.000Z"); assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, { provider: "codex", model: "gpt-5.4", @@ -76,6 +78,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { threadId: ThreadId.makeUnsafe("thread-null-options"), projectId: ProjectId.makeUnsafe("project-null-options"), title: "Null options thread", + pinnedAt: "2026-03-24T00:00:00.000Z", modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6", @@ -114,6 +117,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const persisted = yield* threads.getById({ threadId: ThreadId.makeUnsafe("thread-null-options"), }); + assert.strictEqual(Option.getOrNull(persisted)?.pinnedAt, "2026-03-24T00:00:00.000Z"); assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, { provider: "claudeAgent", model: "claude-opus-4-6", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 48dd51fdca..e65cea7ada 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -15,6 +15,7 @@ import { ModelSelection } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ + pinnedAt: Schema.NullOr(Schema.String), modelSelection: Schema.fromJsonString(ModelSelection), }), ); @@ -31,6 +32,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, + pinned_at, model_selection_json, runtime_mode, interaction_mode, @@ -46,6 +48,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, + ${row.pinnedAt}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, ${row.interactionMode}, @@ -61,6 +64,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, + pinned_at = excluded.pinned_at, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, @@ -83,6 +87,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + pinned_at AS "pinnedAt", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -107,6 +112,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + pinned_at AS "pinnedAt", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c759665f06..6827c42b20 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -31,6 +31,7 @@ import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; +import Migration0019 from "./Migrations/019_ProjectionPins.ts"; /** * Migration loader with all migrations defined inline. @@ -61,6 +62,7 @@ export const migrationEntries = [ [16, "CanonicalizeModelSelections", Migration0016], [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], + [19, "ProjectionPins", Migration0019], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/019_ProjectionPins.ts b/apps/server/src/persistence/Migrations/019_ProjectionPins.ts new file mode 100644 index 0000000000..257a31e5e1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/019_ProjectionPins.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const projectColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + if (!projectColumns.some((column) => column.name === "pinned_at")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN pinned_at TEXT + `; + } + + const threadColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!threadColumns.some((column) => column.name === "pinned_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pinned_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 996ffe6e7b..17b1353c08 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -16,6 +16,7 @@ export const ProjectionProject = Schema.Struct({ projectId: ProjectId, title: Schema.String, workspaceRoot: Schema.String, + pinnedAt: Schema.NullOr(IsoDateTime), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 59505c1253..7937b2a66e 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -24,6 +24,7 @@ export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, + pinnedAt: Schema.NullOr(IsoDateTime), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0e06650fe6..8227a45718 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1221,6 +1221,7 @@ describe("WebSocket Server", () => { projectId: "project-diff", title: "Diff Project", workspaceRoot, + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1234,6 +1235,7 @@ describe("WebSocket Server", () => { threadId: "thread-diff", projectId: "project-diff", title: "Diff Thread", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1306,6 +1308,7 @@ describe("WebSocket Server", () => { projectId: "project-1", title: "WS Project", workspaceRoot, + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1319,6 +1322,7 @@ describe("WebSocket Server", () => { threadId: "thread-1", projectId: "project-1", title: "Thread 1", + pinned: false, modelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 25f8158926..eee9e41c8b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -675,6 +675,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< projectId: bootstrapProjectId, title: bootstrapProjectTitle, workspaceRoot: cwd, + pinned: false, defaultModelSelection: bootstrapProjectDefaultModelSelection, createdAt, }); @@ -698,6 +699,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< threadId, projectId: bootstrapProjectId, title: "New thread", + pinned: false, modelSelection: bootstrapProjectDefaultModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 20816d6935..a70ff3fea0 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -237,6 +237,7 @@ function createSnapshotForTargetUser(options: { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5", @@ -252,6 +253,7 @@ function createSnapshotForTargetUser(options: { id: THREAD_ID, projectId: PROJECT_ID, title: "Browser test thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5", @@ -310,6 +312,7 @@ function addThreadToSnapshot( id: threadId, projectId: PROJECT_ID, title: "New thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5", @@ -357,6 +360,7 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest threadId, projectId: PROJECT_ID, title: "New thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 80c842d91f..45e0efdac9 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -89,6 +89,7 @@ const makeThread = (input?: { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex" as const, model: "gpt-5.4" }, runtimeMode: "full-access" as const, interactionMode: "default" as const, @@ -247,6 +248,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", @@ -283,6 +285,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", @@ -328,6 +331,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { codexThreadId: null, projectId, title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1821c65ed9..a1d70b0485 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -26,6 +26,7 @@ export function buildLocalDraftThread( codexThreadId: null, projectId: draftThread.projectId, title: "New thread", + pinnedAt: null, modelSelection: fallbackModelSelection, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7562f845e2..8cc60ae17b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2774,6 +2774,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: threadIdForSend, projectId: activeProject.id, title, + pinned: false, modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, @@ -3221,6 +3222,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: nextThreadId, projectId: activeProject.id, title: nextThreadTitle, + pinned: false, modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 224bd2f887..613591c6f0 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -76,6 +76,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5", @@ -91,6 +92,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { id: THREAD_ID, projectId: PROJECT_ID, title: "Test thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5", diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d4cd25db4c..308689ee92 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -592,6 +592,7 @@ function makeProject(overrides: Partial = {}): Project { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5.4", @@ -610,6 +611,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5.4", @@ -758,6 +760,73 @@ describe("sortThreadsForSidebar", () => { ThreadId.makeUnsafe("thread-2"), ]); }); + + it("puts pinned threads ahead of unpinned threads in auto-sort modes", () => { + const sorted = sortThreadsForSidebar( + [ + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + pinnedAt: "2026-03-09T10:20:00.000Z", + }, + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + pinnedAt: null, + }, + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ]); + }); + + it("sorts pinned threads by latest pin timestamp before activity recency", () => { + const sorted = sortThreadsForSidebar( + [ + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-1"), + createdAt: "2026-03-09T10:01:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + pinnedAt: "2026-03-09T10:10:00.000Z", + }, + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-2"), + createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + pinnedAt: "2026-03-09T10:20:00.000Z", + }, + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-3"), + createdAt: "2026-03-09T10:10:00.000Z", + updatedAt: "2026-03-09T10:10:00.000Z", + }), + pinnedAt: null, + }, + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-3"), + ]); + }); }); describe("getFallbackThreadIdAfterDelete", () => { @@ -796,6 +865,41 @@ describe("getFallbackThreadIdAfterDelete", () => { expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-newest")); }); + it("respects pinning when choosing the fallback thread after delete", () => { + const fallbackThreadId = getFallbackThreadIdAfterDelete({ + threads: [ + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-active"), + projectId: ProjectId.makeUnsafe("project-1"), + updatedAt: "2026-03-09T10:05:00.000Z", + }), + pinnedAt: null, + }, + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-pinned"), + projectId: ProjectId.makeUnsafe("project-1"), + updatedAt: "2026-03-09T10:03:00.000Z", + }), + pinnedAt: "2026-03-09T10:20:00.000Z", + }, + { + ...makeThread({ + id: ThreadId.makeUnsafe("thread-newer"), + projectId: ProjectId.makeUnsafe("project-1"), + updatedAt: "2026-03-09T10:10:00.000Z", + }), + pinnedAt: null, + }, + ], + deletedThreadId: ThreadId.makeUnsafe("thread-active"), + sortOrder: "updated_at", + }); + + expect(fallbackThreadId).toBe(ThreadId.makeUnsafe("thread-pinned")); + }); + it("skips other threads being deleted in the same action", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ threads: [ @@ -900,6 +1004,65 @@ describe("sortProjectsForSidebar", () => { ]); }); + it("puts pinned projects ahead of unpinned projects in auto-sort modes", () => { + const sorted = sortProjectsForSidebar( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-1"), + name: "Pinned older project", + pinnedAt: "2026-03-09T10:20:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-2"), + name: "Unpinned newer project", + pinnedAt: null, + updatedAt: "2026-03-09T10:05:00.000Z", + }), + ], + [], + "updated_at", + ); + + expect(sorted.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-2"), + ]); + }); + + it("sorts pinned projects by latest pin timestamp before project recency", () => { + const sorted = sortProjectsForSidebar( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-1"), + name: "Pinned older project", + pinnedAt: "2026-03-09T10:10:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-2"), + name: "Pinned newer project", + pinnedAt: "2026-03-09T10:20:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-3"), + name: "Unpinned project", + pinnedAt: null, + updatedAt: "2026-03-09T10:10:00.000Z", + }), + ], + [], + "updated_at", + ); + + expect(sorted.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-2"), + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-3"), + ]); + }); + it("falls back to name and id ordering when projects have no sortable timestamps", () => { const sorted = sortProjectsForSidebar( [ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 16c7752f74..1a3c6f8a5a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,10 +16,12 @@ type SidebarProject = { name: string; createdAt?: string | undefined; updatedAt?: string | undefined; + pinnedAt?: string | null | undefined; }; type SidebarThreadSortInput = Pick & { latestUserMessageAt?: string | null; messages?: Pick[]; + pinnedAt?: string | null | undefined; }; export type ThreadTraversalDirection = "previous" | "next"; @@ -448,10 +450,24 @@ function getThreadSortTimestamp( return getLatestUserMessageTimestamp(thread); } +function getPinnedSortTimestamp(input: { pinnedAt?: string | null | undefined }): number | null { + return toSortableTimestamp(input.pinnedAt ?? undefined); +} + export function sortThreadsForSidebar< T extends Pick & SidebarThreadSortInput, >(threads: readonly T[], sortOrder: SidebarThreadSortOrder): T[] { return threads.toSorted((left, right) => { + const leftPinnedAt = getPinnedSortTimestamp(left); + const rightPinnedAt = getPinnedSortTimestamp(right); + const leftPinned = leftPinnedAt !== null; + const rightPinned = rightPinnedAt !== null; + if (leftPinned !== rightPinned) { + return rightPinned ? 1 : -1; + } + if (leftPinnedAt !== null && rightPinnedAt !== null && leftPinnedAt !== rightPinnedAt) { + return rightPinnedAt > leftPinnedAt ? 1 : -1; + } const rightTimestamp = getThreadSortTimestamp(right, sortOrder); const leftTimestamp = getThreadSortTimestamp(left, sortOrder); const byTimestamp = @@ -526,6 +542,16 @@ export function sortProjectsForSidebar< } return [...projects].toSorted((left, right) => { + const leftPinnedAt = getPinnedSortTimestamp(left); + const rightPinnedAt = getPinnedSortTimestamp(right); + const leftPinned = leftPinnedAt !== null; + const rightPinned = rightPinnedAt !== null; + if (leftPinned !== rightPinned) { + return rightPinned ? 1 : -1; + } + if (leftPinnedAt !== null && rightPinnedAt !== null && leftPinnedAt !== rightPinnedAt) { + return rightPinnedAt > leftPinnedAt ? 1 : -1; + } const rightTimestamp = getProjectSortTimestamp( right, threadsByProjectId.get(right.id) ?? [], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index efa5124288..200569500e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, + PinIcon, PlusIcon, SettingsIcon, SquarePenIcon, @@ -152,6 +153,7 @@ type SidebarThreadSnapshot = Pick< | "id" | "interactionMode" | "latestTurn" + | "pinnedAt" | "projectId" | "proposedPlans" | "session" @@ -163,9 +165,7 @@ type SidebarThreadSnapshot = Pick< latestUserMessageAt: string | null; }; -type SidebarProjectSnapshot = Project & { - expanded: boolean; -}; +type SidebarProjectSnapshot = Project & { expanded: boolean }; const sidebarThreadSnapshotCache = new WeakMap< Thread, @@ -206,6 +206,7 @@ function toSidebarThreadSnapshot( updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, latestTurn: thread.latestTurn, + pinnedAt: thread.pinnedAt, lastVisitedAt, branch: thread.branch, worktreePath: thread.worktreePath, @@ -675,6 +676,7 @@ export default function Sidebar() { projectId, title, workspaceRoot: cwd, + pinned: false, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -832,6 +834,42 @@ export default function Sidebar() { }); }, }); + const setThreadPinned = useCallback(async (thread: SidebarThreadSnapshot, pinned: boolean) => { + const api = readNativeApi(); + if (!api) return; + try { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: thread.id, + pinned, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: `${pinned ? "Pin" : "Unpin"} thread failed`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, []); + const setProjectPinned = useCallback(async (project: Project, pinned: boolean) => { + const api = readNativeApi(); + if (!api) return; + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: project.id, + pinned, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: `${pinned ? "Pin" : "Unpin"} project failed`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, []); const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); @@ -842,6 +880,10 @@ export default function Sidebar() { thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( [ + { + id: "toggle-pin", + label: thread.pinnedAt !== null ? "Unpin thread" : "Pin thread", + }, { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, @@ -851,6 +893,11 @@ export default function Sidebar() { position, ); + if (clicked === "toggle-pin") { + await setThreadPinned(thread, thread.pinnedAt === null); + return; + } + if (clicked === "rename") { setRenamingThreadId(threadId); setRenamingTitle(thread.title); @@ -900,6 +947,7 @@ export default function Sidebar() { markThreadUnread, projectCwdById, threads, + setThreadPinned, ], ); @@ -1018,11 +1066,19 @@ export default function Sidebar() { const clicked = await api.contextMenu.show( [ + { + id: "toggle-pin", + label: project.pinnedAt !== null ? "Unpin project" : "Pin project", + }, { id: "copy-path", label: "Copy Project Path" }, { id: "delete", label: "Remove project", destructive: true }, ], position, ); + if (clicked === "toggle-pin") { + await setProjectPinned(project, project.pinnedAt === null); + return; + } if (clicked === "copy-path") { copyPathToClipboard(project.cwd, { path: project.cwd }); return; @@ -1069,6 +1125,7 @@ export default function Sidebar() { copyPathToClipboard, getDraftThreadByProjectId, projects, + setProjectPinned, threads, ], ); @@ -1160,6 +1217,32 @@ export default function Sidebar() { () => threads.filter((thread) => thread.archivedAt === null), [threads], ); + const threadStatusById = useMemo( + () => + new Map( + visibleThreads.map((thread) => [ + thread.id, + resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }), + ]), + ), + [visibleThreads], + ); + const globalPinnedThreads = useMemo( + () => + sortThreadsForSidebar( + visibleThreads.filter((thread) => thread.pinnedAt !== null), + appSettings.sidebarThreadSortOrder, + ), + [appSettings.sidebarThreadSortOrder, visibleThreads], + ); + const globalPinnedThreadIds = useMemo( + () => globalPinnedThreads.map((thread) => thread.id), + [globalPinnedThreads], + ); const sortedProjects = useMemo( () => sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), @@ -1169,30 +1252,17 @@ export default function Sidebar() { const renderedProjects = useMemo( () => sortedProjects.map((project) => { - const projectThreads = sortThreadsForSidebar( + const allProjectThreads = sortThreadsForSidebar( visibleThreads.filter((thread) => thread.projectId === project.id), appSettings.sidebarThreadSortOrder, ); - const threadStatuses = new Map( - projectThreads.map((thread) => [ - thread.id, - resolveThreadStatusPill({ - thread, - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - }), - ]), - ); + const projectThreads = allProjectThreads.filter((thread) => thread.pinnedAt === null); const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => threadStatuses.get(thread.id) ?? null), + allProjectThreads.map((thread) => threadStatusById.get(thread.id) ?? null), ); const activeThreadId = routeThreadId ?? undefined; const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const pinnedCollapsedThread = - !project.expanded && activeThreadId - ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) - : null; - const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; + const shouldShowThreadPanel = project.expanded; const { hasHiddenThreads, hiddenThreads, @@ -1204,13 +1274,11 @@ export default function Sidebar() { previewLimit: THREAD_PREVIEW_LIMIT, }); const hiddenThreadStatus = resolveProjectStatusIndicator( - hiddenThreads.map((thread) => threadStatuses.get(thread.id) ?? null), + hiddenThreads.map((thread) => threadStatusById.get(thread.id) ?? null), ); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreads = pinnedCollapsedThread - ? [pinnedCollapsedThread] - : visibleProjectThreads; - const showEmptyThreadState = project.expanded && projectThreads.length === 0; + const renderedThreads = visibleProjectThreads; + const showEmptyThreadState = project.expanded && allProjectThreads.length === 0; return { hasHiddenThreads, @@ -1218,8 +1286,6 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - projectThreads, - threadStatuses, renderedThreads, showEmptyThreadState, shouldShowThreadPanel, @@ -1231,12 +1297,13 @@ export default function Sidebar() { expandedThreadListsByProject, routeThreadId, sortedProjects, + threadStatusById, visibleThreads, ], ); const visibleSidebarThreadIds = useMemo( - () => getVisibleSidebarThreadIds(renderedProjects), - [renderedProjects], + () => [...globalPinnedThreadIds, ...getVisibleSidebarThreadIds(renderedProjects)], + [globalPinnedThreadIds, renderedProjects], ); const threadJumpCommandById = useMemo(() => { const mapping = new Map>>(); @@ -1353,272 +1420,273 @@ export default function Sidebar() { updateThreadJumpHintsVisibility, ]); - function renderProjectItem( - renderedProject: (typeof renderedProjects)[number], - dragHandleProps: SortableProjectHandleProps | null, - ) { - const { - hasHiddenThreads, - hiddenThreadStatus, - orderedProjectThreadIds, - project, - projectStatus, - projectThreads, - threadStatuses, - renderedThreads, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - } = renderedProject; - const renderThreadRow = (thread: (typeof projectThreads)[number]) => { - const isActive = routeThreadId === thread.id; - const isSelected = selectedThreadIds.has(thread.id); - const isHighlighted = isActive || isSelected; - const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; - const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; - const threadStatus = threadStatuses.get(thread.id) ?? null; - const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, - ); - const isConfirmingArchive = confirmingArchiveThreadId === thread.id && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; - - return ( - { + const renderThreadRow = ( + thread: SidebarThreadSnapshot, + options: { + leadingVisual?: "project-favicon"; + orderedThreadIds: readonly ThreadId[]; + }, + ) => { + const isActive = routeThreadId === thread.id; + const isSelected = selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; + const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; + const isThreadRunning = + thread.session?.status === "running" && thread.session.activeTurnId != null; + const threadStatus = threadStatusById.get(thread.id) ?? null; + const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); + const terminalStatus = terminalStatusFromRunningIds( + selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, + ); + const isConfirmingArchive = confirmingArchiveThreadId === thread.id && !isThreadRunning; + const threadMetaClassName = isConfirmingArchive + ? "pointer-events-none opacity-0" + : !isThreadRunning + ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" + : "pointer-events-none"; + const hoverActionClassName = + "inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + const hoverActionGroupClassName = + "pointer-events-none absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100"; + const projectCwd = projectCwdById.get(thread.projectId); + + return ( + { + setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + }} + onBlurCapture={(event) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + }); + }} + > + } + size="sm" + isActive={isActive} + data-testid={`thread-row-${thread.id}`} + className={`${resolveThreadRowClassName({ + isActive, + isSelected, + })} relative isolate`} + onClick={(event) => { + handleThreadClick(event, thread.id, options.orderedThreadIds); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(thread.id); }} - onBlurCapture={(event) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; + onContextMenu={(event) => { + event.preventDefault(); + if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + } else { + if (selectedThreadIds.size > 0) { + clearSelection(); } - setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }); + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + } }} > - } - size="sm" - isActive={isActive} - data-testid={`thread-row-${thread.id}`} - className={`${resolveThreadRowClassName({ - isActive, - isSelected, - })} relative isolate`} - onClick={(event) => { - handleThreadClick(event, thread.id, orderedProjectThreadIds); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(thread.id); - }} - onContextMenu={(event) => { - event.preventDefault(); - if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - } - }} - > -
- {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && } - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); +
+ {options.leadingVisual === "project-favicon" ? ( + projectCwd ? ( + + ) : ( + + ) + ) : null} + {prStatus && ( + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + {prStatus.tooltip} + + )} + {threadStatus && } + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(thread.id, renamingTitle, thread.title); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename(thread.id, renamingTitle, thread.title); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {thread.title} + )} +
+
+ {terminalStatus && ( + + + + )} +
+ {isConfirmingArchive ? ( +
-
- {terminalStatus && ( - - - - )} -
- {isConfirmingArchive ? ( - - ) : !isThreadRunning ? ( - appSettings.confirmThreadArchive ? ( -
- -
- ) : ( + Confirm + + ) : null} + {!isConfirmingArchive ? ( +
+ {!isThreadRunning ? ( - -
+ } /> Archive - ) - ) : null} - - {showThreadJumpHints && jumpLabel ? ( - - {jumpLabel} - - ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - - )} - -
+ ) : null} +
+ ) : null} + + {showThreadJumpHints && jumpLabel ? ( + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} + + )} +
- - - ); - }; +
+
+
+ ); + }; + + function renderProjectItem( + renderedProject: (typeof renderedProjects)[number], + dragHandleProps: SortableProjectHandleProps | null, + ) { + const { + hasHiddenThreads, + hiddenThreadStatus, + orderedProjectThreadIds, + project, + projectStatus, + renderedThreads, + showEmptyThreadState, + shouldShowThreadPanel, + isThreadListExpanded, + } = renderedProject; return ( <> @@ -1670,6 +1738,15 @@ export default function Sidebar() { {project.name} + {project.pinnedAt !== null ? ( + + ) : null} { + event.preventDefault(); + event.stopPropagation(); + }} onClick={(event) => { event.preventDefault(); event.stopPropagation(); @@ -1717,7 +1798,10 @@ export default function Sidebar() {
) : null} - {shouldShowThreadPanel && renderedThreads.map((thread) => renderThreadRow(thread))} + {shouldShowThreadPanel && + renderedThreads.map((thread) => + renderThreadRow(thread, { orderedThreadIds: orderedProjectThreadIds }), + )} {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( @@ -2002,6 +2086,21 @@ export default function Sidebar() { ) : null} + {globalPinnedThreads.length > 0 ? ( + + + {globalPinnedThreads.map((thread) => + renderThreadRow(thread, { + leadingVisual: "project-favicon", + orderedThreadIds: globalPinnedThreadIds, + }), + )} + + + ) : null}
diff --git a/apps/web/src/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts index 263610bb95..e43f61f7a0 100644 --- a/apps/web/src/orchestrationEventEffects.test.ts +++ b/apps/web/src/orchestrationEventEffects.test.ts @@ -48,6 +48,7 @@ describe("deriveOrchestrationBatchEffects", () => { threadId: createdThreadId, projectId: ProjectId.makeUnsafe("project-1"), title: "Created thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex" }, runtimeMode: "full-access", interactionMode: "default", @@ -80,6 +81,7 @@ describe("deriveOrchestrationBatchEffects", () => { threadId, projectId: ProjectId.makeUnsafe("project-1"), title: "Recreated thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex" }, runtimeMode: "full-access", interactionMode: "default", diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 6e909b38f0..bbfa9caaf8 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -25,6 +25,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -53,6 +54,7 @@ function makeState(thread: Thread): AppState { id: ProjectId.makeUnsafe("project-1"), name: "Project", cwd: "/tmp/project", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", @@ -97,6 +99,7 @@ function makeReadModelThread(overrides: Partial { id: project2, name: "Project 2", cwd: "/tmp/project-2", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -263,6 +269,7 @@ describe("store read model sync", () => { id: project1, name: "Project 1", cwd: "/tmp/project-1", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -353,6 +360,7 @@ describe("incremental orchestration updates", () => { id: originalProjectId, name: "Project", cwd: "/tmp/project", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, @@ -370,6 +378,7 @@ describe("incremental orchestration updates", () => { projectId: recreatedProjectId, title: "Project Recreated", workspaceRoot: "/tmp/project", + pinnedAt: null, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index eff6a6fd07..f7003eb3b2 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -147,6 +147,7 @@ function mapThread(thread: OrchestrationThread): Thread { codexThreadId: null, projectId: thread.projectId, title: thread.title, + pinnedAt: thread.pinnedAt, modelSelection: normalizeModelSelection(thread.modelSelection), runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, @@ -171,6 +172,7 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec id: project.id, name: project.title, cwd: project.workspaceRoot, + pinnedAt: project.pinnedAt, defaultModelSelection: project.defaultModelSelection ? normalizeModelSelection(project.defaultModelSelection) : null, @@ -424,6 +426,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve id: event.payload.projectId, title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, + pinnedAt: event.payload.pinnedAt, defaultModelSelection: event.payload.defaultModelSelection, scripts: event.payload.scripts, createdAt: event.payload.createdAt, @@ -444,6 +447,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve ...project, ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), + ...(event.payload.pinnedAt !== undefined ? { pinnedAt: event.payload.pinnedAt } : {}), ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection @@ -470,6 +474,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve id: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, + pinnedAt: event.payload.pinnedAt, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -519,6 +524,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.pinnedAt !== undefined ? { pinnedAt: event.payload.pinnedAt } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } : {}), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0ebf150310..f089a46f60 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -82,6 +82,7 @@ export interface Project { id: ProjectId; name: string; cwd: string; + pinnedAt: string | null; defaultModelSelection: ModelSelection | null; createdAt?: string | undefined; updatedAt?: string | undefined; @@ -93,6 +94,7 @@ export interface Thread { codexThreadId: string | null; projectId: ProjectId; title: string; + pinnedAt: string | null; modelSelection: ModelSelection; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 723661ccbb..ac8370dccd 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -10,6 +10,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", + pinnedAt: null, modelSelection: { provider: "codex", model: "gpt-5.3-codex", diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index be53eefd9b..dafaff2e23 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -289,6 +289,7 @@ describe("wsNativeApi", () => { projectId: ProjectId.makeUnsafe("project-1"), title: "Project", workspaceRoot: "/tmp/workspace", + pinnedAt: null, defaultModelSelection: null, scripts: [], createdAt: "2026-02-24T00:00:00.000Z", @@ -331,6 +332,7 @@ describe("wsNativeApi", () => { projectId: ProjectId.makeUnsafe("project-1"), title: "Project", workspaceRoot: "/tmp/project", + pinned: false, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 06bb35038d..4ac287f2fd 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -95,6 +95,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", () assert.strictEqual(parsed.projectId, "project-1"); assert.strictEqual(parsed.title, "Project Title"); assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); + assert.strictEqual(parsed.pinned, false); assert.deepStrictEqual(parsed.defaultModelSelection, { provider: "codex", model: "gpt-5.2", @@ -117,6 +118,7 @@ it.effect("decodes historical project.created payloads with a default provider", updatedAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.defaultModelSelection?.provider, "codex"); + assert.strictEqual(parsed.pinnedAt, null); }), ); @@ -214,6 +216,7 @@ it.effect("decodes thread.created runtime mode for historical events", () => assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); assert.strictEqual(parsed.modelSelection.provider, "codex"); + assert.strictEqual(parsed.pinnedAt, null); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a780a55c78..cb28011c3d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -145,6 +145,7 @@ export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + pinnedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -274,6 +275,7 @@ export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + pinnedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -308,6 +310,7 @@ export const ProjectCreateCommand = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), createdAt: IsoDateTime, }); @@ -318,6 +321,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + pinned: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), }); @@ -334,6 +338,7 @@ const ThreadCreateCommand = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + pinned: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -367,6 +372,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + pinned: Schema.optional(Schema.Boolean), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -624,6 +630,7 @@ export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + pinnedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -634,6 +641,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, @@ -648,6 +656,7 @@ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + pinnedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), modelSelection: ModelSelection, runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -678,6 +687,7 @@ export const ThreadUnarchivedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),