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>(new Map()); + const pendingProjectAnimationStartTopsRef = useRef | null>(null); + const pendingPersistedProjectOrderRef = useRef(null); + const projectReorderFlushInFlightRef = useRef(false); + const previousGroupOrderByProjectRef = useRef(new Map>()); + const previousGroupTopsRef = useRef>(new Map()); + const pendingGroupAnimationStartTopsRef = useRef | null>(null); + const activeDraggedProjectRef = useRef(null); + const pendingGroupDragRef = useRef<{ + projectId: ProjectId; + groupId: string; + startX: number; + startY: number; + pointerId: number; + element: HTMLDivElement; + } | null>(null); + const activeDraggedGroupRef = useRef<{ projectId: ProjectId; groupId: string } | null>(null); + const suppressProjectClickRef = useRef(null); + const suppressGroupClickRef = useRef(null); + const releasePendingGroupPointerCapture = useCallback(() => { + const pendingDrag = pendingGroupDragRef.current; + if (!pendingDrag) return; + if (pendingDrag.element.hasPointerCapture(pendingDrag.pointerId)) { + pendingDrag.element.releasePointerCapture(pendingDrag.pointerId); + } + }, []); + const setGroupOpen = useCallback((projectId: ProjectId, groupId: string, open: boolean) => { + setCollapsedGroupIds((prev) => + setProjectGroupCollapsed(prev, buildProjectGroupCollapseKey(projectId, groupId), open), + ); + }, []); const pendingApprovalByThreadId = useMemo(() => { const map = new Map(); for (const thread of threads) { @@ -321,58 +383,97 @@ export default function Sidebar() { () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); - const threadGitTargets = useMemo( + const orderedProjects = useMemo( + () => orderProjectsByIds(projects, optimisticProjectOrder), + [optimisticProjectOrder, projects], + ); + const orderedGroupIdsByProjectId = useMemo(() => { + const draftThreadsByProjectId = new Map>(); + for (const [threadId, draftThread] of Object.entries(draftThreadsByThreadId)) { + const existingDraftThreads = draftThreadsByProjectId.get(draftThread.projectId) ?? []; + existingDraftThreads.push({ + id: threadId as ThreadId, + createdAt: draftThread.createdAt, + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + }); + draftThreadsByProjectId.set(draftThread.projectId, existingDraftThreads); + } + + const groupIdsByProjectId = new Map>(); + for (const project of orderedProjects) { + const threadEntries = threads + .filter((thread) => thread.projectId === project.id) + .map((thread) => ({ + id: thread.id, + createdAt: thread.createdAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + })); + const draftEntries = draftThreadsByProjectId.get(project.id) ?? []; + + groupIdsByProjectId.set( + project.id, + orderProjectThreadGroups({ + project, + threads: [...threadEntries, ...draftEntries], + }).map((group) => group.id), + ); + } + + return groupIdsByProjectId; + }, [draftThreadsByThreadId, orderedProjects, threads]); + const gitStatusTargets = useMemo( () => - threads.map((thread) => ({ - threadId: thread.id, - branch: thread.branch, - cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, - })), - [projectCwdById, threads], + [ + ...threads.map((thread) => ({ + branch: thread.branch, + cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, + })), + ...Object.values(draftThreadsByThreadId).map((draftThread) => ({ + branch: draftThread.branch, + cwd: draftThread.worktreePath ?? projectCwdById.get(draftThread.projectId) ?? null, + })), + ], + [draftThreadsByThreadId, projectCwdById, threads], ); - const threadGitStatusCwds = useMemo( + const gitStatusCwds = useMemo( () => [ ...new Set( - threadGitTargets + gitStatusTargets .filter((target) => target.branch !== null) .map((target) => target.cwd) .filter((cwd): cwd is string => cwd !== null), ), ], - [threadGitTargets], + [gitStatusTargets], ); const threadGitStatusQueries = useQueries({ - queries: threadGitStatusCwds.map((cwd) => ({ + queries: gitStatusCwds.map((cwd) => ({ ...gitStatusQueryOptions(cwd), staleTime: 30_000, refetchInterval: 60_000, })), }); - const prByThreadId = useMemo(() => { + const gitStatusByCwd = useMemo(() => { const statusByCwd = new Map(); - for (let index = 0; index < threadGitStatusCwds.length; index += 1) { - const cwd = threadGitStatusCwds[index]; + for (let index = 0; index < gitStatusCwds.length; index += 1) { + const cwd = gitStatusCwds[index]; if (!cwd) continue; const status = threadGitStatusQueries[index]?.data; if (status) { statusByCwd.set(cwd, status); } } + return statusByCwd; + }, [gitStatusCwds, threadGitStatusQueries]); - const map = new Map(); - for (const target of threadGitTargets) { - const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; - const branchMatches = - target.branch !== null && status?.branch !== null && status?.branch === target.branch; - map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); - } - return map; - }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); - - const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { - event.preventDefault(); - event.stopPropagation(); - + const openPrUrl = useCallback((prUrl: string) => { const api = readNativeApi(); if (!api) { toastManager.add({ @@ -391,6 +492,89 @@ export default function Sidebar() { }); }, []); + useEffect(() => { + if ( + !shouldClearOptimisticProjectOrder({ + optimisticOrder: optimisticProjectOrder, + persistedOrder: orderProjects(projects).map((project) => project.id), + hasPendingReorder: isProjectReorderPending, + }) + ) { + return; + } + setOptimisticProjectOrder(null); + }, [isProjectReorderPending, optimisticProjectOrder, projects]); + + useLayoutEffect(() => { + if (typeof window === "undefined") { + return; + } + + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const nextProjectOrder = orderedProjects.map((project) => project.id); + const nextProjectTops = collectElementTopPositions(projectRowRefs.current); + const projectAnimationStartTops = + pendingProjectAnimationStartTopsRef.current ?? previousProjectTopsRef.current; + + if ( + !prefersReducedMotion && + hasSidebarReorderChanged(previousProjectOrderRef.current, nextProjectOrder) + ) { + animateSidebarReorder( + projectRowRefs.current, + buildSidebarReorderDeltas(projectAnimationStartTops, nextProjectTops), + ); + } + + previousProjectOrderRef.current = nextProjectOrder; + previousProjectTopsRef.current = nextProjectTops; + pendingProjectAnimationStartTopsRef.current = null; + + const nextGroupTops = collectElementTopPositions(threadGroupRowRefs.current); + const groupAnimationStartTops = + pendingGroupAnimationStartTopsRef.current ?? previousGroupTopsRef.current; + const changedProjectIds = new Set(); + for (const [projectId, nextGroupOrder] of orderedGroupIdsByProjectId.entries()) { + const previousGroupOrder = previousGroupOrderByProjectRef.current.get(projectId) ?? []; + if (hasSidebarReorderChanged(previousGroupOrder, nextGroupOrder)) { + changedProjectIds.add(projectId); + } + } + + if (!prefersReducedMotion && changedProjectIds.size > 0) { + const previousGroupTops = new Map( + [...groupAnimationStartTops.entries()].filter(([key]) => + changedProjectIds.has(key.split("\u0000", 1)[0] ?? ""), + ), + ); + const reorderedGroupTops = new Map( + [...nextGroupTops.entries()].filter(([key]) => + changedProjectIds.has(key.split("\u0000", 1)[0] ?? ""), + ), + ); + + animateSidebarReorder( + threadGroupRowRefs.current, + buildSidebarReorderDeltas(previousGroupTops, reorderedGroupTops), + ); + } + + previousGroupOrderByProjectRef.current = new Map( + [...orderedGroupIdsByProjectId.entries()].map(([projectId, groupIds]) => [projectId, [...groupIds]]), + ); + previousGroupTopsRef.current = nextGroupTops; + pendingGroupAnimationStartTopsRef.current = null; + }, [orderedGroupIdsByProjectId, orderedProjects]); + + const openPrLink = useCallback( + (event: React.MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); + openPrUrl(prUrl); + }, + [openPrUrl], + ); + const handleNewThread = useCallback( ( projectId: ProjectId, @@ -400,10 +584,14 @@ export default function Sidebar() { envMode?: DraftThreadEnvMode; }, ): Promise => { + const groupId = buildThreadGroupId({ + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, + }); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; - const storedDraftThread = getDraftThreadByProjectId(projectId); + const storedDraftThread = getDraftThreadByProjectGroupId(projectId, groupId); if (storedDraftThread) { return (async () => { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { @@ -413,7 +601,7 @@ export default function Sidebar() { ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } - setProjectDraftThreadId(projectId, storedDraftThread.threadId); + setProjectGroupDraftThreadId(projectId, groupId, storedDraftThread.threadId); if (routeThreadId === storedDraftThread.threadId) { return; } @@ -423,10 +611,18 @@ export default function Sidebar() { }); })(); } - clearProjectDraftThreadId(projectId); + clearProjectGroupDraftThreadId(projectId, groupId); const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; - if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { + if ( + activeDraftThread && + routeThreadId && + activeDraftThread.projectId === projectId && + buildThreadGroupId({ + branch: activeDraftThread.branch, + worktreePath: activeDraftThread.worktreePath, + }) === groupId + ) { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { setDraftThreadContext(routeThreadId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), @@ -434,17 +630,17 @@ export default function Sidebar() { ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } - setProjectDraftThreadId(projectId, routeThreadId); + setProjectGroupDraftThreadId(projectId, groupId, routeThreadId); return Promise.resolve(); } const threadId = newThreadId(); const createdAt = new Date().toISOString(); return (async () => { - setProjectDraftThreadId(projectId, threadId, { + setProjectGroupDraftThreadId(projectId, groupId, threadId, { createdAt, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", + envMode: options?.envMode ?? (groupId === MAIN_THREAD_GROUP_ID ? "local" : "worktree"), runtimeMode: DEFAULT_RUNTIME_MODE, }); @@ -455,13 +651,13 @@ export default function Sidebar() { })(); }, [ - clearProjectDraftThreadId, - getDraftThreadByProjectId, + clearProjectGroupDraftThreadId, + getDraftThreadByProjectGroupId, navigate, getDraftThread, routeThreadId, setDraftThreadContext, - setProjectDraftThreadId, + setProjectGroupDraftThreadId, ], ); @@ -521,37 +717,21 @@ export default function Sidebar() { }); await handleNewThread(projectId).catch(() => undefined); } catch (error) { - const description = - error instanceof Error ? error.message : "An error occurred while adding the project."; setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description, - }); - } else { - setAddProjectError(description); - } + setAddProjectError( + error instanceof Error ? error.message : "An error occurred while adding the project.", + ); return; } finishAddingProject(); }, - [ - focusMostRecentThreadForProject, - handleNewThread, - isAddingProject, - projects, - shouldBrowseForProjectImmediately, - ], + [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects], ); const handleAddProject = () => { void addProjectFromPath(newCwd); }; - const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - const handlePickFolder = async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; @@ -564,21 +744,12 @@ export default function Sidebar() { } if (pickedPath) { await addProjectFromPath(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { + } else { addProjectInputRef.current?.focus(); } setIsPickingFolder(false); }; - const handleStartAddProject = () => { - setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); - return; - } - setAddingProject((prev) => !prev); - }; - const cancelRename = useCallback(() => { setRenamingThreadId(null); renamingInputRef.current = null; @@ -728,6 +899,12 @@ export default function Sidebar() { commandId: newCommandId(), threadId, }); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); clearComposerDraftForThread(threadId); clearProjectDraftThreadById(thread.projectId, thread.id); clearTerminalState(threadId); @@ -778,6 +955,7 @@ export default function Sidebar() { projects, removeWorktreeMutation, routeThreadId, + syncServerReadModel, threads, ], ); @@ -811,16 +989,24 @@ export default function Sidebar() { if (!confirmed) return; try { - const projectDraftThread = getDraftThreadByProjectId(projectId); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.threadId); + const projectPrefix = `${projectId}\u0000`; + for (const [mappingId, threadId] of Object.entries(projectGroupDraftThreadIdById)) { + if (!mappingId.startsWith(projectPrefix)) { + continue; + } + clearComposerDraftForThread(threadId as ThreadId); } - clearProjectDraftThreadId(projectId); await api.orchestration.dispatchCommand({ type: "project.delete", commandId: newCommandId(), projectId, }); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error deleting project."; console.error("Failed to remove project", { projectId, error }); @@ -833,82 +1019,220 @@ export default function Sidebar() { }, [ clearComposerDraftForThread, - clearProjectDraftThreadId, - getDraftThreadByProjectId, + projectGroupDraftThreadIdById, projects, + syncServerReadModel, threads, ], ); - const projectDnDSensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 6 }, - }), - ); - const projectCollisionDetection = useCallback((args) => { - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - return pointerCollisions; - } + const handleGroupContextMenu = useCallback( + async ( + input: { + projectId: ProjectId; + projectCwd: string; + groupId: string; + groupLabel: string; + branch: string | null; + worktreePath: string | null; + prUrl: string | null; + entries: SidebarGroupEntry[]; + }, + position: { x: number; y: number }, + ) => { + const api = readNativeApi(); + if (!api) return; - return closestCorners(args); - }, []); + const workspacePath = input.worktreePath ?? input.projectCwd; + const clicked = await api.contextMenu.show( + buildSidebarGroupContextMenuItems({ + isMainGroup: input.groupId === MAIN_THREAD_GROUP_ID, + hasBranch: input.branch !== null, + hasWorktreePath: input.worktreePath !== null, + hasPr: input.prUrl !== null, + }), + position, + ); + if (!clicked) return; - const handleProjectDragEnd = useCallback( - (event: DragEndEvent) => { - dragInProgressRef.current = false; - const { active, over } = event; - if (!over || active.id === over.id) return; - const activeProject = projects.find((project) => project.id === active.id); - const overProject = projects.find((project) => project.id === over.id); - if (!activeProject || !overProject) return; - reorderProjects(activeProject.id, overProject.id); - }, - [projects, reorderProjects], - ); + if (clicked === "open-workspace") { + void api.shell.openInEditor(workspacePath, preferredTerminalEditor()).catch((error) => { + toastManager.add({ + type: "error", + title: "Failed to open workspace", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + return; + } - const handleProjectDragStart = useCallback((_event: DragStartEvent) => { - dragInProgressRef.current = true; - suppressProjectClickAfterDragRef.current = true; - }, []); + if (clicked === "copy-workspace-path" || clicked === "copy-project-path") { + try { + await copyTextToClipboard(clicked === "copy-workspace-path" ? workspacePath : input.projectCwd); + toastManager.add({ + type: "success", + title: "Path copied", + description: clicked === "copy-workspace-path" ? workspacePath : input.projectCwd, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } - const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { - dragInProgressRef.current = false; - }, []); + if (clicked === "copy-branch-name") { + if (!input.branch) return; + try { + await copyTextToClipboard(input.branch); + toastManager.add({ + type: "success", + title: "Branch copied", + description: input.branch, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to copy branch", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } - const handleProjectTitlePointerDownCapture = useCallback(() => { - suppressProjectClickAfterDragRef.current = false; - }, []); + if (clicked === "open-pr") { + if (!input.prUrl) return; + openPrUrl(input.prUrl); + return; + } - const handleProjectTitleClick = useCallback( - (event: React.MouseEvent, projectId: ProjectId) => { - if (dragInProgressRef.current) { - event.preventDefault(); - event.stopPropagation(); + if (clicked === "new-chat") { + void handleNewThread(input.projectId, { + branch: input.branch, + worktreePath: input.worktreePath, + envMode: input.groupId === MAIN_THREAD_GROUP_ID ? "local" : "worktree", + }); return; } - if (suppressProjectClickAfterDragRef.current) { - // Consume the synthetic click emitted after a drag release. - suppressProjectClickAfterDragRef.current = false; - event.preventDefault(); - event.stopPropagation(); + + if (clicked !== "delete-group-worktree-and-chats" || !input.worktreePath) { return; } - toggleProject(projectId); - }, - [toggleProject], - ); - const handleProjectTitleKeyDown = useCallback( - (event: React.KeyboardEvent, projectId: ProjectId) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (dragInProgressRef.current) { + const confirmed = await api.dialogs.confirm( + [ + `Delete all chats in "${input.groupLabel}" and remove its worktree?`, + input.worktreePath, + "", + "This action cannot be undone.", + ].join("\n"), + ); + if (!confirmed) return; + + const deletedEntryIds = new Set(input.entries.map((entry) => entry.id)); + const serverEntries = input.entries.filter((entry) => entry.thread !== null); + const remainingDraftThreadId = + Object.values(projectGroupDraftThreadIdById).find( + (threadId) => !deletedEntryIds.has(threadId as ThreadId) && draftThreadsByThreadId[threadId as ThreadId], + ) ?? null; + const remainingServerThreadId = + threads.find((thread) => !deletedEntryIds.has(thread.id))?.id ?? null; + + try { + for (const entry of serverEntries) { + const thread = entry.thread; + if (!thread) continue; + if (thread.session && thread.session.status !== "closed") { + await api.orchestration + .dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId: thread.id, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + } + + await api.terminal + .close({ + threadId: thread.id, + deleteHistory: true, + }) + .catch(() => undefined); + + await api.orchestration.dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: thread.id, + }); + clearComposerDraftForThread(thread.id); + clearProjectDraftThreadById(input.projectId, thread.id); + clearTerminalState(thread.id); + } + + clearProjectGroupDraftThreadId(input.projectId, input.groupId); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); + } catch (error) { + toastManager.add({ + type: "error", + title: `Failed to delete "${input.groupLabel}"`, + description: error instanceof Error ? error.message : "An error occurred.", + }); return; } - toggleProject(projectId); + + if (routeThreadId && deletedEntryIds.has(routeThreadId)) { + const fallbackThreadId = remainingServerThreadId ?? remainingDraftThreadId; + if (fallbackThreadId) { + void navigate({ + to: "/$threadId", + params: { threadId: fallbackThreadId as ThreadId }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } + } + + try { + await removeWorktreeMutation.mutateAsync({ + cwd: input.projectCwd, + path: input.worktreePath, + force: true, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Chats deleted, but worktree removal failed", + description: `Could not remove ${input.worktreePath}. ${ + error instanceof Error ? error.message : "Unknown error removing worktree." + }`, + }); + } }, - [toggleProject], + [ + clearComposerDraftForThread, + clearProjectDraftThreadById, + clearProjectGroupDraftThreadId, + clearTerminalState, + draftThreadsByThreadId, + handleNewThread, + navigate, + openPrUrl, + projectGroupDraftThreadIdById, + removeWorktreeMutation, + routeThreadId, + syncServerReadModel, + threads, + ], ); useEffect(() => { @@ -976,6 +1300,21 @@ export default function Sidebar() { }; }, []); + useEffect(() => { + if (typeof document === "undefined") return; + if (!draggedGroup && !draggedProjectId) return; + + const previousBodyCursor = document.body.style.cursor; + const previousDocumentCursor = document.documentElement.style.cursor; + document.body.style.cursor = "grabbing"; + document.documentElement.style.cursor = "grabbing"; + + return () => { + document.body.style.cursor = previousBodyCursor; + document.documentElement.style.cursor = previousDocumentCursor; + }; + }, [draggedGroup, draggedProjectId]); + const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); const desktopUpdateTooltip = desktopUpdateState @@ -1003,13 +1342,6 @@ export default function Sidebar() { : shouldHighlightDesktopUpdateError(desktopUpdateState) ? "text-rose-500 animate-pulse" : "text-amber-500 animate-pulse"; - const newThreadShortcutLabel = useMemo( - () => - shortcutLabelForCommand(keybindings, "chat.newLocal") ?? - shortcutLabelForCommand(keybindings, "chat.new"), - [keybindings], - ); - const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; @@ -1068,23 +1400,423 @@ export default function Sidebar() { } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - const expandThreadListForProject = useCallback((projectId: ProjectId) => { - setExpandedThreadListsByProject((current) => { - if (current.has(projectId)) return current; - const next = new Set(current); - next.add(projectId); - return next; - }); - }, []); + const handleProjectGroupReorder = useCallback( + async (projectId: ProjectId, movedGroupId: string, beforeGroupId: string | null) => { + const api = readNativeApi(); + const project = projects.find((entry) => entry.id === projectId); + if (!api || !project || movedGroupId === MAIN_THREAD_GROUP_ID) { + return; + } + const visibleGroupIds = orderProjectThreadGroups({ + project, + threads: [ + ...threads.filter((thread) => thread.projectId === projectId), + ...Object.entries(projectGroupDraftThreadIdById) + .flatMap(([mappingId, threadId]) => { + const separatorIndex = mappingId.indexOf("\u0000"); + if (separatorIndex <= 0) { + return []; + } + const mappingProjectId = mappingId.slice(0, separatorIndex); + if (mappingProjectId !== projectId) { + return []; + } + const draftThread = draftThreadsByThreadId[threadId as ThreadId]; + if (!draftThread) { + return []; + } + return [ + { + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + createdAt: draftThread.createdAt, + }, + ]; + }), + ], + }).map((group) => group.id); + const nextOrder = reorderProjectThreadGroupOrder({ + currentOrder: visibleGroupIds.filter((groupId) => groupId !== MAIN_THREAD_GROUP_ID), + movedGroupId, + beforeGroupId, + }); + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId, + threadGroupOrder: nextOrder, + }); + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); + }, + [draftThreadsByThreadId, projectGroupDraftThreadIdById, projects, syncServerReadModel, threads], + ); - const collapseThreadListForProject = useCallback((projectId: ProjectId) => { - setExpandedThreadListsByProject((current) => { - if (!current.has(projectId)) return current; - const next = new Set(current); - next.delete(projectId); - return next; - }); - }, []); + const handleProjectReorder = useCallback( + async (nextOrder: ProjectId[]) => { + const api = readNativeApi(); + if (!api) { + setOptimisticProjectOrder(null); + setIsProjectReorderPending(false); + pendingPersistedProjectOrderRef.current = null; + projectReorderFlushInFlightRef.current = false; + return; + } + + pendingPersistedProjectOrderRef.current = nextOrder; + if (projectReorderFlushInFlightRef.current) { + return; + } + + projectReorderFlushInFlightRef.current = true; + setIsProjectReorderPending(true); + try { + while (pendingPersistedProjectOrderRef.current) { + const targetOrder = pendingPersistedProjectOrderRef.current; + pendingPersistedProjectOrderRef.current = null; + + for (const [sortOrder, projectId] of targetOrder.entries()) { + const project = projects.find((entry) => entry.id === projectId); + if (!project || project.sortOrder === sortOrder) { + continue; + } + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId, + sortOrder, + }); + } + + await api.orchestration + .getSnapshot() + .then((snapshot) => { + syncServerReadModel(snapshot); + }) + .catch(() => undefined); + } + } finally { + projectReorderFlushInFlightRef.current = false; + setIsProjectReorderPending(false); + } + }, + [projects, syncServerReadModel], + ); + + useEffect(() => { + if (typeof document === "undefined") return; + + const onPointerMove = (event: PointerEvent) => { + const pendingDrag = pendingProjectDragRef.current; + if (!pendingDrag) { + return; + } + + if (event.pointerId !== pendingDrag.pointerId) { + return; + } + + if ( + !hasCrossedThreadGroupDragThreshold({ + startX: pendingDrag.startX, + startY: pendingDrag.startY, + currentX: event.clientX, + currentY: event.clientY, + thresholdPx: 4, + }) + ) { + return; + } + + if (activeDraggedProjectRef.current === null) { + activeDraggedProjectRef.current = pendingDrag.projectId; + suppressProjectClickRef.current = pendingDrag.projectId; + setDraggedProjectId(pendingDrag.projectId); + } + + window.getSelection?.()?.removeAllRanges(); + + const hoveredElement = document.elementFromPoint(event.clientX, event.clientY); + const projectSurface = hoveredElement?.closest("[data-project-drop-surface]"); + if (projectSurface) { + const targetProjectId = projectSurface.dataset.projectId as ProjectId | undefined; + if (!targetProjectId || targetProjectId === pendingDrag.projectId) { + setProjectDropTarget(null); + return; + } + setProjectDropTarget({ beforeProjectId: targetProjectId }); + return; + } + + const endSurface = hoveredElement?.closest("[data-project-drop-end]"); + if (endSurface) { + const lastProjectId = endSurface.dataset.lastProjectId as ProjectId | undefined; + setProjectDropTarget( + lastProjectId && lastProjectId === pendingDrag.projectId ? null : { beforeProjectId: null }, + ); + return; + } + + const dropContainer = document.querySelector("[data-project-drop-container]"); + if (dropContainer) { + const containerRect = dropContainer.getBoundingClientRect(); + const containerEndSurface = + dropContainer.querySelector("[data-project-drop-end]"); + const endSurfaceRect = containerEndSurface?.getBoundingClientRect(); + const shouldSnapToEnd = shouldSnapThreadGroupDropToEnd({ + pointerX: event.clientX, + pointerY: event.clientY, + left: containerRect.left, + right: containerRect.right, + bottom: containerRect.bottom, + snapStartY: endSurfaceRect?.top ?? containerRect.bottom - 24, + thresholdPx: 80, + }); + if (shouldSnapToEnd) { + const lastProjectId = dropContainer.dataset.lastProjectId as ProjectId | undefined; + setProjectDropTarget( + lastProjectId && lastProjectId === pendingDrag.projectId ? null : { beforeProjectId: null }, + ); + return; + } + } + + setProjectDropTarget(null); + }; + + const finishProjectPointerDrag = (pointerId: number | null, canceled: boolean) => { + const pendingDrag = pendingProjectDragRef.current; + if (pendingDrag && pointerId !== null && pendingDrag.pointerId !== pointerId) { + return; + } + + if (pendingDrag?.element.hasPointerCapture(pendingDrag.pointerId)) { + pendingDrag.element.releasePointerCapture(pendingDrag.pointerId); + } + + const draggedProjectId = activeDraggedProjectRef.current; + pendingProjectDragRef.current = null; + activeDraggedProjectRef.current = null; + + if (!draggedProjectId) { + return; + } + + const nextDropTarget = projectDropTarget; + setDraggedProjectId(null); + setProjectDropTarget(null); + + if (canceled || !nextDropTarget) { + return; + } + + const nextProjectOrder = reorderProjectOrder({ + currentOrder: orderedProjects.map((project) => project.id), + movedProjectId: draggedProjectId, + beforeProjectId: nextDropTarget.beforeProjectId, + }); + setOptimisticProjectOrder(nextProjectOrder); + pendingProjectAnimationStartTopsRef.current = collectElementTopPositions(projectRowRefs.current); + void handleProjectReorder(nextProjectOrder); + }; + + const onPointerUp = (event: PointerEvent) => { + finishProjectPointerDrag(event.pointerId, false); + }; + + const onPointerCancel = (event: PointerEvent) => { + finishProjectPointerDrag(event.pointerId, true); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerCancel); + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerCancel); + const pendingDrag = pendingProjectDragRef.current; + if (pendingDrag?.element.hasPointerCapture(pendingDrag.pointerId)) { + pendingDrag.element.releasePointerCapture(pendingDrag.pointerId); + } + }; + }, [handleProjectReorder, orderedProjects, projectDropTarget]); + + useEffect(() => { + if (typeof document === "undefined") return; + + const onPointerMove = (event: PointerEvent) => { + const pendingDrag = pendingGroupDragRef.current; + if (!pendingDrag) { + return; + } + + if (event.pointerId !== pendingDrag.pointerId) { + return; + } + + if ( + !hasCrossedThreadGroupDragThreshold({ + startX: pendingDrag.startX, + startY: pendingDrag.startY, + currentX: event.clientX, + currentY: event.clientY, + thresholdPx: 4, + }) + ) { + return; + } + + if (activeDraggedGroupRef.current === null) { + activeDraggedGroupRef.current = { + projectId: pendingDrag.projectId, + groupId: pendingDrag.groupId, + }; + suppressGroupClickRef.current = buildProjectGroupCollapseKey( + pendingDrag.projectId, + pendingDrag.groupId, + ); + setDraggedGroup(activeDraggedGroupRef.current); + } + + window.getSelection?.()?.removeAllRanges(); + + const hoveredElement = document.elementFromPoint(event.clientX, event.clientY); + const groupSurface = hoveredElement?.closest("[data-thread-group-drop-surface]"); + if (groupSurface) { + const targetProjectId = groupSurface.dataset.projectId; + const targetGroupId = groupSurface.dataset.groupId; + if (!targetProjectId || !targetGroupId) { + setDropTarget(null); + return; + } + const dropEffect = resolveThreadGroupDropEffect({ + draggedProjectId: pendingDrag.projectId, + targetProjectId, + draggedGroupId: pendingDrag.groupId, + targetGroupId, + lastGroupId: groupSurface.dataset.lastGroupId || null, + }); + setDropTarget( + dropEffect === "move" + ? { projectId: targetProjectId as ProjectId, beforeGroupId: targetGroupId } + : null, + ); + return; + } + + const endSurface = hoveredElement?.closest("[data-thread-group-drop-end]"); + if (endSurface) { + const targetProjectId = endSurface.dataset.projectId; + if (!targetProjectId) { + setDropTarget(null); + return; + } + const dropEffect = resolveThreadGroupDropEffect({ + draggedProjectId: pendingDrag.projectId, + targetProjectId, + draggedGroupId: pendingDrag.groupId, + targetGroupId: null, + lastGroupId: endSurface.dataset.lastGroupId || null, + }); + setDropTarget( + dropEffect === "move" + ? { projectId: targetProjectId as ProjectId, beforeGroupId: null } + : null, + ); + return; + } + + const dropContainer = document.querySelector( + `[data-thread-group-drop-container][data-project-id="${pendingDrag.projectId}"]`, + ); + if (dropContainer) { + const containerRect = dropContainer.getBoundingClientRect(); + const endSurface = dropContainer.querySelector("[data-thread-group-drop-end]"); + const endSurfaceRect = endSurface?.getBoundingClientRect(); + const shouldSnapToEnd = shouldSnapThreadGroupDropToEnd({ + pointerX: event.clientX, + pointerY: event.clientY, + left: containerRect.left, + right: containerRect.right, + bottom: containerRect.bottom, + snapStartY: endSurfaceRect?.top ?? containerRect.bottom - 24, + thresholdPx: 80, + }); + if (shouldSnapToEnd) { + const dropEffect = resolveThreadGroupDropEffect({ + draggedProjectId: pendingDrag.projectId, + targetProjectId: pendingDrag.projectId, + draggedGroupId: pendingDrag.groupId, + targetGroupId: null, + lastGroupId: dropContainer.dataset.lastGroupId || null, + }); + setDropTarget( + dropEffect === "move" + ? { projectId: pendingDrag.projectId, beforeGroupId: null } + : null, + ); + return; + } + } + + setDropTarget(null); + }; + + const finishGroupPointerDrag = (pointerId: number | null, canceled: boolean) => { + const pendingDrag = pendingGroupDragRef.current; + if (pendingDrag && pointerId !== null && pendingDrag.pointerId !== pointerId) { + return; + } + + releasePendingGroupPointerCapture(); + + const dragged = activeDraggedGroupRef.current; + pendingGroupDragRef.current = null; + activeDraggedGroupRef.current = null; + + if (!dragged) { + return; + } + + const nextDropTarget = dropTarget; + setDraggedGroup(null); + setDropTarget(null); + + if (canceled) { + return; + } + + if (!nextDropTarget || nextDropTarget.projectId !== dragged.projectId) { + return; + } + + pendingGroupAnimationStartTopsRef.current = collectElementTopPositions(threadGroupRowRefs.current); + void handleProjectGroupReorder(dragged.projectId, dragged.groupId, nextDropTarget.beforeGroupId); + }; + + const onPointerUp = (event: PointerEvent) => { + finishGroupPointerDrag(event.pointerId, false); + }; + + const onPointerCancel = (event: PointerEvent) => { + finishGroupPointerDrag(event.pointerId, true); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerCancel); + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerCancel); + releasePendingGroupPointerCapture(); + }; + }, [dropTarget, handleProjectGroupReorder, releasePendingGroupPointerCapture]); const wordmark = (
    @@ -1134,7 +1866,11 @@ export default function Sidebar() { )} - + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( @@ -1169,23 +1905,21 @@ export default function Sidebar() {
    - {shouldShowProjectPathEntry && ( + {addingProject && (
    {isElectron && ( @@ -1250,281 +1984,584 @@ export default function Sidebar() {
    )} - - - project.id)} - strategy={verticalListSortingStrategy} - > - {projects.map((project) => { - const projectThreads = threads - .filter((thread) => thread.projectId === project.id) - .toSorted((a, b) => { - const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - if (byDate !== 0) return byDate; - return b.id.localeCompare(a.id); - }); - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; - const visibleThreads = - hasHiddenThreads && !isThreadListExpanded - ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) - : projectThreads; - - return ( - - {(dragHandleProps) => ( - -
    - handleProjectTitleClick(event, project.id)} - onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} - onContextMenu={(event) => { - event.preventDefault(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - - - - {project.name} - - - - { + const projectThreads = threads + .filter((thread) => thread.projectId === project.id) + .toSorted((a, b) => { + const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + if (byDate !== 0) return byDate; + return b.id.localeCompare(a.id); + }); + const draftThreadIdsForProject = Object.entries(projectGroupDraftThreadIdById) + .filter(([mappingId]) => mappingId.startsWith(`${project.id}\u0000`)) + .map(([, threadId]) => threadId as ThreadId); + const draftEntries = draftThreadIdsForProject.flatMap((threadId) => { + if (projectThreads.some((thread) => thread.id === threadId)) { + return []; + } + const draftThread = draftThreadsByThreadId[threadId]; + if (!draftThread || draftThread.projectId !== project.id) { + return []; + } + return [ + { + id: threadId, + title: + draftsByThreadId[threadId]?.prompt.trim().split("\n")[0]?.slice(0, 60) || + "New thread", + createdAt: draftThread.createdAt, + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + thread: null, + isDraft: true, + }, + ]; + }); + const projectEntries: SidebarGroupEntry[] = [ + ...projectThreads.map((thread) => ({ + id: thread.id, + title: thread.title, + createdAt: thread.createdAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + thread, + isDraft: false, + })), + ...draftEntries, + ]; + const orderedGroups = orderProjectThreadGroups({ + project, + threads: projectEntries, + }); + const groupPrById = resolveProjectThreadGroupPrById({ + groups: orderedGroups, + projectCwd: project.cwd, + statusByCwd: gitStatusByCwd, + }); + const isAnySidebarDragged = draggedGroup !== null || draggedProjectId !== null; + const isDraggedProject = draggedProjectId === project.id; + const isProjectDropTarget = projectDropTarget?.beforeProjectId === project.id; + + return ( +
    { + if (element) { + projectRowRefs.current.set(project.id, element); + } else { + projectRowRefs.current.delete(project.id); + } + }} + className="group/project relative mb-1 rounded-lg" + data-project-drop-surface="true" + data-project-id={project.id} + > +
    + +
    + { + if (suppressProjectClickRef.current === project.id) { + suppressProjectClickRef.current = null; + return; + } + if (isAnySidebarDragged) return; + toggleProject(project.id); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (isAnySidebarDragged) return; + toggleProject(project.id); + }} + onPointerDown={(event) => { + if (draggedGroup !== null || event.button !== 0) return; + if ( + shouldIgnoreSidebarDragPointerDown({ + currentTarget: event.currentTarget, + target: event.target, + }) + ) { + return; + } + event.currentTarget.setPointerCapture(event.pointerId); + pendingProjectDragRef.current = { + projectId: project.id, + startX: event.clientX, + startY: event.clientY, + pointerId: event.pointerId, + element: event.currentTarget, + }; + }} + onContextMenu={(event) => { + event.preventDefault(); + void handleProjectContextMenu(project.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > + + + + {project.name} + + + {shouldRenderProjectComposeButton() ? ( + + + } + showOnHover + className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(project.id); + }} + > + + + } + /> + New thread + + ) : null} +
    + +
    +
    + + {orderedGroups.map((group) => { + const groupEntries = sortSidebarThreadEntries( + projectEntries.filter( + (entry) => + buildThreadGroupId({ + branch: entry.branch, + worktreePath: entry.worktreePath, + }) === group.id, + ), + appSettings.sidebarThreadOrder, + ); + const canDragGroup = group.id !== MAIN_THREAD_GROUP_ID; + const isAnyGroupDragged = isAnySidebarDragged; + const isDraggedGroup = + draggedGroup?.projectId === project.id && draggedGroup.groupId === group.id; + const lastGroupId = orderedGroups.at(-1)?.id ?? null; + const isGroupOpen = isProjectGroupOpen( + collapsedGroupIds, + project.id, + group.id, + ); + const groupPrStatus = prStatusIndicator(groupPrById.get(group.id) ?? null); + const isValidDropTarget = + dropTarget?.projectId === project.id && dropTarget.beforeGroupId === group.id; + const groupInteractionKey = buildProjectGroupCollapseKey(project.id, group.id); + + return ( +
    { + if (element) { + threadGroupRowRefs.current.set( + buildProjectGroupCollapseKey(project.id, group.id), + element, + ); + } else { + threadGroupRowRefs.current.delete( + buildProjectGroupCollapseKey(project.id, group.id), + ); + } + }} + className="group/thread-group relative mb-1 rounded-lg" + data-thread-group-drop-surface="true" + data-project-id={project.id} + data-group-id={group.id} + data-last-group-id={lastGroupId ?? ""} + > +
    +
    { + if (suppressGroupClickRef.current === groupInteractionKey) { + suppressGroupClickRef.current = null; + return; + } + if (isAnySidebarDragged) return; + setGroupOpen(project.id, group.id, !isGroupOpen); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + if (isAnySidebarDragged) return; + setGroupOpen(project.id, group.id, !isGroupOpen); + }} + onPointerDown={(event) => { + if (!canDragGroup || event.button !== 0 || draggedProjectId !== null) return; + if ( + shouldIgnoreSidebarDragPointerDown({ + currentTarget: event.currentTarget, + target: event.target, + }) + ) { + return; + } + event.currentTarget.setPointerCapture(event.pointerId); + pendingGroupDragRef.current = { + projectId: project.id, + groupId: group.id, + startX: event.clientX, + startY: event.clientY, + pointerId: event.pointerId, + element: event.currentTarget, + }; + }} + onContextMenu={(event) => { + event.preventDefault(); + void handleGroupContextMenu( + { + projectId: project.id, + projectCwd: project.cwd, + groupId: group.id, + groupLabel: group.label, + branch: group.branch, + worktreePath: group.worktreePath, + prUrl: groupPrStatus?.url ?? null, + entries: groupEntries, + }, + { + x: event.clientX, + y: event.clientY, + }, + ); + }} + > + + {groupPrStatus ? ( + + { + openPrLink(event, groupPrStatus.url); + }} + > + + + } + /> + {groupPrStatus.tooltip} + + ) : group.id === MAIN_THREAD_GROUP_ID ? ( + + ) : ( + + )} + + {group.label} + + + {groupEntries.length} + +
    + + { + event.preventDefault(); + event.stopPropagation(); + setProjectExpanded(project.id, true); + setCollapsedGroupIds((prev) => { + const collapseKey = buildProjectGroupCollapseKey( + project.id, + group.id, + ); + const uncollapsed = new Set(prev); + uncollapsed.delete(collapseKey); + return uncollapsed; + }); + void handleNewThread(project.id, { + branch: group.branch, + worktreePath: group.worktreePath, + envMode: + group.id === MAIN_THREAD_GROUP_ID ? "local" : "worktree", + }); + }} /> } - showOnHover - className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - void handleNewThread(project.id); - }} > - - } - /> - - {newThreadShortcutLabel - ? `New thread (${newThreadShortcutLabel})` - : "New thread"} - - -
    - - - - {visibleThreads.map((thread) => { - const isActive = routeThreadId === thread.id; - const threadStatus = resolveThreadStatusPill({ - thread, - hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, - hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true, - }); - const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id) - .runningTerminalIds, - ); - - return ( - + + Compose in {group.label} + + +
    +
    +
    + {groupEntries.map((entry) => { + const thread = entry.thread; + const isActive = routeThreadId === entry.id; + const threadStatus = + thread !== null + ? resolveThreadStatusPill({ + thread, + hasPendingApprovals: + pendingApprovalByThreadId.get(thread.id) === true, + hasPendingUserInput: + pendingUserInputByThreadId.get(thread.id) === true, + }) + : null; + const terminalStatus = + thread !== null + ? terminalStatusFromRunningIds( + selectThreadTerminalState(terminalStateByThreadId, thread.id) + .runningTerminalIds, + ) + : null; + + return ( + } size="sm" isActive={isActive} - className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left hover:bg-accent hover:text-foreground ${ - isActive - ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50" - : "text-muted-foreground" - }`} - onClick={() => { - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onContextMenu={(event) => { - event.preventDefault(); - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > -
    - {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && ( + className={buildThreadRowClassName({ + isActive, + isAnyGroupDragged, + })} + onClick={() => { + void navigate({ + to: "/$threadId", + params: { threadId: entry.id }, + }); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + void navigate({ + to: "/$threadId", + params: { threadId: entry.id }, + }); + }} + onContextMenu={(event) => { + if (thread === null) return; + event.preventDefault(); + void handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > +
    + {threadStatus && ( + - - {threadStatus.label} - - )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(thread.id, renamingTitle, thread.title); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename(thread.id, renamingTitle, thread.title); - } - }} - onClick={(e) => e.stopPropagation()} + className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${ + threadStatus.pulse ? "animate-pulse" : "" + }`} /> - ) : ( - - {thread.title} - - )} -
    -
    - {terminalStatus && ( - - - - )} + {threadStatus.label} + + )} + {thread !== null && renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(thread.id, renamingTitle, thread.title); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename(thread.id, renamingTitle, thread.title); + } + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( - {formatRelativeTime(thread.createdAt)} + {entry.title} -
    - - - ); - })} - - {hasHiddenThreads && !isThreadListExpanded && ( - - } - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - expandThreadListForProject(project.id); - }} - > - Show more - - - )} - {hasHiddenThreads && isThreadListExpanded && ( - - } - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - collapseThreadListForProject(project.id); - }} - > - Show less + )} +
    +
    + {terminalStatus && ( + + + + )} + + {formatRelativeTime( + getSidebarThreadSortTimestamp( + { + id: entry.id, + createdAt: entry.createdAt, + thread: entry.thread, + }, + appSettings.sidebarThreadOrder, + ), + )} + +
    - )} - - - - )} - - ); - })} - - - - - {projects.length === 0 && !shouldShowProjectPathEntry && ( + ); + })} +
    +
    +
    +
    + ); + })} + {draggedGroup?.projectId === project.id ? ( + + ) : null} +
    +
    +
    +
    +
    + ); + })} + {draggedProjectId !== null ? ( + + ) : null} + + + {projects.length === 0 && !addingProject && (
    No projects yet
    diff --git a/apps/web/src/components/sidebarGroupInteractions.test.ts b/apps/web/src/components/sidebarGroupInteractions.test.ts new file mode 100644 index 0000000000..4be6a47fb5 --- /dev/null +++ b/apps/web/src/components/sidebarGroupInteractions.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "vitest"; + +import { + buildThreadGroupCollapsibleKey, + buildThreadGroupChildrenClassName, + buildThreadGroupDragCursorClassName, + buildProjectGroupCollapseKey, + buildProjectChildrenClassName, + buildThreadGroupDropIndicatorClassName, + buildThreadGroupChevronClassName, + buildThreadGroupComposeButtonClassName, + buildThreadGroupHeaderClassName, + buildThreadRowClassName, + buildSidebarInteractionClassName, + expandCollapsedThreadGroupIds, + hasCrossedThreadGroupDragThreshold, + isProjectGroupOpen, + resolveThreadGroupDropEffect, + shouldIgnoreSidebarDragPointerDown, + shouldSnapThreadGroupDropToEnd, + isValidThreadGroupDropTarget, + setProjectGroupCollapsed, + shouldRenderProjectComposeButton, +} from "./sidebarGroupInteractions"; + +describe("sidebarGroupInteractions", () => { + it("removes the project-level compose button", () => { + expect(shouldRenderProjectComposeButton()).toBe(false); + }); + + it("keeps draggable group rows non-selectable and suppresses hover while dragging", () => { + const activeDragClassName = buildThreadGroupHeaderClassName({ + canDragGroup: true, + isDraggedGroup: true, + isAnyGroupDragged: true, + }); + + expect(activeDragClassName).toContain("select-none"); + expect(activeDragClassName).toContain("bg-accent/50"); + expect(activeDragClassName).not.toContain("hover:bg-accent/40"); + expect(activeDragClassName).toContain("cursor-grabbing"); + }); + + it("suppresses thread-row hover highlighting while any group drag is active", () => { + expect( + buildThreadRowClassName({ + isActive: false, + isAnyGroupDragged: true, + }), + ).toContain("pointer-events-none"); + expect( + buildThreadGroupComposeButtonClassName({ + isAnyGroupDragged: true, + }), + ).toContain("pointer-events-none"); + }); + + it("keeps draggable rows pointer-like before drag starts", () => { + expect( + buildThreadGroupHeaderClassName({ + canDragGroup: true, + isDraggedGroup: false, + isAnyGroupDragged: false, + }), + ).toContain("cursor-pointer"); + }); + + it("disables child hit targets while a group drag is active", () => { + expect(buildThreadGroupChildrenClassName({ isOpen: true, isAnyGroupDragged: true })).toContain( + "pointer-events-none", + ); + expect( + buildThreadGroupChildrenClassName({ isOpen: true, isAnyGroupDragged: false }), + ).toContain("grid-rows-[1fr]"); + expect( + buildThreadGroupChildrenClassName({ isOpen: false, isAnyGroupDragged: false }), + ).toContain("grid-rows-[0fr]"); + }); + + it("keeps project bodies mounted and driven by explicit project open state", () => { + expect( + buildProjectChildrenClassName({ isOpen: true, isAnyProjectDragged: false }), + ).toContain("grid-rows-[1fr]"); + expect( + buildProjectChildrenClassName({ isOpen: false, isAnyProjectDragged: false }), + ).toContain("grid-rows-[0fr]"); + expect( + buildProjectChildrenClassName({ isOpen: true, isAnyProjectDragged: true }), + ).toContain("pointer-events-none"); + }); + + it("shows a leading chevron that rotates with the group open state", () => { + expect(buildThreadGroupChevronClassName({ isOpen: false })).not.toContain("rotate-90"); + expect(buildThreadGroupChevronClassName({ isOpen: true })).toContain("rotate-90"); + }); + + it("disables text selection across the sidebar only while a group drag is active", () => { + expect(buildSidebarInteractionClassName({ isAnyGroupDragged: true })).toContain("select-none"); + expect(buildSidebarInteractionClassName({ isAnyGroupDragged: false })).toBe(""); + }); + + it("expands a target group without mutating the existing collapsed set", () => { + const collapsed = new Set(["main", "worktree:/tmp/project/.t3/worktrees/feature-a"]); + const next = expandCollapsedThreadGroupIds(collapsed, "worktree:/tmp/project/.t3/worktrees/feature-a"); + + expect(collapsed.has("worktree:/tmp/project/.t3/worktrees/feature-a")).toBe(true); + expect(next.has("worktree:/tmp/project/.t3/worktrees/feature-a")).toBe(false); + expect(next.has("main")).toBe(true); + }); + + it("scopes collapse state by project and group id", () => { + expect(buildProjectGroupCollapseKey("project-a", "main")).not.toBe( + buildProjectGroupCollapseKey("project-b", "main"), + ); + }); + + it("sets project group collapse state explicitly instead of toggling blindly", () => { + const collapseKey = buildProjectGroupCollapseKey("project-a", "main"); + const collapsed = setProjectGroupCollapsed(new Set(), collapseKey, false); + const expanded = setProjectGroupCollapsed(collapsed, collapseKey, true); + + expect(collapsed.has(collapseKey)).toBe(true); + expect(expanded.has(collapseKey)).toBe(false); + }); + + it("derives group visibility from the scoped project collapse key", () => { + const collapsed = new Set([ + buildProjectGroupCollapseKey("project-a", "worktree:a"), + buildProjectGroupCollapseKey("project-b", "main"), + ]); + + expect(isProjectGroupOpen(collapsed, "project-a", "worktree:a")).toBe(false); + expect(isProjectGroupOpen(collapsed, "project-b", "worktree:a")).toBe(true); + expect(isProjectGroupOpen(collapsed, "project-a", "main")).toBe(true); + }); + + it("shows a visible drop indicator only for the active drop target", () => { + expect(buildThreadGroupDropIndicatorClassName({ isActiveDropTarget: true })).toContain( + "bg-primary", + ); + expect(buildThreadGroupDropIndicatorClassName({ isActiveDropTarget: false })).toContain( + "opacity-0", + ); + }); + + it("invalidates self-drop targets including dropping to end when already last", () => { + expect( + isValidThreadGroupDropTarget({ + draggedGroupId: "worktree:a", + targetGroupId: "worktree:a", + lastGroupId: "worktree:b", + }), + ).toBe(false); + expect( + isValidThreadGroupDropTarget({ + draggedGroupId: "worktree:b", + targetGroupId: null, + lastGroupId: "worktree:b", + }), + ).toBe(false); + expect( + isValidThreadGroupDropTarget({ + draggedGroupId: "worktree:a", + targetGroupId: "worktree:b", + lastGroupId: "worktree:b", + }), + ).toBe(true); + }); + + it("uses the same move-vs-none decision for drag feedback and drop handling", () => { + expect( + resolveThreadGroupDropEffect({ + draggedProjectId: "project-a", + targetProjectId: "project-a", + draggedGroupId: "worktree:a", + targetGroupId: "worktree:b", + lastGroupId: "worktree:b", + }), + ).toBe("move"); + + expect( + resolveThreadGroupDropEffect({ + draggedProjectId: "project-a", + targetProjectId: "project-b", + draggedGroupId: "worktree:a", + targetGroupId: "worktree:b", + lastGroupId: "worktree:b", + }), + ).toBe("none"); + + expect( + resolveThreadGroupDropEffect({ + draggedProjectId: "project-a", + targetProjectId: "project-a", + draggedGroupId: "worktree:b", + targetGroupId: null, + lastGroupId: "worktree:b", + }), + ).toBe("none"); + }); + + it("keeps end-of-list dropping active slightly below the visible project list", () => { + expect( + shouldSnapThreadGroupDropToEnd({ + pointerX: 120, + pointerY: 248, + left: 20, + right: 220, + bottom: 200, + snapStartY: 184, + thresholdPx: 64, + }), + ).toBe(true); + + expect( + shouldSnapThreadGroupDropToEnd({ + pointerX: 120, + pointerY: 300, + left: 20, + right: 220, + bottom: 200, + snapStartY: 184, + thresholdPx: 64, + }), + ).toBe(false); + + expect( + shouldSnapThreadGroupDropToEnd({ + pointerX: 10, + pointerY: 220, + left: 20, + right: 220, + bottom: 200, + snapStartY: 184, + thresholdPx: 64, + }), + ).toBe(false); + + expect( + shouldSnapThreadGroupDropToEnd({ + pointerX: 120, + pointerY: 120, + left: 20, + right: 220, + bottom: 200, + snapStartY: 184, + thresholdPx: 64, + }), + ).toBe(false); + }); + + it("uses a position-scoped collapsible key so reordered open groups remount cleanly", () => { + expect( + buildThreadGroupCollapsibleKey("project-a", "worktree:a", [ + "main", + "worktree:a", + "worktree:b", + ]), + ).not.toBe( + buildThreadGroupCollapsibleKey("project-a", "worktree:a", [ + "main", + "worktree:b", + "worktree:a", + ]), + ); + }); + + it("forces a consistent grabbing cursor while dragging", () => { + expect(buildThreadGroupDragCursorClassName({ isDragging: true })).toContain("cursor-grabbing"); + expect(buildThreadGroupDragCursorClassName({ isDragging: false })).toBe(""); + }); + + it("activates dragging only after the pointer crosses the drag threshold", () => { + expect( + hasCrossedThreadGroupDragThreshold({ + startX: 100, + startY: 100, + currentX: 103, + currentY: 104, + thresholdPx: 4, + }), + ).toBe(false); + + expect( + hasCrossedThreadGroupDragThreshold({ + startX: 100, + startY: 100, + currentX: 105, + currentY: 100, + thresholdPx: 4, + }), + ).toBe(true); + + expect( + hasCrossedThreadGroupDragThreshold({ + startX: 100, + startY: 100, + currentX: 100, + currentY: 106, + thresholdPx: 4, + }), + ).toBe(true); + }); + + it("allows a draggable button surface to start drag while still ignoring nested interactive children", () => { + const currentTarget = { + closest: () => currentTarget, + } as unknown as EventTarget; + const nestedIconButton = { + closest: () => currentTarget, + } as unknown as EventTarget; + const nestedInnerTarget = { + closest: () => nestedIconButton, + } as unknown as EventTarget; + + expect( + shouldIgnoreSidebarDragPointerDown({ + currentTarget, + target: currentTarget, + }), + ).toBe(false); + + expect( + shouldIgnoreSidebarDragPointerDown({ + currentTarget, + target: nestedInnerTarget, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/sidebarGroupInteractions.ts b/apps/web/src/components/sidebarGroupInteractions.ts new file mode 100644 index 0000000000..5f0daf520f --- /dev/null +++ b/apps/web/src/components/sidebarGroupInteractions.ts @@ -0,0 +1,236 @@ +export function shouldRenderProjectComposeButton(): boolean { + return false; +} + +export function buildProjectGroupCollapseKey(projectId: string, groupId: string): string { + return `${projectId}\u0000${groupId}`; +} + +export function buildThreadGroupCollapsibleKey( + projectId: string, + groupId: string, + orderedGroupIds: ReadonlyArray, +): string { + return `${projectId}\u0000${groupId}\u0000${orderedGroupIds.join("\u0001")}`; +} + +export function buildSidebarInteractionClassName(input: { + isAnyGroupDragged: boolean; +}): string { + return input.isAnyGroupDragged ? "select-none" : ""; +} + +export function setProjectGroupCollapsed( + collapsedGroupIds: ReadonlySet, + collapseKey: string, + open: boolean, +): Set { + const next = new Set(collapsedGroupIds); + if (open) { + next.delete(collapseKey); + } else { + next.add(collapseKey); + } + return next; +} + +export function isProjectGroupOpen( + collapsedGroupIds: ReadonlySet, + projectId: string, + groupId: string, +): boolean { + return !collapsedGroupIds.has(buildProjectGroupCollapseKey(projectId, groupId)); +} + +export function expandCollapsedThreadGroupIds( + collapsedGroupIds: ReadonlySet, + groupId: string, +): Set { + const next = new Set(collapsedGroupIds); + next.delete(groupId); + return next; +} + +export function buildThreadGroupHeaderClassName(input: { + canDragGroup: boolean; + isDraggedGroup: boolean; + isAnyGroupDragged: boolean; +}): string { + return [ + "group/thread-group flex items-center gap-1.5 rounded-md px-2 py-1 pr-7", + input.isDraggedGroup ? "bg-accent/50" : "", + !input.isDraggedGroup && !input.isAnyGroupDragged ? "hover:bg-accent/40" : "", + input.canDragGroup + ? input.isAnyGroupDragged + ? "cursor-grabbing select-none" + : "cursor-pointer select-none" + : "cursor-pointer", + ] + .filter(Boolean) + .join(" "); +} + +export function buildThreadGroupDropIndicatorClassName(input: { + isActiveDropTarget: boolean; +}): string { + return [ + "pointer-events-none absolute inset-x-2 -top-1 h-0.5 rounded-full transition-opacity", + input.isActiveDropTarget ? "bg-primary opacity-100" : "bg-transparent opacity-0", + ] + .filter(Boolean) + .join(" "); +} + +export function hasCrossedThreadGroupDragThreshold(input: { + startX: number; + startY: number; + currentX: number; + currentY: number; + thresholdPx: number; +}): boolean { + const deltaX = Math.abs(input.currentX - input.startX); + const deltaY = Math.abs(input.currentY - input.startY); + return deltaX > input.thresholdPx || deltaY > input.thresholdPx; +} + +export function shouldIgnoreSidebarDragPointerDown(input: { + currentTarget: EventTarget; + target: EventTarget | null; +}): boolean { + if ( + input.target === null || + typeof input.target !== "object" || + !("closest" in input.target) || + typeof input.target.closest !== "function" + ) { + return false; + } + + const interactiveAncestor = input.target.closest("button, a, input, textarea, select"); + return interactiveAncestor !== null && interactiveAncestor !== input.currentTarget; +} + +export function isValidThreadGroupDropTarget(input: { + draggedGroupId: string; + targetGroupId: string | null; + lastGroupId: string | null; +}): boolean { + if (input.targetGroupId === input.draggedGroupId) { + return false; + } + if (input.targetGroupId === null && input.draggedGroupId === input.lastGroupId) { + return false; + } + return true; +} + +export function resolveThreadGroupDropEffect(input: { + draggedProjectId: string | null; + targetProjectId: string; + draggedGroupId: string | null; + targetGroupId: string | null; + lastGroupId: string | null; +}): "move" | "none" { + if ( + input.draggedProjectId === null || + input.draggedGroupId === null || + input.draggedProjectId !== input.targetProjectId + ) { + return "none"; + } + + return isValidThreadGroupDropTarget({ + draggedGroupId: input.draggedGroupId, + targetGroupId: input.targetGroupId, + lastGroupId: input.lastGroupId, + }) + ? "move" + : "none"; +} + +export function shouldSnapThreadGroupDropToEnd(input: { + pointerX: number; + pointerY: number; + left: number; + right: number; + bottom: number; + snapStartY: number; + thresholdPx: number; +}): boolean { + const withinHorizontalBounds = input.pointerX >= input.left && input.pointerX <= input.right; + const withinVerticalBounds = + input.pointerY >= input.snapStartY && input.pointerY <= input.bottom + input.thresholdPx; + + return withinHorizontalBounds && withinVerticalBounds; +} + +export function buildThreadGroupDragCursorClassName(input: { + isDragging: boolean; +}): string { + return input.isDragging ? "cursor-grabbing" : ""; +} + +export function buildThreadGroupChevronClassName(input: { + isOpen: boolean; +}): string { + return [ + "size-3 shrink-0 text-muted-foreground/50 transition-transform duration-150", + input.isOpen ? "rotate-90" : "", + ] + .filter(Boolean) + .join(" "); +} + +export function buildThreadGroupComposeButtonClassName(input: { + isAnyGroupDragged: boolean; +}): string { + return [ + "inline-flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/65 opacity-0 transition-colors", + input.isAnyGroupDragged + ? "pointer-events-none" + : "group-hover/thread-group:opacity-100 hover:bg-secondary hover:text-foreground", + ] + .filter(Boolean) + .join(" "); +} + +export function buildThreadRowClassName(input: { + isActive: boolean; + isAnyGroupDragged: boolean; +}): string { + return [ + "h-7 w-full translate-x-0 cursor-default justify-start px-2 pl-3 text-left", + input.isAnyGroupDragged ? "pointer-events-none" : "hover:bg-accent hover:text-foreground", + input.isActive + ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50" + : "text-muted-foreground", + ] + .filter(Boolean) + .join(" "); +} + +export function buildThreadGroupChildrenClassName(input: { + isOpen: boolean; + isAnyGroupDragged: boolean; +}): string { + return [ + "grid transition-[grid-template-rows,opacity] duration-200 ease-out", + input.isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0", + input.isAnyGroupDragged ? "pointer-events-none" : "", + ] + .filter(Boolean) + .join(" "); +} + +export function buildProjectChildrenClassName(input: { + isOpen: boolean; + isAnyProjectDragged: boolean; +}): string { + return [ + "grid transition-[grid-template-rows,opacity] duration-200 ease-out", + input.isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0", + input.isAnyProjectDragged ? "pointer-events-none" : "", + ] + .filter(Boolean) + .join(" "); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 2bcd9cbbce..93cd6c9e4c 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,11 +1,8 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - type ComposerImageAttachment, - createDebouncedStorage, - useComposerDraftStore, -} from "./composerDraftStore"; +import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; +import { MAIN_THREAD_GROUP_ID } from "./threadGroups"; function makeImage(input: { id: string; @@ -203,6 +200,26 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("normalizes stored branch and worktree metadata", () => { + const store = useComposerDraftStore.getState(); + + store.setProjectDraftThreadId(projectId, threadId, { + branch: " feature/test ", + worktreePath: " /tmp/worktree-test ", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ + projectId, + branch: "feature/test", + worktreePath: "/tmp/worktree-test", + envMode: "worktree", + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-01-01T00:00:00.000Z", + }); + }); + it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); @@ -335,6 +352,29 @@ describe("composerDraftStore project draft thread mapping", () => { envMode: "worktree", }); }); + + it("clears every matching project-group mapping for a thread id", () => { + const store = useComposerDraftStore.getState(); + + store.setProjectDraftThreadId(projectId, threadId); + store.setProjectGroupDraftThreadId(projectId, "branch:feature/test", threadId, { + branch: "feature/test", + worktreePath: null, + envMode: "worktree", + }); + store.setPrompt(threadId, "remove all mappings"); + + store.clearProjectDraftThreadById(projectId, threadId); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); + expect( + useComposerDraftStore + .getState() + .getDraftThreadByProjectGroupId(projectId, "branch:feature/test"), + ).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); }); describe("composerDraftStore codex fast mode", () => { @@ -456,124 +496,79 @@ describe("composerDraftStore runtime and interaction settings", () => { }); }); -// --------------------------------------------------------------------------- -// createDebouncedStorage -// --------------------------------------------------------------------------- - -function createMockStorage() { - const store = new Map(); - return { - getItem: vi.fn((name: string) => store.get(name) ?? null), - setItem: vi.fn((name: string, value: string) => { - store.set(name, value); - }), - removeItem: vi.fn((name: string) => { - store.delete(name); - }), - }; -} +describe("composerDraftStore project group draft thread mapping", () => { + const projectId = ProjectId.makeUnsafe("project-group"); + const mainThreadId = ThreadId.makeUnsafe("thread-main-group"); + const featureThreadId = ThreadId.makeUnsafe("thread-feature-group"); -describe("createDebouncedStorage", () => { beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("delegates getItem immediately", () => { - const base = createMockStorage(); - base.getItem.mockReturnValueOnce("value"); - const storage = createDebouncedStorage(base); - - expect(storage.getItem("key")).toBe("value"); - expect(base.getItem).toHaveBeenCalledWith("key"); - }); - - it("does not write to base storage until the debounce fires", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); - - storage.setItem("key", "v1"); - expect(base.setItem).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(299); - expect(base.setItem).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(1); - expect(base.setItem).toHaveBeenCalledWith("key", "v1"); - }); - - it("only writes the last value when setItem is called rapidly", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); - - storage.setItem("key", "v1"); - storage.setItem("key", "v2"); - storage.setItem("key", "v3"); - - vi.advanceTimersByTime(300); - expect(base.setItem).toHaveBeenCalledTimes(1); - expect(base.setItem).toHaveBeenCalledWith("key", "v3"); - }); - - it("removeItem cancels a pending setItem write", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); - - storage.setItem("key", "v1"); - storage.removeItem("key"); - - vi.advanceTimersByTime(300); - expect(base.setItem).not.toHaveBeenCalled(); - expect(base.removeItem).toHaveBeenCalledWith("key"); + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + projectGroupDraftThreadIdById: {}, + }); }); - it("flush writes the pending value immediately", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); - - storage.setItem("key", "v1"); - expect(base.setItem).not.toHaveBeenCalled(); - - storage.flush(); - expect(base.setItem).toHaveBeenCalledWith("key", "v1"); - - // Timer should be cancelled; no duplicate write. - vi.advanceTimersByTime(300); - expect(base.setItem).toHaveBeenCalledTimes(1); - }); + it("stores independent drafts for Main and a worktree subgroup in the same project", () => { + const store = useComposerDraftStore.getState(); - it("flush is a no-op when nothing is pending", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); + store.setProjectGroupDraftThreadId(projectId, MAIN_THREAD_GROUP_ID, mainThreadId, { + branch: null, + worktreePath: null, + envMode: "local", + }); + store.setProjectGroupDraftThreadId( + projectId, + "worktree:/tmp/project/.t3/worktrees/feature-a", + featureThreadId, + { + branch: "feature/a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + envMode: "worktree", + }, + ); - storage.flush(); - expect(base.setItem).not.toHaveBeenCalled(); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectGroupId(projectId, MAIN_THREAD_GROUP_ID), + ).toMatchObject({ + threadId: mainThreadId, + branch: null, + worktreePath: null, + envMode: "local", + }); + expect( + useComposerDraftStore + .getState() + .getDraftThreadByProjectGroupId( + projectId, + "worktree:/tmp/project/.t3/worktrees/feature-a", + ), + ).toMatchObject({ + threadId: featureThreadId, + branch: "feature/a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + envMode: "worktree", + }); }); - it("flush after removeItem is a no-op", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); - - storage.setItem("key", "v1"); - storage.removeItem("key"); - storage.flush(); - - expect(base.setItem).not.toHaveBeenCalled(); - }); + it("clears only the targeted subgroup draft mapping", () => { + const store = useComposerDraftStore.getState(); - it("setItem works normally after removeItem cancels a pending write", () => { - const base = createMockStorage(); - const storage = createDebouncedStorage(base); + store.setProjectGroupDraftThreadId(projectId, MAIN_THREAD_GROUP_ID, mainThreadId); + store.setProjectGroupDraftThreadId(projectId, "branch:feature/a", featureThreadId, { + branch: "feature/a", + worktreePath: null, + envMode: "worktree", + }); - storage.setItem("key", "v1"); - storage.removeItem("key"); - storage.setItem("key", "v2"); + store.clearProjectGroupDraftThreadId(projectId, "branch:feature/a"); - vi.advanceTimersByTime(300); - expect(base.setItem).toHaveBeenCalledTimes(1); - expect(base.setItem).toHaveBeenCalledWith("key", "v2"); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectGroupId(projectId, MAIN_THREAD_GROUP_ID), + ).toMatchObject({ threadId: mainThreadId }); + expect( + useComposerDraftStore.getState().getDraftThreadByProjectGroupId(projectId, "branch:feature/a"), + ).toBeNull(); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 0369b97735..2a141b5f20 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -15,6 +15,7 @@ import { type ChatImageAttachment, } from "./types"; import { Debouncer } from "@tanstack/react-pacer"; +import { buildThreadGroupId, MAIN_THREAD_GROUP_ID } from "./threadGroups"; import { create } from "zustand"; import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; @@ -55,7 +56,6 @@ const composerDebouncedStorage: DebouncedStorage = ? createDebouncedStorage(localStorage) : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} }; -// Flush pending composer draft writes before page unload to prevent data loss. if (typeof window !== "undefined") { window.addEventListener("beforeunload", () => { composerDebouncedStorage.flush(); @@ -100,6 +100,7 @@ interface PersistedDraftThreadState { interface PersistedComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; + projectGroupDraftThreadIdById: Record; projectDraftThreadIdByProjectId: Record; } @@ -130,12 +131,35 @@ interface ProjectDraftThread extends DraftThreadState { threadId: ThreadId; } +interface ProjectGroupDraftThread extends DraftThreadState { + threadId: ThreadId; + groupId: string; +} + interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; + projectGroupDraftThreadIdById: Record; projectDraftThreadIdByProjectId: Record; + getDraftThreadByProjectGroupId: ( + projectId: ProjectId, + groupId: string, + ) => ProjectGroupDraftThread | null; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; + setProjectGroupDraftThreadId: ( + projectId: ProjectId, + groupId: string, + threadId: ThreadId, + options?: { + branch?: string | null; + worktreePath?: string | null; + createdAt?: string; + envMode?: DraftThreadEnvMode; + runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; + }, + ) => void; setProjectDraftThreadId: ( projectId: ProjectId, threadId: ThreadId, @@ -160,6 +184,12 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; }, ) => void; + clearProjectGroupDraftThreadId: (projectId: ProjectId, groupId: string) => void; + clearProjectGroupDraftThreadById: ( + projectId: ProjectId, + groupId: string, + threadId: ThreadId, + ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; @@ -188,6 +218,7 @@ interface ComposerDraftStoreState { const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { draftsByThreadId: {}, draftThreadsByThreadId: {}, + projectGroupDraftThreadIdById: {}, projectDraftThreadIdByProjectId: {}, }; @@ -229,6 +260,24 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { }; } +function normalizeDraftThreadMetadataValue(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +function normalizeDraftThreadState(draftThread: DraftThreadState): DraftThreadState { + const worktreePath = normalizeDraftThreadMetadataValue(draftThread.worktreePath); + return { + ...draftThread, + branch: normalizeDraftThreadMetadataValue(draftThread.branch), + worktreePath, + envMode: worktreePath ? "worktree" : draftThread.envMode, + }; +} + function composerImageDedupKey(image: ComposerImageAttachment): string { // Keep this independent from File.lastModified so dedupe is stable for hydrated // images reconstructed from localStorage (which get a fresh lastModified value). @@ -311,6 +360,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer const candidate = value as Record; const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; + const rawProjectGroupDraftThreadIdById = candidate.projectGroupDraftThreadIdById; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; const draftThreadsByThreadId: PersistedComposerDraftStoreState["draftThreadsByThreadId"] = {}; if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { @@ -326,9 +376,12 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer const candidateDraftThread = rawDraftThread as Record; const projectId = candidateDraftThread.projectId; const createdAt = candidateDraftThread.createdAt; - const branch = candidateDraftThread.branch; - const worktreePath = candidateDraftThread.worktreePath; - const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; + const normalizedBranch = normalizeDraftThreadMetadataValue( + typeof candidateDraftThread.branch === "string" ? candidateDraftThread.branch : null, + ); + const normalizedWorktreePath = normalizeDraftThreadMetadataValue( + typeof candidateDraftThread.worktreePath === "string" ? candidateDraftThread.worktreePath : null, + ); if (typeof projectId !== "string" || projectId.length === 0) { continue; } @@ -348,12 +401,28 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer candidateDraftThread.interactionMode === "default" ? candidateDraftThread.interactionMode : DEFAULT_INTERACTION_MODE, - branch: typeof branch === "string" ? branch : null, + branch: normalizedBranch, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), }; } } + const projectGroupDraftThreadIdById: PersistedComposerDraftStoreState["projectGroupDraftThreadIdById"] = + {}; + if (rawProjectGroupDraftThreadIdById && typeof rawProjectGroupDraftThreadIdById === "object") { + for (const [mappingId, threadId] of Object.entries( + rawProjectGroupDraftThreadIdById as Record, + )) { + if ( + typeof mappingId === "string" && + mappingId.length > 0 && + typeof threadId === "string" && + threadId.length > 0 + ) { + projectGroupDraftThreadIdById[mappingId] = threadId as ThreadId; + } + } + } const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] = {}; if ( @@ -370,6 +439,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer threadId.length > 0 ) { projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId; + const mainGroupMappingId = `${projectId}\u0000${MAIN_THREAD_GROUP_ID}`; + if (!projectGroupDraftThreadIdById[mainGroupMappingId]) { + projectGroupDraftThreadIdById[mainGroupMappingId] = threadId as ThreadId; + } if (!draftThreadsByThreadId[threadId as ThreadId]) { draftThreadsByThreadId[threadId as ThreadId] = { projectId: projectId as ProjectId, @@ -390,7 +463,12 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer } } if (!rawDraftMap || typeof rawDraftMap !== "object") { - return { draftsByThreadId: {}, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; + return { + draftsByThreadId: {}, + draftThreadsByThreadId, + projectGroupDraftThreadIdById, + projectDraftThreadIdByProjectId, + }; } const nextDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record)) { @@ -457,10 +535,43 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, + projectGroupDraftThreadIdById, projectDraftThreadIdByProjectId, }; } +function toProjectGroupDraftMapId(projectId: ProjectId, groupId: string): string { + return `${projectId}\u0000${groupId}`; +} + +function toMainProjectDraftMap(projectGroupDraftThreadIdById: Record) { + const next: Record = {}; + for (const [mappingId, threadId] of Object.entries(projectGroupDraftThreadIdById)) { + const separatorIndex = mappingId.indexOf("\u0000"); + if (separatorIndex <= 0) continue; + const projectId = mappingId.slice(0, separatorIndex) as ProjectId; + const groupId = mappingId.slice(separatorIndex + 1); + if (groupId === MAIN_THREAD_GROUP_ID) { + next[projectId] = threadId as ThreadId; + } + } + return next; +} + +function isThreadMappedByAnyProjectGroup( + projectGroupDraftThreadIdById: Record, + threadId: ThreadId, +): boolean { + return Object.values(projectGroupDraftThreadIdById).includes(threadId); +} + +function toDraftThreadGroupId(draftThread: DraftThreadState): string { + return buildThreadGroupId({ + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + }); +} + function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraftStoreState { if (!raw) { return EMPTY_PERSISTED_DRAFT_STORE_STATE; @@ -567,12 +678,13 @@ export const useComposerDraftStore = create()( (set, get) => ({ draftsByThreadId: {}, draftThreadsByThreadId: {}, + projectGroupDraftThreadIdById: {}, projectDraftThreadIdByProjectId: {}, - getDraftThreadByProjectId: (projectId) => { - if (projectId.length === 0) { + getDraftThreadByProjectGroupId: (projectId, groupId) => { + if (projectId.length === 0 || groupId.length === 0) { return null; } - const threadId = get().projectDraftThreadIdByProjectId[projectId]; + const threadId = get().projectGroupDraftThreadIdById[toProjectGroupDraftMapId(projectId, groupId)]; if (!threadId) { return null; } @@ -582,59 +694,88 @@ export const useComposerDraftStore = create()( } return { threadId, + groupId, ...draftThread, }; }, + getDraftThreadByProjectId: (projectId) => { + let groupDraft = get().getDraftThreadByProjectGroupId(projectId, MAIN_THREAD_GROUP_ID); + if (!groupDraft) { + const projectPrefix = `${projectId}\u0000`; + const fallbackEntry = Object.entries(get().projectGroupDraftThreadIdById).find( + ([mappingId]) => mappingId.startsWith(projectPrefix), + ); + if (fallbackEntry) { + groupDraft = get().getDraftThreadByProjectGroupId( + projectId, + fallbackEntry[0].slice(projectPrefix.length), + ); + } + } + if (!groupDraft) { + return null; + } + const { groupId: _groupId, ...projectDraft } = groupDraft; + return projectDraft; + }, getDraftThread: (threadId) => { if (threadId.length === 0) { return null; } return get().draftThreadsByThreadId[threadId] ?? null; }, - setProjectDraftThreadId: (projectId, threadId, options) => { - if (projectId.length === 0 || threadId.length === 0) { + setProjectGroupDraftThreadId: (projectId, groupId, threadId, options) => { + if (projectId.length === 0 || groupId.length === 0 || threadId.length === 0) { return; } set((state) => { const existingThread = state.draftThreadsByThreadId[threadId]; - const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId]; + const mappingId = toProjectGroupDraftMapId(projectId, groupId); + const previousThreadIdForGroup = state.projectGroupDraftThreadIdById[mappingId]; + const normalizedExistingThread = existingThread + ? normalizeDraftThreadState(existingThread) + : null; const nextWorktreePath = options?.worktreePath === undefined - ? (existingThread?.worktreePath ?? null) - : (options.worktreePath ?? null); - const nextDraftThread: DraftThreadState = { + ? (normalizedExistingThread?.worktreePath ?? null) + : normalizeDraftThreadMetadataValue(options.worktreePath ?? null); + const nextDraftThread = normalizeDraftThreadState({ projectId, - createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), - runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + createdAt: + options?.createdAt ?? normalizedExistingThread?.createdAt ?? new Date().toISOString(), + runtimeMode: + options?.runtimeMode ?? + normalizedExistingThread?.runtimeMode ?? + DEFAULT_RUNTIME_MODE, interactionMode: options?.interactionMode ?? - existingThread?.interactionMode ?? + normalizedExistingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, branch: options?.branch === undefined - ? (existingThread?.branch ?? null) - : (options.branch ?? null), + ? (normalizedExistingThread?.branch ?? null) + : normalizeDraftThreadMetadataValue(options.branch ?? null), worktreePath: nextWorktreePath, envMode: options?.envMode ?? - (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), - }; - const hasSameProjectMapping = previousThreadIdForProject === threadId; + (nextWorktreePath ? "worktree" : (normalizedExistingThread?.envMode ?? "local")), + }); + const hasSameGroupMapping = previousThreadIdForGroup === threadId; const hasSameDraftThread = - existingThread && - existingThread.projectId === nextDraftThread.projectId && - existingThread.createdAt === nextDraftThread.createdAt && - existingThread.runtimeMode === nextDraftThread.runtimeMode && - existingThread.interactionMode === nextDraftThread.interactionMode && - existingThread.branch === nextDraftThread.branch && - existingThread.worktreePath === nextDraftThread.worktreePath && - existingThread.envMode === nextDraftThread.envMode; - if (hasSameProjectMapping && hasSameDraftThread) { + normalizedExistingThread && + normalizedExistingThread.projectId === nextDraftThread.projectId && + normalizedExistingThread.createdAt === nextDraftThread.createdAt && + normalizedExistingThread.runtimeMode === nextDraftThread.runtimeMode && + normalizedExistingThread.interactionMode === nextDraftThread.interactionMode && + normalizedExistingThread.branch === nextDraftThread.branch && + normalizedExistingThread.worktreePath === nextDraftThread.worktreePath && + normalizedExistingThread.envMode === nextDraftThread.envMode; + if (hasSameGroupMapping && hasSameDraftThread) { return state; } - const nextProjectDraftThreadIdByProjectId: Record = { - ...state.projectDraftThreadIdByProjectId, - [projectId]: threadId, + const nextProjectGroupDraftThreadIdById = { + ...state.projectGroupDraftThreadIdById, + [mappingId]: threadId, }; const nextDraftThreadsByThreadId: Record = { ...state.draftThreadsByThreadId, @@ -642,23 +783,27 @@ export const useComposerDraftStore = create()( }; let nextDraftsByThreadId = state.draftsByThreadId; if ( - previousThreadIdForProject && - previousThreadIdForProject !== threadId && - !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject) + previousThreadIdForGroup && + previousThreadIdForGroup !== threadId && + !isThreadMappedByAnyProjectGroup(nextProjectGroupDraftThreadIdById, previousThreadIdForGroup) ) { - delete nextDraftThreadsByThreadId[previousThreadIdForProject]; - if (state.draftsByThreadId[previousThreadIdForProject] !== undefined) { + delete nextDraftThreadsByThreadId[previousThreadIdForGroup]; + if (state.draftsByThreadId[previousThreadIdForGroup] !== undefined) { nextDraftsByThreadId = { ...state.draftsByThreadId }; - delete nextDraftsByThreadId[previousThreadIdForProject]; + delete nextDraftsByThreadId[previousThreadIdForGroup]; } } return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, + projectGroupDraftThreadIdById: nextProjectGroupDraftThreadIdById, + projectDraftThreadIdByProjectId: toMainProjectDraftMap(nextProjectGroupDraftThreadIdById), }; }); }, + setProjectDraftThreadId: (projectId, threadId, options) => { + get().setProjectGroupDraftThreadId(projectId, MAIN_THREAD_GROUP_ID, threadId, options); + }, setDraftThreadContext: (threadId, options) => { if (threadId.length === 0) { return; @@ -668,72 +813,78 @@ export const useComposerDraftStore = create()( if (!existing) { return state; } + const normalizedExisting = normalizeDraftThreadState(existing); const nextProjectId = options.projectId ?? existing.projectId; if (nextProjectId.length === 0) { return state; } const nextWorktreePath = - options.worktreePath === undefined ? existing.worktreePath : (options.worktreePath ?? null); - const nextDraftThread: DraftThreadState = { + options.worktreePath === undefined + ? normalizedExisting.worktreePath + : normalizeDraftThreadMetadataValue(options.worktreePath ?? null); + const nextDraftThread = normalizeDraftThreadState({ projectId: nextProjectId, createdAt: options.createdAt === undefined - ? existing.createdAt - : options.createdAt || existing.createdAt, - runtimeMode: options.runtimeMode ?? existing.runtimeMode, - interactionMode: options.interactionMode ?? existing.interactionMode, - branch: options.branch === undefined ? existing.branch : (options.branch ?? null), + ? normalizedExisting.createdAt + : options.createdAt || normalizedExisting.createdAt, + runtimeMode: options.runtimeMode ?? normalizedExisting.runtimeMode, + interactionMode: options.interactionMode ?? normalizedExisting.interactionMode, + branch: + options.branch === undefined + ? normalizedExisting.branch + : normalizeDraftThreadMetadataValue(options.branch ?? null), worktreePath: nextWorktreePath, envMode: options.envMode ?? - (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), - }; + (nextWorktreePath ? "worktree" : (normalizedExisting.envMode ?? "local")), + }); const isUnchanged = - nextDraftThread.projectId === existing.projectId && - nextDraftThread.createdAt === existing.createdAt && - nextDraftThread.runtimeMode === existing.runtimeMode && - nextDraftThread.interactionMode === existing.interactionMode && - nextDraftThread.branch === existing.branch && - nextDraftThread.worktreePath === existing.worktreePath && - nextDraftThread.envMode === existing.envMode; + nextDraftThread.projectId === normalizedExisting.projectId && + nextDraftThread.createdAt === normalizedExisting.createdAt && + nextDraftThread.runtimeMode === normalizedExisting.runtimeMode && + nextDraftThread.interactionMode === normalizedExisting.interactionMode && + nextDraftThread.branch === normalizedExisting.branch && + nextDraftThread.worktreePath === normalizedExisting.worktreePath && + nextDraftThread.envMode === normalizedExisting.envMode; if (isUnchanged) { return state; } - const nextProjectDraftThreadIdByProjectId: Record = { - ...state.projectDraftThreadIdByProjectId, - [nextProjectId]: threadId, - }; - if (existing.projectId !== nextProjectId) { - if (nextProjectDraftThreadIdByProjectId[existing.projectId] === threadId) { - delete nextProjectDraftThreadIdByProjectId[existing.projectId]; + const nextProjectGroupDraftThreadIdById = { ...state.projectGroupDraftThreadIdById }; + for (const [mappingId, draftThreadId] of Object.entries(nextProjectGroupDraftThreadIdById)) { + if (draftThreadId === threadId) { + delete nextProjectGroupDraftThreadIdById[mappingId]; } } + nextProjectGroupDraftThreadIdById[ + toProjectGroupDraftMapId(nextProjectId, toDraftThreadGroupId(nextDraftThread)) + ] = threadId; return { draftThreadsByThreadId: { ...state.draftThreadsByThreadId, [threadId]: nextDraftThread, }, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, + projectGroupDraftThreadIdById: nextProjectGroupDraftThreadIdById, + projectDraftThreadIdByProjectId: toMainProjectDraftMap(nextProjectGroupDraftThreadIdById), }; }); }, - clearProjectDraftThreadId: (projectId) => { - if (projectId.length === 0) { + clearProjectGroupDraftThreadId: (projectId, groupId) => { + if (projectId.length === 0 || groupId.length === 0) { return; } set((state) => { - const threadId = state.projectDraftThreadIdByProjectId[projectId]; + const mappingId = toProjectGroupDraftMapId(projectId, groupId); + const threadId = state.projectGroupDraftThreadIdById[mappingId]; if (threadId === undefined) { return state; } - const { [projectId]: _removed, ...restProjectMappingsRaw } = - state.projectDraftThreadIdByProjectId; - const restProjectMappings = restProjectMappingsRaw as Record; + const { [mappingId]: _removed, ...restGroupMappings } = state.projectGroupDraftThreadIdById; const nextDraftThreadsByThreadId: Record = { ...state.draftThreadsByThreadId, }; let nextDraftsByThreadId = state.draftsByThreadId; - if (!Object.values(restProjectMappings).includes(threadId)) { + if (!isThreadMappedByAnyProjectGroup(restGroupMappings, threadId)) { delete nextDraftThreadsByThreadId[threadId]; if (state.draftsByThreadId[threadId] !== undefined) { nextDraftsByThreadId = { ...state.draftsByThreadId }; @@ -743,38 +894,49 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: restProjectMappings, + projectGroupDraftThreadIdById: restGroupMappings, + projectDraftThreadIdByProjectId: toMainProjectDraftMap(restGroupMappings), }; }); }, + clearProjectGroupDraftThreadById: (projectId, groupId, threadId) => { + if (projectId.length === 0 || groupId.length === 0 || threadId.length === 0) { + return; + } + const mappingId = toProjectGroupDraftMapId(projectId, groupId); + if (get().projectGroupDraftThreadIdById[mappingId] !== threadId) { + return; + } + get().clearProjectGroupDraftThreadId(projectId, groupId); + }, + clearProjectDraftThreadId: (projectId) => { + const mainMappingId = toProjectGroupDraftMapId(projectId, MAIN_THREAD_GROUP_ID); + if (get().projectGroupDraftThreadIdById[mainMappingId]) { + get().clearProjectGroupDraftThreadId(projectId, MAIN_THREAD_GROUP_ID); + return; + } + const projectPrefix = `${projectId}\u0000`; + const fallbackGroupId = Object.keys(get().projectGroupDraftThreadIdById).find((mappingId) => + mappingId.startsWith(projectPrefix), + ); + if (fallbackGroupId) { + get().clearProjectGroupDraftThreadId(projectId, fallbackGroupId.slice(projectPrefix.length)); + } + }, clearProjectDraftThreadById: (projectId, threadId) => { if (projectId.length === 0 || threadId.length === 0) { return; } - set((state) => { - if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) { - return state; - } - const { [projectId]: _removed, ...restProjectMappingsRaw } = - state.projectDraftThreadIdByProjectId; - const restProjectMappings = restProjectMappingsRaw as Record; - const nextDraftThreadsByThreadId: Record = { - ...state.draftThreadsByThreadId, - }; - let nextDraftsByThreadId = state.draftsByThreadId; - if (!Object.values(restProjectMappings).includes(threadId)) { - delete nextDraftThreadsByThreadId[threadId]; - if (state.draftsByThreadId[threadId] !== undefined) { - nextDraftsByThreadId = { ...state.draftsByThreadId }; - delete nextDraftsByThreadId[threadId]; - } - } - return { - draftsByThreadId: nextDraftsByThreadId, - draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: restProjectMappings, - }; - }); + const projectPrefix = `${projectId}\u0000`; + const groupIdsToClear = Object.entries(get().projectGroupDraftThreadIdById).flatMap( + ([mappingId, draftThreadId]) => + mappingId.startsWith(projectPrefix) && draftThreadId === threadId + ? [mappingId.slice(projectPrefix.length)] + : [], + ); + for (const groupId of groupIdsToClear) { + get().clearProjectGroupDraftThreadId(projectId, groupId); + } }, clearDraftThread: (threadId) => { if (threadId.length === 0) { @@ -782,22 +944,24 @@ export const useComposerDraftStore = create()( } set((state) => { const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; - const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( + const hasProjectMapping = isThreadMappedByAnyProjectGroup( + state.projectGroupDraftThreadIdById, threadId, ); if (!hasDraftThread && !hasProjectMapping) { return state; } - const nextProjectDraftThreadIdByProjectId = Object.fromEntries( - Object.entries(state.projectDraftThreadIdByProjectId).filter( + const nextProjectGroupDraftThreadIdById = Object.fromEntries( + Object.entries(state.projectGroupDraftThreadIdById).filter( ([, draftThreadId]) => draftThreadId !== threadId, ), - ) as Record; + ) as Record; const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = state.draftThreadsByThreadId; return { draftThreadsByThreadId: restDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, + projectGroupDraftThreadIdById: nextProjectGroupDraftThreadIdById, + projectDraftThreadIdByProjectId: toMainProjectDraftMap(nextProjectGroupDraftThreadIdById), }; }); }, @@ -1185,7 +1349,8 @@ export const useComposerDraftStore = create()( set((state) => { const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; - const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( + const hasProjectMapping = isThreadMappedByAnyProjectGroup( + state.projectGroupDraftThreadIdById, threadId, ); if (!hasComposerDraft && !hasDraftThread && !hasProjectMapping) { @@ -1195,15 +1360,16 @@ export const useComposerDraftStore = create()( state.draftsByThreadId; const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = state.draftThreadsByThreadId; - const nextProjectDraftThreadIdByProjectId = Object.fromEntries( - Object.entries(state.projectDraftThreadIdByProjectId).filter( + const nextProjectGroupDraftThreadIdById = Object.fromEntries( + Object.entries(state.projectGroupDraftThreadIdById).filter( ([, draftThreadId]) => draftThreadId !== threadId, ), - ) as Record; + ) as Record; return { draftsByThreadId: restComposerDraftsByThreadId, draftThreadsByThreadId: restDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, + projectGroupDraftThreadIdById: nextProjectGroupDraftThreadIdById, + projectDraftThreadIdByProjectId: toMainProjectDraftMap(nextProjectGroupDraftThreadIdById), }; }); }, @@ -1256,7 +1422,13 @@ export const useComposerDraftStore = create()( } return { draftsByThreadId: persistedDraftsByThreadId, - draftThreadsByThreadId: state.draftThreadsByThreadId, + draftThreadsByThreadId: Object.fromEntries( + Object.entries(state.draftThreadsByThreadId).map(([threadId, draftThread]) => [ + threadId, + normalizeDraftThreadState(draftThread), + ]), + ) as PersistedComposerDraftStoreState["draftThreadsByThreadId"], + projectGroupDraftThreadIdById: state.projectGroupDraftThreadIdById, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, }; }, @@ -1272,6 +1444,7 @@ export const useComposerDraftStore = create()( ...currentState, draftsByThreadId, draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, + projectGroupDraftThreadIdById: normalizedPersisted.projectGroupDraftThreadIdById, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, }; }, diff --git a/apps/web/src/projectOrder.test.ts b/apps/web/src/projectOrder.test.ts new file mode 100644 index 0000000000..827420e62c --- /dev/null +++ b/apps/web/src/projectOrder.test.ts @@ -0,0 +1,198 @@ +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + orderProjects, + orderProjectsByIds, + projectOrdersEqual, + shouldClearOptimisticProjectOrder, + reorderProjectOrder, +} from "./projectOrder"; +import { type Project } 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, + }; +} + +describe("projectOrder", () => { + it("supports an optimistic project order override while preserving unknown projects", () => { + const ordered = orderProjectsByIds( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-a"), + cwd: "/tmp/project-a", + sortOrder: 0, + }), + makeProject({ + id: ProjectId.makeUnsafe("project-b"), + cwd: "/tmp/project-b", + sortOrder: 1, + }), + makeProject({ + id: ProjectId.makeUnsafe("project-c"), + cwd: "/tmp/project-c", + sortOrder: 2, + }), + ], + [ProjectId.makeUnsafe("project-c"), ProjectId.makeUnsafe("project-a")], + ); + + expect(ordered.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ]); + }); + + it("compares project order arrays by position", () => { + expect( + projectOrdersEqual( + [ProjectId.makeUnsafe("project-a"), ProjectId.makeUnsafe("project-b")], + [ProjectId.makeUnsafe("project-a"), ProjectId.makeUnsafe("project-b")], + ), + ).toBe(true); + expect( + projectOrdersEqual( + [ProjectId.makeUnsafe("project-a"), ProjectId.makeUnsafe("project-b")], + [ProjectId.makeUnsafe("project-b"), ProjectId.makeUnsafe("project-a")], + ), + ).toBe(false); + }); + + it("keeps the optimistic order until the project reorder queue has drained", () => { + expect( + shouldClearOptimisticProjectOrder({ + optimisticOrder: [ + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ], + persistedOrder: [ + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ], + hasPendingReorder: true, + }), + ).toBe(false); + + expect( + shouldClearOptimisticProjectOrder({ + optimisticOrder: [ + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ], + persistedOrder: [ + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ], + hasPendingReorder: false, + }), + ).toBe(true); + }); + + it("clears an empty optimistic order once persistence catches up", () => { + expect( + shouldClearOptimisticProjectOrder({ + optimisticOrder: [], + persistedOrder: [], + hasPendingReorder: false, + }), + ).toBe(true); + }); + + it("sorts projects by shared sort order before creation order", () => { + const ordered = orderProjects([ + makeProject({ + id: ProjectId.makeUnsafe("project-b"), + name: "Project B", + cwd: "/tmp/project-b", + sortOrder: 2, + }), + makeProject({ + id: ProjectId.makeUnsafe("project-a"), + name: "Project A", + cwd: "/tmp/project-a", + sortOrder: 0, + }), + makeProject({ + id: ProjectId.makeUnsafe("project-c"), + name: "Project C", + cwd: "/tmp/project-c", + sortOrder: 1, + }), + ]); + + expect(ordered.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-b"), + ]); + }); + + it("reorders projects by moving one project before another", () => { + expect( + reorderProjectOrder({ + currentOrder: [ + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-c"), + ], + movedProjectId: ProjectId.makeUnsafe("project-c"), + beforeProjectId: ProjectId.makeUnsafe("project-a"), + }), + ).toEqual([ + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ]); + }); + + it("supports dropping a project at the end of the list", () => { + expect( + reorderProjectOrder({ + currentOrder: [ + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-c"), + ], + movedProjectId: ProjectId.makeUnsafe("project-a"), + beforeProjectId: null, + }), + ).toEqual([ + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-c"), + ProjectId.makeUnsafe("project-a"), + ]); + }); + + it("treats dropping a project onto itself as a no-op", () => { + expect( + reorderProjectOrder({ + currentOrder: [ + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-c"), + ], + movedProjectId: ProjectId.makeUnsafe("project-b"), + beforeProjectId: ProjectId.makeUnsafe("project-b"), + }), + ).toEqual([ + ProjectId.makeUnsafe("project-a"), + ProjectId.makeUnsafe("project-b"), + ProjectId.makeUnsafe("project-c"), + ]); + }); +}); diff --git a/apps/web/src/projectOrder.ts b/apps/web/src/projectOrder.ts new file mode 100644 index 0000000000..51e370fd4c --- /dev/null +++ b/apps/web/src/projectOrder.ts @@ -0,0 +1,100 @@ +import { type ProjectId } from "@t3tools/contracts"; + +import { type Project } from "./types"; + +export function projectOrdersEqual( + left: readonly T[] | null | undefined, + right: readonly T[] | null | undefined, +): boolean { + if (left === right) { + return true; + } + if (!left || !right || left.length !== right.length) { + return false; + } + return left.every((id, index) => id === right[index]); +} + +export function shouldClearOptimisticProjectOrder(input: { + optimisticOrder: readonly T[] | null | undefined; + persistedOrder: readonly T[] | null | undefined; + hasPendingReorder: boolean; +}): boolean { + if (!input.optimisticOrder) { + return false; + } + if (input.hasPendingReorder) { + return false; + } + return projectOrdersEqual(input.optimisticOrder, input.persistedOrder); +} + +export function orderProjects>(projects: readonly T[]): T[] { + return projects + .map((project, index) => ({ index, project })) + .toSorted((left, right) => { + if (left.project.sortOrder !== right.project.sortOrder) { + return left.project.sortOrder - right.project.sortOrder; + } + return left.index - right.index; + }) + .map(({ project }) => project); +} + +export function orderProjectsByIds>( + projects: readonly T[], + orderedIds: readonly ProjectId[] | null | undefined, +): T[] { + const orderedProjects = orderProjects(projects); + if (!orderedIds || orderedIds.length === 0) { + return orderedProjects; + } + + const projectById = new Map(orderedProjects.map((project) => [project.id, project] as const)); + const seenProjectIds = new Set(); + const nextProjects: T[] = []; + + for (const projectId of orderedIds) { + const project = projectById.get(projectId); + if (!project || seenProjectIds.has(project.id)) { + continue; + } + nextProjects.push(project); + seenProjectIds.add(project.id); + } + + for (const project of orderedProjects) { + if (seenProjectIds.has(project.id)) { + continue; + } + nextProjects.push(project); + } + + return nextProjects; +} + +export function reorderProjectOrder(input: { + currentOrder: readonly ProjectId[]; + movedProjectId: ProjectId; + beforeProjectId: ProjectId | null; +}): ProjectId[] { + if (input.beforeProjectId === input.movedProjectId) { + return [...input.currentOrder]; + } + const withoutMoved = input.currentOrder.filter((projectId) => projectId !== input.movedProjectId); + + if (input.beforeProjectId === null) { + return [...withoutMoved, input.movedProjectId]; + } + + const insertIndex = withoutMoved.indexOf(input.beforeProjectId); + if (insertIndex === -1) { + return [input.movedProjectId, ...withoutMoved]; + } + + return [ + ...withoutMoved.slice(0, insertIndex), + input.movedProjectId, + ...withoutMoved.slice(insertIndex), + ]; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 8dfeb210f2..b290de9987 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -4,7 +4,11 @@ import { useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; +import { + MAX_CUSTOM_MODEL_LENGTH, + SIDEBAR_THREAD_ORDER_OPTIONS, + useAppSettings, +} from "../appSettings"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; @@ -12,6 +16,13 @@ import { ensureNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { SidebarInset } from "~/components/ui/sidebar"; @@ -96,6 +107,7 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const sidebarThreadOrder = settings.sidebarThreadOrder; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const openKeybindingsFile = useCallback(() => { @@ -234,6 +246,45 @@ function SettingsRouteView() {

    +
    +
    +

    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, });