diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts
index 2f79ea9d5a..d5457af522 100644
--- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts
+++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts
@@ -33,6 +33,8 @@ function makeSnapshot(input: {
workspaceRoot: input.workspaceRoot,
defaultModel: null,
scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deletedAt: null,
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
index 6e4e824f39..db57133183 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
@@ -1490,6 +1490,30 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
},
});
+ yield* appendAndProject({
+ type: "thread.session-set",
+ eventId: EventId.makeUnsafe("evt-conflict-6"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-conflict"),
+ occurredAt: "2026-02-26T13:00:05.000Z",
+ commandId: CommandId.makeUnsafe("cmd-conflict-6"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-conflict-6"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-conflict"),
+ session: {
+ threadId: ThreadId.makeUnsafe("thread-conflict"),
+ status: "ready",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: null,
+ lastError: null,
+ updatedAt: "2026-02-26T13:00:05.000Z",
+ },
+ },
+ });
+
const turnRows = yield* sql<{
readonly turnId: string;
readonly checkpointTurnCount: number | null;
@@ -1516,6 +1540,556 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
}),
);
+ it.effect("does not complete a running turn on assistant message finalization alone", () =>
+ Effect.gen(function* () {
+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
+ const eventStore = yield* OrchestrationEventStore;
+ const sql = yield* SqlClient.SqlClient;
+ const appendAndProject = (event: Parameters[0]) =>
+ eventStore
+ .append(event)
+ .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
+
+ yield* appendAndProject({
+ type: "project.created",
+ eventId: EventId.makeUnsafe("evt-turn-open-1"),
+ aggregateKind: "project",
+ aggregateId: ProjectId.makeUnsafe("project-turn-open"),
+ occurredAt: "2026-03-09T12:00:00.000Z",
+ commandId: CommandId.makeUnsafe("cmd-turn-open-1"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-turn-open-1"),
+ metadata: {},
+ payload: {
+ projectId: ProjectId.makeUnsafe("project-turn-open"),
+ title: "Project Turn Open",
+ workspaceRoot: "/tmp/project-turn-open",
+ defaultModel: null,
+ scripts: [],
+ createdAt: "2026-03-09T12:00:00.000Z",
+ updatedAt: "2026-03-09T12:00:00.000Z",
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.created",
+ eventId: EventId.makeUnsafe("evt-turn-open-2"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-turn-open"),
+ occurredAt: "2026-03-09T12:00:01.000Z",
+ commandId: CommandId.makeUnsafe("cmd-turn-open-2"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-turn-open-2"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-turn-open"),
+ projectId: ProjectId.makeUnsafe("project-turn-open"),
+ title: "Thread Turn Open",
+ model: "gpt-5-codex",
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt: "2026-03-09T12:00:01.000Z",
+ updatedAt: "2026-03-09T12:00:01.000Z",
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.session-set",
+ eventId: EventId.makeUnsafe("evt-turn-open-3"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-turn-open"),
+ occurredAt: "2026-03-09T12:00:02.000Z",
+ commandId: CommandId.makeUnsafe("cmd-turn-open-3"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-turn-open-3"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-turn-open"),
+ session: {
+ threadId: ThreadId.makeUnsafe("thread-turn-open"),
+ status: "running",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: TurnId.makeUnsafe("turn-1"),
+ lastError: null,
+ updatedAt: "2026-03-09T12:00:02.000Z",
+ },
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.message-sent",
+ eventId: EventId.makeUnsafe("evt-turn-open-4"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-turn-open"),
+ occurredAt: "2026-03-09T12:00:03.000Z",
+ commandId: CommandId.makeUnsafe("cmd-turn-open-4"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-turn-open-4"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-turn-open"),
+ messageId: MessageId.makeUnsafe("assistant-turn-open"),
+ role: "assistant",
+ text: "done",
+ turnId: TurnId.makeUnsafe("turn-1"),
+ streaming: false,
+ createdAt: "2026-03-09T12:00:03.000Z",
+ updatedAt: "2026-03-09T12:00:03.000Z",
+ },
+ });
+
+ const turnRows = yield* sql<{
+ readonly state: string;
+ readonly completedAt: string | null;
+ readonly assistantMessageId: string | null;
+ }>`
+ SELECT
+ state,
+ completed_at AS "completedAt",
+ assistant_message_id AS "assistantMessageId"
+ FROM projection_turns
+ WHERE thread_id = 'thread-turn-open'
+ AND turn_id = 'turn-1'
+ `;
+
+ assert.deepEqual(turnRows, [
+ {
+ state: "running",
+ completedAt: null,
+ assistantMessageId: "assistant-turn-open",
+ },
+ ]);
+ }),
+ );
+
+ it.effect("does not complete a running turn on checkpoint completion alone", () =>
+ Effect.gen(function* () {
+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
+ const eventStore = yield* OrchestrationEventStore;
+ const sql = yield* SqlClient.SqlClient;
+ const appendAndProject = (event: Parameters[0]) =>
+ eventStore
+ .append(event)
+ .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
+
+ yield* appendAndProject({
+ type: "project.created",
+ eventId: EventId.makeUnsafe("evt-checkpoint-open-1"),
+ aggregateKind: "project",
+ aggregateId: ProjectId.makeUnsafe("project-checkpoint-open"),
+ occurredAt: "2026-03-09T12:10:00.000Z",
+ commandId: CommandId.makeUnsafe("cmd-checkpoint-open-1"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-checkpoint-open-1"),
+ metadata: {},
+ payload: {
+ projectId: ProjectId.makeUnsafe("project-checkpoint-open"),
+ title: "Project Checkpoint Open",
+ workspaceRoot: "/tmp/project-checkpoint-open",
+ defaultModel: null,
+ scripts: [],
+ createdAt: "2026-03-09T12:10:00.000Z",
+ updatedAt: "2026-03-09T12:10:00.000Z",
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.created",
+ eventId: EventId.makeUnsafe("evt-checkpoint-open-2"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-checkpoint-open"),
+ occurredAt: "2026-03-09T12:10:01.000Z",
+ commandId: CommandId.makeUnsafe("cmd-checkpoint-open-2"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-checkpoint-open-2"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-checkpoint-open"),
+ projectId: ProjectId.makeUnsafe("project-checkpoint-open"),
+ title: "Thread Checkpoint Open",
+ model: "gpt-5-codex",
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt: "2026-03-09T12:10:01.000Z",
+ updatedAt: "2026-03-09T12:10:01.000Z",
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.session-set",
+ eventId: EventId.makeUnsafe("evt-checkpoint-open-3"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-checkpoint-open"),
+ occurredAt: "2026-03-09T12:10:02.000Z",
+ commandId: CommandId.makeUnsafe("cmd-checkpoint-open-3"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-checkpoint-open-3"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-checkpoint-open"),
+ session: {
+ threadId: ThreadId.makeUnsafe("thread-checkpoint-open"),
+ status: "running",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: TurnId.makeUnsafe("turn-1"),
+ lastError: null,
+ updatedAt: "2026-03-09T12:10:02.000Z",
+ },
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.turn-diff-completed",
+ eventId: EventId.makeUnsafe("evt-checkpoint-open-4"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-checkpoint-open"),
+ occurredAt: "2026-03-09T12:10:03.000Z",
+ commandId: CommandId.makeUnsafe("cmd-checkpoint-open-4"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-checkpoint-open-4"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-checkpoint-open"),
+ turnId: TurnId.makeUnsafe("turn-1"),
+ checkpointTurnCount: 1,
+ checkpointRef: CheckpointRef.makeUnsafe(
+ "refs/t3/checkpoints/thread-checkpoint-open/turn/1",
+ ),
+ status: "ready",
+ files: [],
+ assistantMessageId: MessageId.makeUnsafe("assistant-checkpoint-open"),
+ completedAt: "2026-03-09T12:10:03.000Z",
+ },
+ });
+
+ const turnRows = yield* sql<{
+ readonly state: string;
+ readonly completedAt: string | null;
+ readonly assistantMessageId: string | null;
+ }>`
+ SELECT
+ state,
+ completed_at AS "completedAt",
+ assistant_message_id AS "assistantMessageId"
+ FROM projection_turns
+ WHERE thread_id = 'thread-checkpoint-open'
+ AND turn_id = 'turn-1'
+ `;
+
+ assert.deepEqual(turnRows, [
+ {
+ state: "running",
+ completedAt: null,
+ assistantMessageId: "assistant-checkpoint-open",
+ },
+ ]);
+ }),
+ );
+
+ it.effect("finalizes running turns only when the session lifecycle closes", () =>
+ Effect.gen(function* () {
+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
+ const eventStore = yield* OrchestrationEventStore;
+ const sql = yield* SqlClient.SqlClient;
+ const appendAndProject = (event: Parameters[0]) =>
+ eventStore
+ .append(event)
+ .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
+
+ const scenarios = [
+ { name: "ready", status: "ready", expectedState: "completed" },
+ { name: "error", status: "error", expectedState: "error" },
+ { name: "stopped", status: "stopped", expectedState: "interrupted" },
+ ] as const;
+
+ for (const [index, scenario] of scenarios.entries()) {
+ const projectId = ProjectId.makeUnsafe(`project-session-close-${scenario.name}`);
+ const threadId = ThreadId.makeUnsafe(`thread-session-close-${scenario.name}`);
+ const baseMinute = 20 + index;
+
+ yield* appendAndProject({
+ type: "project.created",
+ eventId: EventId.makeUnsafe(`evt-session-close-${scenario.name}-1`),
+ aggregateKind: "project",
+ aggregateId: projectId,
+ occurredAt: `2026-03-09T12:${baseMinute}:00.000Z`,
+ commandId: CommandId.makeUnsafe(`cmd-session-close-${scenario.name}-1`),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe(`cmd-session-close-${scenario.name}-1`),
+ metadata: {},
+ payload: {
+ projectId,
+ title: `Project ${scenario.name}`,
+ workspaceRoot: `/tmp/project-session-close-${scenario.name}`,
+ defaultModel: null,
+ scripts: [],
+ createdAt: `2026-03-09T12:${baseMinute}:00.000Z`,
+ updatedAt: `2026-03-09T12:${baseMinute}:00.000Z`,
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.created",
+ eventId: EventId.makeUnsafe(`evt-session-close-${scenario.name}-2`),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ occurredAt: `2026-03-09T12:${baseMinute}:01.000Z`,
+ commandId: CommandId.makeUnsafe(`cmd-session-close-${scenario.name}-2`),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe(`cmd-session-close-${scenario.name}-2`),
+ metadata: {},
+ payload: {
+ threadId,
+ projectId,
+ title: `Thread ${scenario.name}`,
+ model: "gpt-5-codex",
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt: `2026-03-09T12:${baseMinute}:01.000Z`,
+ updatedAt: `2026-03-09T12:${baseMinute}:01.000Z`,
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.session-set",
+ eventId: EventId.makeUnsafe(`evt-session-close-${scenario.name}-3`),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ occurredAt: `2026-03-09T12:${baseMinute}:02.000Z`,
+ commandId: CommandId.makeUnsafe(`cmd-session-close-${scenario.name}-3`),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe(`cmd-session-close-${scenario.name}-3`),
+ metadata: {},
+ payload: {
+ threadId,
+ session: {
+ threadId,
+ status: "running",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: TurnId.makeUnsafe("turn-1"),
+ lastError: null,
+ updatedAt: `2026-03-09T12:${baseMinute}:02.000Z`,
+ },
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.session-set",
+ eventId: EventId.makeUnsafe(`evt-session-close-${scenario.name}-4`),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ occurredAt: `2026-03-09T12:${baseMinute}:04.000Z`,
+ commandId: CommandId.makeUnsafe(`cmd-session-close-${scenario.name}-4`),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe(`cmd-session-close-${scenario.name}-4`),
+ metadata: {},
+ payload: {
+ threadId,
+ session: {
+ threadId,
+ status: scenario.status,
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: null,
+ lastError: scenario.status === "error" ? "Provider crashed" : null,
+ updatedAt: `2026-03-09T12:${baseMinute}:04.000Z`,
+ },
+ },
+ });
+ }
+
+ const turnRows = yield* sql<{
+ readonly threadId: string;
+ readonly state: string;
+ readonly completedAt: string | null;
+ }>`
+ SELECT
+ thread_id AS "threadId",
+ state,
+ completed_at AS "completedAt"
+ FROM projection_turns
+ WHERE thread_id IN (
+ 'thread-session-close-ready',
+ 'thread-session-close-error',
+ 'thread-session-close-stopped'
+ )
+ AND turn_id = 'turn-1'
+ ORDER BY thread_id ASC
+ `;
+
+ assert.deepEqual(turnRows, [
+ {
+ threadId: "thread-session-close-error",
+ state: "error",
+ completedAt: "2026-03-09T12:21:04.000Z",
+ },
+ {
+ threadId: "thread-session-close-ready",
+ state: "completed",
+ completedAt: "2026-03-09T12:20:04.000Z",
+ },
+ {
+ threadId: "thread-session-close-stopped",
+ state: "interrupted",
+ completedAt: "2026-03-09T12:22:04.000Z",
+ },
+ ]);
+ }),
+ );
+
+ it.effect("does not overwrite already terminal turn rows on later session updates", () =>
+ Effect.gen(function* () {
+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
+ const eventStore = yield* OrchestrationEventStore;
+ const sql = yield* SqlClient.SqlClient;
+ const appendAndProject = (event: Parameters[0]) =>
+ eventStore
+ .append(event)
+ .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
+
+ yield* appendAndProject({
+ type: "project.created",
+ eventId: EventId.makeUnsafe("evt-terminal-preserve-1"),
+ aggregateKind: "project",
+ aggregateId: ProjectId.makeUnsafe("project-terminal-preserve"),
+ occurredAt: "2026-03-09T12:30:00.000Z",
+ commandId: CommandId.makeUnsafe("cmd-terminal-preserve-1"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-terminal-preserve-1"),
+ metadata: {},
+ payload: {
+ projectId: ProjectId.makeUnsafe("project-terminal-preserve"),
+ title: "Project Terminal Preserve",
+ workspaceRoot: "/tmp/project-terminal-preserve",
+ defaultModel: null,
+ scripts: [],
+ createdAt: "2026-03-09T12:30:00.000Z",
+ updatedAt: "2026-03-09T12:30:00.000Z",
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.created",
+ eventId: EventId.makeUnsafe("evt-terminal-preserve-2"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ occurredAt: "2026-03-09T12:30:01.000Z",
+ commandId: CommandId.makeUnsafe("cmd-terminal-preserve-2"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-terminal-preserve-2"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ projectId: ProjectId.makeUnsafe("project-terminal-preserve"),
+ title: "Thread Terminal Preserve",
+ model: "gpt-5-codex",
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt: "2026-03-09T12:30:01.000Z",
+ updatedAt: "2026-03-09T12:30:01.000Z",
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.session-set",
+ eventId: EventId.makeUnsafe("evt-terminal-preserve-3"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ occurredAt: "2026-03-09T12:30:02.000Z",
+ commandId: CommandId.makeUnsafe("cmd-terminal-preserve-3"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-terminal-preserve-3"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ session: {
+ threadId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ status: "running",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: TurnId.makeUnsafe("turn-1"),
+ lastError: null,
+ updatedAt: "2026-03-09T12:30:02.000Z",
+ },
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.session-set",
+ eventId: EventId.makeUnsafe("evt-terminal-preserve-4"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ occurredAt: "2026-03-09T12:30:03.000Z",
+ commandId: CommandId.makeUnsafe("cmd-terminal-preserve-4"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-terminal-preserve-4"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ session: {
+ threadId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ status: "ready",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: null,
+ lastError: null,
+ updatedAt: "2026-03-09T12:30:03.000Z",
+ },
+ },
+ });
+
+ yield* appendAndProject({
+ type: "thread.session-set",
+ eventId: EventId.makeUnsafe("evt-terminal-preserve-5"),
+ aggregateKind: "thread",
+ aggregateId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ occurredAt: "2026-03-09T12:30:04.000Z",
+ commandId: CommandId.makeUnsafe("cmd-terminal-preserve-5"),
+ causationEventId: null,
+ correlationId: CorrelationId.makeUnsafe("cmd-terminal-preserve-5"),
+ metadata: {},
+ payload: {
+ threadId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ session: {
+ threadId: ThreadId.makeUnsafe("thread-terminal-preserve"),
+ status: "error",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: null,
+ lastError: "late error",
+ updatedAt: "2026-03-09T12:30:04.000Z",
+ },
+ },
+ });
+
+ const turnRows = yield* sql<{
+ readonly state: string;
+ readonly completedAt: string | null;
+ }>`
+ SELECT
+ state,
+ completed_at AS "completedAt"
+ FROM projection_turns
+ WHERE thread_id = 'thread-terminal-preserve'
+ AND turn_id = 'turn-1'
+ `;
+
+ assert.deepEqual(turnRows, [
+ {
+ state: "completed",
+ completedAt: "2026-03-09T12:30:03.000Z",
+ },
+ ]);
+ }),
+ );
+
it.effect("does not fallback-retain messages whose turnId is removed by revert", () =>
Effect.gen(function* () {
const projectionPipeline = yield* OrchestrationProjectionPipeline;
@@ -1933,4 +2507,47 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
]);
}),
);
+
+ it.effect("projects persist updated thread group order from project.meta.update", () =>
+ Effect.gen(function* () {
+ const engine = yield* OrchestrationEngineService;
+ const sql = yield* SqlClient.SqlClient;
+ const createdAt = new Date().toISOString();
+
+ yield* engine.dispatch({
+ type: "project.create",
+ commandId: CommandId.makeUnsafe("cmd-groups-project-create"),
+ projectId: ProjectId.makeUnsafe("project-groups"),
+ title: "Group Project",
+ workspaceRoot: "/tmp/project-groups",
+ defaultModel: "gpt-5-codex",
+ createdAt,
+ });
+
+ yield* engine.dispatch({
+ type: "project.meta.update",
+ commandId: CommandId.makeUnsafe("cmd-groups-project-update"),
+ projectId: ProjectId.makeUnsafe("project-groups"),
+ threadGroupOrder: [
+ "worktree:/tmp/project-groups/.t3/worktrees/feature-a",
+ "branch:release/1.0",
+ ],
+ });
+
+ const projectRows = yield* sql<{
+ readonly threadGroupOrderJson: string;
+ }>`
+ SELECT
+ thread_group_order_json AS "threadGroupOrderJson"
+ FROM projection_projects
+ WHERE project_id = 'project-groups'
+ `;
+ assert.deepEqual(projectRows, [
+ {
+ threadGroupOrderJson:
+ '["worktree:/tmp/project-groups/.t3/worktrees/feature-a","branch:release/1.0"]',
+ },
+ ]);
+ }),
+ );
});
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
index 24b81d514a..26ecc57f01 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
@@ -25,6 +25,7 @@ import {
import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";
import {
type ProjectionTurn,
+ type ProjectionTurnById,
ProjectionTurnRepository,
} from "../../persistence/Services/ProjectionTurns.ts";
import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts";
@@ -239,6 +240,52 @@ function collectThreadAttachmentRelativePaths(
return relativePaths;
}
+function isTerminalProjectionTurnState(state: ProjectionTurn["state"]): boolean {
+ return state === "completed" || state === "error" || state === "interrupted";
+}
+
+function terminalProjectionTurnStateFromSessionStatus(
+ status: "idle" | "starting" | "running" | "ready" | "interrupted" | "stopped" | "error",
+): ProjectionTurn["state"] | null {
+ switch (status) {
+ case "ready":
+ return "completed";
+ case "error":
+ return "error";
+ case "interrupted":
+ case "stopped":
+ return "interrupted";
+ default:
+ return null;
+ }
+}
+
+function findLatestUnresolvedConcreteTurn(
+ turns: ReadonlyArray,
+): ProjectionTurnById | null {
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
+ const turn = turns[index];
+ if (!turn || turn.turnId === null || isTerminalProjectionTurnState(turn.state)) {
+ continue;
+ }
+ return {
+ threadId: turn.threadId,
+ turnId: turn.turnId,
+ pendingMessageId: turn.pendingMessageId,
+ assistantMessageId: turn.assistantMessageId,
+ state: turn.state,
+ requestedAt: turn.requestedAt,
+ startedAt: turn.startedAt,
+ completedAt: turn.completedAt,
+ checkpointTurnCount: turn.checkpointTurnCount,
+ checkpointRef: turn.checkpointRef,
+ checkpointStatus: turn.checkpointStatus,
+ checkpointFiles: turn.checkpointFiles,
+ };
+ }
+ return null;
+}
+
const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSideEffects) {
const serverConfig = yield* Effect.service(ServerConfig);
const fileSystem = yield* Effect.service(FileSystem.FileSystem);
@@ -366,6 +413,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
workspaceRoot: event.payload.workspaceRoot,
defaultModel: event.payload.defaultModel,
scripts: event.payload.scripts,
+ threadGroupOrder: event.payload.threadGroupOrder,
+ sortOrder: event.payload.sortOrder,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
deletedAt: null,
@@ -389,6 +438,12 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
? { defaultModel: event.payload.defaultModel }
: {}),
...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
+ ...(event.payload.threadGroupOrder !== undefined
+ ? { threadGroupOrder: event.payload.threadGroupOrder }
+ : {}),
+ ...(event.payload.sortOrder !== undefined
+ ? { sortOrder: event.payload.sortOrder }
+ : {}),
updatedAt: event.payload.updatedAt,
});
return;
@@ -784,65 +839,89 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
case "thread.session-set": {
const turnId = event.payload.session.activeTurnId;
- if (turnId === null || event.payload.session.status !== "running") {
- return;
- }
-
- const existingTurn = yield* projectionTurnRepository.getByTurnId({
- threadId: event.payload.threadId,
- turnId,
- });
- const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({
- threadId: event.payload.threadId,
- });
- if (Option.isSome(existingTurn)) {
- const nextState =
- existingTurn.value.state === "completed" || existingTurn.value.state === "error"
- ? existingTurn.value.state
- : "running";
- yield* projectionTurnRepository.upsertByTurnId({
- ...existingTurn.value,
- state: nextState,
- pendingMessageId:
- existingTurn.value.pendingMessageId ??
- (Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null),
- startedAt:
- existingTurn.value.startedAt ??
- (Option.isSome(pendingTurnStart)
+ if (event.payload.session.status === "running" && turnId !== null) {
+ const existingTurn = yield* projectionTurnRepository.getByTurnId({
+ threadId: event.payload.threadId,
+ turnId,
+ });
+ const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({
+ threadId: event.payload.threadId,
+ });
+ if (Option.isSome(existingTurn)) {
+ yield* projectionTurnRepository.upsertByTurnId({
+ ...existingTurn.value,
+ state: isTerminalProjectionTurnState(existingTurn.value.state)
+ ? existingTurn.value.state
+ : "running",
+ pendingMessageId:
+ existingTurn.value.pendingMessageId ??
+ (Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null),
+ startedAt:
+ existingTurn.value.startedAt ??
+ (Option.isSome(pendingTurnStart)
+ ? pendingTurnStart.value.requestedAt
+ : event.payload.session.updatedAt),
+ requestedAt:
+ existingTurn.value.requestedAt ??
+ (Option.isSome(pendingTurnStart)
+ ? pendingTurnStart.value.requestedAt
+ : event.payload.session.updatedAt),
+ completedAt: isTerminalProjectionTurnState(existingTurn.value.state)
+ ? existingTurn.value.completedAt
+ : null,
+ });
+ } else {
+ yield* projectionTurnRepository.upsertByTurnId({
+ turnId,
+ threadId: event.payload.threadId,
+ pendingMessageId: Option.isSome(pendingTurnStart)
+ ? pendingTurnStart.value.messageId
+ : null,
+ assistantMessageId: null,
+ state: "running",
+ requestedAt: Option.isSome(pendingTurnStart)
? pendingTurnStart.value.requestedAt
- : event.occurredAt),
- requestedAt:
- existingTurn.value.requestedAt ??
- (Option.isSome(pendingTurnStart)
+ : event.payload.session.updatedAt,
+ startedAt: Option.isSome(pendingTurnStart)
? pendingTurnStart.value.requestedAt
- : event.occurredAt),
- });
- } else {
- yield* projectionTurnRepository.upsertByTurnId({
- turnId,
+ : event.payload.session.updatedAt,
+ completedAt: null,
+ checkpointTurnCount: null,
+ checkpointRef: null,
+ checkpointStatus: null,
+ checkpointFiles: [],
+ });
+ }
+
+ yield* projectionTurnRepository.deletePendingTurnStartByThreadId({
threadId: event.payload.threadId,
- pendingMessageId: Option.isSome(pendingTurnStart)
- ? pendingTurnStart.value.messageId
- : null,
- assistantMessageId: null,
- state: "running",
- requestedAt: Option.isSome(pendingTurnStart)
- ? pendingTurnStart.value.requestedAt
- : event.occurredAt,
- startedAt: Option.isSome(pendingTurnStart)
- ? pendingTurnStart.value.requestedAt
- : event.occurredAt,
- completedAt: null,
- checkpointTurnCount: null,
- checkpointRef: null,
- checkpointStatus: null,
- checkpointFiles: [],
});
+ return;
+ }
+
+ const terminalState = terminalProjectionTurnStateFromSessionStatus(
+ event.payload.session.status,
+ );
+ if (terminalState === null) {
+ return;
}
- yield* projectionTurnRepository.deletePendingTurnStartByThreadId({
+ const turns = yield* projectionTurnRepository.listByThreadId({
threadId: event.payload.threadId,
});
+ const latestUnresolvedTurn = findLatestUnresolvedConcreteTurn(turns);
+ if (!latestUnresolvedTurn) {
+ return;
+ }
+
+ yield* projectionTurnRepository.upsertByTurnId({
+ ...latestUnresolvedTurn,
+ turnId: latestUnresolvedTurn.turnId,
+ state: terminalState,
+ requestedAt: latestUnresolvedTurn.requestedAt ?? event.payload.session.updatedAt,
+ startedAt: latestUnresolvedTurn.startedAt ?? event.payload.session.updatedAt,
+ completedAt: latestUnresolvedTurn.completedAt ?? event.payload.session.updatedAt,
+ });
return;
}
@@ -858,18 +937,18 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
yield* projectionTurnRepository.upsertByTurnId({
...existingTurn.value,
assistantMessageId: event.payload.messageId,
- state: event.payload.streaming
+ state: isTerminalProjectionTurnState(existingTurn.value.state)
? existingTurn.value.state
- : existingTurn.value.state === "interrupted"
- ? "interrupted"
- : existingTurn.value.state === "error"
- ? "error"
- : "completed",
- completedAt: event.payload.streaming
+ : "running",
+ startedAt:
+ existingTurn.value.startedAt ??
+ event.payload.createdAt,
+ requestedAt:
+ existingTurn.value.requestedAt ??
+ event.payload.createdAt,
+ completedAt: isTerminalProjectionTurnState(existingTurn.value.state)
? existingTurn.value.completedAt
- : (existingTurn.value.completedAt ?? event.payload.updatedAt),
- startedAt: existingTurn.value.startedAt ?? event.payload.createdAt,
- requestedAt: existingTurn.value.requestedAt ?? event.payload.createdAt,
+ : null,
});
return;
}
@@ -878,10 +957,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
threadId: event.payload.threadId,
pendingMessageId: null,
assistantMessageId: event.payload.messageId,
- state: event.payload.streaming ? "running" : "completed",
+ state: "running",
requestedAt: event.payload.createdAt,
startedAt: event.payload.createdAt,
- completedAt: event.payload.streaming ? null : event.payload.updatedAt,
+ completedAt: null,
checkpointTurnCount: null,
checkpointRef: null,
checkpointStatus: null,
@@ -930,7 +1009,6 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
threadId: event.payload.threadId,
turnId: event.payload.turnId,
});
- const nextState = event.payload.status === "error" ? "error" : "completed";
yield* projectionTurnRepository.clearCheckpointTurnConflict({
threadId: event.payload.threadId,
turnId: event.payload.turnId,
@@ -940,15 +1018,20 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
if (Option.isSome(existingTurn)) {
yield* projectionTurnRepository.upsertByTurnId({
...existingTurn.value,
- assistantMessageId: event.payload.assistantMessageId,
- state: nextState,
+ assistantMessageId:
+ event.payload.assistantMessageId ?? existingTurn.value.assistantMessageId,
+ state: isTerminalProjectionTurnState(existingTurn.value.state)
+ ? existingTurn.value.state
+ : "running",
checkpointTurnCount: event.payload.checkpointTurnCount,
checkpointRef: event.payload.checkpointRef,
checkpointStatus: event.payload.status,
checkpointFiles: event.payload.files,
startedAt: existingTurn.value.startedAt ?? event.payload.completedAt,
requestedAt: existingTurn.value.requestedAt ?? event.payload.completedAt,
- completedAt: event.payload.completedAt,
+ completedAt: isTerminalProjectionTurnState(existingTurn.value.state)
+ ? existingTurn.value.completedAt
+ : null,
});
return;
}
@@ -957,10 +1040,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
threadId: event.payload.threadId,
pendingMessageId: null,
assistantMessageId: event.payload.assistantMessageId,
- state: nextState,
+ state: "running",
requestedAt: event.payload.completedAt,
startedAt: event.payload.completedAt,
- completedAt: event.payload.completedAt,
+ completedAt: null,
checkpointTurnCount: event.payload.checkpointTurnCount,
checkpointRef: event.payload.checkpointRef,
checkpointStatus: event.payload.status,
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
index e7e9cd4e12..e548a28913 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
@@ -42,6 +42,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
workspace_root,
default_model,
scripts_json,
+ thread_group_order_json,
+ sort_order,
created_at,
updated_at,
deleted_at
@@ -52,12 +54,41 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
'/tmp/project-1',
'gpt-5-codex',
'[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
+ '["worktree:/tmp/project-1/.t3/worktrees/feature-a"]',
+ 1,
'2026-02-24T00:00:00.000Z',
'2026-02-24T00:00:01.000Z',
NULL
)
`;
+ yield* sql`
+ INSERT INTO projection_projects (
+ project_id,
+ title,
+ workspace_root,
+ default_model,
+ scripts_json,
+ thread_group_order_json,
+ sort_order,
+ created_at,
+ updated_at,
+ deleted_at
+ )
+ VALUES (
+ 'project-0',
+ 'Project 0',
+ '/tmp/project-0',
+ NULL,
+ '[]',
+ '[]',
+ 0,
+ '2026-02-25T00:00:00.000Z',
+ '2026-02-25T00:00:01.000Z',
+ NULL
+ )
+ `;
+
yield* sql`
INSERT INTO projection_threads (
thread_id,
@@ -85,6 +116,29 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
)
`;
+ yield* sql`
+ INSERT INTO provider_session_runtime (
+ thread_id,
+ provider_name,
+ adapter_key,
+ runtime_mode,
+ status,
+ last_seen_at,
+ resume_cursor_json,
+ runtime_payload_json
+ )
+ VALUES (
+ 'thread-1',
+ 'codex',
+ 'codex',
+ 'full-access',
+ 'running',
+ '2026-02-24T00:00:03.500Z',
+ NULL,
+ '{"cwd":"/tmp/project-1/.t3/worktrees/feature-a"}'
+ )
+ `;
+
yield* sql`
INSERT INTO projection_thread_messages (
message_id,
@@ -207,8 +261,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
const snapshot = yield* snapshotQuery.getSnapshot();
assert.equal(snapshot.snapshotSequence, 5);
- assert.equal(snapshot.updatedAt, "2026-02-24T00:00:09.000Z");
+ assert.equal(snapshot.updatedAt, "2026-02-25T00:00:01.000Z");
assert.deepEqual(snapshot.projects, [
+ {
+ id: asProjectId("project-0"),
+ title: "Project 0",
+ workspaceRoot: "/tmp/project-0",
+ defaultModel: null,
+ scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
+ createdAt: "2026-02-25T00:00:00.000Z",
+ updatedAt: "2026-02-25T00:00:01.000Z",
+ deletedAt: null,
+ },
{
id: asProjectId("project-1"),
title: "Project 1",
@@ -223,6 +289,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
runOnWorktreeCreate: false,
},
],
+ threadGroupOrder: ["worktree:/tmp/project-1/.t3/worktrees/feature-a"],
+ sortOrder: 1,
createdAt: "2026-02-24T00:00:00.000Z",
updatedAt: "2026-02-24T00:00:01.000Z",
deletedAt: null,
@@ -237,7 +305,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
interactionMode: "default",
runtimeMode: "full-access",
branch: null,
- worktreePath: null,
+ worktreePath: "/tmp/project-1/.t3/worktrees/feature-a",
latestTurn: {
turnId: asTurnId("turn-1"),
state: "completed",
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
index 5fd38a5401..60fe0fcaee 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -6,6 +6,7 @@ import {
OrchestrationCheckpointFile,
OrchestrationReadModel,
ProjectScript,
+ ThreadGroupId,
TurnId,
type OrchestrationCheckpointSummary,
type OrchestrationLatestTurn,
@@ -44,6 +45,7 @@ const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
Struct.assign({
scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
+ threadGroupOrder: Schema.fromJsonString(Schema.Array(ThreadGroupId)),
}),
);
const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
@@ -139,11 +141,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
workspace_root AS "workspaceRoot",
default_model AS "defaultModel",
scripts_json AS "scripts",
+ thread_group_order_json AS "threadGroupOrder",
+ sort_order AS "sortOrder",
created_at AS "createdAt",
updated_at AS "updatedAt",
deleted_at AS "deletedAt"
FROM projection_projects
- ORDER BY created_at ASC, project_id ASC
+ ORDER BY sort_order ASC, created_at ASC, project_id ASC
`,
});
@@ -153,20 +157,32 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
execute: () =>
sql`
SELECT
- thread_id AS "threadId",
- project_id AS "projectId",
- title,
- model,
- runtime_mode AS "runtimeMode",
- interaction_mode AS "interactionMode",
- branch,
- worktree_path AS "worktreePath",
- latest_turn_id AS "latestTurnId",
- created_at AS "createdAt",
- updated_at AS "updatedAt",
- deleted_at AS "deletedAt"
- FROM projection_threads
- ORDER BY created_at ASC, thread_id ASC
+ threads.thread_id AS "threadId",
+ threads.project_id AS "projectId",
+ threads.title AS "title",
+ threads.model AS "model",
+ threads.runtime_mode AS "runtimeMode",
+ threads.interaction_mode AS "interactionMode",
+ threads.branch AS "branch",
+ COALESCE(
+ threads.worktree_path,
+ CASE
+ WHEN json_extract(runtime.runtime_payload_json, '$.cwd') IS NOT NULL
+ AND json_extract(runtime.runtime_payload_json, '$.cwd') <> projects.workspace_root
+ THEN json_extract(runtime.runtime_payload_json, '$.cwd')
+ ELSE NULL
+ END
+ ) AS "worktreePath",
+ threads.latest_turn_id AS "latestTurnId",
+ threads.created_at AS "createdAt",
+ threads.updated_at AS "updatedAt",
+ threads.deleted_at AS "deletedAt"
+ FROM projection_threads AS threads
+ INNER JOIN projection_projects AS projects
+ ON projects.project_id = threads.project_id
+ LEFT JOIN provider_session_runtime AS runtime
+ ON runtime.thread_id = threads.thread_id
+ ORDER BY threads.created_at ASC, threads.thread_id ASC
`,
});
@@ -519,6 +535,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
workspaceRoot: row.workspaceRoot,
defaultModel: row.defaultModel,
scripts: row.scripts,
+ threadGroupOrder: row.threadGroupOrder,
+ sortOrder: row.sortOrder,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts
index f95e4db754..6450400378 100644
--- a/apps/server/src/orchestration/commandInvariants.test.ts
+++ b/apps/server/src/orchestration/commandInvariants.test.ts
@@ -30,6 +30,8 @@ const readModel: OrchestrationReadModel = {
workspaceRoot: "/tmp/project-a",
defaultModel: "gpt-5-codex",
scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -40,6 +42,8 @@ const readModel: OrchestrationReadModel = {
workspaceRoot: "/tmp/project-b",
defaultModel: "gpt-5-codex",
scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 1,
createdAt: now,
updatedAt: now,
deletedAt: null,
diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts
index 516d8b2a28..f5e63788e1 100644
--- a/apps/server/src/orchestration/decider.projectScripts.test.ts
+++ b/apps/server/src/orchestration/decider.projectScripts.test.ts
@@ -38,6 +38,55 @@ 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 as { sortOrder: number }).sortOrder).toBe(0);
+ });
+
+ it("assigns the next shared sort order on project.create", async () => {
+ const now = new Date().toISOString();
+ const initial = createEmptyReadModel(now);
+ const withProject = await Effect.runPromise(
+ projectEvent(initial, {
+ sequence: 1,
+ eventId: asEventId("evt-project-create-first"),
+ aggregateKind: "project",
+ aggregateId: asProjectId("project-first"),
+ type: "project.created",
+ occurredAt: now,
+ commandId: CommandId.makeUnsafe("cmd-project-create-first"),
+ causationEventId: null,
+ correlationId: CommandId.makeUnsafe("cmd-project-create-first"),
+ metadata: {},
+ payload: {
+ projectId: asProjectId("project-first"),
+ title: "First",
+ workspaceRoot: "/tmp/first",
+ defaultModel: null,
+ scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
+ createdAt: now,
+ updatedAt: now,
+ },
+ }),
+ );
+
+ const result = await Effect.runPromise(
+ decideOrchestrationCommand({
+ command: {
+ type: "project.create",
+ commandId: CommandId.makeUnsafe("cmd-project-create-second"),
+ projectId: asProjectId("project-second"),
+ title: "Second",
+ workspaceRoot: "/tmp/second",
+ createdAt: now,
+ },
+ readModel: withProject,
+ }),
+ );
+
+ const event = Array.isArray(result) ? result[0] : result;
+ expect(event.type).toBe("project.created");
+ expect((event.payload as { sortOrder: number }).sortOrder).toBe(1);
});
it("propagates scripts in project.meta.update payload", async () => {
@@ -61,6 +110,8 @@ describe("decider project scripts", () => {
workspaceRoot: "/tmp/scripts",
defaultModel: null,
scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
createdAt: now,
updatedAt: now,
},
@@ -94,6 +145,101 @@ describe("decider project scripts", () => {
expect((event.payload as { scripts?: unknown[] }).scripts).toEqual(scripts);
});
+ it("propagates thread group order in project.meta.update payload", async () => {
+ const now = new Date().toISOString();
+ const initial = createEmptyReadModel(now);
+ const readModel = await Effect.runPromise(
+ projectEvent(initial, {
+ sequence: 1,
+ eventId: asEventId("evt-project-create-groups"),
+ aggregateKind: "project",
+ aggregateId: asProjectId("project-groups"),
+ type: "project.created",
+ occurredAt: now,
+ commandId: CommandId.makeUnsafe("cmd-project-create-groups"),
+ causationEventId: null,
+ correlationId: CommandId.makeUnsafe("cmd-project-create-groups"),
+ metadata: {},
+ payload: {
+ projectId: asProjectId("project-groups"),
+ title: "Groups",
+ workspaceRoot: "/tmp/groups",
+ defaultModel: null,
+ scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
+ createdAt: now,
+ updatedAt: now,
+ },
+ }),
+ );
+
+ const result = await Effect.runPromise(
+ decideOrchestrationCommand({
+ command: {
+ type: "project.meta.update",
+ commandId: CommandId.makeUnsafe("cmd-project-update-groups"),
+ projectId: asProjectId("project-groups"),
+ threadGroupOrder: ["worktree:/tmp/groups/.t3/worktrees/feature-a", "branch:release/1.0"],
+ },
+ readModel,
+ }),
+ );
+
+ const event = Array.isArray(result) ? result[0] : result;
+ expect(event.type).toBe("project.meta-updated");
+ expect((event.payload as { threadGroupOrder?: string[] }).threadGroupOrder).toEqual([
+ "worktree:/tmp/groups/.t3/worktrees/feature-a",
+ "branch:release/1.0",
+ ]);
+ });
+
+ it("propagates shared sort order in project.meta.update payload", async () => {
+ const now = new Date().toISOString();
+ const initial = createEmptyReadModel(now);
+ const readModel = await Effect.runPromise(
+ projectEvent(initial, {
+ sequence: 1,
+ eventId: asEventId("evt-project-create-sort-order"),
+ aggregateKind: "project",
+ aggregateId: asProjectId("project-order"),
+ type: "project.created",
+ occurredAt: now,
+ commandId: CommandId.makeUnsafe("cmd-project-create-sort-order"),
+ causationEventId: null,
+ correlationId: CommandId.makeUnsafe("cmd-project-create-sort-order"),
+ metadata: {},
+ payload: {
+ projectId: asProjectId("project-order"),
+ title: "Order",
+ workspaceRoot: "/tmp/order",
+ defaultModel: null,
+ scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
+ createdAt: now,
+ updatedAt: now,
+ },
+ }),
+ );
+
+ const result = await Effect.runPromise(
+ decideOrchestrationCommand({
+ command: {
+ type: "project.meta.update",
+ commandId: CommandId.makeUnsafe("cmd-project-update-sort-order"),
+ projectId: asProjectId("project-order"),
+ sortOrder: 3,
+ },
+ readModel,
+ }),
+ );
+
+ const event = Array.isArray(result) ? result[0] : result;
+ expect(event.type).toBe("project.meta-updated");
+ expect((event.payload as { sortOrder?: number }).sortOrder).toBe(3);
+ });
+
it("emits user message and turn-start-requested events for thread.turn.start", async () => {
const now = new Date().toISOString();
const initial = createEmptyReadModel(now);
@@ -115,6 +261,8 @@ describe("decider project scripts", () => {
workspaceRoot: "/tmp/project",
defaultModel: null,
scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
createdAt: now,
updatedAt: now,
},
@@ -222,6 +370,8 @@ describe("decider project scripts", () => {
workspaceRoot: "/tmp/project",
defaultModel: null,
scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
createdAt: now,
updatedAt: now,
},
@@ -301,6 +451,8 @@ describe("decider project scripts", () => {
workspaceRoot: "/tmp/project",
defaultModel: null,
scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
createdAt: now,
updatedAt: now,
},
diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts
index 2218f88cf5..547368b738 100644
--- a/apps/server/src/orchestration/decider.ts
+++ b/apps/server/src/orchestration/decider.ts
@@ -64,6 +64,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
command,
projectId: command.projectId,
});
+ const sortOrder =
+ readModel.projects.reduce(
+ (maxSortOrder, project) => Math.max(maxSortOrder, project.sortOrder),
+ -1,
+ ) + 1;
return {
...withEventBase({
@@ -79,6 +84,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
workspaceRoot: command.workspaceRoot,
defaultModel: command.defaultModel ?? null,
scripts: [],
+ threadGroupOrder: [],
+ sortOrder,
createdAt: command.createdAt,
updatedAt: command.createdAt,
},
@@ -106,6 +113,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}),
...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}),
...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
+ ...(command.threadGroupOrder !== undefined
+ ? { threadGroupOrder: command.threadGroupOrder }
+ : {}),
+ ...(command.sortOrder !== undefined ? { sortOrder: command.sortOrder } : {}),
updatedAt: occurredAt,
},
};
diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts
index 71f5b6bd4b..a0832dc170 100644
--- a/apps/server/src/orchestration/projector.test.ts
+++ b/apps/server/src/orchestration/projector.test.ts
@@ -3,6 +3,7 @@ import {
EventId,
ProjectId,
ThreadId,
+ TurnId,
type OrchestrationEvent,
} from "@t3tools/contracts";
import { Effect } from "effect";
@@ -214,6 +215,637 @@ describe("orchestration projector", () => {
expect(thread?.session?.status).toBe("running");
});
+ it("does not complete a running turn on assistant message finalization alone", async () => {
+ const createdAt = "2026-03-09T12:00:00.000Z";
+ const runningAt = "2026-03-09T12:00:05.000Z";
+ const completeAt = "2026-03-09T12:00:10.000Z";
+ const model = createEmptyReadModel(createdAt);
+
+ const afterCreate = await Effect.runPromise(
+ projectEvent(
+ model,
+ makeEvent({
+ sequence: 1,
+ type: "thread.created",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: createdAt,
+ commandId: "cmd-create",
+ payload: {
+ threadId: "thread-1",
+ projectId: "project-1",
+ title: "demo",
+ model: "gpt-5.3-codex",
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ }),
+ ),
+ );
+
+ const afterRunning = await Effect.runPromise(
+ projectEvent(
+ afterCreate,
+ makeEvent({
+ sequence: 2,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: runningAt,
+ commandId: "cmd-running",
+ payload: {
+ threadId: "thread-1",
+ session: {
+ threadId: "thread-1",
+ status: "running",
+ providerName: "codex",
+ providerSessionId: "session-1",
+ providerThreadId: "provider-thread-1",
+ runtimeMode: "approval-required",
+ activeTurnId: "turn-1",
+ lastError: null,
+ updatedAt: runningAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ const afterAssistantFinal = await Effect.runPromise(
+ projectEvent(
+ afterRunning,
+ makeEvent({
+ sequence: 3,
+ type: "thread.message-sent",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: completeAt,
+ commandId: "cmd-complete",
+ payload: {
+ threadId: "thread-1",
+ messageId: "assistant-msg-1",
+ role: "assistant",
+ text: "final answer",
+ turnId: "turn-1",
+ streaming: false,
+ createdAt: completeAt,
+ updatedAt: completeAt,
+ },
+ }),
+ ),
+ );
+
+ expect(afterAssistantFinal.threads[0]?.latestTurn).toEqual({
+ turnId: TurnId.makeUnsafe("turn-1"),
+ state: "running",
+ requestedAt: runningAt,
+ startedAt: runningAt,
+ completedAt: null,
+ assistantMessageId: null,
+ });
+ });
+
+ it("does not complete a running turn on checkpoint completion alone", async () => {
+ const createdAt = "2026-03-09T12:00:00.000Z";
+ const runningAt = "2026-03-09T12:00:05.000Z";
+ const checkpointAt = "2026-03-09T12:00:12.000Z";
+ const model = createEmptyReadModel(createdAt);
+
+ const afterCreate = await Effect.runPromise(
+ projectEvent(
+ model,
+ makeEvent({
+ sequence: 1,
+ type: "thread.created",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: createdAt,
+ commandId: "cmd-create",
+ payload: {
+ threadId: "thread-1",
+ projectId: "project-1",
+ title: "demo",
+ model: "gpt-5.3-codex",
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ }),
+ ),
+ );
+
+ const afterRunning = await Effect.runPromise(
+ projectEvent(
+ afterCreate,
+ makeEvent({
+ sequence: 2,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: runningAt,
+ commandId: "cmd-running",
+ payload: {
+ threadId: "thread-1",
+ session: {
+ threadId: "thread-1",
+ status: "running",
+ providerName: "codex",
+ providerSessionId: "session-1",
+ providerThreadId: "provider-thread-1",
+ runtimeMode: "approval-required",
+ activeTurnId: "turn-1",
+ lastError: null,
+ updatedAt: runningAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ const afterCheckpoint = await Effect.runPromise(
+ projectEvent(
+ afterRunning,
+ makeEvent({
+ sequence: 3,
+ type: "thread.turn-diff-completed",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: checkpointAt,
+ commandId: "cmd-checkpoint",
+ payload: {
+ threadId: "thread-1",
+ turnId: "turn-1",
+ checkpointTurnCount: 1,
+ checkpointRef: "refs/t3/checkpoints/thread-1/turn/1",
+ status: "ready",
+ files: [],
+ assistantMessageId: "assistant-msg-1",
+ completedAt: checkpointAt,
+ },
+ }),
+ ),
+ );
+
+ expect(afterCheckpoint.threads[0]?.latestTurn).toEqual({
+ turnId: TurnId.makeUnsafe("turn-1"),
+ state: "running",
+ requestedAt: runningAt,
+ startedAt: runningAt,
+ completedAt: null,
+ assistantMessageId: "assistant-msg-1",
+ });
+ });
+
+ it("finalizes a running turn only when the session lifecycle closes", async () => {
+ const scenarios = [
+ { threadId: "thread-ready", status: "ready", expectedState: "completed" },
+ { threadId: "thread-error", status: "error", expectedState: "error" },
+ { threadId: "thread-stopped", status: "stopped", expectedState: "interrupted" },
+ { threadId: "thread-interrupted", status: "interrupted", expectedState: "interrupted" },
+ ] as const;
+
+ for (const [index, scenario] of scenarios.entries()) {
+ const createdAt = `2026-03-09T12:0${index}:00.000Z`;
+ const runningAt = `2026-03-09T12:0${index}:05.000Z`;
+ const closedAt = `2026-03-09T12:0${index}:10.000Z`;
+ const model = createEmptyReadModel(createdAt);
+
+ const afterCreate = await Effect.runPromise(
+ projectEvent(
+ model,
+ makeEvent({
+ sequence: 1,
+ type: "thread.created",
+ aggregateKind: "thread",
+ aggregateId: scenario.threadId,
+ occurredAt: createdAt,
+ commandId: `cmd-create-${index}`,
+ payload: {
+ threadId: scenario.threadId,
+ projectId: "project-1",
+ title: "demo",
+ model: "gpt-5.3-codex",
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ }),
+ ),
+ );
+
+ const afterRunning = await Effect.runPromise(
+ projectEvent(
+ afterCreate,
+ makeEvent({
+ sequence: 2,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: scenario.threadId,
+ occurredAt: runningAt,
+ commandId: `cmd-running-${index}`,
+ payload: {
+ threadId: scenario.threadId,
+ session: {
+ threadId: scenario.threadId,
+ status: "running",
+ providerName: "codex",
+ providerSessionId: `session-${index}`,
+ providerThreadId: `provider-thread-${index}`,
+ runtimeMode: "approval-required",
+ activeTurnId: "turn-1",
+ lastError: null,
+ updatedAt: runningAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ const afterClosed = await Effect.runPromise(
+ projectEvent(
+ afterRunning,
+ makeEvent({
+ sequence: 3,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: scenario.threadId,
+ occurredAt: closedAt,
+ commandId: `cmd-closed-${index}`,
+ payload: {
+ threadId: scenario.threadId,
+ session: {
+ threadId: scenario.threadId,
+ status: scenario.status,
+ providerName: "codex",
+ providerSessionId: `session-${index}`,
+ providerThreadId: `provider-thread-${index}`,
+ runtimeMode: "approval-required",
+ activeTurnId: null,
+ lastError: scenario.status === "error" ? "Provider crashed" : null,
+ updatedAt: closedAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ expect(afterClosed.threads[0]?.latestTurn).toEqual({
+ turnId: TurnId.makeUnsafe("turn-1"),
+ state: scenario.expectedState,
+ requestedAt: runningAt,
+ startedAt: runningAt,
+ completedAt: closedAt,
+ assistantMessageId: null,
+ });
+ }
+ });
+
+ it("does not finalize a running turn when ready arrives with an active turn id", async () => {
+ const createdAt = "2026-03-09T16:00:00.000Z";
+ const runningAt = "2026-03-09T16:00:05.000Z";
+ const readyNoiseAt = "2026-03-09T16:00:10.000Z";
+ const model = createEmptyReadModel(createdAt);
+
+ const afterCreate = await Effect.runPromise(
+ projectEvent(
+ model,
+ makeEvent({
+ sequence: 1,
+ type: "thread.created",
+ aggregateKind: "thread",
+ aggregateId: "thread-ready-noise",
+ occurredAt: createdAt,
+ commandId: "cmd-create-ready-noise",
+ payload: {
+ threadId: "thread-ready-noise",
+ projectId: "project-1",
+ title: "demo",
+ model: "gpt-5.3-codex",
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ }),
+ ),
+ );
+
+ const afterRunning = await Effect.runPromise(
+ projectEvent(
+ afterCreate,
+ makeEvent({
+ sequence: 2,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: "thread-ready-noise",
+ occurredAt: runningAt,
+ commandId: "cmd-running-ready-noise",
+ payload: {
+ threadId: "thread-ready-noise",
+ session: {
+ threadId: "thread-ready-noise",
+ status: "running",
+ providerName: "codex",
+ runtimeMode: "approval-required",
+ activeTurnId: "turn-1",
+ lastError: null,
+ updatedAt: runningAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ const afterReadyNoise = await Effect.runPromise(
+ projectEvent(
+ afterRunning,
+ makeEvent({
+ sequence: 3,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: "thread-ready-noise",
+ occurredAt: readyNoiseAt,
+ commandId: "cmd-ready-noise",
+ payload: {
+ threadId: "thread-ready-noise",
+ session: {
+ threadId: "thread-ready-noise",
+ status: "ready",
+ providerName: "codex",
+ runtimeMode: "approval-required",
+ activeTurnId: "turn-1",
+ lastError: null,
+ updatedAt: readyNoiseAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ expect(afterReadyNoise.threads[0]?.latestTurn).toEqual({
+ turnId: TurnId.makeUnsafe("turn-1"),
+ state: "running",
+ requestedAt: runningAt,
+ startedAt: runningAt,
+ completedAt: null,
+ assistantMessageId: null,
+ });
+ });
+
+ it("does not refresh completion timestamps when repeated ready noise replays after completion", async () => {
+ const createdAt = "2026-03-09T17:00:00.000Z";
+ const runningAt = "2026-03-09T17:00:05.000Z";
+ const completedAt = "2026-03-09T17:00:10.000Z";
+ const readyNoiseAt = "2026-03-09T17:00:15.000Z";
+ const model = createEmptyReadModel(createdAt);
+
+ const afterCreate = await Effect.runPromise(
+ projectEvent(
+ model,
+ makeEvent({
+ sequence: 1,
+ type: "thread.created",
+ aggregateKind: "thread",
+ aggregateId: "thread-ready-replay",
+ occurredAt: createdAt,
+ commandId: "cmd-create-ready-replay",
+ payload: {
+ threadId: "thread-ready-replay",
+ projectId: "project-1",
+ title: "demo",
+ model: "gpt-5.3-codex",
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ }),
+ ),
+ );
+
+ const afterRunning = await Effect.runPromise(
+ projectEvent(
+ afterCreate,
+ makeEvent({
+ sequence: 2,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: "thread-ready-replay",
+ occurredAt: runningAt,
+ commandId: "cmd-running-ready-replay",
+ payload: {
+ threadId: "thread-ready-replay",
+ session: {
+ threadId: "thread-ready-replay",
+ status: "running",
+ providerName: "codex",
+ runtimeMode: "approval-required",
+ activeTurnId: "turn-1",
+ lastError: null,
+ updatedAt: runningAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ const afterCompleted = await Effect.runPromise(
+ projectEvent(
+ afterRunning,
+ makeEvent({
+ sequence: 3,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: "thread-ready-replay",
+ occurredAt: completedAt,
+ commandId: "cmd-ready-complete",
+ payload: {
+ threadId: "thread-ready-replay",
+ session: {
+ threadId: "thread-ready-replay",
+ status: "ready",
+ providerName: "codex",
+ runtimeMode: "approval-required",
+ activeTurnId: null,
+ lastError: null,
+ updatedAt: completedAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ const afterReadyNoise = await Effect.runPromise(
+ projectEvent(
+ afterCompleted,
+ makeEvent({
+ sequence: 4,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: "thread-ready-replay",
+ occurredAt: readyNoiseAt,
+ commandId: "cmd-ready-noise-replay",
+ payload: {
+ threadId: "thread-ready-replay",
+ session: {
+ threadId: "thread-ready-replay",
+ status: "ready",
+ providerName: "codex",
+ runtimeMode: "approval-required",
+ activeTurnId: "turn-1",
+ lastError: null,
+ updatedAt: readyNoiseAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ expect(afterReadyNoise.threads[0]?.latestTurn).toEqual({
+ turnId: TurnId.makeUnsafe("turn-1"),
+ state: "completed",
+ requestedAt: runningAt,
+ startedAt: runningAt,
+ completedAt,
+ assistantMessageId: null,
+ });
+ });
+
+ it("does not overwrite an already terminal latest turn on later session events", async () => {
+ const createdAt = "2026-03-09T12:10:00.000Z";
+ const runningAt = "2026-03-09T12:10:05.000Z";
+ const completedAt = "2026-03-09T12:10:10.000Z";
+ const erroredAt = "2026-03-09T12:10:20.000Z";
+ const model = createEmptyReadModel(createdAt);
+
+ const afterCreate = await Effect.runPromise(
+ projectEvent(
+ model,
+ makeEvent({
+ sequence: 1,
+ type: "thread.created",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: createdAt,
+ commandId: "cmd-create",
+ payload: {
+ threadId: "thread-1",
+ projectId: "project-1",
+ title: "demo",
+ model: "gpt-5.3-codex",
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ }),
+ ),
+ );
+
+ const afterRunning = await Effect.runPromise(
+ projectEvent(
+ afterCreate,
+ makeEvent({
+ sequence: 2,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: runningAt,
+ commandId: "cmd-running",
+ payload: {
+ threadId: "thread-1",
+ session: {
+ threadId: "thread-1",
+ status: "running",
+ providerName: "codex",
+ providerSessionId: "session-1",
+ providerThreadId: "provider-thread-1",
+ runtimeMode: "approval-required",
+ activeTurnId: "turn-1",
+ lastError: null,
+ updatedAt: runningAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ const afterCompleted = await Effect.runPromise(
+ projectEvent(
+ afterRunning,
+ makeEvent({
+ sequence: 3,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: completedAt,
+ commandId: "cmd-ready",
+ payload: {
+ threadId: "thread-1",
+ session: {
+ threadId: "thread-1",
+ status: "ready",
+ providerName: "codex",
+ providerSessionId: "session-1",
+ providerThreadId: "provider-thread-1",
+ runtimeMode: "approval-required",
+ activeTurnId: null,
+ lastError: null,
+ updatedAt: completedAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ const afterErrored = await Effect.runPromise(
+ projectEvent(
+ afterCompleted,
+ makeEvent({
+ sequence: 4,
+ type: "thread.session-set",
+ aggregateKind: "thread",
+ aggregateId: "thread-1",
+ occurredAt: erroredAt,
+ commandId: "cmd-error",
+ payload: {
+ threadId: "thread-1",
+ session: {
+ threadId: "thread-1",
+ status: "error",
+ providerName: "codex",
+ providerSessionId: "session-1",
+ providerThreadId: "provider-thread-1",
+ runtimeMode: "approval-required",
+ activeTurnId: null,
+ lastError: "late error",
+ updatedAt: erroredAt,
+ },
+ },
+ }),
+ ),
+ );
+
+ expect(afterErrored.threads[0]?.latestTurn).toEqual({
+ turnId: TurnId.makeUnsafe("turn-1"),
+ state: "completed",
+ requestedAt: runningAt,
+ startedAt: runningAt,
+ completedAt,
+ assistantMessageId: null,
+ });
+ });
+
it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => {
const createdAt = "2026-02-23T08:00:00.000Z";
const updatedAt = "2026-02-23T08:00:05.000Z";
diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts
index c0badfe958..1b729f791c 100644
--- a/apps/server/src/orchestration/projector.ts
+++ b/apps/server/src/orchestration/projector.ts
@@ -35,6 +35,27 @@ function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error"
return "completed" as const;
}
+function sessionStatusToLatestTurnState(status: string) {
+ if (status === "ready") return "completed" as const;
+ if (status === "error") return "error" as const;
+ if (status === "interrupted" || status === "stopped") return "interrupted" as const;
+ return null;
+}
+
+function isLatestTurnTerminal(
+ state: OrchestrationThread["latestTurn"] extends infer T
+ ? T extends { state: infer S }
+ ? S
+ : never
+ : never,
+) {
+ return state === "completed" || state === "error" || state === "interrupted";
+}
+
+function isSessionTurnTerminalStatus(status: OrchestrationSession["status"]) {
+ return status === "error" || status === "interrupted" || status === "stopped";
+}
+
function updateThread(
threads: ReadonlyArray,
threadId: ThreadId,
@@ -183,6 +204,8 @@ export function projectEvent(
workspaceRoot: payload.workspaceRoot,
defaultModel: payload.defaultModel,
scripts: payload.scripts,
+ threadGroupOrder: payload.threadGroupOrder,
+ sortOrder: payload.sortOrder,
createdAt: payload.createdAt,
updatedAt: payload.updatedAt,
deletedAt: null,
@@ -215,6 +238,10 @@ export function projectEvent(
? { defaultModel: payload.defaultModel }
: {}),
...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
+ ...(payload.threadGroupOrder !== undefined
+ ? { threadGroupOrder: payload.threadGroupOrder }
+ : {}),
+ ...(payload.sortOrder !== undefined ? { sortOrder: payload.sortOrder } : {}),
updatedAt: payload.updatedAt,
}
: project,
@@ -415,13 +442,23 @@ export function projectEvent(
event.type,
"session",
);
+ const terminalLatestTurnState = sessionStatusToLatestTurnState(session.status);
+ const sessionHasActiveTurn = session.activeTurnId !== null;
+ const shouldKeepRunningLatestTurn =
+ sessionHasActiveTurn && !isSessionTurnTerminalStatus(session.status);
+ const shouldPreserveTerminalLatestTurn =
+ shouldKeepRunningLatestTurn &&
+ thread.latestTurn?.turnId === session.activeTurnId &&
+ isLatestTurnTerminal(thread.latestTurn.state);
return {
...nextBase,
threads: updateThread(nextBase.threads, payload.threadId, {
session,
latestTurn:
- session.status === "running" && session.activeTurnId !== null
+ shouldPreserveTerminalLatestTurn
+ ? thread.latestTurn
+ : shouldKeepRunningLatestTurn
? {
turnId: session.activeTurnId,
state: "running",
@@ -439,7 +476,16 @@ export function projectEvent(
? thread.latestTurn.assistantMessageId
: null,
}
- : thread.latestTurn,
+ : !sessionHasActiveTurn &&
+ thread.latestTurn !== null &&
+ !isLatestTurnTerminal(thread.latestTurn.state) &&
+ terminalLatestTurnState !== null
+ ? {
+ ...thread.latestTurn,
+ state: terminalLatestTurnState,
+ completedAt: session.updatedAt,
+ }
+ : thread.latestTurn,
updatedAt: event.occurredAt,
}),
};
@@ -516,20 +562,14 @@ export function projectEvent(
...nextBase,
threads: updateThread(nextBase.threads, payload.threadId, {
checkpoints,
- latestTurn: {
- turnId: payload.turnId,
- state: checkpointStatusToLatestTurnState(payload.status),
- requestedAt:
- thread.latestTurn?.turnId === payload.turnId
- ? thread.latestTurn.requestedAt
- : payload.completedAt,
- startedAt:
- thread.latestTurn?.turnId === payload.turnId
- ? (thread.latestTurn.startedAt ?? payload.completedAt)
- : payload.completedAt,
- completedAt: payload.completedAt,
- assistantMessageId: payload.assistantMessageId,
- },
+ latestTurn:
+ thread.latestTurn?.turnId === payload.turnId
+ ? {
+ ...thread.latestTurn,
+ assistantMessageId:
+ payload.assistantMessageId ?? thread.latestTurn.assistantMessageId,
+ }
+ : thread.latestTurn,
updatedAt: event.occurredAt,
}),
};
diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts
index 5dbc8c2d1f..e55d4bc2d3 100644
--- a/apps/server/src/persistence/Layers/ProjectionProjects.ts
+++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts
@@ -11,11 +11,14 @@ import {
ProjectionProjectRepository,
type ProjectionProjectRepositoryShape,
} from "../Services/ProjectionProjects.ts";
-import { ProjectScript } from "@t3tools/contracts";
+import { ProjectScript, ThreadGroupId } from "@t3tools/contracts";
// Makes sure that the scripts are parsed from the JSON string the DB returns
const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
- Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }),
+ Struct.assign({
+ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
+ threadGroupOrder: Schema.fromJsonString(Schema.Array(ThreadGroupId)),
+ }),
);
function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
@@ -38,6 +41,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root,
default_model,
scripts_json,
+ thread_group_order_json,
+ sort_order,
created_at,
updated_at,
deleted_at
@@ -48,6 +53,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
${row.workspaceRoot},
${row.defaultModel},
${row.scripts},
+ ${row.threadGroupOrder},
+ ${row.sortOrder},
${row.createdAt},
${row.updatedAt},
${row.deletedAt}
@@ -58,6 +65,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root = excluded.workspace_root,
default_model = excluded.default_model,
scripts_json = excluded.scripts_json,
+ thread_group_order_json = excluded.thread_group_order_json,
+ sort_order = excluded.sort_order,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
deleted_at = excluded.deleted_at
@@ -75,6 +84,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root AS "workspaceRoot",
default_model AS "defaultModel",
scripts_json AS "scripts",
+ thread_group_order_json AS "threadGroupOrder",
+ sort_order AS "sortOrder",
created_at AS "createdAt",
updated_at AS "updatedAt",
deleted_at AS "deletedAt"
@@ -94,11 +105,13 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
workspace_root AS "workspaceRoot",
default_model AS "defaultModel",
scripts_json AS "scripts",
+ thread_group_order_json AS "threadGroupOrder",
+ sort_order AS "sortOrder",
created_at AS "createdAt",
updated_at AS "updatedAt",
deleted_at AS "deletedAt"
FROM projection_projects
- ORDER BY created_at ASC, project_id ASC
+ ORDER BY sort_order ASC, created_at ASC, project_id ASC
`,
});
diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts
index 7deb890dd8..73e3f8132b 100644
--- a/apps/server/src/persistence/Migrations.ts
+++ b/apps/server/src/persistence/Migrations.ts
@@ -25,6 +25,8 @@ import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts";
import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts";
import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts";
import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";
+import Migration0014 from "./Migrations/014_ProjectionProjectsThreadGroupOrder.ts";
+import Migration0015 from "./Migrations/015_ProjectionProjectsSortOrder.ts";
import { Effect } from "effect";
/**
@@ -51,6 +53,8 @@ const loader = Migrator.fromRecord({
"11_OrchestrationThreadCreatedRuntimeMode": Migration0011,
"12_ProjectionThreadsInteractionMode": Migration0012,
"13_ProjectionThreadProposedPlans": Migration0013,
+ "14_ProjectionProjectsThreadGroupOrder": Migration0014,
+ "15_ProjectionProjectsSortOrder": Migration0015,
});
/**
diff --git a/apps/server/src/persistence/Migrations/005_Projections.ts b/apps/server/src/persistence/Migrations/005_Projections.ts
index c950da76a1..cb2047bcfa 100644
--- a/apps/server/src/persistence/Migrations/005_Projections.ts
+++ b/apps/server/src/persistence/Migrations/005_Projections.ts
@@ -11,6 +11,8 @@ export default Effect.gen(function* () {
workspace_root TEXT NOT NULL,
default_model TEXT,
scripts_json TEXT NOT NULL,
+ thread_group_order_json TEXT NOT NULL DEFAULT '[]',
+ sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
diff --git a/apps/server/src/persistence/Migrations/014_ProjectionProjectsThreadGroupOrder.test.ts b/apps/server/src/persistence/Migrations/014_ProjectionProjectsThreadGroupOrder.test.ts
new file mode 100644
index 0000000000..aaeb9aec96
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/014_ProjectionProjectsThreadGroupOrder.test.ts
@@ -0,0 +1,79 @@
+import { assert, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import * as SqliteClient from "../NodeSqliteClient.ts";
+import Migration0014 from "./014_ProjectionProjectsThreadGroupOrder.ts";
+
+const layer = it.layer(SqliteClient.layerMemory());
+
+const baseProjectionProjectsSchema = `
+ CREATE TABLE projection_projects (
+ project_id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ workspace_root TEXT NOT NULL,
+ default_model TEXT,
+ scripts_json TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ deleted_at TEXT
+ )
+`;
+
+const projectionProjectsSchemaWithThreadGroupOrder = `
+ CREATE TABLE projection_projects (
+ project_id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ workspace_root TEXT NOT NULL,
+ default_model TEXT,
+ scripts_json TEXT NOT NULL,
+ thread_group_order_json TEXT NOT NULL DEFAULT '[]',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ deleted_at TEXT
+ )
+`;
+
+layer("014_ProjectionProjectsThreadGroupOrder", (it) => {
+ it.effect("adds thread_group_order_json when the column is missing", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ yield* sql`DROP TABLE IF EXISTS projection_projects`;
+ yield* sql.unsafe(baseProjectionProjectsSchema);
+ yield* Migration0014;
+
+ const columns = yield* sql`PRAGMA table_info(projection_projects)`.values;
+ assert.deepStrictEqual(
+ columns.map((column) => column[1]),
+ [
+ "project_id",
+ "title",
+ "workspace_root",
+ "default_model",
+ "scripts_json",
+ "created_at",
+ "updated_at",
+ "deleted_at",
+ "thread_group_order_json",
+ ],
+ );
+ }),
+ );
+
+ it.effect("does not fail when the column already exists", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ yield* sql`DROP TABLE IF EXISTS projection_projects`;
+ yield* sql.unsafe(projectionProjectsSchemaWithThreadGroupOrder);
+ yield* Migration0014;
+
+ const columns = yield* sql`PRAGMA table_info(projection_projects)`.values;
+ assert.deepStrictEqual(
+ columns.filter((column) => column[1] === "thread_group_order_json").length,
+ 1,
+ );
+ }),
+ );
+});
diff --git a/apps/server/src/persistence/Migrations/014_ProjectionProjectsThreadGroupOrder.ts b/apps/server/src/persistence/Migrations/014_ProjectionProjectsThreadGroupOrder.ts
new file mode 100644
index 0000000000..676f46c1f8
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/014_ProjectionProjectsThreadGroupOrder.ts
@@ -0,0 +1,21 @@
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+export default Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ // Fresh databases created from migration 005 already include this column, so
+ // replaying migration 014 must be a no-op rather than relying on driver-specific
+ // duplicate-column error handling.
+ const columns = yield* sql`PRAGMA table_info(projection_projects)`.values;
+ const hasThreadGroupOrderColumn = columns.some(
+ (column) => column[1] === "thread_group_order_json",
+ );
+
+ if (!hasThreadGroupOrderColumn) {
+ yield* sql`
+ ALTER TABLE projection_projects
+ ADD COLUMN thread_group_order_json TEXT NOT NULL DEFAULT '[]'
+ `;
+ }
+});
diff --git a/apps/server/src/persistence/Migrations/015_ProjectionProjectsSortOrder.test.ts b/apps/server/src/persistence/Migrations/015_ProjectionProjectsSortOrder.test.ts
new file mode 100644
index 0000000000..9494f3b05c
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/015_ProjectionProjectsSortOrder.test.ts
@@ -0,0 +1,79 @@
+import { assert, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+import * as SqliteClient from "../NodeSqliteClient.ts";
+import Migration0015 from "./015_ProjectionProjectsSortOrder.ts";
+
+const layer = it.layer(SqliteClient.layerMemory());
+
+const baseProjectionProjectsSchema = `
+ CREATE TABLE projection_projects (
+ project_id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ workspace_root TEXT NOT NULL,
+ default_model TEXT,
+ scripts_json TEXT NOT NULL,
+ thread_group_order_json TEXT NOT NULL DEFAULT '[]',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ deleted_at TEXT
+ )
+`;
+
+const projectionProjectsSchemaWithSortOrder = `
+ CREATE TABLE projection_projects (
+ project_id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ workspace_root TEXT NOT NULL,
+ default_model TEXT,
+ scripts_json TEXT NOT NULL,
+ thread_group_order_json TEXT NOT NULL DEFAULT '[]',
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ deleted_at TEXT
+ )
+`;
+
+layer("015_ProjectionProjectsSortOrder", (it) => {
+ it.effect("adds sort_order when the column is missing", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ yield* sql`DROP TABLE IF EXISTS projection_projects`;
+ yield* sql.unsafe(baseProjectionProjectsSchema);
+ yield* Migration0015;
+
+ const columns = yield* sql`PRAGMA table_info(projection_projects)`.values;
+ assert.deepStrictEqual(
+ columns.map((column) => column[1]),
+ [
+ "project_id",
+ "title",
+ "workspace_root",
+ "default_model",
+ "scripts_json",
+ "thread_group_order_json",
+ "created_at",
+ "updated_at",
+ "deleted_at",
+ "sort_order",
+ ],
+ );
+ }),
+ );
+
+ it.effect("does not fail when the column already exists", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ yield* sql`DROP TABLE IF EXISTS projection_projects`;
+ yield* sql.unsafe(projectionProjectsSchemaWithSortOrder);
+ yield* Migration0015;
+
+ const columns = yield* sql`PRAGMA table_info(projection_projects)`.values;
+ assert.deepStrictEqual(columns.filter((column) => column[1] === "sort_order").length, 1);
+ }),
+ );
+});
diff --git a/apps/server/src/persistence/Migrations/015_ProjectionProjectsSortOrder.ts b/apps/server/src/persistence/Migrations/015_ProjectionProjectsSortOrder.ts
new file mode 100644
index 0000000000..e1b1a7992e
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/015_ProjectionProjectsSortOrder.ts
@@ -0,0 +1,16 @@
+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 columns = yield* sql`PRAGMA table_info(projection_projects)`.values;
+ const hasSortOrderColumn = columns.some((column) => column[1] === "sort_order");
+
+ if (!hasSortOrderColumn) {
+ yield* sql`
+ ALTER TABLE projection_projects
+ ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0
+ `;
+ }
+});
diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts
index 1380a9609a..b1dcc5f078 100644
--- a/apps/server/src/persistence/Services/ProjectionProjects.ts
+++ b/apps/server/src/persistence/Services/ProjectionProjects.ts
@@ -6,7 +6,7 @@
*
* @module ProjectionProjectRepository
*/
-import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts";
+import { IsoDateTime, NonNegativeInt, ProjectId, ProjectScript, ThreadGroupId } from "@t3tools/contracts";
import { Option, Schema, ServiceMap } from "effect";
import type { Effect } from "effect";
@@ -18,6 +18,8 @@ export const ProjectionProject = Schema.Struct({
workspaceRoot: Schema.String,
defaultModel: Schema.NullOr(Schema.String),
scripts: Schema.Array(ProjectScript),
+ threadGroupOrder: Schema.Array(ThreadGroupId),
+ sortOrder: NonNegativeInt,
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
deletedAt: Schema.NullOr(IsoDateTime),
diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts
index 6c6cbac495..57bd3065d3 100644
--- a/apps/web/src/appSettings.test.ts
+++ b/apps/web/src/appSettings.test.ts
@@ -5,6 +5,8 @@ import {
getSlashModelOptions,
normalizeCustomModelSlugs,
resolveAppModelSelection,
+ resolveSidebarThreadOrder,
+ SIDEBAR_THREAD_ORDER_OPTIONS,
} from "./appSettings";
describe("normalizeCustomModelSlugs", () => {
@@ -82,3 +84,18 @@ describe("getSlashModelOptions", () => {
expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
});
});
+
+describe("resolveSidebarThreadOrder", () => {
+ it("defaults invalid values to recent-activity", () => {
+ expect(resolveSidebarThreadOrder("something-else")).toBe("recent-activity");
+ });
+
+ it("keeps the supported thread ordering preferences", () => {
+ expect(resolveSidebarThreadOrder("recent-activity")).toBe("recent-activity");
+ expect(resolveSidebarThreadOrder("created-at")).toBe("created-at");
+ expect(SIDEBAR_THREAD_ORDER_OPTIONS.map((option) => option.value)).toEqual([
+ "recent-activity",
+ "created-at",
+ ]);
+ });
+});
diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts
index 5ed218fb25..88ef4e10a4 100644
--- a/apps/web/src/appSettings.ts
+++ b/apps/web/src/appSettings.ts
@@ -6,6 +6,20 @@ import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/s
const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
const MAX_CUSTOM_MODEL_COUNT = 32;
export const MAX_CUSTOM_MODEL_LENGTH = 256;
+export const SIDEBAR_THREAD_ORDER_OPTIONS = [
+ {
+ value: "recent-activity",
+ label: "Recent activity",
+ description: "Sort chats by the latest user message, final assistant message, plan, or assistant question.",
+ },
+ {
+ value: "created-at",
+ label: "Created time",
+ description: "Sort chats by when the thread was originally created.",
+ },
+] as const;
+export type SidebarThreadOrder = (typeof SIDEBAR_THREAD_ORDER_OPTIONS)[number]["value"];
+const SidebarThreadOrderSchema = Schema.Literals(["recent-activity", "created-at"]);
const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = {
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
};
@@ -21,6 +35,9 @@ const AppSettingsSchema = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
+ sidebarThreadOrder: SidebarThreadOrderSchema.pipe(
+ Schema.withConstructorDefault(() => Option.some("recent-activity")),
+ ),
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
@@ -32,6 +49,10 @@ export interface AppModelOption {
isCustom: boolean;
}
+export function resolveSidebarThreadOrder(value: string | null | undefined): SidebarThreadOrder {
+ return value === "created-at" ? "created-at" : "recent-activity";
+}
+
const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
let listeners: Array<() => void> = [];
@@ -70,6 +91,7 @@ export function normalizeCustomModelSlugs(
function normalizeAppSettings(settings: AppSettings): AppSettings {
return {
...settings,
+ sidebarThreadOrder: resolveSidebarThreadOrder(settings.sidebarThreadOrder),
customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
};
}
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index 0f0250e48f..8c1731c484 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -8,6 +8,7 @@ import {
type ProjectId,
type ServerConfig,
type ThreadId,
+ type TurnId,
type WsWelcomePayload,
WS_CHANNELS,
WS_METHODS,
@@ -194,6 +195,8 @@ function createSnapshotForTargetUser(options: {
workspaceRoot: "/repo/project",
defaultModel: "gpt-5",
scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
createdAt: NOW_ISO,
updatedAt: NOW_ISO,
deletedAt: null,
@@ -256,6 +259,69 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
};
}
+function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
+ const snapshot = createSnapshotForTargetUser({
+ targetMessageId: "msg-user-pending-input" as MessageId,
+ targetText: "pending input thread",
+ });
+ const turnId = "turn-pending-user-input" as TurnId;
+ const pendingUserInputActivity: OrchestrationReadModel["threads"][number]["activities"][number] = {
+ id: "activity-pending-user-input" as OrchestrationReadModel["threads"][number]["activities"][number]["id"],
+ createdAt: isoAt(102),
+ tone: "info",
+ kind: "user-input.requested",
+ summary: "User input requested",
+ turnId,
+ payload: {
+ requestId: "req-user-input-browser",
+ questions: [
+ {
+ id: "affected_course",
+ header: "Affected Course",
+ question: "Which student calendar is broken?",
+ options: [
+ {
+ label: "The Combine (Recommended)",
+ description: "Matches the report.",
+ },
+ {
+ label: "The Invitational",
+ description: "Alternative calendar.",
+ },
+ ],
+ },
+ ],
+ },
+ };
+ return {
+ ...snapshot,
+ threads: snapshot.threads.map((thread) =>
+ thread.id !== THREAD_ID
+ ? thread
+ : Object.assign({}, thread, {
+ latestTurn: {
+ turnId,
+ state: "running",
+ requestedAt: isoAt(100),
+ startedAt: isoAt(101),
+ completedAt: null,
+ assistantMessageId: null,
+ },
+ session: {
+ threadId: THREAD_ID,
+ status: "running",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: turnId,
+ lastError: null,
+ updatedAt: isoAt(101),
+ },
+ activities: [pendingUserInputActivity],
+ }),
+ ),
+ };
+}
+
function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-plan-target" as MessageId,
@@ -858,6 +924,43 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});
+ it("submits structured user-input answers from a running thread", async () => {
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: createSnapshotWithPendingUserInput(),
+ });
+
+ try {
+ const optionButton = page.getByRole("button", { name: /The Combine \(Recommended\)/i });
+ await expect.element(optionButton).toBeVisible();
+ await optionButton.click();
+
+ const submitButton = page.getByRole("button", { name: /Submit answers/i });
+ await expect.element(submitButton).toBeEnabled();
+ await submitButton.click();
+
+ await vi.waitFor(
+ () => {
+ const dispatches = wsRequests.filter(
+ (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand,
+ );
+ const userInputCommand = dispatches
+ .map((request) => request.command as { type?: string; requestId?: string } | undefined)
+ .find((command) => command?.type === "thread.user-input.respond");
+ expect(userInputCommand).toEqual(
+ expect.objectContaining({
+ type: "thread.user-input.respond",
+ requestId: "req-user-input-browser",
+ }),
+ );
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 1023b37e1b..cea66a10f2 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -873,8 +873,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
[threadActivities],
);
const pendingUserInputs = useMemo(
- () => derivePendingUserInputs(threadActivities),
- [threadActivities],
+ () =>
+ derivePendingUserInputs(threadActivities, {
+ latestTurn: activeLatestTurn,
+ session: activeThread?.session ?? null,
+ }),
+ [activeLatestTurn, activeThread?.session, threadActivities],
);
const activePendingUserInput = pendingUserInputs[0] ?? null;
const activePendingDraftAnswers = useMemo(
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
index d0216d0e40..8c2d9972a9 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -72,10 +72,23 @@ describe("resolveThreadStatusPill", () => {
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
+ ).toMatchObject({ label: "Planning", pulse: true });
+ });
+
+ it("shows working for non-plan threads that are actively running", () => {
+ expect(
+ resolveThreadStatusPill({
+ thread: {
+ ...baseThread,
+ interactionMode: "default",
+ },
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ }),
).toMatchObject({ label: "Working", pulse: true });
});
- it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => {
+ it("shows plan submitted when a settled plan turn has a proposed plan ready for follow-up", () => {
expect(
resolveThreadStatusPill({
thread: {
@@ -99,7 +112,34 @@ describe("resolveThreadStatusPill", () => {
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
- ).toMatchObject({ label: "Plan Ready", pulse: false });
+ ).toMatchObject({ label: "Plan Submitted", pulse: false });
+ });
+
+ it("shows errored when the latest turn failed after the thread was last visited", () => {
+ expect(
+ resolveThreadStatusPill({
+ thread: {
+ ...baseThread,
+ latestTurn: {
+ turnId: "turn-1" as never,
+ state: "error",
+ assistantMessageId: null,
+ requestedAt: "2026-03-09T10:00:00.000Z",
+ startedAt: "2026-03-09T10:00:00.000Z",
+ completedAt: "2026-03-09T10:05:00.000Z",
+ },
+ lastVisitedAt: "2026-03-09T10:04:00.000Z",
+ session: {
+ ...baseThread.session,
+ status: "error",
+ updatedAt: "2026-03-09T10:05:00.000Z",
+ orchestrationStatus: "error",
+ },
+ },
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ }),
+ ).toMatchObject({ label: "Errored", pulse: false });
});
it("shows completed when there is an unseen completion and no active blocker", () => {
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index e950d8de6e..0d81b740f1 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -4,11 +4,13 @@ import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
export interface ThreadStatusPill {
label:
| "Working"
+ | "Planning"
| "Connecting"
| "Completed"
| "Pending Approval"
| "Awaiting Input"
- | "Plan Ready";
+ | "Plan Submitted"
+ | "Errored";
colorClass: string;
dotClass: string;
pulse: boolean;
@@ -19,6 +21,28 @@ type ThreadStatusInput = Pick<
"interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
>;
+function hasUnreadAt(timestamp: string | undefined, lastVisitedAt: string | undefined): boolean {
+ if (!timestamp) {
+ return false;
+ }
+
+ const updatedAt = Date.parse(timestamp);
+ if (Number.isNaN(updatedAt)) {
+ return false;
+ }
+
+ if (!lastVisitedAt) {
+ return true;
+ }
+
+ const visitedAt = Date.parse(lastVisitedAt);
+ if (Number.isNaN(visitedAt)) {
+ return true;
+ }
+
+ return updatedAt > visitedAt;
+}
+
export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
if (!thread.latestTurn?.completedAt) return false;
const completedAt = Date.parse(thread.latestTurn.completedAt);
@@ -55,6 +79,15 @@ export function resolveThreadStatusPill(input: {
};
}
+ if (thread.session?.status === "running" && thread.interactionMode === "plan") {
+ return {
+ label: "Planning",
+ colorClass: "text-cyan-600 dark:text-cyan-300/90",
+ dotClass: "bg-cyan-500 dark:bg-cyan-300/90",
+ pulse: true,
+ };
+ }
+
if (thread.session?.status === "running") {
return {
label: "Working",
@@ -80,9 +113,33 @@ export function resolveThreadStatusPill(input: {
findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null;
if (hasPlanReadyPrompt) {
return {
- label: "Plan Ready",
- colorClass: "text-violet-600 dark:text-violet-300/90",
- dotClass: "bg-violet-500 dark:bg-violet-300/90",
+ label: "Plan Submitted",
+ colorClass: "text-teal-600 dark:text-teal-300/90",
+ dotClass: "bg-teal-500 dark:bg-teal-300/90",
+ pulse: false,
+ };
+ }
+
+ if (
+ thread.latestTurn?.state === "error" &&
+ hasUnreadAt(thread.latestTurn.completedAt ?? undefined, thread.lastVisitedAt)
+ ) {
+ return {
+ label: "Errored",
+ colorClass: "text-rose-600 dark:text-rose-300/90",
+ dotClass: "bg-rose-500 dark:bg-rose-300/90",
+ pulse: false,
+ };
+ }
+
+ if (
+ thread.session?.status === "error" &&
+ hasUnreadAt(thread.session.updatedAt, thread.lastVisitedAt)
+ ) {
+ return {
+ label: "Errored",
+ colorClass: "text-rose-600 dark:text-rose-300/90",
+ dotClass: "bg-rose-500 dark:bg-rose-300/90",
pulse: false,
};
}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 284ed0da9a..94d98f75a8 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1,7 +1,9 @@
import {
ArrowLeftIcon,
ChevronRightIcon,
+ CircleDotIcon,
FolderIcon,
+ GitBranchIcon,
GitPullRequestIcon,
PlusIcon,
RocketIcon,
@@ -10,22 +12,7 @@ import {
TerminalIcon,
TriangleAlertIcon,
} from "lucide-react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import {
- DndContext,
- type DragCancelEvent,
- type CollisionDetection,
- PointerSensor,
- type DragStartEvent,
- closestCorners,
- pointerWithin,
- useSensor,
- useSensors,
- type DragEndEvent,
-} from "@dnd-kit/core";
-import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
-import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
-import { CSS } from "@dnd-kit/utilities";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
DEFAULT_RUNTIME_MODE,
DEFAULT_MODEL_BY_PROVIDER,
@@ -42,14 +29,49 @@ import { isElectron } from "../env";
import { APP_STAGE_LABEL } from "../branding";
import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
import { useStore } from "../store";
-import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
+import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
+import { type Thread } from "../types";
import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { readNativeApi } from "../nativeApi";
import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
+import { getSidebarThreadSortTimestamp, sortSidebarThreadEntries } from "../sidebarThreadOrder";
+import { buildSidebarGroupContextMenuItems } from "../sidebarGroupContextMenu";
+import {
+ orderProjects,
+ orderProjectsByIds,
+ shouldClearOptimisticProjectOrder,
+ reorderProjectOrder,
+} from "../projectOrder";
+import {
+ animateSidebarReorder,
+ buildSidebarReorderDeltas,
+ collectElementTopPositions,
+ hasSidebarReorderChanged,
+} from "../sidebarReorderAnimation";
+import { preferredTerminalEditor } from "../terminal-links";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { toastManager } from "./ui/toast";
+import {
+ buildProjectChildrenClassName,
+ buildProjectGroupCollapseKey,
+ buildThreadGroupChildrenClassName,
+ buildThreadGroupDragCursorClassName,
+ buildSidebarInteractionClassName,
+ buildThreadGroupChevronClassName,
+ buildThreadGroupComposeButtonClassName,
+ buildThreadGroupDropIndicatorClassName,
+ buildThreadGroupHeaderClassName,
+ buildThreadRowClassName,
+ hasCrossedThreadGroupDragThreshold,
+ isProjectGroupOpen,
+ resolveThreadGroupDropEffect,
+ shouldIgnoreSidebarDragPointerDown,
+ shouldSnapThreadGroupDropToEnd,
+ setProjectGroupCollapsed,
+ shouldRenderProjectComposeButton,
+} from "./sidebarGroupInteractions";
import {
getArm64IntelBuildWarningDescription,
getDesktopUpdateActionError,
@@ -63,7 +85,6 @@ import {
} from "./desktopUpdate.logic";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
-import { Collapsible, CollapsibleContent } from "./ui/collapsible";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import {
SidebarContent,
@@ -82,10 +103,16 @@ import {
} from "./ui/sidebar";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
import { isNonEmpty as isNonEmptyString } from "effect/String";
+import {
+ MAIN_THREAD_GROUP_ID,
+ buildThreadGroupId,
+ orderProjectThreadGroups,
+ resolveProjectThreadGroupPrById,
+ reorderProjectThreadGroupOrder,
+} from "../threadGroups";
import { resolveThreadStatusPill } from "./Sidebar.logic";
const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
-const THREAD_PREVIEW_LIMIT = 6;
async function copyTextToClipboard(text: string): Promise {
if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) {
@@ -118,6 +145,15 @@ interface PrStatusIndicator {
}
type ThreadPr = GitStatusResult["pr"];
+type SidebarGroupEntry = {
+ id: ThreadId;
+ title: string;
+ createdAt: string;
+ branch: string | null;
+ worktreePath: string | null;
+ thread: Thread | null;
+ isDraft: boolean;
+};
function terminalStatusFromRunningIds(
runningTerminalIds: string[],
@@ -222,52 +258,31 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
);
}
-type SortableProjectHandleProps = Pick, "attributes" | "listeners">;
-
-function SortableProjectItem({
- projectId,
- children,
-}: {
- projectId: ProjectId;
- children: (handleProps: SortableProjectHandleProps) => React.ReactNode;
-}) {
- const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } =
- useSortable({ id: projectId });
- return (
-
- {children({ attributes, listeners })}
-
- );
-}
-
export default function Sidebar() {
const projects = useStore((store) => store.projects);
const threads = useStore((store) => store.threads);
+ const syncServerReadModel = useStore((store) => store.syncServerReadModel);
const markThreadUnread = useStore((store) => store.markThreadUnread);
const toggleProject = useStore((store) => store.toggleProject);
- const reorderProjects = useStore((store) => store.reorderProjects);
+ const setProjectExpanded = useStore((store) => store.setProjectExpanded);
const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
- const getDraftThreadByProjectId = useComposerDraftStore(
- (store) => store.getDraftThreadByProjectId,
+ const draftsByThreadId = useComposerDraftStore((store) => store.draftsByThreadId);
+ const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId);
+ const projectGroupDraftThreadIdById = useComposerDraftStore(
+ (store) => store.projectGroupDraftThreadIdById,
+ );
+ const getDraftThreadByProjectGroupId = useComposerDraftStore(
+ (store) => store.getDraftThreadByProjectGroupId,
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
- const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
+ const setProjectGroupDraftThreadId = useComposerDraftStore(
+ (store) => store.setProjectGroupDraftThreadId,
+ );
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
- const clearProjectDraftThreadId = useComposerDraftStore(
- (store) => store.clearProjectDraftThreadId,
+ const clearProjectGroupDraftThreadId = useComposerDraftStore(
+ (store) => store.clearProjectGroupDraftThreadId,
);
const clearProjectDraftThreadById = useComposerDraftStore(
(store) => store.clearProjectDraftThreadById,
@@ -293,16 +308,63 @@ export default function Sidebar() {
const addProjectInputRef = useRef(null);
const [renamingThreadId, setRenamingThreadId] = useState(null);
const [renamingTitle, setRenamingTitle] = useState("");
- const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
- ReadonlySet
- >(() => new Set());
const renamingCommittedRef = useRef(false);
const renamingInputRef = useRef(null);
- const dragInProgressRef = useRef(false);
- const suppressProjectClickAfterDragRef = useRef(false);
const [desktopUpdateState, setDesktopUpdateState] = useState(null);
- const shouldBrowseForProjectImmediately = isElectron;
- const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
+ const [optimisticProjectOrder, setOptimisticProjectOrder] = useState(null);
+ const [isProjectReorderPending, setIsProjectReorderPending] = useState(false);
+ const [draggedProjectId, setDraggedProjectId] = useState(null);
+ const [projectDropTarget, setProjectDropTarget] = useState<{ beforeProjectId: ProjectId | null } | null>(
+ null,
+ );
+ const [draggedGroup, setDraggedGroup] = useState<{ projectId: ProjectId; groupId: string } | null>(
+ null,
+ );
+ const [dropTarget, setDropTarget] = useState<{ projectId: ProjectId; beforeGroupId: string | null } | null>(
+ null,
+ );
+ const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set());
+ const pendingProjectDragRef = useRef<{
+ projectId: ProjectId;
+ startX: number;
+ startY: number;
+ pointerId: number;
+ element: HTMLElement;
+ } | null>(null);
+ const projectRowRefs = useRef(new Map());
+ const threadGroupRowRefs = useRef(new Map());
+ const previousProjectOrderRef = useRef>([]);
+ const previousProjectTopsRef = useRef
+
+
+
Sidebar
+
+ Choose how chats are ordered in each project.
+
+
+
+
+
+
Codex App Server
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index 3d1d269d48..498d489e08 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -220,6 +220,120 @@ describe("derivePendingUserInputs", () => {
},
]);
});
+
+ it("removes prompts when the provider reports an unknown pending user input request", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "user-input-open",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "user-input.requested",
+ summary: "User input requested",
+ tone: "info",
+ payload: {
+ requestId: "req-user-input-1",
+ questions: [
+ {
+ id: "sandbox_mode",
+ header: "Sandbox",
+ question: "Which mode should be used?",
+ options: [
+ {
+ label: "workspace-write",
+ description: "Allow workspace writes only",
+ },
+ ],
+ },
+ ],
+ },
+ }),
+ makeActivity({
+ id: "user-input-failed",
+ createdAt: "2026-02-23T00:00:02.000Z",
+ kind: "provider.user-input.respond.failed",
+ summary: "Provider user input response failed",
+ tone: "error",
+ payload: {
+ requestId: "req-user-input-1",
+ detail:
+ "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending user input request: req-user-input-1",
+ },
+ }),
+ ];
+
+ expect(derivePendingUserInputs(activities)).toEqual([]);
+ });
+
+ it("removes prompts when the thread session is already in error", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "user-input-open",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "user-input.requested",
+ summary: "User input requested",
+ tone: "info",
+ payload: {
+ requestId: "req-user-input-1",
+ questions: [
+ {
+ id: "sandbox_mode",
+ header: "Sandbox",
+ question: "Which mode should be used?",
+ options: [
+ {
+ label: "workspace-write",
+ description: "Allow workspace writes only",
+ },
+ ],
+ },
+ ],
+ },
+ }),
+ ];
+
+ expect(
+ derivePendingUserInputs(activities, {
+ session: { status: "error" },
+ }),
+ ).toEqual([]);
+ });
+
+ it("removes prompts when their turn has already settled", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "user-input-open",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "user-input.requested",
+ summary: "User input requested",
+ tone: "info",
+ turnId: "turn-1",
+ payload: {
+ requestId: "req-user-input-1",
+ questions: [
+ {
+ id: "sandbox_mode",
+ header: "Sandbox",
+ question: "Which mode should be used?",
+ options: [
+ {
+ label: "workspace-write",
+ description: "Allow workspace writes only",
+ },
+ ],
+ },
+ ],
+ },
+ }),
+ ];
+
+ expect(
+ derivePendingUserInputs(activities, {
+ latestTurn: {
+ turnId: TurnId.makeUnsafe("turn-1"),
+ state: "error",
+ },
+ }),
+ ).toEqual([]);
+ });
});
describe("deriveActivePlanState", () => {
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index e9351ca2b2..2631934703 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -45,6 +45,11 @@ export interface PendingUserInput {
questions: ReadonlyArray
;
}
+interface PendingUserInputContext {
+ latestTurn?: Pick | null;
+ session?: Pick | null;
+}
+
export interface ActivePlanState {
createdAt: string;
turnId: TurnId | null;
@@ -262,8 +267,12 @@ function parseUserInputQuestions(
export function derivePendingUserInputs(
activities: ReadonlyArray,
+ context?: PendingUserInputContext,
): PendingUserInput[] {
- const openByRequestId = new Map();
+ const openByRequestId = new Map<
+ ApprovalRequestId,
+ PendingUserInput & { turnId: TurnId | null }
+ >();
const ordered = [...activities].toSorted(compareActivitiesByOrder);
for (const activity of ordered) {
@@ -285,18 +294,42 @@ export function derivePendingUserInputs(
requestId,
createdAt: activity.createdAt,
questions,
+ turnId: activity.turnId,
});
continue;
}
if (activity.kind === "user-input.resolved" && requestId) {
openByRequestId.delete(requestId);
+ continue;
+ }
+
+ const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
+ if (
+ activity.kind === "provider.user-input.respond.failed" &&
+ requestId &&
+ detail?.includes("Unknown pending user input request")
+ ) {
+ openByRequestId.delete(requestId);
}
}
- return [...openByRequestId.values()].toSorted((left, right) =>
- left.createdAt.localeCompare(right.createdAt),
- );
+ if (context?.session?.status === "error" || context?.session?.status === "closed") {
+ return [];
+ }
+
+ const latestTurn = context?.latestTurn;
+ if (latestTurn && latestTurn.state !== "running") {
+ for (const [requestId, pending] of openByRequestId.entries()) {
+ if (pending.turnId === latestTurn.turnId) {
+ openByRequestId.delete(requestId);
+ }
+ }
+ }
+
+ return [...openByRequestId.values()]
+ .map(({ turnId: _turnId, ...pending }) => pending)
+ .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
}
export function deriveActivePlanState(
diff --git a/apps/web/src/sidebarGroupContextMenu.test.ts b/apps/web/src/sidebarGroupContextMenu.test.ts
new file mode 100644
index 0000000000..a263176bfe
--- /dev/null
+++ b/apps/web/src/sidebarGroupContextMenu.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+
+import { buildSidebarGroupContextMenuItems } from "./sidebarGroupContextMenu";
+
+describe("sidebarGroupContextMenu", () => {
+ it("builds a light menu for Main", () => {
+ expect(
+ buildSidebarGroupContextMenuItems({
+ isMainGroup: true,
+ hasBranch: false,
+ hasWorktreePath: false,
+ hasPr: false,
+ }),
+ ).toEqual([
+ { id: "open-workspace", label: "Open workspace" },
+ { id: "copy-workspace-path", label: "Copy workspace path" },
+ { id: "new-chat", label: "New chat" },
+ ]);
+ });
+
+ it("builds a branch-only menu without destructive actions", () => {
+ expect(
+ buildSidebarGroupContextMenuItems({
+ isMainGroup: false,
+ hasBranch: true,
+ hasWorktreePath: false,
+ hasPr: true,
+ }),
+ ).toEqual([
+ { id: "open-workspace", label: "Open workspace" },
+ { id: "copy-branch-name", label: "Copy branch name" },
+ { id: "copy-project-path", label: "Copy project path" },
+ { id: "open-pr", label: "Open PR" },
+ { id: "new-chat", label: "New chat" },
+ ]);
+ });
+
+ it("builds a full worktree menu with destructive action", () => {
+ expect(
+ buildSidebarGroupContextMenuItems({
+ isMainGroup: false,
+ hasBranch: true,
+ hasWorktreePath: true,
+ hasPr: true,
+ }),
+ ).toEqual([
+ { id: "open-workspace", label: "Open workspace" },
+ { id: "copy-branch-name", label: "Copy branch name" },
+ { id: "copy-workspace-path", label: "Copy worktree path" },
+ { id: "open-pr", label: "Open PR" },
+ { id: "new-chat", label: "New chat" },
+ {
+ id: "delete-group-worktree-and-chats",
+ label: "Delete chats and worktree",
+ destructive: true,
+ },
+ ]);
+ });
+});
diff --git a/apps/web/src/sidebarGroupContextMenu.ts b/apps/web/src/sidebarGroupContextMenu.ts
new file mode 100644
index 0000000000..8cab8b3ce0
--- /dev/null
+++ b/apps/web/src/sidebarGroupContextMenu.ts
@@ -0,0 +1,55 @@
+import type { ContextMenuItem } from "@t3tools/contracts";
+
+export type SidebarGroupContextMenuAction =
+ | "open-workspace"
+ | "copy-workspace-path"
+ | "copy-project-path"
+ | "copy-branch-name"
+ | "open-pr"
+ | "new-chat"
+ | "delete-group-worktree-and-chats";
+
+export function buildSidebarGroupContextMenuItems(input: {
+ isMainGroup: boolean;
+ hasBranch: boolean;
+ hasWorktreePath: boolean;
+ hasPr: boolean;
+}): ContextMenuItem[] {
+ if (input.isMainGroup) {
+ return [
+ { id: "open-workspace", label: "Open workspace" },
+ { id: "copy-workspace-path", label: "Copy workspace path" },
+ { id: "new-chat", label: "New chat" },
+ ];
+ }
+
+ const items: ContextMenuItem[] = [
+ { id: "open-workspace", label: "Open workspace" },
+ ];
+
+ if (input.hasBranch) {
+ items.push({ id: "copy-branch-name", label: "Copy branch name" });
+ }
+
+ items.push(
+ input.hasWorktreePath
+ ? { id: "copy-workspace-path", label: "Copy worktree path" }
+ : { id: "copy-project-path", label: "Copy project path" },
+ );
+
+ if (input.hasPr) {
+ items.push({ id: "open-pr", label: "Open PR" });
+ }
+
+ items.push({ id: "new-chat", label: "New chat" });
+
+ if (input.hasWorktreePath) {
+ items.push({
+ id: "delete-group-worktree-and-chats",
+ label: "Delete chats and worktree",
+ destructive: true,
+ });
+ }
+
+ return items;
+}
diff --git a/apps/web/src/sidebarReorderAnimation.test.ts b/apps/web/src/sidebarReorderAnimation.test.ts
new file mode 100644
index 0000000000..c76df03ad7
--- /dev/null
+++ b/apps/web/src/sidebarReorderAnimation.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ animateSidebarReorder,
+ buildSidebarReorderDeltas,
+ collectElementTopPositions,
+ hasSidebarReorderChanged,
+ SIDEBAR_REORDER_TRANSITION,
+} from "./sidebarReorderAnimation";
+
+function makeElement(top: number) {
+ return {
+ style: {
+ transition: "",
+ transform: "",
+ willChange: "",
+ },
+ getBoundingClientRect: () => ({ top }),
+ };
+}
+
+describe("sidebarReorderAnimation", () => {
+ it("detects when the visible order changes", () => {
+ expect(
+ hasSidebarReorderChanged(["project-a", "project-b"], ["project-a", "project-b"]),
+ ).toBe(false);
+ expect(
+ hasSidebarReorderChanged(["project-a", "project-b"], ["project-b", "project-a"]),
+ ).toBe(true);
+ expect(hasSidebarReorderChanged(["project-a"], ["project-a", "project-b"])).toBe(true);
+ });
+
+ it("builds vertical animation deltas from previous and next row positions", () => {
+ expect(
+ buildSidebarReorderDeltas(
+ new Map([
+ ["project-a", 20],
+ ["project-b", 60],
+ ]),
+ new Map([
+ ["project-a", 60],
+ ["project-b", 20],
+ ]),
+ ),
+ ).toEqual([
+ { id: "project-a", deltaY: -40 },
+ { id: "project-b", deltaY: 40 },
+ ]);
+ });
+
+ it("ignores rows without meaningful movement", () => {
+ expect(
+ buildSidebarReorderDeltas(
+ new Map([
+ ["project-a", 20],
+ ["project-b", 60],
+ ]),
+ new Map([
+ ["project-a", 20.2],
+ ["project-b", 60],
+ ]),
+ ),
+ ).toEqual([]);
+ });
+
+ it("collects top positions from the current rendered row map", () => {
+ expect(
+ collectElementTopPositions(
+ new Map([
+ ["project-a", makeElement(20)],
+ ["project-b", makeElement(60)],
+ ]),
+ ),
+ ).toEqual(
+ new Map([
+ ["project-a", 20],
+ ["project-b", 60],
+ ]),
+ );
+ });
+
+ it("does not let a stale cleanup cancel a newer reorder animation", () => {
+ const project = makeElement(20);
+ const animationFrames: Array<() => void> = [];
+ const timeouts: Array<() => void> = [];
+
+ animateSidebarReorder(
+ new Map([["project-a", project]]),
+ [{ id: "project-a", deltaY: 40 }],
+ {
+ requestAnimationFrame: (callback) => animationFrames.push(callback),
+ setTimeout: (callback) => timeouts.push(callback),
+ },
+ );
+
+ animationFrames.shift()?.();
+ expect(project.style.transition).toBe(SIDEBAR_REORDER_TRANSITION);
+ expect(project.style.transform).toBe("translateY(0)");
+
+ animateSidebarReorder(
+ new Map([["project-a", project]]),
+ [{ id: "project-a", deltaY: -40 }],
+ {
+ requestAnimationFrame: (callback) => animationFrames.push(callback),
+ setTimeout: (callback) => timeouts.push(callback),
+ },
+ );
+
+ animationFrames.shift()?.();
+ expect(project.style.transition).toBe(SIDEBAR_REORDER_TRANSITION);
+ expect(project.style.transform).toBe("translateY(0)");
+
+ timeouts.shift()?.();
+ expect(project.style.transition).toBe(SIDEBAR_REORDER_TRANSITION);
+ expect(project.style.transform).toBe("translateY(0)");
+ expect(project.style.willChange).toBe("transform");
+
+ timeouts.shift()?.();
+ expect(project.style.transition).toBe("");
+ expect(project.style.transform).toBe("");
+ expect(project.style.willChange).toBe("");
+ });
+});
diff --git a/apps/web/src/sidebarReorderAnimation.ts b/apps/web/src/sidebarReorderAnimation.ts
new file mode 100644
index 0000000000..7d2ea5ada1
--- /dev/null
+++ b/apps/web/src/sidebarReorderAnimation.ts
@@ -0,0 +1,117 @@
+export interface SidebarReorderDelta {
+ id: T;
+ deltaY: number;
+}
+
+export const SIDEBAR_REORDER_TRANSITION = "transform 200ms ease-out";
+
+interface SidebarAnimatableElement {
+ style: {
+ transition: string;
+ transform: string;
+ willChange: string;
+ };
+ getBoundingClientRect(): { top: number };
+}
+
+interface SidebarAnimationScheduler {
+ requestAnimationFrame: (callback: () => void) => void;
+ setTimeout: (callback: () => void, delayMs: number) => void;
+}
+
+const activeSidebarAnimationTokenByElement = new WeakMap();
+
+export function hasSidebarReorderChanged(
+ previousOrder: ReadonlyArray,
+ nextOrder: ReadonlyArray,
+): boolean {
+ if (previousOrder.length !== nextOrder.length) {
+ return true;
+ }
+
+ return previousOrder.some((id, index) => id !== nextOrder[index]);
+}
+
+export function buildSidebarReorderDeltas(
+ previousTops: ReadonlyMap,
+ nextTops: ReadonlyMap,
+): Array> {
+ const deltas: Array> = [];
+
+ for (const [id, nextTop] of nextTops.entries()) {
+ const previousTop = previousTops.get(id);
+ if (previousTop === undefined) {
+ continue;
+ }
+
+ const deltaY = previousTop - nextTop;
+ if (Math.abs(deltaY) < 0.5) {
+ continue;
+ }
+
+ deltas.push({ id, deltaY });
+ }
+
+ return deltas;
+}
+
+export function collectElementTopPositions(
+ elements: ReadonlyMap,
+): Map {
+ return new Map(
+ [...elements.entries()].map(([id, element]) => [id, element.getBoundingClientRect().top] as const),
+ );
+}
+
+export function animateSidebarReorder(
+ elements: ReadonlyMap,
+ deltas: ReadonlyArray>,
+ scheduler?: SidebarAnimationScheduler,
+): void {
+ if (deltas.length === 0) {
+ return;
+ }
+
+ const resolvedScheduler = scheduler ?? {
+ requestAnimationFrame: (callback: () => void) => window.requestAnimationFrame(callback),
+ setTimeout: (callback: () => void, delayMs: number) => {
+ window.setTimeout(callback, delayMs);
+ },
+ };
+
+ for (const { id, deltaY } of deltas) {
+ const element = elements.get(id);
+ if (!element) {
+ continue;
+ }
+
+ const nextToken = (activeSidebarAnimationTokenByElement.get(element) ?? 0) + 1;
+ activeSidebarAnimationTokenByElement.set(element, nextToken);
+
+ element.style.transition = "none";
+ element.style.transform = `translateY(${deltaY}px)`;
+ element.style.willChange = "transform";
+ element.getBoundingClientRect();
+
+ const cleanup = () => {
+ if (activeSidebarAnimationTokenByElement.get(element) !== nextToken) {
+ return;
+ }
+
+ element.style.transition = "";
+ element.style.transform = "";
+ element.style.willChange = "";
+ activeSidebarAnimationTokenByElement.delete(element);
+ };
+
+ resolvedScheduler.requestAnimationFrame(() => {
+ if (activeSidebarAnimationTokenByElement.get(element) !== nextToken) {
+ return;
+ }
+
+ element.style.transition = SIDEBAR_REORDER_TRANSITION;
+ element.style.transform = "translateY(0)";
+ resolvedScheduler.setTimeout(cleanup, 220);
+ });
+ }
+}
diff --git a/apps/web/src/sidebarThreadOrder.test.ts b/apps/web/src/sidebarThreadOrder.test.ts
new file mode 100644
index 0000000000..ee4a04c07f
--- /dev/null
+++ b/apps/web/src/sidebarThreadOrder.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it } from "vitest";
+
+import { sortSidebarThreadEntries } from "./sidebarThreadOrder";
+import { type Thread } from "./types";
+
+function makeThread(overrides: Partial): Thread {
+ return {
+ id: "thread" as Thread["id"],
+ codexThreadId: null,
+ projectId: "project" as Thread["projectId"],
+ title: "Thread",
+ model: "gpt-5.4",
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ session: null,
+ messages: [],
+ turnDiffSummaries: [],
+ activities: [],
+ proposedPlans: [],
+ error: null,
+ createdAt: "2026-03-09T12:00:00.000Z",
+ latestTurn: null,
+ branch: null,
+ worktreePath: null,
+ ...overrides,
+ };
+}
+
+describe("sortSidebarThreadEntries", () => {
+ it("sorts by recent activity when configured", () => {
+ const olderThread = makeThread({
+ id: "thread-older" as Thread["id"],
+ createdAt: "2026-03-09T11:00:00.000Z",
+ messages: [
+ {
+ id: "older-user" as Thread["messages"][number]["id"],
+ role: "user",
+ text: "hello",
+ streaming: false,
+ createdAt: "2026-03-09T11:01:00.000Z",
+ },
+ ],
+ });
+ const newerActivityThread = makeThread({
+ id: "thread-new-activity" as Thread["id"],
+ createdAt: "2026-03-09T10:00:00.000Z",
+ messages: [
+ {
+ id: "new-user" as Thread["messages"][number]["id"],
+ role: "user",
+ text: "latest",
+ streaming: false,
+ createdAt: "2026-03-09T12:30:00.000Z",
+ },
+ ],
+ });
+
+ const ordered = sortSidebarThreadEntries(
+ [
+ { id: olderThread.id, createdAt: olderThread.createdAt, thread: olderThread },
+ {
+ id: newerActivityThread.id,
+ createdAt: newerActivityThread.createdAt,
+ thread: newerActivityThread,
+ },
+ ],
+ "recent-activity",
+ );
+
+ expect(ordered.map((entry) => entry.id)).toEqual([
+ newerActivityThread.id,
+ olderThread.id,
+ ]);
+ });
+
+ it("sorts by created-at when configured", () => {
+ const olderThread = makeThread({
+ id: "thread-older" as Thread["id"],
+ createdAt: "2026-03-09T11:00:00.000Z",
+ messages: [
+ {
+ id: "older-user" as Thread["messages"][number]["id"],
+ role: "user",
+ text: "latest",
+ streaming: false,
+ createdAt: "2026-03-09T12:30:00.000Z",
+ },
+ ],
+ });
+ const newerThread = makeThread({
+ id: "thread-newer" as Thread["id"],
+ createdAt: "2026-03-09T12:00:00.000Z",
+ });
+
+ const ordered = sortSidebarThreadEntries(
+ [
+ { id: olderThread.id, createdAt: olderThread.createdAt, thread: olderThread },
+ { id: newerThread.id, createdAt: newerThread.createdAt, thread: newerThread },
+ ],
+ "created-at",
+ );
+
+ expect(ordered.map((entry) => entry.id)).toEqual([newerThread.id, olderThread.id]);
+ });
+
+ it("treats proposed plans and pending questions as recent activity", () => {
+ const planThread = makeThread({
+ id: "thread-plan" as Thread["id"],
+ createdAt: "2026-03-09T08:00:00.000Z",
+ proposedPlans: [
+ {
+ id: "plan-1" as Thread["proposedPlans"][number]["id"],
+ turnId: null,
+ planMarkdown: "# plan",
+ createdAt: "2026-03-09T09:00:00.000Z",
+ updatedAt: "2026-03-09T12:15:00.000Z",
+ },
+ ],
+ });
+ const questionThread = makeThread({
+ id: "thread-question" as Thread["id"],
+ createdAt: "2026-03-09T07:00:00.000Z",
+ activities: [
+ {
+ id: "act-1" as Thread["activities"][number]["id"],
+ createdAt: "2026-03-09T12:20:00.000Z",
+ kind: "user-input.requested",
+ summary: "Need input",
+ tone: "info",
+ payload: {
+ requestId: "req-1",
+ questions: [
+ {
+ id: "answer",
+ header: "Answer",
+ question: "Need input",
+ options: [
+ {
+ label: "continue",
+ description: "Continue execution",
+ },
+ ],
+ },
+ ],
+ },
+ turnId: null,
+ sequence: 1,
+ },
+ ],
+ });
+
+ const ordered = sortSidebarThreadEntries(
+ [
+ { id: planThread.id, createdAt: planThread.createdAt, thread: planThread },
+ { id: questionThread.id, createdAt: questionThread.createdAt, thread: questionThread },
+ ],
+ "recent-activity",
+ );
+
+ expect(ordered.map((entry) => entry.id)).toEqual([questionThread.id, planThread.id]);
+ });
+});
diff --git a/apps/web/src/sidebarThreadOrder.ts b/apps/web/src/sidebarThreadOrder.ts
new file mode 100644
index 0000000000..5d4551ba68
--- /dev/null
+++ b/apps/web/src/sidebarThreadOrder.ts
@@ -0,0 +1,73 @@
+import { derivePendingUserInputs } from "./session-logic";
+import type { Thread } from "./types";
+import type { SidebarThreadOrder } from "./appSettings";
+
+export interface SidebarThreadEntryLike {
+ id: string;
+ createdAt: string;
+ thread: Thread | null;
+}
+
+function latestIso(left: string, right: string): string {
+ return left.localeCompare(right) >= 0 ? left : right;
+}
+
+export function getSidebarThreadRecentActivityAt(entry: SidebarThreadEntryLike): string {
+ const thread = entry.thread;
+ if (thread === null) {
+ return entry.createdAt;
+ }
+
+ let latestActivityAt = latestIso(entry.createdAt, thread.createdAt);
+
+ for (const message of thread.messages) {
+ if (message.role === "user") {
+ latestActivityAt = latestIso(latestActivityAt, message.createdAt);
+ continue;
+ }
+
+ if (message.role === "assistant" && !message.streaming && message.completedAt) {
+ latestActivityAt = latestIso(latestActivityAt, message.completedAt);
+ }
+ }
+
+ for (const proposedPlan of thread.proposedPlans) {
+ latestActivityAt = latestIso(latestActivityAt, proposedPlan.updatedAt);
+ }
+
+ for (
+ const pendingUserInput of derivePendingUserInputs(thread.activities, {
+ latestTurn: thread.latestTurn,
+ session: thread.session,
+ })
+ ) {
+ latestActivityAt = latestIso(latestActivityAt, pendingUserInput.createdAt);
+ }
+
+ return latestActivityAt;
+}
+
+export function getSidebarThreadSortTimestamp(
+ entry: SidebarThreadEntryLike,
+ order: SidebarThreadOrder,
+): string {
+ return order === "created-at" ? entry.createdAt : getSidebarThreadRecentActivityAt(entry);
+}
+
+export function sortSidebarThreadEntries(
+ entries: readonly T[],
+ order: SidebarThreadOrder,
+): T[] {
+ return [...entries].toSorted((left, right) => {
+ const bySortTimestamp =
+ getSidebarThreadSortTimestamp(right, order).localeCompare(
+ getSidebarThreadSortTimestamp(left, order),
+ );
+ if (bySortTimestamp !== 0) return bySortTimestamp;
+
+ const byCreatedAt = right.createdAt.localeCompare(left.createdAt);
+ if (byCreatedAt !== 0) return byCreatedAt;
+
+ return right.id.localeCompare(left.id);
+ });
+}
diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts
index 92d084f2d6..6d94209d38 100644
--- a/apps/web/src/store.test.ts
+++ b/apps/web/src/store.test.ts
@@ -7,7 +7,7 @@ import {
} from "@t3tools/contracts";
import { describe, expect, it } from "vitest";
-import { markThreadUnread, reorderProjects, syncServerReadModel, type AppState } from "./store";
+import { markThreadUnread, syncServerReadModel, type AppState } from "./store";
import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
function makeThread(overrides: Partial = {}): Thread {
@@ -43,6 +43,8 @@ function makeState(thread: Thread): AppState {
model: "gpt-5-codex",
expanded: true,
scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
},
],
threads: [thread],
@@ -87,28 +89,14 @@ function makeReadModel(thread: OrchestrationReadModel["threads"][number]): Orche
updatedAt: "2026-02-27T00:00:00.000Z",
deletedAt: null,
scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
},
],
threads: [thread],
};
}
-function makeReadModelProject(
- overrides: Partial,
-): OrchestrationReadModel["projects"][number] {
- return {
- id: ProjectId.makeUnsafe("project-1"),
- title: "Project",
- workspaceRoot: "/tmp/project",
- defaultModel: "gpt-5.3-codex",
- createdAt: "2026-02-27T00:00:00.000Z",
- updatedAt: "2026-02-27T00:00:00.000Z",
- deletedAt: null,
- scripts: [],
- ...overrides,
- };
-}
-
describe("store pure functions", () => {
it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {
const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
@@ -148,46 +136,6 @@ describe("store pure functions", () => {
expect(next).toEqual(initialState);
});
-
- 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: [
- {
- id: project1,
- name: "Project 1",
- cwd: "/tmp/project-1",
- model: DEFAULT_MODEL_BY_PROVIDER.codex,
- expanded: true,
- scripts: [],
- },
- {
- id: project2,
- name: "Project 2",
- cwd: "/tmp/project-2",
- model: DEFAULT_MODEL_BY_PROVIDER.codex,
- expanded: true,
- scripts: [],
- },
- {
- id: project3,
- name: "Project 3",
- cwd: "/tmp/project-3",
- model: DEFAULT_MODEL_BY_PROVIDER.codex,
- expanded: true,
- scripts: [],
- },
- ],
- threads: [],
- threadsHydrated: true,
- };
-
- const next = reorderProjects(state, project1, project3);
-
- expect(next.projects.map((project) => project.id)).toEqual([project2, project3, project1]);
- });
});
describe("store read model sync", () => {
@@ -204,57 +152,46 @@ describe("store read model sync", () => {
expect(next.threads[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
});
- it("preserves the current project order when syncing incoming read model updates", () => {
- const project1 = ProjectId.makeUnsafe("project-1");
- const project2 = ProjectId.makeUnsafe("project-2");
- const project3 = ProjectId.makeUnsafe("project-3");
- const initialState: AppState = {
+ it("syncs shared project thread group order from the read model", () => {
+ const initialState = makeState(makeThread());
+ const sourceReadModel = makeReadModel(makeReadModelThread({}));
+ const readModel = {
+ ...sourceReadModel,
projects: [
{
- id: project2,
- name: "Project 2",
- cwd: "/tmp/project-2",
- model: DEFAULT_MODEL_BY_PROVIDER.codex,
- expanded: true,
- scripts: [],
- },
- {
- id: project1,
- name: "Project 1",
- cwd: "/tmp/project-1",
- model: DEFAULT_MODEL_BY_PROVIDER.codex,
- expanded: true,
- scripts: [],
+ ...sourceReadModel.projects[0]!,
+ threadGroupOrder: [
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ "branch:release/1.0",
+ ],
},
+ ...sourceReadModel.projects.slice(1),
],
- threads: [],
- threadsHydrated: true,
};
- const readModel: OrchestrationReadModel = {
- snapshotSequence: 2,
- updatedAt: "2026-02-27T00:00:00.000Z",
+
+ const next = syncServerReadModel(initialState, readModel);
+
+ expect(next.projects[0]?.threadGroupOrder).toEqual([
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ "branch:release/1.0",
+ ]);
+ });
+
+ it("syncs shared project sort order from the read model", () => {
+ const initialState = makeState(makeThread());
+ const sourceReadModel = makeReadModel(makeReadModelThread({}));
+ const readModel = {
+ ...sourceReadModel,
projects: [
- makeReadModelProject({
- id: project1,
- title: "Project 1",
- workspaceRoot: "/tmp/project-1",
- }),
- makeReadModelProject({
- id: project2,
- title: "Project 2",
- workspaceRoot: "/tmp/project-2",
- }),
- makeReadModelProject({
- id: project3,
- title: "Project 3",
- workspaceRoot: "/tmp/project-3",
- }),
+ {
+ ...sourceReadModel.projects[0]!,
+ sortOrder: 4,
+ },
],
- threads: [],
};
const next = syncServerReadModel(initialState, readModel);
- expect(next.projects.map((project) => project.id)).toEqual([project2, project1, project3]);
+ expect(next.projects[0]?.sortOrder).toBe(4);
});
});
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
index 0dbd025aaf..ba530a2578 100644
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -14,7 +14,6 @@ import {
} from "@t3tools/shared/model";
import { create } from "zustand";
import { type ChatMessage, type Project, type Thread } from "./types";
-import { Debouncer } from "@tanstack/react-pacer";
// ── State ────────────────────────────────────────────────────────────
@@ -43,7 +42,6 @@ const initialState: AppState = {
threadsHydrated: false,
};
const persistedExpandedProjectCwds = new Set();
-const persistedProjectOrderCwds: string[] = [];
// ── Persist helpers ──────────────────────────────────────────────────
@@ -52,30 +50,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[];
- };
+ const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[] };
persistedExpandedProjectCwds.clear();
- persistedProjectOrderCwds.length = 0;
for (const cwd of parsed.expandedProjectCwds ?? []) {
if (typeof cwd === "string" && cwd.length > 0) {
persistedExpandedProjectCwds.add(cwd);
}
}
- for (const cwd of parsed.projectOrderCwds ?? []) {
- if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) {
- persistedProjectOrderCwds.push(cwd);
- }
- }
return { ...initialState };
} catch {
return initialState;
}
}
-let legacyKeysCleanedUp = false;
-
function persistState(state: AppState): void {
if (typeof window === "undefined") return;
try {
@@ -85,20 +72,15 @@ function persistState(state: AppState): void {
expandedProjectCwds: state.projects
.filter((project) => project.expanded)
.map((project) => project.cwd),
- projectOrderCwds: state.projects.map((project) => project.cwd),
}),
);
- if (!legacyKeysCleanedUp) {
- legacyKeysCleanedUp = true;
- for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
- window.localStorage.removeItem(legacyKey);
- }
+ for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
+ window.localStorage.removeItem(legacyKey);
}
} catch {
// Ignore quota/storage errors to avoid breaking chat UX.
}
}
-const debouncedPersistState = new Debouncer(persistState, { wait: 500 });
// ── Pure helpers ──────────────────────────────────────────────────────
@@ -121,17 +103,10 @@ function mapProjectsFromReadModel(
incoming: OrchestrationReadModel["projects"],
previous: Project[],
): Project[] {
- const previousById = new Map(previous.map((project) => [project.id, project] as const));
- const previousByCwd = new Map(previous.map((project) => [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) => [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(project.workspaceRoot);
+ return incoming.map((project) => {
+ const existing =
+ previous.find((entry) => entry.id === project.id) ??
+ previous.find((entry) => entry.cwd === project.workspaceRoot);
return {
id: project.id,
name: project.title,
@@ -145,25 +120,10 @@ function mapProjectsFromReadModel(
? persistedExpandedProjectCwds.has(project.workspaceRoot)
: true),
scripts: project.scripts.map((script) => ({ ...script })),
- } satisfies Project;
+ threadGroupOrder: [...(project.threadGroupOrder ?? [])],
+ sortOrder: project.sortOrder ?? existing?.sortOrder ?? 0,
+ };
});
-
- return mappedProjects
- .map((project, incomingIndex) => {
- const previousIndex = previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd);
- const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(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);
}
function toLegacySessionStatus(
@@ -384,22 +344,6 @@ export function setProjectExpanded(
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 setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
const threads = updateThread(state.threads, threadId, (t) => {
if (t.error === error) return t;
@@ -435,7 +379,6 @@ interface AppStore extends AppState {
markThreadUnread: (threadId: ThreadId) => void;
toggleProject: (projectId: Project["id"]) => void;
setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
- reorderProjects: (draggedProjectId: Project["id"], targetProjectId: Project["id"]) => void;
setError: (threadId: ThreadId, error: string | null) => void;
setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;
}
@@ -449,22 +392,13 @@ export const useStore = create((set) => ({
toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
setProjectExpanded: (projectId, expanded) =>
set((state) => setProjectExpanded(state, projectId, expanded)),
- reorderProjects: (draggedProjectId, targetProjectId) =>
- set((state) => reorderProjects(state, draggedProjectId, targetProjectId)),
setError: (threadId, error) => set((state) => setError(state, threadId, error)),
setThreadBranch: (threadId, branch, worktreePath) =>
set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
}));
-// Persist state changes with debouncing to avoid localStorage thrashing
-useStore.subscribe((state) => debouncedPersistState.maybeExecute(state));
-
-// Flush pending writes synchronously before page unload to prevent data loss.
-if (typeof window !== "undefined") {
- window.addEventListener("beforeunload", () => {
- debouncedPersistState.flush();
- });
-}
+// Persist on every state change
+useStore.subscribe((state) => persistState(state));
export function StoreProvider({ children }: { children: ReactNode }) {
useEffect(() => {
diff --git a/apps/web/src/threadGroups.test.ts b/apps/web/src/threadGroups.test.ts
new file mode 100644
index 0000000000..5c6fc7db63
--- /dev/null
+++ b/apps/web/src/threadGroups.test.ts
@@ -0,0 +1,409 @@
+import { ProjectId, ThreadId, type GitStatusResult } from "@t3tools/contracts";
+import { describe, expect, it } from "vitest";
+
+import {
+ MAIN_THREAD_GROUP_ID,
+ buildThreadGroupId,
+ orderProjectThreadGroups,
+ resolveProjectThreadGroupPrById,
+ reorderProjectThreadGroupOrder,
+} from "./threadGroups";
+import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, type Thread } from "./types";
+
+function makeProject(overrides: Partial = {}): Project {
+ return {
+ id: ProjectId.makeUnsafe("project-1"),
+ name: "Project",
+ cwd: "/tmp/project",
+ model: "gpt-5-codex",
+ expanded: true,
+ scripts: [],
+ threadGroupOrder: [],
+ sortOrder: 0,
+ ...overrides,
+ };
+}
+
+function makeThread(overrides: Partial = {}): Thread {
+ return {
+ id: ThreadId.makeUnsafe("thread-1"),
+ codexThreadId: null,
+ projectId: ProjectId.makeUnsafe("project-1"),
+ title: "Thread",
+ model: "gpt-5-codex",
+ runtimeMode: DEFAULT_RUNTIME_MODE,
+ interactionMode: DEFAULT_INTERACTION_MODE,
+ session: null,
+ messages: [],
+ turnDiffSummaries: [],
+ activities: [],
+ proposedPlans: [],
+ error: null,
+ createdAt: "2026-03-01T00:00:00.000Z",
+ latestTurn: null,
+ branch: null,
+ worktreePath: null,
+ ...overrides,
+ };
+}
+
+function makeGitStatus(overrides: Partial = {}): GitStatusResult {
+ return {
+ branch: "feature/a",
+ hasWorkingTreeChanges: false,
+ workingTree: {
+ files: [],
+ insertions: 0,
+ deletions: 0,
+ },
+ hasUpstream: true,
+ aheadCount: 0,
+ behindCount: 0,
+ pr: null,
+ ...overrides,
+ };
+}
+
+describe("threadGroups", () => {
+ it("uses worktree identity before branch identity", () => {
+ expect(
+ buildThreadGroupId({
+ branch: "feature/a",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ }),
+ ).toBe("worktree:/tmp/project/.t3/worktrees/feature-a");
+ expect(buildThreadGroupId({ branch: "feature/a", worktreePath: null })).toBe("branch:feature/a");
+ expect(buildThreadGroupId({ branch: null, worktreePath: null })).toBe(MAIN_THREAD_GROUP_ID);
+ });
+
+ it("labels worktree groups from the path when branch metadata is missing", () => {
+ const project = makeProject();
+ const [mainGroup, worktreeGroup] = orderProjectThreadGroups({
+ project,
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-main"),
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree"),
+ branch: null,
+ worktreePath: "/tmp/project/.t3/worktrees/feature-draft-only",
+ }),
+ ],
+ });
+
+ expect(mainGroup?.label).toBe("Main");
+ expect(worktreeGroup?.label).toBe("feature-draft-only");
+ });
+
+ it("normalizes branch and worktree metadata stored on ordered groups", () => {
+ const groups = orderProjectThreadGroups({
+ project: makeProject(),
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree-spaced"),
+ branch: " feature/a ",
+ worktreePath: " /tmp/project/.t3/worktrees/feature-a ",
+ }),
+ ],
+ });
+
+ expect(groups[1]).toMatchObject({
+ id: "worktree:/tmp/project/.t3/worktrees/feature-a",
+ branch: "feature/a",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ label: "feature/a",
+ });
+
+ const prByGroupId = resolveProjectThreadGroupPrById({
+ groups,
+ projectCwd: "/tmp/project",
+ statusByCwd: new Map([
+ [
+ "/tmp/project/.t3/worktrees/feature-a",
+ makeGitStatus({
+ branch: "feature/a",
+ pr: {
+ number: 12,
+ title: "Feature A",
+ url: "https://example.com/pr/12",
+ baseBranch: "main",
+ headBranch: "feature/a",
+ state: "open",
+ },
+ }),
+ ],
+ ]),
+ });
+
+ expect(prByGroupId.get("worktree:/tmp/project/.t3/worktrees/feature-a")?.number).toBe(12);
+ });
+
+ it("refreshes group metadata when a newer thread in the same normalized group arrives", () => {
+ const groups = orderProjectThreadGroups({
+ project: makeProject(),
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree-older"),
+ branch: null,
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ createdAt: "2026-03-01T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree-newer"),
+ branch: "feature/a",
+ worktreePath: " /tmp/project/.t3/worktrees/feature-a ",
+ createdAt: "2026-03-05T00:00:00.000Z",
+ }),
+ ],
+ });
+
+ expect(groups[1]).toMatchObject({
+ id: "worktree:/tmp/project/.t3/worktrees/feature-a",
+ branch: "feature/a",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ label: "feature/a",
+ latestActivityAt: "2026-03-05T00:00:00.000Z",
+ });
+ });
+
+ it("pins Main first, inserts new groups next, then keeps shared project order", () => {
+ const project = makeProject({
+ threadGroupOrder: ["worktree:/tmp/project/.t3/worktrees/feature-a", "branch:release/1.0"],
+ });
+ const groups = orderProjectThreadGroups({
+ project,
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-main"),
+ createdAt: "2026-03-01T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-feature-b"),
+ branch: "feature/b",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-b",
+ createdAt: "2026-03-05T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-feature-a"),
+ branch: "feature/a",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ createdAt: "2026-03-04T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-release"),
+ branch: "release/1.0",
+ worktreePath: null,
+ createdAt: "2026-03-03T00:00:00.000Z",
+ }),
+ ],
+ });
+
+ expect(groups.map((group) => group.id)).toEqual([
+ MAIN_THREAD_GROUP_ID,
+ "worktree:/tmp/project/.t3/worktrees/feature-b",
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ "branch:release/1.0",
+ ]);
+ });
+
+ it("reorders non-main groups without losing unknown entries", () => {
+ expect(
+ reorderProjectThreadGroupOrder({
+ currentOrder: ["worktree:/tmp/project/.t3/worktrees/feature-a", "branch:release/1.0"],
+ movedGroupId: "branch:release/1.0",
+ beforeGroupId: "worktree:/tmp/project/.t3/worktrees/feature-a",
+ }),
+ ).toEqual(["branch:release/1.0", "worktree:/tmp/project/.t3/worktrees/feature-a"]);
+ });
+
+ it("ignores Main and duplicate ids from shared project order", () => {
+ const project = makeProject({
+ threadGroupOrder: [
+ MAIN_THREAD_GROUP_ID,
+ "branch:release/1.0",
+ "branch:release/1.0",
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ ],
+ });
+ const groups = orderProjectThreadGroups({
+ project,
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-main"),
+ createdAt: "2026-03-01T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-release"),
+ branch: "release/1.0",
+ worktreePath: null,
+ createdAt: "2026-03-03T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-feature-a"),
+ branch: "feature/a",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ createdAt: "2026-03-04T00:00:00.000Z",
+ }),
+ ],
+ });
+
+ expect(groups.map((group) => group.id)).toEqual([
+ MAIN_THREAD_GROUP_ID,
+ "branch:release/1.0",
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ ]);
+ });
+
+ it("keeps reordered shared group order non-main and unique", () => {
+ expect(
+ reorderProjectThreadGroupOrder({
+ currentOrder: [
+ MAIN_THREAD_GROUP_ID,
+ "branch:release/1.0",
+ "branch:release/1.0",
+ ],
+ movedGroupId: "worktree:/tmp/project/.t3/worktrees/feature-a",
+ beforeGroupId: MAIN_THREAD_GROUP_ID,
+ }),
+ ).toEqual([
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ "branch:release/1.0",
+ ]);
+ });
+
+ it("treats dropping a group onto itself as a no-op", () => {
+ expect(
+ reorderProjectThreadGroupOrder({
+ currentOrder: [
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ "branch:release/1.0",
+ "worktree:/tmp/project/.t3/worktrees/feature-b",
+ ],
+ movedGroupId: "branch:release/1.0",
+ beforeGroupId: "branch:release/1.0",
+ }),
+ ).toEqual([
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ "branch:release/1.0",
+ "worktree:/tmp/project/.t3/worktrees/feature-b",
+ ]);
+ });
+
+ it("includes draft-only groups in ordering", () => {
+ const project = makeProject({
+ threadGroupOrder: [],
+ });
+ const groups = orderProjectThreadGroups({
+ project,
+ threads: [
+ {
+ branch: "feature/draft-only",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-draft-only",
+ createdAt: "2026-03-06T00:00:00.000Z",
+ },
+ ],
+ });
+
+ expect(groups.map((group) => group.id)).toEqual([
+ MAIN_THREAD_GROUP_ID,
+ "worktree:/tmp/project/.t3/worktrees/feature-draft-only",
+ ]);
+ });
+
+ it("resolves PR state for worktree and branch groups but never Main", () => {
+ const groups = orderProjectThreadGroups({
+ project: makeProject(),
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-main"),
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-branch"),
+ branch: "feature/a",
+ worktreePath: null,
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree"),
+ branch: "feature/b",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-b",
+ }),
+ ],
+ });
+
+ const prByGroupId = resolveProjectThreadGroupPrById({
+ groups,
+ projectCwd: "/tmp/project",
+ statusByCwd: new Map([
+ [
+ "/tmp/project",
+ makeGitStatus({
+ branch: "feature/a",
+ pr: {
+ number: 12,
+ title: "Feature A",
+ url: "https://example.com/pr/12",
+ baseBranch: "main",
+ headBranch: "feature/a",
+ state: "open",
+ },
+ }),
+ ],
+ [
+ "/tmp/project/.t3/worktrees/feature-b",
+ makeGitStatus({
+ branch: "feature/b",
+ pr: {
+ number: 34,
+ title: "Feature B",
+ url: "https://example.com/pr/34",
+ baseBranch: "main",
+ headBranch: "feature/b",
+ state: "merged",
+ },
+ }),
+ ],
+ ]),
+ });
+
+ expect(prByGroupId.get(MAIN_THREAD_GROUP_ID)).toBeNull();
+ expect(prByGroupId.get("branch:feature/a")?.number).toBe(12);
+ expect(prByGroupId.get("worktree:/tmp/project/.t3/worktrees/feature-b")?.number).toBe(34);
+ });
+
+ it("omits group PR state when the git status branch does not match the group branch", () => {
+ const groups = orderProjectThreadGroups({
+ project: makeProject(),
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree"),
+ branch: "feature/b",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-b",
+ }),
+ ],
+ });
+
+ const prByGroupId = resolveProjectThreadGroupPrById({
+ groups,
+ projectCwd: "/tmp/project",
+ statusByCwd: new Map([
+ [
+ "/tmp/project/.t3/worktrees/feature-b",
+ makeGitStatus({
+ branch: "feature/c",
+ pr: {
+ number: 99,
+ title: "Wrong Branch",
+ url: "https://example.com/pr/99",
+ baseBranch: "main",
+ headBranch: "feature/c",
+ state: "closed",
+ },
+ }),
+ ],
+ ]),
+ });
+
+ expect(prByGroupId.get("worktree:/tmp/project/.t3/worktrees/feature-b")).toBeNull();
+ });
+});
diff --git a/apps/web/src/threadGroups.ts b/apps/web/src/threadGroups.ts
new file mode 100644
index 0000000000..9aa37b97ff
--- /dev/null
+++ b/apps/web/src/threadGroups.ts
@@ -0,0 +1,181 @@
+import type { GitStatusResult, ThreadGroupId } from "@t3tools/contracts";
+import { type Project } from "./types";
+import { formatWorktreePathForDisplay } from "./worktreeCleanup";
+
+export const MAIN_THREAD_GROUP_ID: ThreadGroupId = "main";
+
+export interface ThreadGroupIdentity {
+ branch: string | null;
+ worktreePath: string | null;
+}
+
+export interface ThreadGroupSeed extends ThreadGroupIdentity {
+ createdAt: string;
+}
+
+export interface OrderedProjectThreadGroup {
+ id: ThreadGroupId;
+ branch: string | null;
+ worktreePath: string | null;
+ label: string;
+ latestActivityAt: string;
+}
+
+export type ThreadGroupPrStatus = GitStatusResult["pr"];
+
+function normalizeMetadataString(value: string | null | undefined): string | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const normalized = value.trim();
+ return normalized.length > 0 ? normalized : null;
+}
+
+function normalizeThreadGroupIdentity(input: ThreadGroupIdentity): ThreadGroupIdentity {
+ return {
+ branch: normalizeMetadataString(input.branch),
+ worktreePath: normalizeMetadataString(input.worktreePath),
+ };
+}
+
+function normalizeProjectThreadGroupOrder(threadGroupOrder: readonly ThreadGroupId[]): ThreadGroupId[] {
+ const seen = new Set();
+ const next: ThreadGroupId[] = [];
+ for (const groupId of threadGroupOrder) {
+ if (groupId === MAIN_THREAD_GROUP_ID || seen.has(groupId)) {
+ continue;
+ }
+ seen.add(groupId);
+ next.push(groupId);
+ }
+ return next;
+}
+
+export function buildThreadGroupId(input: ThreadGroupIdentity): ThreadGroupId {
+ const normalized = normalizeThreadGroupIdentity(input);
+ if (normalized.worktreePath) {
+ return `worktree:${normalized.worktreePath}`;
+ }
+ if (normalized.branch) {
+ return `branch:${normalized.branch}`;
+ }
+ return MAIN_THREAD_GROUP_ID;
+}
+
+function threadGroupLabel(input: ThreadGroupIdentity): string {
+ const normalized = normalizeThreadGroupIdentity(input);
+ if (normalized.worktreePath) {
+ return normalized.branch ?? formatWorktreePathForDisplay(normalized.worktreePath);
+ }
+ if (normalized.branch) {
+ return normalized.branch;
+ }
+ return "Main";
+}
+
+export function orderProjectThreadGroups(input: {
+ project: Project;
+ threads: T[];
+}): OrderedProjectThreadGroup[] {
+ const groups = new Map();
+ for (const thread of input.threads) {
+ const identity = normalizeThreadGroupIdentity({
+ branch: thread.branch,
+ worktreePath: thread.worktreePath,
+ });
+ const id = buildThreadGroupId(identity);
+ const existing = groups.get(id);
+ if (!existing) {
+ groups.set(id, {
+ id,
+ branch: identity.branch,
+ worktreePath: identity.worktreePath,
+ label: threadGroupLabel(identity),
+ latestActivityAt: thread.createdAt,
+ });
+ continue;
+ }
+ if (thread.createdAt > existing.latestActivityAt) {
+ existing.latestActivityAt = thread.createdAt;
+ existing.branch = identity.branch;
+ existing.worktreePath = identity.worktreePath;
+ existing.label = threadGroupLabel(identity);
+ }
+ }
+
+ const mainGroup =
+ groups.get(MAIN_THREAD_GROUP_ID) ??
+ ({
+ id: MAIN_THREAD_GROUP_ID,
+ branch: null,
+ worktreePath: null,
+ label: "Main",
+ latestActivityAt: "",
+ } satisfies OrderedProjectThreadGroup);
+
+ const nonMainGroups = [...groups.values()].filter((group) => group.id !== MAIN_THREAD_GROUP_ID);
+ const normalizedProjectThreadGroupOrder = normalizeProjectThreadGroupOrder(
+ input.project.threadGroupOrder,
+ );
+ const orderedKnownIds = new Set(normalizedProjectThreadGroupOrder);
+ const newGroups = nonMainGroups
+ .filter((group) => !orderedKnownIds.has(group.id))
+ .toSorted((left, right) => right.latestActivityAt.localeCompare(left.latestActivityAt));
+ const knownGroups = normalizedProjectThreadGroupOrder
+ .map((groupId) => groups.get(groupId))
+ .filter((group): group is OrderedProjectThreadGroup => group !== undefined);
+
+ return [mainGroup, ...newGroups, ...knownGroups];
+}
+
+export function reorderProjectThreadGroupOrder(input: {
+ currentOrder: ThreadGroupId[];
+ movedGroupId: ThreadGroupId;
+ beforeGroupId: ThreadGroupId | null;
+}): ThreadGroupId[] {
+ const normalizedCurrentOrder = normalizeProjectThreadGroupOrder(input.currentOrder);
+ if (input.movedGroupId === MAIN_THREAD_GROUP_ID) {
+ return normalizedCurrentOrder;
+ }
+ if (input.beforeGroupId === input.movedGroupId) {
+ return normalizedCurrentOrder;
+ }
+ const withoutMoved = normalizedCurrentOrder.filter((groupId) => groupId !== input.movedGroupId);
+ if (input.beforeGroupId === MAIN_THREAD_GROUP_ID) {
+ return [input.movedGroupId, ...withoutMoved];
+ }
+ if (!input.beforeGroupId) {
+ return [...withoutMoved, input.movedGroupId];
+ }
+ const insertIndex = withoutMoved.indexOf(input.beforeGroupId);
+ if (insertIndex === -1) {
+ return [input.movedGroupId, ...withoutMoved];
+ }
+ return [
+ ...withoutMoved.slice(0, insertIndex),
+ input.movedGroupId,
+ ...withoutMoved.slice(insertIndex),
+ ];
+}
+
+export function resolveProjectThreadGroupPrById(input: {
+ groups: readonly OrderedProjectThreadGroup[];
+ projectCwd: string;
+ statusByCwd: ReadonlyMap;
+}): Map {
+ const prByGroupId = new Map();
+
+ for (const group of input.groups) {
+ if (group.id === MAIN_THREAD_GROUP_ID || group.branch === null) {
+ prByGroupId.set(group.id, null);
+ continue;
+ }
+
+ const cwd = group.worktreePath ?? input.projectCwd;
+ const status = input.statusByCwd.get(cwd);
+ const branchMatches = status?.branch !== null && status?.branch === group.branch;
+ prByGroupId.set(group.id, branchMatches ? (status?.pr ?? null) : null);
+ }
+
+ return prByGroupId;
+}
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index d5fff12991..36feb2397a 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -2,6 +2,7 @@ import type {
OrchestrationLatestTurn,
OrchestrationProposedPlanId,
OrchestrationSessionStatus,
+ ThreadGroupId as OrchestrationThreadGroupId,
OrchestrationThreadActivity,
ProjectScript as ContractProjectScript,
ThreadId,
@@ -81,6 +82,8 @@ export interface Project {
model: string;
expanded: boolean;
scripts: ProjectScript[];
+ threadGroupOrder: OrchestrationThreadGroupId[];
+ sortOrder: number;
}
export interface Thread {
diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts
index 25a641edbb..e13d544323 100644
--- a/packages/contracts/src/orchestration.test.ts
+++ b/packages/contracts/src/orchestration.test.ts
@@ -5,8 +5,10 @@ import { Effect, Schema } from "effect";
import {
DEFAULT_PROVIDER_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
+ OrchestrationReadModel,
OrchestrationGetTurnDiffInput,
OrchestrationSession,
+ ProjectMetaUpdatedPayload,
ProjectCreateCommand,
ThreadTurnStartCommand,
ThreadCreatedPayload,
@@ -16,7 +18,9 @@ import {
const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
+const decodeOrchestrationReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
+const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);
const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
ThreadTurnStartRequestedPayload,
@@ -98,6 +102,58 @@ it.effect("rejects command fields that become empty after trim", () =>
}),
);
+it.effect("decodes project meta updates with trimmed thread group order entries", () =>
+ Effect.gen(function* () {
+ const parsed = yield* decodeProjectMetaUpdatedPayload({
+ projectId: "project-1",
+ threadGroupOrder: [" worktree:/tmp/feature-b ", "branch:feature/a"],
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ });
+
+ assert.deepStrictEqual(parsed.threadGroupOrder, [
+ "worktree:/tmp/feature-b",
+ "branch:feature/a",
+ ]);
+ }),
+);
+
+it.effect("defaults project thread group order to an empty array when omitted from read models", () =>
+ Effect.gen(function* () {
+ const parsed = yield* decodeOrchestrationReadModel({
+ snapshotSequence: 1,
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ projects: [
+ {
+ id: "project-1",
+ title: "Project",
+ workspaceRoot: "/tmp/project",
+ defaultModel: null,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ deletedAt: null,
+ },
+ ],
+ threads: [],
+ });
+
+ assert.deepStrictEqual(parsed.projects[0]?.threadGroupOrder, []);
+ assert.strictEqual(parsed.projects[0]?.sortOrder, 0);
+ }),
+);
+
+it.effect("decodes project meta updates with explicit shared sort order", () =>
+ Effect.gen(function* () {
+ const parsed = yield* decodeProjectMetaUpdatedPayload({
+ projectId: "project-1",
+ sortOrder: 2,
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ });
+
+ assert.strictEqual(parsed.sortOrder, 2);
+ }),
+);
+
it.effect("decodes thread.turn.start defaults for provider and runtime mode", () =>
Effect.gen(function* () {
const parsed = yield* decodeThreadTurnStartCommand({
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index ecc59f0b98..772eef3b13 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -129,12 +129,19 @@ export const ProjectScript = Schema.Struct({
});
export type ProjectScript = typeof ProjectScript.Type;
+export const ThreadGroupId = TrimmedNonEmptyString.check(
+ Schema.isPattern(/^(main|worktree:.+|branch:.+)$/),
+);
+export type ThreadGroupId = typeof ThreadGroupId.Type;
+
export const OrchestrationProject = Schema.Struct({
id: ProjectId,
title: TrimmedNonEmptyString,
workspaceRoot: TrimmedNonEmptyString,
defaultModel: Schema.NullOr(TrimmedNonEmptyString),
scripts: Schema.Array(ProjectScript),
+ threadGroupOrder: Schema.Array(ThreadGroupId).pipe(Schema.withDecodingDefault(() => [])),
+ sortOrder: NonNegativeInt.pipe(Schema.withDecodingDefault(() => 0)),
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
deletedAt: Schema.NullOr(IsoDateTime),
@@ -301,6 +308,8 @@ const ProjectMetaUpdateCommand = Schema.Struct({
workspaceRoot: Schema.optional(TrimmedNonEmptyString),
defaultModel: Schema.optional(TrimmedNonEmptyString),
scripts: Schema.optional(Schema.Array(ProjectScript)),
+ threadGroupOrder: Schema.optional(Schema.Array(ThreadGroupId)),
+ sortOrder: Schema.optional(NonNegativeInt),
});
const ProjectDeleteCommand = Schema.Struct({
@@ -593,6 +602,8 @@ export const ProjectCreatedPayload = Schema.Struct({
workspaceRoot: TrimmedNonEmptyString,
defaultModel: Schema.NullOr(TrimmedNonEmptyString),
scripts: Schema.Array(ProjectScript),
+ threadGroupOrder: Schema.Array(ThreadGroupId).pipe(Schema.withDecodingDefault(() => [])),
+ sortOrder: NonNegativeInt.pipe(Schema.withDecodingDefault(() => 0)),
createdAt: IsoDateTime,
updatedAt: IsoDateTime,
});
@@ -603,6 +614,8 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
workspaceRoot: Schema.optional(TrimmedNonEmptyString),
defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
scripts: Schema.optional(Schema.Array(ProjectScript)),
+ threadGroupOrder: Schema.optional(Schema.Array(ThreadGroupId)),
+ sortOrder: Schema.optional(NonNegativeInt),
updatedAt: IsoDateTime,
});