diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 82fac97f8c87..60a50692caa8 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -113,6 +113,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getThreadGroupShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), searchThreads: () => Effect.succeed({ matches: [] }), @@ -214,6 +215,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getThreadGroupShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), searchThreads: () => Effect.succeed({ matches: [] }), @@ -305,6 +307,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getThreadGroupShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), searchThreads: () => Effect.succeed({ matches: [] }), @@ -381,6 +384,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getThreadGroupShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), searchThreads: () => Effect.succeed({ matches: [] }), @@ -442,6 +446,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getThreadGroupShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), searchThreads: () => Effect.succeed({ matches: [] }), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 64d8dfab1733..922142057e29 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -234,6 +234,7 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadActiveReorder: true, threadTitleRegeneration: true, + threadGroups: true, threadPullRequests: true, pullRequestStackActions: true, threadPullRequestLinking: true, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6837d849d779..80a695a2da12 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -309,6 +309,10 @@ function createTextGeneration( Effect.succeed({ title: "Update workflow", }), + generateThreadGroupName: () => + Effect.succeed({ + name: "Workflow", + }), ...overrides, }; @@ -357,6 +361,17 @@ function createTextGeneration( }), ), ), + generateThreadGroupName: (input) => + implementation.generateThreadGroupName(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateThreadGroupName", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), }; } diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 4a2e99f3ebc0..22d7e1de561e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -455,6 +455,7 @@ describe("OrchestrationEngine", () => { getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), + getThreadGroupShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), searchThreads: () => Effect.succeed({ matches: [] }), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index fb2fadde5e63..88a440db34db 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -3,6 +3,7 @@ import type { OrchestrationEvent, OrchestrationReadModel, ProjectId, + ThreadGroupId, ThreadId, } from "@t3tools/contracts"; import { OrchestrationCommand } from "@t3tools/contracts"; @@ -62,8 +63,8 @@ interface CommandEnvelope { } function commandToAggregateRef(command: OrchestrationCommand): { - readonly aggregateKind: "project" | "thread"; - readonly aggregateId: ProjectId | ThreadId; + readonly aggregateKind: "project" | "thread" | "thread-group"; + readonly aggregateId: ProjectId | ThreadId | ThreadGroupId; } { switch (command.type) { case "project.create": @@ -73,6 +74,14 @@ function commandToAggregateRef(command: OrchestrationCommand): { aggregateKind: "project", aggregateId: command.projectId, }; + case "thread-group.create": + case "thread-group.meta.update": + case "thread-group.delete": + case "thread-group.name.generate.complete": + return { + aggregateKind: "thread-group", + aggregateId: command.groupId, + }; default: return { aggregateKind: "thread", @@ -293,10 +302,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + // Keyed by the command's own aggregate: group commands also emit + // thread events, and a retry is matched against the command. yield* commandReceiptRepository.upsert({ commandId: envelope.command.commandId, - aggregateKind: lastSavedEvent.aggregateKind, - aggregateId: lastSavedEvent.aggregateId, + aggregateKind: aggregateRef.aggregateKind, + aggregateId: aggregateRef.aggregateId, acceptedAt: lastSavedEvent.occurredAt, resultSequence: lastSavedEvent.sequence, status: "accepted", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f6913426a0a3..3784fb9a11fc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -36,6 +36,7 @@ import { type ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import * as ProjectionThreadGroups from "../../persistence/ProjectionThreadGroups.ts"; import * as ProjectionThreadPullRequests from "../../persistence/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { @@ -482,6 +483,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const eventStore = yield* OrchestrationEventStore; const projectionStateRepository = yield* ProjectionStateRepository; const projectionProjectRepository = yield* ProjectionProjectRepository; + const projectionThreadGroupRepository = + yield* ProjectionThreadGroups.ProjectionThreadGroupRepository; const projectionThreadRepository = yield* ProjectionThreadRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; @@ -564,6 +567,59 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + // Thread groups ride the projects projector: they are project-scoped + // rows with the same lifecycle shape and no per-thread fan-out. + case "thread-group.created": + yield* projectionThreadGroupRepository.upsert({ + groupId: event.payload.groupId, + projectId: event.payload.projectId, + name: event.payload.name, + icon: event.payload.icon, + nameGenerationRequestId: event.payload.nameGeneration?.requestId ?? null, + nameGenerationStartedAt: event.payload.nameGeneration?.startedAt ?? null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + deletedAt: null, + }); + return; + + case "thread-group.meta-updated": { + const existingRow = yield* projectionThreadGroupRepository.getById({ + groupId: event.payload.groupId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadGroupRepository.upsert({ + ...existingRow.value, + ...(event.payload.name !== undefined ? { name: event.payload.name } : {}), + ...(event.payload.icon !== undefined ? { icon: event.payload.icon } : {}), + ...(event.payload.nameGeneration !== undefined + ? { + nameGenerationRequestId: event.payload.nameGeneration?.requestId ?? null, + nameGenerationStartedAt: event.payload.nameGeneration?.startedAt ?? null, + } + : {}), + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread-group.deleted": { + const existingRow = yield* projectionThreadGroupRepository.getById({ + groupId: event.payload.groupId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadGroupRepository.upsert({ + ...existingRow.value, + deletedAt: event.payload.deletedAt, + updatedAt: event.payload.deletedAt, + }); + return; + } + default: return; } @@ -797,6 +853,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.group-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + groupId: event.payload.groupId, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, @@ -2183,6 +2254,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( makeOrchestrationProjectionPipeline(), ).pipe( Layer.provideMerge(ProjectionProjectRepositoryLive), + Layer.provideMerge(ProjectionThreadGroups.layer), Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 6183dbc66168..7efb6e30b259 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -487,6 +487,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { activeOrderKey: "hq", titleRegeneration: null, titleState: null, + groupId: null, deletedAt: null, messages: [ { @@ -613,6 +614,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { activeOrderKey: "hq", titleRegeneration: null, titleState: null, + groupId: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5bfe411fd845..2f907e2d4a06 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -26,6 +26,8 @@ import { type OrchestrationSession, type OrchestrationThreadActivity, type OrchestrationThreadShell, + type OrchestrationThreadGroup, + ThreadGroupId, ModelSelection, ProjectId, ThreadLinkedPullRequest, @@ -63,6 +65,7 @@ import { ProjectionThreadProposedPlan } from "../../persistence/Services/Project import { ProjectionThreadPullRequest } from "../../persistence/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import * as ProjectionThreadGroups from "../../persistence/ProjectionThreadGroups.ts"; import { decodeThreadDetailPageCursor, encodeThreadDetailPageCursor, @@ -126,6 +129,10 @@ const ProjectionThreadPullRequestDbRowSchema = ProjectionThreadPullRequest.mapFi stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)), }), ); +const ProjectionThreadGroupDbRowSchema = ProjectionThreadGroups.ProjectionThreadGroupDbRow; +const ThreadGroupIdLookupInput = Schema.Struct({ + groupId: ThreadGroupId, +}); const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), @@ -406,6 +413,23 @@ function mapProjectShellRow( }; } +function mapThreadGroupRow( + row: Schema.Schema.Type, +): OrchestrationThreadGroup { + return { + id: row.groupId, + projectId: row.projectId, + name: row.name, + icon: row.icon, + nameGeneration: + row.nameGenerationRequestId !== null && row.nameGenerationStartedAt !== null + ? { requestId: row.nameGenerationRequestId, startedAt: row.nameGenerationStartedAt } + : null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + function mapProposedPlanRow( row: Schema.Schema.Type, ): OrchestrationProposedPlan { @@ -563,6 +587,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, title_state_json AS "titleState", + group_id AS "groupId", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -604,6 +629,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, title_state_json AS "titleState", + group_id AS "groupId", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -647,6 +673,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, title_state_json AS "titleState", + group_id AS "groupId", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -992,6 +1019,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listActiveThreadGroupRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadGroupDbRowSchema, + execute: () => + sql` + SELECT + group_id AS "groupId", + project_id AS "projectId", + name, + icon_json AS "icon", + name_generation_request_id AS "nameGenerationRequestId", + name_generation_started_at AS "nameGenerationStartedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_thread_groups + WHERE deleted_at IS NULL + ORDER BY created_at ASC, group_id ASC + `, + }); + + const getActiveThreadGroupRowById = SqlSchema.findOneOption({ + Request: ThreadGroupIdLookupInput, + Result: ProjectionThreadGroupDbRowSchema, + execute: ({ groupId }) => + sql` + SELECT + group_id AS "groupId", + project_id AS "projectId", + name, + icon_json AS "icon", + name_generation_request_id AS "nameGenerationRequestId", + name_generation_started_at AS "nameGenerationStartedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_thread_groups + WHERE group_id = ${groupId} + AND deleted_at IS NULL + `, + }); + const readProjectionCounts = SqlSchema.findOne({ Request: Schema.Void, Result: ProjectionCountsRowSchema, @@ -1212,6 +1281,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, title_state_json AS "titleState", + group_id AS "groupId", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -2106,6 +2176,14 @@ pending_approval_requests AS ( ), ), ), + listActiveThreadGroupRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadGroups:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadGroups:decodeRows", + ), + ), + ), ]), ) .pipe( @@ -2121,6 +2199,7 @@ pending_approval_requests AS ( checkpointRows, latestTurnRows, stateRows, + threadGroupRows, ]) => Effect.gen(function* () { const messagesByThread = new Map>(); @@ -2308,6 +2387,7 @@ pending_approval_requests AS ( activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, + groupId: row.groupId ?? null, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -2320,6 +2400,7 @@ pending_approval_requests AS ( snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + threadGroups: threadGroupRows.map(mapThreadGroupRow), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -2398,6 +2479,14 @@ pending_approval_requests AS ( ), ), ), + listActiveThreadGroupRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadGroups:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadGroups:decodeRows", + ), + ), + ), ]), ) .pipe( @@ -2410,6 +2499,7 @@ pending_approval_requests AS ( sessionRows, latestTurnRows, stateRows, + threadGroupRows, ]) => Effect.gen(function* () { const linkedThreadIds = new Set(pullRequestRows.map((row) => row.threadId)); @@ -2553,6 +2643,7 @@ pending_approval_requests AS ( activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, + groupId: row.groupId ?? null, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -2566,6 +2657,7 @@ pending_approval_requests AS ( snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + threadGroups: threadGroupRows.map(mapThreadGroupRow), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", } satisfies OrchestrationReadModel; }), @@ -2630,11 +2722,27 @@ pending_approval_requests AS ( ), ), ), + listActiveThreadGroupRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreadGroups:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreadGroups:decodeRows", + ), + ), + ), ]), ) .pipe( Effect.flatMap( - ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + sessionRows, + pullRequestRows, + latestTurnRows, + stateRows, + threadGroupRows, + ]) => Effect.gen(function* () { let updatedAt: string | null = null; for (const row of projectRows) { @@ -2658,6 +2766,9 @@ pending_approval_requests AS ( for (const row of stateRows) { updatedAt = maxIso(updatedAt, row.updatedAt); } + for (const row of threadGroupRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows); @@ -2709,6 +2820,7 @@ pending_approval_requests AS ( activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, + groupId: row.groupId ?? null, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -2721,6 +2833,7 @@ pending_approval_requests AS ( } satisfies OrchestrationThreadShell) : Result.failVoid, ), + threadGroups: threadGroupRows.map(mapThreadGroupRow), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -2872,6 +2985,7 @@ pending_approval_requests AS ( activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, + groupId: row.groupId ?? null, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -2882,6 +2996,7 @@ pending_approval_requests AS ( ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), })), + threadGroups: [], updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; @@ -3155,6 +3270,19 @@ pending_approval_requests AS ( }); }); + const getThreadGroupShellById: ProjectionSnapshotQueryShape["getThreadGroupShellById"] = ( + groupId, + ) => + getActiveThreadGroupRowById({ groupId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadGroupShellById:query", + "ProjectionSnapshotQuery.getThreadGroupShellById:decodeRow", + ), + ), + Effect.map(Option.map(mapThreadGroupRow)), + ); + const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => Effect.gen(function* () { const [threadRow, latestTurnRow, sessionRow, pullRequestRows] = yield* Effect.all([ @@ -3228,6 +3356,7 @@ pending_approval_requests AS ( activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), titleState: threadRow.value.titleState, + groupId: threadRow.value.groupId ?? null, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -3529,6 +3658,7 @@ pending_approval_requests AS ( activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), titleState: threadRow.value.titleState, + groupId: threadRow.value.groupId ?? null, deletedAt: null, messages: messageRows.map((row) => { const message = { @@ -3746,6 +3876,7 @@ pending_approval_requests AS ( getThreadCheckpointContext, getFullThreadDiffContext, getThreadShellById, + getThreadGroupShellById, getThreadRuntimeContext, getTurnStartMessage, getThreadDetailById, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b653047d8255..045276204784 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,12 +1,14 @@ import { type ChatAttachment, CommandId, + DEFAULT_THREAD_GROUP_NAME, EventId, type ModelSelection, type OrchestrationEvent, ProviderDriverKind, type ProjectId, type OrchestrationSession, + ThreadGroupId, ThreadId, type ProviderSession, type RuntimeMode, @@ -1192,6 +1194,116 @@ const make = Effect.gen(function* () { processThreadTitleRegenerationSafely, ); + // Group naming mirrors title regeneration: the decider stamps a pending + // request on the group, the reactor generates from the members' titles, and + // the completion command is dropped if a rename or a newer request landed + // in between. + const generateThreadGroupName = Effect.fn("generateThreadGroupName")(function* ( + groupId: ThreadGroupId, + requestId: CommandId, + ) { + const group = Option.getOrUndefined( + yield* projectionSnapshotQuery.getThreadGroupShellById(groupId), + ); + if (!group || group.nameGeneration?.requestId !== requestId) { + return { _tag: "Superseded" } as const; + } + const shell = yield* projectionSnapshotQuery.getShellSnapshot(); + const threadTitles = shell.threads + .filter((thread) => thread.groupId === groupId && thread.archivedAt === null) + .map((thread) => thread.title); + if (threadTitles.length === 0) { + return { _tag: "Completed", name: undefined } as const; + } + const project = yield* resolveProject(group.projectId); + const cwd = project?.workspaceRoot ?? process.cwd(); + const { textGenerationModelSelection: modelSelection } = resolveProjectSettings( + yield* serverSettingsService.getSettings, + group.projectId, + ).settings; + const generated = yield* textGeneration.generateThreadGroupName({ + cwd, + threadTitles, + modelSelection, + }); + if (generated.name === DEFAULT_THREAD_GROUP_NAME || generated.name === group.name) { + return { _tag: "Completed", name: undefined } as const; + } + return { _tag: "Completed", name: generated.name } as const; + }); + const dispatchThreadGroupNameCompletion = Effect.fn("dispatchThreadGroupNameCompletion")( + function* (input: { + readonly groupId: ThreadGroupId; + readonly requestId: CommandId; + readonly name?: string; + }) { + yield* orchestrationEngine.dispatch({ + type: "thread-group.name.generate.complete", + commandId: yield* serverCommandId("thread-group-name-complete"), + groupId: input.groupId, + requestId: input.requestId, + ...(input.name !== undefined ? { name: input.name } : {}), + }); + }, + ); + const processThreadGroupNamingSafely = Effect.fn("processThreadGroupNamingSafely")( + function* (request: { readonly groupId: ThreadGroupId; readonly requestId: CommandId }) { + const result = yield* generateThreadGroupName(request.groupId, request.requestId).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("provider command reactor failed to name thread group", { + groupId: request.groupId, + cause: Cause.pretty(cause), + }).pipe(Effect.as({ _tag: "Completed", name: undefined } as const)); + }), + ); + if (result._tag === "Superseded") { + return; + } + const completion = { + groupId: request.groupId, + requestId: request.requestId, + ...(result.name !== undefined ? { name: result.name } : {}), + }; + // One retry, like title regeneration: a transient dispatch failure must + // not leave the group stuck in its pending "Naming…" state. + yield* dispatchThreadGroupNameCompletion(completion).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("provider command reactor retrying group name completion", { + groupId: request.groupId, + cause: Cause.pretty(cause), + }).pipe(Effect.andThen(dispatchThreadGroupNameCompletion(completion))); + }), + ); + }, + (effect, request) => + effect.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("provider command reactor failed to complete group naming", { + groupId: request.groupId, + cause: Cause.pretty(cause), + }); + }), + ), + ); + const threadGroupNamingWorker = yield* makeDrainableWorker(processThreadGroupNamingSafely); + const findPendingThreadGroupNames = Effect.fn("findPendingThreadGroupNames")(function* () { + const readModel = yield* projectionSnapshotQuery.getCommandReadModel(); + return (readModel.threadGroups ?? []).flatMap((group) => + group.nameGeneration == null + ? [] + : [{ groupId: group.id, requestId: group.nameGeneration.requestId }], + ); + }); + const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* ( receivedEvent: Extract, ) { @@ -1880,9 +1992,51 @@ const make = Effect.gen(function* () { } }); + const pendingGroupNames = yield* findPendingThreadGroupNames().pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + return Effect.logWarning("provider command reactor failed to find pending group names", { + failureKind: Cause.hasDies(cause) ? "defect" : "failure", + reasonCount: cause.reasons.length, + }).pipe(Effect.as([])); + }), + ); + const processThreadGroupEvent = Effect.fn("processThreadGroupEvent")(function* ( + event: OrchestrationEvent, + ) { + if (event.type === "thread-group.created" && event.payload.nameGeneration != null) { + yield* threadGroupNamingWorker.enqueue({ + groupId: event.payload.groupId, + requestId: event.payload.nameGeneration.requestId, + }); + } else if ( + event.type === "thread-group.meta-updated" && + event.payload.regenerateName === true && + event.payload.nameGeneration != null + ) { + yield* threadGroupNamingWorker.enqueue({ + groupId: event.payload.groupId, + requestId: event.payload.nameGeneration.requestId, + }); + } + }); + // Subscribe before returning, even while event handling waits for server activation. const domainEvents = yield* orchestrationEngine.subscribeDomainEvents; - yield* forkParked(Stream.runForEach(domainEvents, processEvent)); + yield* forkParked( + Stream.runForEach(domainEvents, (event) => + processEvent(event).pipe(Effect.andThen(processThreadGroupEvent(event))), + ), + ); + // Interrupted group naming re-runs: the request stays pending on the group + // until a completion lands, so a restart just picks the work back up. + yield* forkParked( + Effect.forEach(pendingGroupNames, (request) => threadGroupNamingWorker.enqueue(request), { + discard: true, + }), + ); // Earlier events do not replay. Clear interrupted requests by their captured // IDs, then schedule persisted refinements after subscribing to their events. @@ -1920,6 +2074,7 @@ const make = Effect.gen(function* () { drain: Effect.gen(function* () { yield* worker.drain; yield* threadTitleRegenerationWorker.drain; + yield* threadGroupNamingWorker.drain; }), } satisfies ProviderCommandReactorShape; }); diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 29468dc3f84e..75fdab6864a3 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -30,6 +30,10 @@ import { ThreadApprovalResponseRequestedPayload as ContractsThreadApprovalResponseRequestedPayloadSchema, ThreadCheckpointRevertRequestedPayload as ContractsThreadCheckpointRevertRequestedPayloadSchema, ThreadSessionStopRequestedPayload as ContractsThreadSessionStopRequestedPayloadSchema, + ThreadGroupSetPayload as ContractsThreadGroupSetPayloadSchema, + ThreadGroupCreatedPayload as ContractsThreadGroupCreatedPayloadSchema, + ThreadGroupMetaUpdatedPayload as ContractsThreadGroupMetaUpdatedPayloadSchema, + ThreadGroupDeletedPayload as ContractsThreadGroupDeletedPayloadSchema, } from "@t3tools/contracts"; // Server-internal alias surface, backed by contract schemas as the source of truth. @@ -54,6 +58,10 @@ export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchem export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema; export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema; export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema; +export const ThreadGroupSetPayload = ContractsThreadGroupSetPayloadSchema; +export const ThreadGroupCreatedPayload = ContractsThreadGroupCreatedPayloadSchema; +export const ThreadGroupMetaUpdatedPayload = ContractsThreadGroupMetaUpdatedPayloadSchema; +export const ThreadGroupDeletedPayload = ContractsThreadGroupDeletedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 46f269a0cf40..f864771dd9ee 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -24,7 +24,9 @@ import type { OrchestrationThreadDetailSnapshot, OrchestrationThreadDetailWindow, OrchestrationThreadShell, + OrchestrationThreadGroup, ProjectId, + ThreadGroupId, ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -218,6 +220,11 @@ export interface ProjectionSnapshotQueryShape { threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + /** Read a single active (not deleted) thread group by id. */ + readonly getThreadGroupShellById: ( + groupId: ThreadGroupId, + ) => Effect.Effect, ProjectionRepositoryError>; + /** Read the active thread and session facts used to ingest provider events. */ readonly getThreadRuntimeContext: ( threadId: ThreadId, diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 873eab007bea..172e6b9d3e45 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -3,7 +3,9 @@ import type { OrchestrationProject, OrchestrationReadModel, OrchestrationThread, + OrchestrationThreadGroup, ProjectId, + ThreadGroupId, ThreadId, } from "@t3tools/contracts"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; @@ -96,6 +98,23 @@ export function requireActiveProjectWorkspaceRootAbsent(input: { ); } +export function requireThreadGroup(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly groupId: ThreadGroupId; +}): Effect.Effect { + const group = (input.readModel.threadGroups ?? []).find((entry) => entry.id === input.groupId); + if (group) { + return Effect.succeed(group); + } + return Effect.fail( + invariantError( + input.command.type, + `Thread group '${input.groupId}' does not exist for command '${input.command.type}'.`, + ), + ); +} + export function requireThread(input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index fea36b5717fe..0f591209ce29 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, EventId, ProjectId, + ThreadGroupId, ThreadId, type OrchestrationCommand, type OrchestrationEvent, @@ -136,6 +137,193 @@ function normalizeDeleteEvent(event: PlannedEvent | ReadonlyArray) }); } +const seedGroupedReadModel = Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const seeded = yield* seedReadModel; + const withGroup = yield* projectEvent(seeded, { + sequence: 4, + eventId: asEventId("evt-group-create"), + aggregateKind: "thread-group", + aggregateId: ThreadGroupId.make("group-1"), + type: "thread-group.created", + occurredAt: now, + commandId: asCommandId("cmd-group-create"), + causationEventId: null, + correlationId: asCommandId("cmd-group-create"), + metadata: {}, + payload: { + groupId: ThreadGroupId.make("group-1"), + projectId: asProjectId("project-delete"), + name: "Group", + icon: null, + nameGeneration: null, + createdAt: now, + updatedAt: now, + }, + }); + return yield* projectEvent(withGroup, { + sequence: 5, + eventId: asEventId("evt-group-set"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-1"), + type: "thread.group-set", + occurredAt: now, + commandId: asCommandId("cmd-group-create"), + causationEventId: null, + correlationId: asCommandId("cmd-group-create"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-1"), + groupId: ThreadGroupId.make("group-1"), + updatedAt: now, + }, + }); +}); + +it.layer(NodeServices.layer)("decider thread group flows", (it) => { + it.effect("deleting a group's last member retires the group", () => + Effect.gen(function* () { + const readModel = yield* seedGroupedReadModel; + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.delete", + commandId: asCommandId("cmd-thread-delete-grouped"), + threadId: asThreadId("thread-delete-1"), + }, + readModel, + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.deleted", "thread-group.deleted"]); + expect(events[1]?.aggregateId).toBe("group-1"); + }), + ); + + it.effect( + "moving the last member out retires the group; re-setting the same group is a no-op", + () => + Effect.gen(function* () { + const readModel = yield* seedGroupedReadModel; + const moved = yield* decideOrchestrationCommand({ + command: { + type: "thread.group.set", + commandId: asCommandId("cmd-group-unset"), + threadId: asThreadId("thread-delete-1"), + groupId: null, + }, + readModel, + }); + const movedEvents = Array.isArray(moved) ? moved : [moved]; + expect(movedEvents.map((event) => event.type)).toEqual([ + "thread.group-set", + "thread-group.deleted", + ]); + + const same = yield* decideOrchestrationCommand({ + command: { + type: "thread.group.set", + commandId: asCommandId("cmd-group-same"), + threadId: asThreadId("thread-delete-1"), + groupId: ThreadGroupId.make("group-1"), + }, + readModel, + }); + const sameEvents = Array.isArray(same) ? same : [same]; + expect(sameEvents.map((event) => event.type)).toEqual(["thread.group-set"]); + if (sameEvents[0]?.type === "thread.group-set") { + expect(sameEvents[0].payload.updatedAt).toBe(readModel.threads[0]?.updatedAt); + } + }), + ); + + it.effect("grouping and moving reject a soft-deleted thread", () => + Effect.gen(function* () { + const readModel = yield* seedGroupedReadModel; + const deletedAt = "2026-01-01T00:00:01.000Z"; + const withDeleted = { + ...readModel, + threads: readModel.threads.map((thread) => + thread.id === "thread-delete-2" ? { ...thread, deletedAt } : thread, + ), + }; + const createError = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread-group.create", + commandId: asCommandId("cmd-group-create-deleted"), + groupId: ThreadGroupId.make("group-3"), + projectId: asProjectId("project-delete"), + threadIds: [asThreadId("thread-delete-2")], + createdAt: deletedAt, + }, + readModel: withDeleted, + }), + ); + expect(createError.message).toContain("is deleted"); + const setError = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.group.set", + commandId: asCommandId("cmd-group-set-deleted"), + threadId: asThreadId("thread-delete-2"), + groupId: ThreadGroupId.make("group-1"), + }, + readModel: withDeleted, + }), + ); + expect(setError.message).toContain("is deleted"); + }), + ); + + it.effect("creating a group rejects members from another project", () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread-group.create", + commandId: asCommandId("cmd-group-create-cross"), + groupId: ThreadGroupId.make("group-2"), + projectId: asProjectId("project-delete"), + threadIds: [asThreadId("thread-delete-2")], + createdAt: "2026-01-01T00:00:00.000Z", + }, + readModel: { + ...readModel, + threads: readModel.threads.map((thread) => + thread.id === "thread-delete-2" + ? { ...thread, projectId: asProjectId("project-other") } + : thread, + ), + }, + }), + ); + expect(error.message).toContain("belongs to another project"); + }), + ); + + it.effect("a name completion for a superseded request changes nothing", () => + Effect.gen(function* () { + const readModel = yield* seedGroupedReadModel; + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread-group.name.generate.complete", + commandId: asCommandId("cmd-group-name-complete"), + groupId: ThreadGroupId.make("group-1"), + requestId: asCommandId("cmd-stale-request"), + name: "Stale name", + }, + readModel, + }); + const events = Array.isArray(result) ? result : [result]; + expect(events[0]?.type).toBe("thread-group.meta-updated"); + if (events[0]?.type === "thread-group.meta-updated") { + expect(events[0].payload.name).toBeUndefined(); + expect(events[0].payload.nameGeneration).toBeUndefined(); + } + }), + ); +}); + it.layer(NodeServices.layer)("decider deletion flows", (it) => { it.effect("rejects deleting a non-empty project without force", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 27b7f1074ae6..4a9797b82dd4 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,8 +1,10 @@ import { + DEFAULT_THREAD_GROUP_NAME, EventId, MAX_SCRIPT_ID_LENGTH, SCRIPT_RUN_COMMAND_PATTERN, MessageId, + ProjectIconOverride, ThreadLinkedPullRequest, UserInputRequestedPayload, isImportedAgentSessionMessageId, @@ -10,6 +12,7 @@ import { type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, + type ThreadGroupId, type ThreadPullRequestKey, type ThreadPullRequestLink, type OrchestrationThreadActivity, @@ -40,6 +43,7 @@ import { requireProject, requireProjectAbsent, requireThread, + requireThreadGroup, requireThreadArchived, requireThreadAbsent, requireThreadNotArchived, @@ -54,6 +58,7 @@ const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload); const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); +const threadGroupIconsEqual = Schema.toEquivalence(Schema.NullOr(ProjectIconOverride)); /** * Blocked-on-you work derived from the thread's retained activities: an @@ -170,6 +175,51 @@ function withEventBase( type PlannedOrchestrationEvent = Omit; +/** + * Groups that lose their last member in this command are deleted with it, so + * the sidebar never renders a hollow folder. Archived members still count: + * an archived thread keeps its group and returns to it on unarchive. + */ +const emptiedThreadGroupDeletions = Effect.fn("emptiedThreadGroupDeletions")(function* (input: { + readonly readModel: OrchestrationReadModel; + readonly movedThreadIds: ReadonlySet; + readonly occurredAt: string; + readonly commandId: OrchestrationCommand["commandId"]; +}): Effect.fn.Return< + ReadonlyArray, + PlatformError.PlatformError, + Crypto.Crypto +> { + const previousGroupIds = new Set(); + for (const thread of input.readModel.threads) { + if (input.movedThreadIds.has(thread.id) && thread.groupId != null) { + previousGroupIds.add(thread.groupId); + } + } + const events: PlannedOrchestrationEvent[] = []; + for (const groupId of previousGroupIds) { + const remaining = input.readModel.threads.some( + (thread) => + thread.groupId === groupId && + thread.deletedAt === null && + !input.movedThreadIds.has(thread.id), + ); + if (remaining) continue; + if (!(input.readModel.threadGroups ?? []).some((group) => group.id === groupId)) continue; + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread-group", + aggregateId: groupId, + occurredAt: input.occurredAt, + commandId: input.commandId, + })), + type: "thread-group.deleted", + payload: { groupId, deletedAt: input.occurredAt }, + }); + } + return events; +}); + type DecideOrchestrationCommandResult = | PlannedOrchestrationEvent | ReadonlyArray; @@ -417,7 +467,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); const occurredAt = yield* nowIso; - return { + const deleted: PlannedOrchestrationEvent = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -430,6 +480,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" deletedAt: occurredAt, }, }; + // Deleting the last member retires its group the same way moving it + // out would; archiving does not, so an unarchive lands back in place. + const emptied = yield* emptiedThreadGroupDeletions({ + readModel, + movedThreadIds: new Set([command.threadId]), + occurredAt, + commandId: command.commandId, + }); + return emptied.length === 0 ? deleted : [deleted, ...emptied]; } case "thread.archive": { @@ -1034,6 +1093,220 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread-group.create": { + yield* requireProject({ readModel, command, projectId: command.projectId }); + if ((readModel.threadGroups ?? []).some((group) => group.id === command.groupId)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread group '${command.groupId}' already exists.`, + }), + ); + } + const threadIds = [...new Set(command.threadIds)]; + for (const threadId of threadIds) { + const thread = yield* requireThreadNotArchived({ readModel, command, threadId }); + if (thread.deletedAt !== null) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${threadId}' is deleted and cannot join group '${command.groupId}'.`, + }), + ); + } + if (thread.projectId !== command.projectId) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${threadId}' belongs to another project and cannot join group '${command.groupId}'.`, + }), + ); + } + } + const occurredAt = command.createdAt; + const created: PlannedOrchestrationEvent = { + ...(yield* withEventBase({ + aggregateKind: "thread-group", + aggregateId: command.groupId, + occurredAt, + commandId: command.commandId, + })), + type: "thread-group.created", + payload: { + groupId: command.groupId, + projectId: command.projectId, + name: command.name ?? DEFAULT_THREAD_GROUP_NAME, + icon: command.icon ?? null, + nameGeneration: + command.generateName === true + ? { requestId: command.commandId, startedAt: occurredAt } + : null, + createdAt: occurredAt, + updatedAt: occurredAt, + }, + }; + const memberships: PlannedOrchestrationEvent[] = []; + for (const threadId of threadIds) { + memberships.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.group-set", + payload: { threadId, groupId: command.groupId, updatedAt: occurredAt }, + }); + } + const emptied = yield* emptiedThreadGroupDeletions({ + readModel, + movedThreadIds: new Set(threadIds), + occurredAt, + commandId: command.commandId, + }); + return [created, ...memberships, ...emptied]; + } + + case "thread-group.meta.update": { + const group = yield* requireThreadGroup({ readModel, command, groupId: command.groupId }); + const occurredAt = yield* nowIso; + const base = yield* withEventBase({ + aggregateKind: "thread-group", + aggregateId: command.groupId, + occurredAt, + commandId: command.commandId, + }); + if (command.regenerateName === true) { + return { + ...base, + type: "thread-group.meta-updated", + payload: { + groupId: command.groupId, + regenerateName: true as const, + nameGeneration: { requestId: command.commandId, startedAt: occurredAt }, + updatedAt: occurredAt, + }, + }; + } + const unchanged = + (command.name === undefined || command.name === group.name) && + (command.icon === undefined || threadGroupIconsEqual(command.icon, group.icon)); + return { + ...base, + type: "thread-group.meta-updated", + payload: { + groupId: command.groupId, + ...(command.name !== undefined ? { name: command.name, nameGeneration: null } : {}), + ...(command.icon !== undefined ? { icon: command.icon } : {}), + updatedAt: unchanged ? group.updatedAt : occurredAt, + }, + }; + } + + case "thread-group.delete": { + yield* requireThreadGroup({ readModel, command, groupId: command.groupId }); + const occurredAt = yield* nowIso; + const releases: PlannedOrchestrationEvent[] = []; + for (const thread of readModel.threads) { + if (thread.groupId !== command.groupId || thread.deletedAt !== null) continue; + releases.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: thread.id, + occurredAt, + commandId: command.commandId, + })), + type: "thread.group-set", + payload: { threadId: thread.id, groupId: null, updatedAt: occurredAt }, + }); + } + const deleted: PlannedOrchestrationEvent = { + ...(yield* withEventBase({ + aggregateKind: "thread-group", + aggregateId: command.groupId, + occurredAt, + commandId: command.commandId, + })), + type: "thread-group.deleted", + payload: { groupId: command.groupId, deletedAt: occurredAt }, + }; + return [...releases, deleted]; + } + + case "thread.group.set": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.deletedAt !== null) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' is deleted and cannot change groups.`, + }), + ); + } + if (command.groupId !== null) { + const group = yield* requireThreadGroup({ readModel, command, groupId: command.groupId }); + if (group.projectId !== thread.projectId) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' belongs to another project than group '${command.groupId}'.`, + }), + ); + } + } + const occurredAt = yield* nowIso; + // Idempotent by re-emission (see thread.settle): setting the group the + // thread already has lands on the same state without churning updatedAt. + const unchanged = (thread.groupId ?? null) === command.groupId; + const membership: PlannedOrchestrationEvent = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.group-set", + payload: { + threadId: command.threadId, + groupId: command.groupId, + updatedAt: unchanged ? thread.updatedAt : occurredAt, + }, + }; + if (unchanged) return membership; + const emptied = yield* emptiedThreadGroupDeletions({ + readModel, + movedThreadIds: new Set([command.threadId]), + occurredAt, + commandId: command.commandId, + }); + return [membership, ...emptied]; + } + + case "thread-group.name.generate.complete": { + const group = yield* requireThreadGroup({ readModel, command, groupId: command.groupId }); + const requestIsCurrent = group.nameGeneration?.requestId === command.requestId; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread-group", + aggregateId: command.groupId, + occurredAt, + commandId: command.commandId, + })), + type: "thread-group.meta-updated", + payload: { + groupId: command.groupId, + ...(requestIsCurrent && command.name !== undefined ? { name: command.name } : {}), + ...(requestIsCurrent ? { nameGeneration: null } : {}), + updatedAt: requestIsCurrent ? occurredAt : group.updatedAt, + }, + }; + } + case "thread.pull-request.link": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index c4e1996f1ddd..7abe7618dd19 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -3,6 +3,7 @@ import { EventId, ProjectId, ProviderDriverKind, + ThreadGroupId, ThreadId, type OrchestrationEvent, } from "@t3tools/contracts"; @@ -29,7 +30,9 @@ function makeEvent(input: { aggregateId: input.aggregateKind === "project" ? ProjectId.make(input.aggregateId) - : ThreadId.make(input.aggregateId), + : input.aggregateKind === "thread-group" + ? ThreadGroupId.make(input.aggregateId) + : ThreadId.make(input.aggregateId), occurredAt: input.occurredAt, commandId: input.commandId === null ? null : CommandId.make(input.commandId), causationEventId: null, @@ -299,6 +302,126 @@ describe("orchestration projector", () => { expect(unarchived.threads[0]?.archivedAt).toBeNull(); }); + effectIt.effect("applies thread group lifecycle and membership events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-thread-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { provider: ProviderDriverKind.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const withGroup = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread-group.created", + aggregateKind: "thread-group", + aggregateId: "group-1", + occurredAt: now, + commandId: "cmd-group-create", + payload: { + groupId: "group-1", + projectId: "project-1", + name: "New group", + icon: null, + nameGeneration: { requestId: "cmd-group-create", startedAt: now }, + createdAt: now, + updatedAt: now, + }, + }), + ); + const grouped = yield* projectEvent( + withGroup, + makeEvent({ + sequence: 3, + type: "thread.group-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-group-create", + payload: { threadId: "thread-1", groupId: "group-1", updatedAt: now }, + }), + ); + expect(grouped.threadGroups).toEqual([ + { + id: "group-1", + projectId: "project-1", + name: "New group", + icon: null, + nameGeneration: { requestId: "cmd-group-create", startedAt: now }, + createdAt: now, + updatedAt: now, + }, + ]); + expect(grouped.threads[0]?.groupId).toBe("group-1"); + + const named = yield* projectEvent( + grouped, + makeEvent({ + sequence: 4, + type: "thread-group.meta-updated", + aggregateKind: "thread-group", + aggregateId: "group-1", + occurredAt: now, + commandId: "cmd-group-name", + payload: { + groupId: "group-1", + name: "Auth work", + nameGeneration: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + }), + ); + expect(named.threadGroups?.[0]?.name).toBe("Auth work"); + expect(named.threadGroups?.[0]?.nameGeneration).toBeNull(); + expect(named.threadGroups?.[0]?.updatedAt).toBe("2026-01-01T00:00:01.000Z"); + + const released = yield* projectEvent( + named, + makeEvent({ + sequence: 5, + type: "thread.group-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-group-delete", + payload: { threadId: "thread-1", groupId: null, updatedAt: now }, + }), + ); + const deleted = yield* projectEvent( + released, + makeEvent({ + sequence: 6, + type: "thread-group.deleted", + aggregateKind: "thread-group", + aggregateId: "group-1", + occurredAt: now, + commandId: "cmd-group-delete", + payload: { groupId: "group-1", deletedAt: now }, + }), + ); + expect(deleted.threadGroups).toEqual([]); + expect(deleted.threads[0]?.groupId).toBeNull(); + }), + ); + it("keeps projector forward-compatible for unhandled event types", async () => { const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 53013770b15b..2e87ba88ea33 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -53,6 +53,10 @@ import { ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, + ThreadGroupSetPayload, + ThreadGroupCreatedPayload, + ThreadGroupMetaUpdatedPayload, + ThreadGroupDeletedPayload, } from "./Schemas.ts"; type ThreadPatch = Partial>; @@ -317,6 +321,7 @@ export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { snapshotSequence: 0, projects: [], threads: [], + threadGroups: [], updatedAt: nowIso, }; } @@ -636,6 +641,74 @@ export function projectEvent( }), ); + case "thread.group-set": + return decodeForEvent(ThreadGroupSetPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + groupId: payload.groupId, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread-group.created": + return decodeForEvent(ThreadGroupCreatedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => { + const nextGroup = { + id: payload.groupId, + projectId: payload.projectId, + name: payload.name, + icon: payload.icon, + nameGeneration: payload.nameGeneration ?? null, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + }; + const threadGroups = nextBase.threadGroups ?? []; + return { + ...nextBase, + threadGroups: threadGroups.some((group) => group.id === payload.groupId) + ? threadGroups.map((group) => (group.id === payload.groupId ? nextGroup : group)) + : [...threadGroups, nextGroup], + }; + }), + ); + + case "thread-group.meta-updated": + return decodeForEvent( + ThreadGroupMetaUpdatedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threadGroups: (nextBase.threadGroups ?? []).map((group) => + group.id === payload.groupId + ? { + ...group, + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.icon !== undefined ? { icon: payload.icon } : {}), + ...(payload.nameGeneration !== undefined + ? { nameGeneration: payload.nameGeneration } + : {}), + updatedAt: payload.updatedAt, + } + : group, + ), + })), + ); + + case "thread-group.deleted": + return decodeForEvent(ThreadGroupDeletedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threadGroups: (nextBase.threadGroups ?? []).filter( + (group) => group.id !== payload.groupId, + ), + })), + ); + case "thread.pull-request-linked": return decodeForEvent( ThreadPullRequestLinkedPayload, diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index d995e81422d0..ef2dae091637 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -10,6 +10,7 @@ import { OrchestrationEventType, ProjectId, ProjectIconOverride, + ThreadGroupId, ThreadId, } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -38,7 +39,7 @@ const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMeta const AppendEventRequestSchema = Schema.Struct({ eventId: EventId, aggregateKind: OrchestrationAggregateKind, - streamId: Schema.Union([ProjectId, ThreadId]), + streamId: Schema.Union([ProjectId, ThreadId, ThreadGroupId]), type: OrchestrationEventType, causationEventId: Schema.NullOr(EventId), correlationId: Schema.NullOr(CommandId), @@ -54,7 +55,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ eventId: EventId, type: OrchestrationEventType, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([ProjectId, ThreadId, ThreadGroupId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 4feaf3a185b9..89ec39241b56 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -38,6 +38,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id, title, title_state_json, + group_id, model_selection_json, runtime_mode, interaction_mode, @@ -70,6 +71,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.projectId}, ${row.title}, ${row.titleState == null ? null : JSON.stringify(row.titleState)}, + ${row.groupId ?? null}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, ${row.interactionMode}, @@ -102,6 +104,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id = excluded.project_id, title = excluded.title, title_state_json = excluded.title_state_json, + group_id = excluded.group_id, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, @@ -141,6 +144,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id AS "projectId", title, title_state_json AS "titleState", + group_id AS "groupId", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -182,6 +186,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id AS "projectId", title, title_state_json AS "titleState", + group_id AS "groupId", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ad015534ea01..6f7d381820da 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -64,6 +64,7 @@ import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts"; import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts"; import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; +import Migration0053 from "./Migrations/053_ProjectionThreadGroups.ts"; /** * Migration loader with all migrations defined inline. @@ -128,6 +129,7 @@ const migrationEntries = [ [50, "ProjectionThreadPullRequests", Migration0050], [51, "ProjectionThreadMessageContext", Migration0051], [52, "ProjectionThreadTitleState", Migration0052], + [53, "ProjectionThreadGroups", Migration0053], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/053_ProjectionThreadGroups.ts b/apps/server/src/persistence/Migrations/053_ProjectionThreadGroups.ts new file mode 100644 index 000000000000..ff49adbe5cf6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/053_ProjectionThreadGroups.ts @@ -0,0 +1,32 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_groups ( + group_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + icon_json TEXT, + name_generation_request_id TEXT, + name_generation_started_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_groups_project_id + ON projection_thread_groups(project_id) + `; + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!columns.some((column) => column.name === "group_id")) { + yield* sql`ALTER TABLE projection_threads ADD COLUMN group_id TEXT`; + } +}); diff --git a/apps/server/src/persistence/ProjectionThreadGroups.ts b/apps/server/src/persistence/ProjectionThreadGroups.ts new file mode 100644 index 000000000000..eaa3a4778612 --- /dev/null +++ b/apps/server/src/persistence/ProjectionThreadGroups.ts @@ -0,0 +1,130 @@ +import { + CommandId, + IsoDateTime, + ProjectIconOverride, + ProjectId, + ThreadGroupId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError, type ProjectionRepositoryError } from "./Errors.ts"; + +/** Projected thread group rows: user-made sidebar folders of threads. */ +export const ProjectionThreadGroup = Schema.Struct({ + groupId: ThreadGroupId, + projectId: ProjectId, + name: Schema.String, + icon: Schema.NullOr(ProjectIconOverride), + nameGenerationRequestId: Schema.NullOr(CommandId), + nameGenerationStartedAt: Schema.NullOr(IsoDateTime), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionThreadGroup = typeof ProjectionThreadGroup.Type; + +export const GetProjectionThreadGroupInput = Schema.Struct({ + groupId: ThreadGroupId, +}); +export type GetProjectionThreadGroupInput = typeof GetProjectionThreadGroupInput.Type; + +export const ProjectionThreadGroupDbRow = ProjectionThreadGroup.mapFields( + Struct.assign({ + icon: Schema.NullOr(Schema.fromJsonString(ProjectIconOverride)), + }), +); + +export class ProjectionThreadGroupRepository extends Context.Service< + ProjectionThreadGroupRepository, + { + /** Insert or replace a projected group row, keyed by `groupId`. */ + readonly upsert: (row: ProjectionThreadGroup) => Effect.Effect; + /** Read a projected group row by id, deleted rows included. */ + readonly getById: ( + input: GetProjectionThreadGroupInput, + ) => Effect.Effect, ProjectionRepositoryError>; + } +>()("t3/persistence/ProjectionThreadGroups/ProjectionThreadGroupRepository") {} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: ProjectionThreadGroup, + execute: (row) => + sql` + INSERT INTO projection_thread_groups ( + group_id, + project_id, + name, + icon_json, + name_generation_request_id, + name_generation_started_at, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${row.groupId}, + ${row.projectId}, + ${row.name}, + ${row.icon === null ? null : JSON.stringify(row.icon)}, + ${row.nameGenerationRequestId}, + ${row.nameGenerationStartedAt}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.deletedAt} + ) + ON CONFLICT (group_id) + DO UPDATE SET + project_id = excluded.project_id, + name = excluded.name, + icon_json = excluded.icon_json, + name_generation_request_id = excluded.name_generation_request_id, + name_generation_started_at = excluded.name_generation_started_at, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at + `, + }); + + const getRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadGroupInput, + Result: ProjectionThreadGroupDbRow, + execute: ({ groupId }) => + sql` + SELECT + group_id AS "groupId", + project_id AS "projectId", + name, + icon_json AS "icon", + name_generation_request_id AS "nameGenerationRequestId", + name_generation_started_at AS "nameGenerationStartedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_thread_groups + WHERE group_id = ${groupId} + `, + }); + + const upsert: ProjectionThreadGroupRepository["Service"]["upsert"] = (row) => + upsertRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadGroupRepository.upsert:query")), + ); + const getById: ProjectionThreadGroupRepository["Service"]["getById"] = (input) => + getRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadGroupRepository.getById:query")), + ); + + return ProjectionThreadGroupRepository.of({ upsert, getById }); +}); + +export const layer = Layer.effect(ProjectionThreadGroupRepository, make); diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts index 1498984827e5..7881e755d62e 100644 --- a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts @@ -13,6 +13,7 @@ import { OrchestrationAggregateKind, OrchestrationCommandReceiptStatus, ProjectId, + ThreadGroupId, ThreadId, } from "@t3tools/contracts"; import * as Option from "effect/Option"; @@ -25,7 +26,7 @@ import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; export const OrchestrationCommandReceipt = Schema.Struct({ commandId: CommandId, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([ProjectId, ThreadId, ThreadGroupId]), acceptedAt: IsoDateTime, resultSequence: NonNegativeInt, status: OrchestrationCommandReceiptStatus, diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 2c8186a321c0..d56744f72f6a 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,6 +14,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadGroupId, ThreadLinkedPullRequest, ThreadTitleState, ThreadId, @@ -31,6 +32,7 @@ export const ProjectionThread = Schema.Struct({ projectId: ProjectId, title: Schema.String, titleState: Schema.optional(Schema.NullOr(ThreadTitleState)), + groupId: Schema.optional(Schema.NullOr(ThreadGroupId)), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index b1cba460f959..97198ebd707d 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -59,6 +59,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getThreadGroupShellById: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 31fd6a195827..42fabf0928df 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -51,6 +51,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getThreadGroupShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 1d86bc981340..88c6caa331ac 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4981,6 +4981,7 @@ describe("agent browser access", () => { getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getThreadGroupShellById: () => Effect.die("unused"), getThreadShellById: (requestedThreadId) => Effect.gen(function* () { assert.equal(requestedThreadId, threadId); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index e36d04f08df3..54c929d0f59c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -258,6 +258,7 @@ describe("ProviderSessionReaper", () => { ? Option.some(input.readModel.threads.find((thread) => thread.id === threadId)!) : Option.none(), ), + getThreadGroupShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 4a8aca46bdda..0eea01ebba03 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -197,6 +197,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getThreadGroupShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), @@ -326,6 +327,7 @@ it.effect.each([ getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getThreadGroupShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), @@ -398,6 +400,7 @@ it.effect( getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getThreadGroupShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), @@ -462,6 +465,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getThreadGroupShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 6fd59041ddb0..b4b7fd387410 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -26,11 +26,13 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildThreadGroupNamePrompt, } from "./TextGenerationPrompts.ts"; import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, + sanitizeThreadGroupName, } from "./TextGenerationUtils.ts"; const ANTIGRAVITY_TIMEOUT_MS = 180_000; @@ -405,10 +407,21 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener }; }); + const generateThreadGroupName: TextGeneration.TextGeneration["Service"]["generateThreadGroupName"] = + Effect.fn("AntigravityTextGeneration.generateThreadGroupName")(function* (input) { + const generated = yield* runAntigravityJson({ + operation: "generateThreadGroupName", + ...buildThreadGroupNamePrompt({ threadTitles: input.threadTitles }), + modelSelection: input.modelSelection, + }); + return { name: sanitizeThreadGroupName(generated.name) }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadGroupName, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 357ecd686e46..468f87b26c00 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -25,6 +25,7 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildThreadGroupNamePrompt, } from "./TextGenerationPrompts.ts"; import { normalizeCliError, @@ -32,6 +33,7 @@ import { sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, + sanitizeThreadGroupName, } from "./TextGenerationUtils.ts"; import { getModelSelectionStringOptionValue, @@ -102,7 +104,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateThreadGroupName", value: unknown, detail: string, ): Effect.Effect => @@ -132,7 +135,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadGroupName"; cwd: string; prompt: string; outputSchemaJson: S; @@ -185,9 +189,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ); const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { - // Titles need only the supplied prompt, not configuration from the checkout. + // Titles and group names need only the supplied prompt, not configuration from the checkout. const workingDirectory = - operation === "generateThreadTitle" + operation === "generateThreadTitle" || operation === "generateThreadGroupName" ? yield* fileSystem .makeTempDirectoryScoped({ prefix: "t3code-claude-title-" }) .pipe( @@ -410,10 +414,26 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }; }); + const generateThreadGroupName: TextGeneration.TextGeneration["Service"]["generateThreadGroupName"] = + Effect.fn("ClaudeTextGeneration.generateThreadGroupName")(function* (input) { + const { prompt, outputSchema } = buildThreadGroupNamePrompt({ + threadTitles: input.threadTitles, + }); + const generated = yield* runClaudeJson({ + operation: "generateThreadGroupName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { name: sanitizeThreadGroupName(generated.name) }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadGroupName, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 4c9ac59d8422..610b7960d2d9 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -27,6 +27,7 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildThreadGroupNamePrompt, } from "./TextGenerationPrompts.ts"; import { normalizeCliError, @@ -34,6 +35,7 @@ import { sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, + sanitizeThreadGroupName, } from "./TextGenerationUtils.ts"; import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; @@ -103,7 +105,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateThreadGroupName", value: unknown, ): Effect.Effect => encodeJsonString(value).pipe( @@ -122,7 +125,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateThreadGroupName", attachments: TextGeneration.BranchNameGenerationInput["attachments"], ): Effect.fn.Return { if (!attachments || attachments.length === 0) { @@ -164,7 +168,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadGroupName"; cwd: string; prompt: string; outputSchemaJson: S; @@ -417,10 +422,27 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateThreadGroupName: TextGeneration.TextGeneration["Service"]["generateThreadGroupName"] = + Effect.fn("CodexTextGeneration.generateThreadGroupName")(function* (input) { + const { prompt, outputSchema } = buildThreadGroupNamePrompt({ + threadTitles: input.threadTitles, + }); + const generated = yield* runCodexJson({ + operation: "generateThreadGroupName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths: [], + modelSelection: input.modelSelection, + }); + return { name: sanitizeThreadGroupName(generated.name) }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadGroupName, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index ab7bee7e6437..b51d1ad1c71c 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -16,11 +16,13 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildThreadGroupNamePrompt, } from "./TextGenerationPrompts.ts"; import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, + sanitizeThreadGroupName, } from "./TextGenerationUtils.ts"; import { applyCursorAcpModelSelection, @@ -54,7 +56,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadGroupName"; cwd: string; prompt: string; outputSchemaJson: S; @@ -261,10 +264,26 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateThreadGroupName: TextGeneration.TextGeneration["Service"]["generateThreadGroupName"] = + Effect.fn("CursorTextGeneration.generateThreadGroupName")(function* (input) { + const { prompt, outputSchema } = buildThreadGroupNamePrompt({ + threadTitles: input.threadTitles, + }); + const generated = yield* runCursorJson({ + operation: "generateThreadGroupName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { name: sanitizeThreadGroupName(generated.name) }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadGroupName, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index f1d8569b8dc4..b18f00027302 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -18,11 +18,13 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildThreadGroupNamePrompt, } from "./TextGenerationPrompts.ts"; import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, + sanitizeThreadGroupName, } from "./TextGenerationUtils.ts"; import { applyGrokAcpModelSelection, @@ -54,7 +56,8 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadGroupName"; cwd: string; prompt: string; outputSchemaJson: S; @@ -263,10 +266,26 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateThreadGroupName: TextGeneration.TextGeneration["Service"]["generateThreadGroupName"] = + Effect.fn("GrokTextGeneration.generateThreadGroupName")(function* (input) { + const { prompt, outputSchema } = buildThreadGroupNamePrompt({ + threadTitles: input.threadTitles, + }); + const generated = yield* runGrokJson({ + operation: "generateThreadGroupName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { name: sanitizeThreadGroupName(generated.name) }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadGroupName, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 3ab0b4966dad..1e010f762638 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -19,12 +19,14 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildThreadGroupNamePrompt, } from "./TextGenerationPrompts.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, + sanitizeThreadGroupName, } from "./TextGenerationUtils.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; @@ -34,6 +36,7 @@ const OpenCodeTextGenerationOperation = Schema.Literals([ "generatePrContent", "generateBranchName", "generateThreadTitle", + "generateThreadGroupName", ]); type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; @@ -453,10 +456,26 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }; }); + const generateThreadGroupName: TextGeneration.TextGeneration["Service"]["generateThreadGroupName"] = + Effect.fn("OpenCodeTextGeneration.generateThreadGroupName")(function* (input) { + const { prompt, outputSchema } = buildThreadGroupNamePrompt({ + threadTitles: input.threadTitles, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateThreadGroupName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { name: sanitizeThreadGroupName(generated.name) }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateThreadGroupName, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index fd2bdbff711c..69f83bb4ca32 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -24,6 +24,8 @@ const makeStubTextGeneration = ( generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + generateThreadGroupName: () => + Effect.die("generateThreadGroupName stub not configured for this test"), ...overrides, }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 27fc28df7bf7..1d53eee1f1c6 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -77,6 +77,18 @@ export interface ThreadTitleGenerationResult { needsRefinement?: boolean | undefined; } +export interface ThreadGroupNameGenerationInput { + cwd: string; + /** Titles of the threads the group holds, in sidebar order. */ + threadTitles: ReadonlyArray; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface ThreadGroupNameGenerationResult { + name: string; +} + /** * TextGeneration - Service tag for commit and change request text generation. */ @@ -108,6 +120,11 @@ export class TextGeneration extends Context.Service< readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; + + /** Generate a short folder-style name for a group of threads from their titles. */ + readonly generateThreadGroupName: ( + input: ThreadGroupNameGenerationInput, + ) => Effect.Effect; } >()("t3/textGeneration/TextGeneration") {} @@ -115,7 +132,8 @@ type TextGenerationOp = | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateThreadGroupName"; const resolveInstance = ( registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], @@ -168,6 +186,10 @@ export const make = Effect.gen(function* () { }), ), ), + generateThreadGroupName: (input) => + resolveInstance(registry, "generateThreadGroupName", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateThreadGroupName(input)), + ), }); }); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index bf572a4bdbe0..56793fed6098 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -327,3 +327,34 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { return { prompt, outputSchema }; } + +// --------------------------------------------------------------------------- +// Thread group name +// --------------------------------------------------------------------------- + +export interface ThreadGroupNamePromptInput { + threadTitles: ReadonlyArray; +} + +const THREAD_GROUP_NAME_PROMPT = `Name a sidebar folder that will hold the T3 Code threads listed below. +Return JSON with the key name. + +Editorial rules: +- 1-4 words, fewer than 30 characters. +- Name the shared subject, feature area, or goal the threads have in common. +- Prefer a compact noun phrase. No verbs like "fix" or "add" unless every thread is that action. +- Do not copy one thread title. Do not number or list the threads. +- Avoid project names already visible in the UI, quotes, labels, filler, and trailing punctuation. +- If the threads share nothing recognizable, name the most common theme rather than refusing.`; + +export function buildThreadGroupNamePrompt(input: ThreadGroupNamePromptInput) { + const titles = input.threadTitles + .slice(0, 50) + .map((title) => `- ${title.replace(/\s+/g, " ").trim()}`) + .join("\n"); + const prompt = `${THREAD_GROUP_NAME_PROMPT}\n\nThread titles:\n${limitSection(titles, 6_000)}`; + const outputSchema = Schema.Struct({ + name: Schema.String, + }); + return { prompt, outputSchema }; +} diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index e590ac4a244a..8a6326f94bfa 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -1,4 +1,4 @@ -import { TextGenerationError } from "@t3tools/contracts"; +import { DEFAULT_THREAD_GROUP_NAME, TextGenerationError } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -121,3 +121,25 @@ export function normalizeCliError( cause: error, }); } + +const MAX_THREAD_GROUP_NAME_CHARS = 60; + +/** Normalise a raw group name to one short line. Empty input yields the default name. */ +export function sanitizeThreadGroupName(raw: string): string { + const normalized = raw + .trim() + .split(/\r?\n/g)[0] + ?.trim() + .replace(/^['"`]+|['"`]+$/g, "") + .replace(/[.!,;:]+$/g, "") + .trim() + .replace(/\s+/g, " "); + if (!normalized || normalized.length === 0) { + return DEFAULT_THREAD_GROUP_NAME; + } + // Models often answer in lowercase; a folder label reads as a title. + const capitalized = normalized.charAt(0).toUpperCase() + normalized.slice(1); + return capitalized.length <= MAX_THREAD_GROUP_NAME_CHARS + ? capitalized + : capitalized.slice(0, MAX_THREAD_GROUP_NAME_CHARS).trim(); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f31e62162b52..c6e1b44247e5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -68,6 +68,7 @@ import { AssetWorkspaceContextResolutionError, RpcClientId, EnvironmentAuthorizationError, + ThreadGroupId, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -867,6 +868,17 @@ const makeWsRpcLayer = ( ); case "thread.unarchived": return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); + case "thread-group.created": + case "thread-group.meta-updated": + return threadGroupUpsertOrRemove(ThreadGroupId.make(event.aggregateId), event.sequence); + case "thread-group.deleted": + return Effect.succeed( + Option.some({ + kind: "thread-group-removed" as const, + sequence: event.sequence, + threadGroupId: ThreadGroupId.make(event.aggregateId), + }), + ); default: if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); @@ -881,7 +893,7 @@ const makeWsRpcLayer = ( // If both attempts fail, log and drop the stream item; treating an error as // a missing row would incorrectly remove a still-active aggregate. const retryShellProjectionRead = ( - aggregateKind: "project" | "thread", + aggregateKind: "project" | "thread" | "thread-group", aggregateId: string, read: Effect.Effect, ): Effect.Effect, never, never> => @@ -927,6 +939,35 @@ const makeWsRpcLayer = ( ), ); + const threadGroupUpsertOrRemove = ( + threadGroupId: ThreadGroupId, + sequence: number, + ): Effect.Effect, never, never> => + retryShellProjectionRead( + "thread-group", + threadGroupId, + projectionSnapshotQuery.getThreadGroupShellById(threadGroupId), + ).pipe( + Effect.map( + Option.flatMap((threadGroup) => + Option.match(threadGroup, { + onNone: () => + Option.some({ + kind: "thread-group-removed" as const, + sequence, + threadGroupId, + }), + onSome: (nextThreadGroup) => + Option.some({ + kind: "thread-group-upserted" as const, + sequence, + threadGroup: nextThreadGroup, + }), + }), + ), + ), + ); + // Refetch a thread's shell and emit an upsert if it is still active, or a // `thread-removed` if the projection has no active row for it. Emitting a // removal on a `none` (rather than dropping the event) is what keeps @@ -2105,7 +2146,26 @@ const makeWsRpcLayer = ( }), synchronizedThenLive, ); - }), + }).pipe( + // Pre-groups clients fail to decode the group stream kinds, so + // only subscribers that opted in receive them. + Effect.map((stream) => + input.threadGroups === true + ? stream + : stream.pipe( + Stream.filter( + (item) => + item.kind !== "thread-group-upserted" && + item.kind !== "thread-group-removed", + ), + Stream.map((item): OrchestrationShellStreamItem => { + if (item.kind !== "snapshot") return item; + const { threadGroups: _threadGroups, ...snapshot } = item.snapshot; + return { kind: "snapshot" as const, snapshot }; + }), + ), + ), + ), { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: (_input) => diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index cf88d4965f09..5c05556538f6 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,4 +1,5 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { ProjectIconOverride } from "@t3tools/contracts"; import { getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, @@ -43,42 +44,10 @@ export function ProjectFavicon(input: { faviconPath: project.faviconPath, }), ); - if (project.projectIcon?.kind === "monogram") { - return ( - - ); - } - if (project.projectIcon?.kind === "emoji") { - return ( - - ); - } - if (project.projectIcon?.kind === "lucide") { - const colorClassName = projectIconColorClassName(project.projectIcon.color); - const iconClassName = cn( - "inline-flex size-3.5 shrink-0 items-center justify-center", - colorClassName, - input.className, - ); - return ( - - ); + if (project.projectIcon) { + // Called as a plain function (no hooks inside) so the rendered tree is + // exactly the glyph markup, with no extra component layer in between. + return ProjectIconOverrideGlyph({ icon: project.projectIcon, className: input.className }); } const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon; @@ -109,6 +78,44 @@ export function ProjectFavicon(input: { ); } +/** Renders a saved icon override on its own: projects and thread groups share + * the picker, so they share the glyph too. */ +export function ProjectIconOverrideGlyph({ + icon, + className, +}: { + readonly icon: ProjectIconOverride; + readonly className?: string | undefined; +}) { + if (icon.kind === "monogram") { + return ; + } + if (icon.kind === "emoji") { + return ( + + ); + } + const colorClassName = projectIconColorClassName(icon.color); + return ( + + ); +} + function ProjectFaviconFallback({ className, icon: Icon, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 27e47d131e0d..4c0e0a1c18eb 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -476,6 +476,72 @@ export function buildMultiSelectThreadContextMenuItems(input: { ]; } +export type ThreadGroupContextMenuId = + | "rename" + | "regenerate-name" + | "change-icon" + | "settle-all" + | "ungroup"; + +/** Right-click menu for a sidebar group header. */ +export function buildThreadGroupContextMenuItems(input: { + isNaming: boolean; + supportsNaming: boolean; + settleableCount: number; +}): readonly ContextMenuItem[] { + return [ + { id: "rename", label: "Rename group", icon: "pencil" }, + ...(input.supportsNaming + ? [ + { + id: "regenerate-name" as const, + label: input.isNaming ? "Naming…" : "Generate name", + icon: "refresh-cw", + disabled: input.isNaming, + }, + ] + : []), + { id: "change-icon", label: "Change icon and color…", icon: "folder" }, + ...(input.settleableCount > 0 + ? [ + { + id: "settle-all" as const, + label: `Settle threads (${input.settleableCount})`, + icon: "circle-check", + separatorBefore: true, + }, + ] + : []), + { id: "ungroup", label: "Ungroup", icon: "folder-tree", separatorBefore: true }, + ]; +} + +/** + * Bulk grouping offers "Group threads" only when every selected row can join + * one group: same environment, same project, and a server that has groups. + * "Remove from group" counts only rows that are actually grouped. + */ +export function buildBulkGroupContextMenuItems(input: { + count: number; + canGroupTogether: boolean; + groupedCount: number; + shortcutLabel: string | null; +}): readonly ContextMenuItem<"group" | "ungroup">[] { + return [ + ...(input.canGroupTogether + ? [ + { + id: "group" as const, + label: `Group threads (${input.count})${input.shortcutLabel ? ` ${input.shortcutLabel}` : ""}`, + }, + ] + : []), + ...(input.groupedCount > 0 + ? [{ id: "ungroup" as const, label: `Remove from group (${input.groupedCount})` }] + : []), + ]; +} + export function buildBulkTitleRegenerationContextMenuItem(input: { supportedCount: number; actionableCount: number; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b11032987fd4..3b4b030c86bd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,7 +22,10 @@ import { threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { + EnvironmentThreadGroup, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/models"; import { parseScopedThreadKey, scopeProjectRef, @@ -34,6 +37,7 @@ import { type EnvironmentMachineKind, type ProjectIconOverride, type ScopedThreadRef, + type ThreadGroupId, type ThreadId, } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; @@ -61,7 +65,9 @@ import { XIcon, } from "lucide-react"; import { + lazy, memo, + Suspense, useCallback, useEffect, useLayoutEffect, @@ -128,6 +134,7 @@ import { readThreadShell, useAllEnvironmentProjectSnapshotsReady, useProjects, + useThreadGroups, useThreadShells, } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -146,12 +153,16 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; -import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { buildThreadActionMenuItems, threadGroupIdFromMenuId } from "./threadActionMenu.logic"; +import { SidebarThreadGroupRow } from "./sidebar/SidebarThreadGroupRow"; +import { newThreadGroupId } from "~/lib/utils"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, + buildBulkGroupContextMenuItems, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, + buildThreadGroupContextMenuItems, deleteSelectedThreadEntries, filterSidebarProjectScopeItems, formatWorkingDurationLabel, @@ -252,6 +263,28 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Fresh keys deliberately reset both shelves to collapsed for existing users. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; +// Per-group fold state, keyed by scoped group key. Groups start collapsed: +// a fresh group is a way to tuck work away, not to spread it out. +const THREAD_GROUPS_EXPANDED_KEY = "t3code:sidebar:thread-groups-expanded"; +const ThreadGroupsExpandedSchema = Schema.Record(Schema.String, Schema.Boolean); +const EMPTY_THREAD_GROUPS_EXPANDED: Record = {}; + +const ProjectIconPickerDialog = lazy(() => + import("./settings/ProjectIconPickerDialog").then((module) => ({ + default: module.ProjectIconPickerDialog, + })), +); + +type SidebarThreadGroupRowEntry = { + readonly thread: EnvironmentThreadShell; + readonly section: SidebarSection; +}; +type SidebarThreadGroupBlock = { + readonly key: string; + readonly group: EnvironmentThreadGroup; + /** Every visible member, pinned first, then active, snoozed, settled. */ + readonly rows: readonly SidebarThreadGroupRowEntry[]; +}; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -2132,6 +2165,7 @@ export default function Sidebar() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); + const threadGroups = useThreadGroups(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -2156,6 +2190,16 @@ export default function Sidebar() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const createThreadGroup = useAtomCommand(threadEnvironment.createGroup, { + reportFailure: false, + }); + const updateThreadGroup = useAtomCommand(threadEnvironment.updateGroup, { + reportFailure: false, + }); + const deleteThreadGroup = useAtomCommand(threadEnvironment.deleteGroup, { + reportFailure: false, + }); + const setThreadGroup = useAtomCommand(threadEnvironment.setGroup, { reportFailure: false }); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ @@ -2517,6 +2561,7 @@ export default function Sidebar() { activeThreads, snoozedThreads, settledThreads, + threadGroupBlocks, snoozeNow, } = useMemo(() => { // Snooze classification uses a REAL clock, not the quantized minute: @@ -2537,8 +2582,42 @@ export default function Sidebar() { const settled: EnvironmentThreadShell[] = []; const draggable = new Set(); const activeReorderable = new Set(); + // Grouped threads leave the flat sections entirely: a group holds its + // own pinned/active/snoozed/settled members so parked work folds away + // with the group instead of stretching the shared shelves. + type GroupBucket = { + group: EnvironmentThreadGroup; + pinned: EnvironmentThreadShell[]; + active: EnvironmentThreadShell[]; + snoozed: EnvironmentThreadShell[]; + settled: EnvironmentThreadShell[]; + }; + const groupByKey = new Map(); + for (const group of threadGroups) { + if ( + scopedProjectKeys !== null && + !scopedProjectKeys.has(`${group.environmentId}:${group.projectId}`) + ) { + continue; + } + if (serverConfigs.get(group.environmentId)?.environment.capabilities.threadGroups !== true) { + continue; + } + groupByKey.set(`${group.environmentId}:${group.id}`, { + group, + pinned: [], + active: [], + snoozed: [], + settled: [], + }); + } for (const thread of visible) { const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; + const bucket = + thread.groupId == null + ? undefined + : groupByKey.get(`${thread.environmentId}:${thread.groupId}`); + const target = bucket ?? { pinned, active, snoozed, settled }; // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user // could neither un-settle nor pin them, so auto-settling them would @@ -2546,13 +2625,20 @@ export default function Sidebar() { const supportsSettlement = capabilities?.threadSettlement === true; const supportsSnooze = capabilities?.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey); + // Grouped rows are not part of the sortable list, so they never drag. + if (bucket === undefined && capabilities?.threadActiveReorder === true) { + activeReorderable.add(threadKey); + } // Older servers retain their existing drag actions. Active placement // additionally requires its own ordering capability at the drop target. - if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) { + if ( + bucket === undefined && + capabilities?.threadPinning === true && + capabilities.threadPinReorder === true + ) { draggable.add(threadKey); } - if (optimisticDrop?.key === threadKey) { + if (bucket === undefined && optimisticDrop?.key === threadKey) { const projected = applySidebarThreadDrop( thread, optimisticDrop.section, @@ -2571,15 +2657,55 @@ export default function Sidebar() { ); } else if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { // Snooze outranks settlement and pinning until the thread wakes. - snoozed.push(thread); + target.snoozed.push(thread); } else if (supportsSettlement && thread.settledOverride === "settled") { - settled.push(thread); + target.settled.push(thread); } else if (thread.pinnedAt != null) { - pinned.push(thread); + target.pinned.push(thread); } else { - active.push(thread); + target.active.push(thread); } } + const bySoonestWake = (left: EnvironmentThreadShell, right: EnvironmentThreadShell) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null); + // Empty groups (every member archived or deleted) stay out of the list; + // the server drops a group when its last member is moved out. + const groupBlocks: SidebarThreadGroupBlock[] = [...groupByKey.entries()] + .filter( + ([, bucket]) => + bucket.pinned.length + + bucket.active.length + + bucket.snoozed.length + + bucket.settled.length > + 0, + ) + .sort( + ([, left], [, right]) => + left.group.createdAt.localeCompare(right.group.createdAt) || + left.group.id.localeCompare(right.group.id), + ) + .map(([key, bucket]) => { + const rows: SidebarThreadGroupRowEntry[] = [ + ...sortPinnedThreadsForSidebar(bucket.pinned).map((thread) => ({ + thread, + section: "pinned" as const, + })), + ...sortThreadsForSidebar(bucket.active).map((thread) => ({ + thread, + section: "active" as const, + })), + ...bucket.snoozed.toSorted(bySoonestWake).map((thread) => ({ + thread, + section: "snoozed" as const, + })), + ...sortSettledThreadsForSidebar(bucket.settled).map((thread) => ({ + thread, + section: "settled" as const, + })), + ]; + return { key, group: bucket.group, rows }; + }); // One shared rule on every platform (see sortPinnedThreadsByOrderKey): // user-arranged keys first, keyless threads in creation order below. // Server capability only gates DRAGGING — it must not influence the @@ -2607,23 +2733,38 @@ export default function Sidebar() { getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }), // Soonest wake first: "what comes back next" is the shelf's question. - snoozedThreads: snoozed.toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ), + snoozedThreads: snoozed.toSorted(bySoonestWake), settledThreads: sortSettledThreadsForSidebar(settled), + threadGroupBlocks: groupBlocks, snoozeNow: preciseNow, }; - }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); + }, [ + nowMinute, + optimisticDrop, + scopedProjectKeys, + serverConfigs, + snoozeWakeTick, + threadGroups, + threads, + ]); + const groupedThreadCount = useMemo( + () => threadGroupBlocks.reduce((total, block) => total + block.rows.length, 0), + [threadGroupBlocks], + ); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const isSearchingThreads = threadSearchQuery.trim().length > 0; const searchableThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], - [activeThreads, pinnedThreads, settledThreads, snoozedThreads], + () => [ + ...threadGroupBlocks.flatMap((block) => block.rows.map((row) => row.thread)), + ...pinnedThreads, + ...activeThreads, + ...snoozedThreads, + ...settledThreads, + ], + [activeThreads, pinnedThreads, settledThreads, snoozedThreads, threadGroupBlocks], ); const threadSearchResults = useMemo( () => searchSidebarThreads(searchableThreads, threadSearchQuery), @@ -2648,10 +2789,17 @@ export default function Sidebar() { // moment a snooze expires instead of on the next minute tick. Sorted // soonest-first, so entry 0 is the boundary. useEffect(() => { - const nextWakeAtMs = - snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null - ? Date.parse(snoozedThreads[0].snoozedUntil) - : Number.NaN; + let nextWakeAtMs = Number.NaN; + const consider = (thread: EnvironmentThreadShell) => { + if (thread.snoozedUntil == null) return; + const wakeAtMs = Date.parse(thread.snoozedUntil); + if (Number.isNaN(wakeAtMs)) return; + nextWakeAtMs = Number.isNaN(nextWakeAtMs) ? wakeAtMs : Math.min(nextWakeAtMs, wakeAtMs); + }; + if (snoozedThreads[0]) consider(snoozedThreads[0]); + for (const block of threadGroupBlocks) { + for (const row of block.rows) if (row.section === "snoozed") consider(row.thread); + } if (Number.isNaN(nextWakeAtMs)) return; // setTimeout delays are signed 32-bit: anything larger overflows and // fires immediately, turning a far-future wake (event-condition snoozes @@ -2660,7 +2808,7 @@ export default function Sidebar() { const delayMs = Math.min(Math.max(0, nextWakeAtMs - Date.now()) + 50, 2_147_483_647); const id = window.setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); return () => window.clearTimeout(id); - }, [snoozedThreads]); + }, [snoozedThreads, threadGroupBlocks]); // The settled tail renders in pages: history shouldn't dominate the // sidebar, and the common lookups are recent. Expansion resets when the @@ -2740,9 +2888,79 @@ export default function Sidebar() { return routeThread === undefined ? EMPTY_THREADS : [routeThread]; }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); + const threadGroupsRef = useRef(threadGroups); + threadGroupsRef.current = threadGroups; + const threadGroupBlocksRef = useRef(threadGroupBlocks); + threadGroupBlocksRef.current = threadGroupBlocks; + const [threadGroupsExpanded, setThreadGroupsExpanded] = useLocalStorage( + THREAD_GROUPS_EXPANDED_KEY, + EMPTY_THREAD_GROUPS_EXPANDED, + ThreadGroupsExpandedSchema, + ); + const toggleThreadGroup = useCallback( + (group: EnvironmentThreadGroup) => { + const key = `${group.environmentId}:${group.id}`; + // Keys of groups that no longer exist are dropped on the way, so the + // record cannot grow with every group ever created. Only environments + // with a live group list are pruned: a disconnected one keeps its folds. + const known = new Set(threadGroupsRef.current.map((g) => `${g.environmentId}:${g.id}`)); + const knownEnvironments = new Set( + threadGroupsRef.current.map((g) => g.environmentId), + ); + setThreadGroupsExpanded((value) => ({ + ...Object.fromEntries( + Object.entries(value).filter( + ([k]) => known.has(k) || !knownEnvironments.has(k.slice(0, k.indexOf(":"))), + ), + ), + [key]: !(value[key] ?? false), + })); + }, + [setThreadGroupsExpanded], + ); + // A collapsed group still shows the open thread, like the shelves: the + // route's row must never vanish behind a fold. + const renderedThreadGroups = useMemo( + () => + threadGroupBlocks.map((block) => { + const expanded = threadGroupsExpanded[block.key] === true; + const rows = expanded + ? block.rows + : routeThreadKey === null + ? [] + : block.rows.filter( + (row) => + scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)) === + routeThreadKey, + ); + // Mirrors the member rows: dismissed stack links stay hidden, and a + // legacy or branch link counts once its badge can render. + const pullRequestCount = block.rows.filter( + (row) => + resolveThreadCurrentPullRequestLink(row.thread.pullRequests) !== null || + row.thread.linkedPullRequest != null || + row.thread.branchPullRequest != null, + ).length; + return { ...block, expanded, renderedRows: rows, pullRequestCount }; + }), + [routeThreadKey, threadGroupBlocks, threadGroupsExpanded], + ); + const orderedThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], - [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], + () => [ + ...renderedThreadGroups.flatMap((block) => block.renderedRows.map((row) => row.thread)), + ...pinnedThreads, + ...activeThreads, + ...visibleSnoozedThreads, + ...renderedSettledThreads, + ], + [ + renderedThreadGroups, + pinnedThreads, + activeThreads, + visibleSnoozedThreads, + renderedSettledThreads, + ], ); const orderedThreadKeys = useMemo( () => @@ -2778,24 +2996,35 @@ export default function Sidebar() { handleNewThreadRef.current = newThreadContext.handleNewThread; const settledThreadKeys = useMemo( () => - new Set( - settledThreads.map((thread) => + new Set([ + ...settledThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), - ), - [settledThreads], + ...threadGroupBlocks.flatMap((block) => + block.rows + .filter((row) => row.section === "settled") + .map((row) => scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id))), + ), + ]), + [settledThreads, threadGroupBlocks], ); const settledThreadKeysRef = useRef(settledThreadKeys); settledThreadKeysRef.current = settledThreadKeys; const snoozedThreadKeys = useMemo( () => - new Set( - snoozedThreads.map((thread) => + new Set([ + ...snoozedThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), - ), - [snoozedThreads], + ...threadGroupBlocks.flatMap((block) => + block.rows + .filter((row) => row.section === "snoozed") + .map((row) => scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id))), + ), + ]), + [snoozedThreads, threadGroupBlocks], ); + const snoozedThreadKeysRef = useRef(snoozedThreadKeys); snoozedThreadKeysRef.current = snoozedThreadKeys; @@ -3378,14 +3607,26 @@ export default function Sidebar() { // rendered order or a row's section changes. Keying the motion pass on that // keeps ordinary updates from forcing a layout read and animating rows // whose position drifted for other reasons. + // Group blocks are siblings of the flat rows in the same measured list, so + // a fold, a membership change, or a group appearing shifts every row below + // it and must refresh the motion baseline too. const sidebarListOrderKey = useMemo( () => - sidebarListItems - .map((item) => (item.kind === "thread" ? `${item.key}:${item.section}` : item.marker)) - .join("\0"), - [sidebarListItems], + [ + ...renderedThreadGroups.map( + (block) => + `${block.key}:${block.expanded ? "open" : "closed"}:${block.renderedRows + .map((row) => `${row.thread.id}:${row.section}`) + .join(",")}`, + ), + ...sidebarListItems.map((item) => + item.kind === "thread" ? `${item.key}:${item.section}` : item.marker, + ), + ].join("\0"), + [renderedThreadGroups, sidebarListItems], ); - const sidebarListHasRows = sidebarListItems.length + visibleDraftSessionCount > 0; + const sidebarListHasRows = + sidebarListItems.length + renderedThreadGroups.length + visibleDraftSessionCount > 0; useLayoutEffect(() => { // Drag release clears the baseline, so its commit cannot replay the // sortable preview; rows glide from their released positions instead. @@ -3756,6 +3997,258 @@ export default function Sidebar() { [attemptUnsnooze, performSnooze, timestampFormat], ); + // Group creation from a selection (or the open thread via ⌘G). Every member + // must share one environment and project: a group is a project-scoped + // folder, and the server rejects cross-project members. + const groupThreadsTogether = useCallback( + async (selected: readonly EnvironmentThreadShell[]) => { + const first = selected[0]; + if (!first) return; + if ( + !selected.every( + (thread) => + thread.environmentId === first.environmentId && thread.projectId === first.projectId, + ) + ) { + toastManager.add({ + type: "warning", + title: "Select threads from one project to group them", + }); + return; + } + if (serverConfigs.get(first.environmentId)?.environment.capabilities.threadGroups !== true) { + toastManager.add({ + type: "warning", + title: "Thread groups need a newer server", + description: "Update the T3 Code server in this environment to group threads.", + }); + return; + } + const groupId = newThreadGroupId(); + // Remembered so Undo can put re-filed threads back where they were. + const previousGroupIds = new Map( + selected.map((thread) => [thread.id, thread.groupId ?? null] as const), + ); + clearSelection(); + const result = await createThreadGroup({ + environmentId: first.environmentId, + input: { + groupId, + projectId: first.projectId, + threadIds: selected.map((thread) => thread.id), + generateName: true, + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to group threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Grouped ${selected.length} thread${selected.length === 1 ? "" : "s"}`, + description: "Naming the group from its threads…", + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + // Only the threads this action grouped, and only those still in + // this group, go back to their previous group (or none); anything + // filed elsewhere meanwhile stays, and the server retires the + // group once it is empty. + // A previous group emptied by this action was retired with it, + // so restoring into it would be rejected; those threads ungroup. + const liveGroupKeys = new Set( + threadGroupsRef.current.map((group) => `${group.environmentId}:${group.id}`), + ); + for (const thread of selected) { + const current = readThreadShell(scopeThreadRef(thread.environmentId, thread.id)); + if (current === null || current.groupId !== groupId) continue; + const previousGroupId = previousGroupIds.get(thread.id) ?? null; + const restoreGroupId = + previousGroupId !== null && + liveGroupKeys.has(`${thread.environmentId}:${previousGroupId}`) + ? previousGroupId + : null; + void setThreadGroup({ + environmentId: thread.environmentId, + input: { threadId: thread.id, groupId: restoreGroupId }, + }).then((undone) => { + if (undone._tag !== "Failure" || isAtomCommandInterrupted(undone)) return; + const error = squashAtomCommandFailure(undone); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to undo grouping", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + } + }, + }, + }), + ); + }, + [clearSelection, createThreadGroup, serverConfigs, setThreadGroup], + ); + const groupThreadsTogetherRef = useRef(groupThreadsTogether); + groupThreadsTogetherRef.current = groupThreadsTogether; + const moveThreadToGroup = useCallback( + async (thread: EnvironmentThreadShell, groupId: ThreadGroupId | null) => { + const result = await setThreadGroup({ + environmentId: thread.environmentId, + input: { threadId: thread.id, groupId }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: + groupId === null ? "Failed to remove thread from group" : "Failed to move thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [setThreadGroup], + ); + + const [renamingGroupKey, setRenamingGroupKey] = useState(null); + const [renamingGroupName, setRenamingGroupName] = useState(""); + const startThreadGroupRename = useCallback((group: EnvironmentThreadGroup) => { + setRenamingGroupKey(`${group.environmentId}:${group.id}`); + setRenamingGroupName(group.name); + }, []); + const cancelThreadGroupRename = useCallback(() => setRenamingGroupKey(null), []); + const commitThreadGroupRename = useCallback( + (group: EnvironmentThreadGroup, name: string) => { + void (async () => { + const trimmed = name.trim(); + setRenamingGroupKey(null); + if (trimmed.length === 0) { + toastManager.add({ type: "warning", title: "Group name cannot be empty" }); + return; + } + if (trimmed === group.name) return; + const result = await updateThreadGroup({ + environmentId: group.environmentId, + input: { groupId: group.id, name: trimmed }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename group", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [updateThreadGroup], + ); + const [iconPickerGroup, setIconPickerGroup] = useState(null); + const handleThreadGroupContextMenu = useCallback( + (group: EnvironmentThreadGroup, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + if (!api) return; + const block = threadGroupBlocksRef.current.find( + (candidate) => candidate.key === `${group.environmentId}:${group.id}`, + ); + const supportsSettlement = + serverConfigs.get(group.environmentId)?.environment.capabilities.threadSettlement === + true; + const settleable = + block?.rows.filter( + (row) => supportsSettlement && row.section !== "settled" && row.section !== "snoozed", + ) ?? []; + const clicked = await settlePromise(() => + api.contextMenu.show( + buildThreadGroupContextMenuItems({ + isNaming: group.nameGeneration != null, + supportsNaming: true, + settleableCount: settleable.length, + }), + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "rename": + startThreadGroupRename(group); + return; + case "regenerate-name": { + if (group.nameGeneration != null) return; + const result = await updateThreadGroup({ + environmentId: group.environmentId, + input: { groupId: group.id, regenerateName: true }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to generate group name", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + case "change-icon": + setIconPickerGroup(group); + return; + case "settle-all": { + const coSettlingKeys = new Set( + settleable.map((row) => + scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)), + ), + ); + for (const row of settleable) { + attemptSettle(scopeThreadRef(row.thread.environmentId, row.thread.id), { + coSettlingKeys, + }); + } + return; + } + case "ungroup": { + const result = await deleteThreadGroup({ + environmentId: group.environmentId, + input: { groupId: group.id }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to ungroup threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + default: + return; + } + })(); + }, + [attemptSettle, deleteThreadGroup, serverConfigs, startThreadGroupRename, updateThreadGroup], + ); + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( async (position: { x: number; y: number }) => { @@ -3807,9 +4300,31 @@ export default function Sidebar() { pinnedCount: pinnedSelectedThreads.length, }); const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const firstSelected = selectedThreads[0]; + const canGroupTogether = + firstSelected !== undefined && + serverConfigs.get(firstSelected.environmentId)?.environment.capabilities.threadGroups === + true && + selectedThreads.every( + (thread) => + thread.environmentId === firstSelected.environmentId && + thread.projectId === firstSelected.projectId, + ); + const groupedSelectedThreads = selectedThreads.filter( + (thread) => + thread.groupId != null && + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadGroups === true, + ); + const groupMenuItems = buildBulkGroupContextMenuItems({ + count, + canGroupTogether, + groupedCount: groupedSelectedThreads.length, + shortcutLabel: shortcutLabelForCommand(keybindings, "thread.group"), + }); const clicked = await settlePromise(() => api.contextMenu.show( [ + ...groupMenuItems, ...(unpinMenuItem ? [unpinMenuItem] : []), { id: "settle", label: `Settle (${count})` }, ...(canSnoozeSelection @@ -3896,6 +4411,17 @@ export default function Sidebar() { } return; } + if (clicked.value === "group") { + await groupThreadsTogether(selectedThreads); + return; + } + if (clicked.value === "ungroup") { + clearSelection(); + for (const thread of groupedSelectedThreads) { + void moveThreadToGroup(thread, null); + } + return; + } if (clicked.value === "unpin") { // Each unpin reports its own failure, like the single-row action. for (const thread of pinnedSelectedThreads) { @@ -4003,6 +4529,9 @@ export default function Sidebar() { attemptUnsnooze, updateThreadMetadata, timestampFormat, + groupThreadsTogether, + keybindings, + moveThreadToGroup, ], ); @@ -4040,6 +4569,8 @@ export default function Sidebar() { const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); const isPinned = thread.pinnedAt != null; + const supportsGroups = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadGroups === true; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); const clicked = await settlePromise(() => @@ -4060,6 +4591,23 @@ export default function Sidebar() { titleRegeneration: supportsTitleRegeneration, }, snoozePresets, + ...(supportsGroups + ? { + groups: { + currentGroupId: thread.groupId ?? null, + // Visible folders only, so a thread cannot be filed + // into a group the sidebar does not show. + options: threadGroupBlocksRef.current + .map((block) => block.group) + .filter( + (group) => + group.environmentId === thread.environmentId && + group.projectId === thread.projectId, + ) + .map((group) => ({ id: group.id, name: group.name })), + }, + } + : {}), }), position, ), @@ -4073,6 +4621,19 @@ export default function Sidebar() { if (preset) attemptSnooze(threadRef, preset); return; } + if (clicked.value === "group:new") { + await groupThreadsTogether([thread]); + return; + } + if (clicked.value === "group:none") { + await moveThreadToGroup(thread, null); + return; + } + if (clicked.value?.startsWith("group:")) { + const groupId = threadGroupIdFromMenuId(clicked.value); + if (groupId !== null) await moveThreadToGroup(thread, groupId); + return; + } switch (clicked.value) { case "project-settings": { const projectGroup = projectGroupsRef.current.find((group) => @@ -4250,6 +4811,8 @@ export default function Sidebar() { startThreadRename, updateThreadMetadata, timestampFormat, + groupThreadsTogether, + moveThreadToGroup, ], ); @@ -4282,6 +4845,26 @@ export default function Sidebar() { navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); return true; }; + if (command === "thread.group") { + // Selection first; with nothing selected the open thread starts a + // group of one, which the "Move to group" menu can grow later. + // Resolve from the shell store, not the rendered rows: a selected + // member folded away by its group must still join the new group. + const selectedKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + const selected = selectedKeys.flatMap((threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + const thread = threadRef === null ? null : readThreadShell(threadRef); + return thread ? [thread] : []; + }); + const routeThread = + routeThreadKey === null ? null : (threadByKey.get(routeThreadKey) ?? null); + const targets = selected.length > 0 ? selected : routeThread ? [routeThread] : []; + if (targets.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + void groupThreadsTogetherRef.current(targets); + return; + } const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { navigateToThreadKey( @@ -4760,6 +5343,34 @@ export default function Sidebar() { onNavigateToDraft={navigateToDraft} />, ]; + // Groups sit above the flat sections, folders first. + // Their rows are plain thread rows (no sortable bag), so + // the drag list below never has to reason about them. + for (const block of renderedThreadGroups) { + items.push( + + {block.renderedRows.length > 0 + ? block.renderedRows.map((row) => + renderThreadRowInner(row.thread, row.section), + ) + : null} + , + ); + } for (const item of sidebarListItems) { if (item.kind === "thread") { items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); @@ -4885,7 +5496,8 @@ export default function Sidebar() { ) : null} {!isSearchingThreads && visibleDraftSessionCount === 0 && - pinnedThreads.length + + groupedThreadCount + + pinnedThreads.length + activeThreads.length + snoozedThreads.length + settledThreads.length === @@ -4913,6 +5525,36 @@ export default function Sidebar() { + {iconPickerGroup ? ( + + { + if (!open) setIconPickerGroup(null); + }} + onSelect={(icon) => { + const group = iconPickerGroup; + void updateThreadGroup({ + environmentId: group.environmentId, + input: { groupId: group.id, icon }, + }).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to change group icon", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }} + /> + + ) : null} ); } diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx index f2474e531c07..321869ca8243 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -46,12 +46,15 @@ export function ProjectIconPickerDialog({ open, onOpenChange, onSelect, + title = "Choose project icon", }: { readonly current: ProjectIconOverride | null; readonly projectName: string; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly onSelect: (icon: ProjectIconOverride) => void; + /** Thread groups reuse this picker; the heading names what is being decorated. */ + readonly title?: string; }) { const automatic = deriveProjectIdentity(projectName); const [mode, setMode] = useState(current?.kind ?? "lucide"); @@ -102,7 +105,7 @@ export function ProjectIconPickerDialog({ - Choose project icon + {title} Choose an icon, emoji, or monogram. diff --git a/apps/web/src/components/sidebar/SidebarThreadGroupRow.tsx b/apps/web/src/components/sidebar/SidebarThreadGroupRow.tsx new file mode 100644 index 000000000000..9e72588991b3 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadGroupRow.tsx @@ -0,0 +1,238 @@ +import type { EnvironmentThreadGroup } from "@t3tools/client-runtime/state/shell"; +import { FolderIcon } from "lucide-react"; +import { + memo, + useCallback, + useEffect, + useRef, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; + +import { projectIconColorClassName, projectIconTintClassName } from "../../projectIconColors"; +import { cn } from "~/lib/utils"; +import { ProjectIconOverrideGlyph } from "../ProjectFavicon"; +import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; + +// Longest gap between the clicks of a double-click on common platforms. +const DOUBLE_CLICK_WINDOW_MS = 500; + +/** + * A user-made folder in the sidebar: a tinted card carrying the group's color + * with a header row that toggles its member thread rows. Members render as + * ordinary thread rows nested inside the card, so every row affordance + * (status, settle, snooze, selection) works unchanged inside a group. + */ +export const SidebarThreadGroupRow = memo(function SidebarThreadGroupRow(props: { + group: EnvironmentThreadGroup; + count: number; + /** Members that carry a pull request, shown on the header while collapsed. */ + pullRequestCount: number; + expanded: boolean; + isRenaming: boolean; + renamingName: string; + onToggle: (group: EnvironmentThreadGroup) => void; + onContextMenu: (group: EnvironmentThreadGroup, position: { x: number; y: number }) => void; + onStartRename: (group: EnvironmentThreadGroup) => void; + onRenameNameChange: (name: string) => void; + onCommitRename: (group: EnvironmentThreadGroup, name: string) => void; + onCancelRename: () => void; + children?: ReactNode; +}) { + const { + group, + isRenaming, + onCancelRename, + onCommitRename, + onContextMenu, + onRenameNameChange, + onStartRename, + onToggle, + renamingName, + } = props; + const isNaming = group.nameGeneration != null; + const color = group.icon && group.icon.kind !== "emoji" ? group.icon.color : null; + const nameClassName = color ? projectIconColorClassName(color) : "text-sidebar-foreground"; + // The card's surface comes from the group color; a group + // without a color takes a neutral tint so it still reads as a container. + const tintClassName = color ? projectIconTintClassName(color) : "bg-sidebar-foreground/[0.05]"; + // The header's label replaces its children for assistive tech, so the + // member and collapsed-only pull request counts are spelled out here. + const headerLabel = [ + `${group.name} group, ${props.count} thread${props.count === 1 ? "" : "s"}`, + ...(!props.expanded && props.pullRequestCount > 0 + ? [`${props.pullRequestCount} pull request${props.pullRequestCount === 1 ? "" : "s"}`] + : []), + ].join(", "); + // Folds made by the clicks of a rename double-click, so the double-click can + // undo them. Touch reports every tap as a first click, so this counts folds + // instead of trusting `event.detail`. + const foldClicksRef = useRef({ count: 0, at: 0 }); + const handleClick = useCallback( + (event: ReactMouseEvent) => { + // A click beside the text box commits the rename; it must not fold too. + if (isRenaming || (event.target as HTMLElement).closest("input")) return; + // The second click of a mouse double-click must not fold the group back. + if (event.detail > 1) return; + const now = event.timeStamp; + const clicks = foldClicksRef.current; + foldClicksRef.current = { + count: now - clicks.at < DOUBLE_CLICK_WINDOW_MS ? clicks.count + 1 : 1, + at: now, + }; + onToggle(group); + }, + [group, isRenaming, onToggle], + ); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onToggle(group); + }, + [group, onToggle], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onContextMenu(group, { x: event.clientX, y: event.clientY }); + }, + [group, onContextMenu], + ); + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if ((event.target as HTMLElement).closest("input")) return; + event.preventDefault(); + // Put back whatever the clicks of this pair folded so renaming never + // changes the fold. + if (foldClicksRef.current.count % 2 === 1) onToggle(group); + foldClicksRef.current = { count: 0, at: 0 }; + onStartRename(group); + }, + [group, isRenaming, onStartRename, onToggle], + ); + const renameCommittedRef = useRef(false); + useEffect(() => { + if (isRenaming) renameCommittedRef.current = false; + }, [isRenaming]); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter") { + event.preventDefault(); + renameCommittedRef.current = true; + onCommitRename(group, renamingName); + } else if (event.key === "Escape") { + event.preventDefault(); + renameCommittedRef.current = true; + onCancelRename(); + } + }, + [group, onCancelRename, onCommitRename, renamingName], + ); + const handleRenameBlur = useCallback(() => { + if (!renameCommittedRef.current) onCommitRename(group, renamingName); + }, [group, onCommitRename, renamingName]); + + return ( +
  • +
    + + {group.icon ? ( + + ) : ( + + )} + + {isRenaming ? ( + onRenameNameChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" + /> + ) : ( + + {group.name} + + )} + {isNaming ? ( + + Generating group name + + ) : null} + {!props.expanded && props.pullRequestCount > 0 ? ( + + + {props.pullRequestCount} + + ) : null} +
    + {props.children ? ( +
      + {props.children} +
    + ) : null} +
  • + ); +}); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 35b14ec44397..cd2f19713c1e 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -1,4 +1,4 @@ -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { ContextMenuItem, ThreadGroupId } from "@t3tools/contracts"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; /** @@ -16,6 +16,10 @@ export type ThreadActionMenuId = | "snooze" | `snooze:${string}` | "unsnooze" + | "move-to-group" + | "group:new" + | "group:none" + | `group:${string}` | "rename" | "regenerate-title" | "mark-unread" @@ -42,6 +46,26 @@ export interface ThreadActionMenuState { readonly titleRegeneration: boolean; }; readonly snoozePresets: ReadonlyArray; + /** Sidebar groups in this thread's project. Absent on servers without + thread groups (or surfaces that do not offer them), which hides the item. */ + readonly groups?: { + readonly currentGroupId: ThreadGroupId | null; + readonly options: ReadonlyArray<{ readonly id: ThreadGroupId; readonly name: string }>; + }; +} + +// Group ids live under their own prefix so they can never collide with the +// `group:new` / `group:none` action ids. +const THREAD_GROUP_MENU_ID_PREFIX = "group:id:"; + +function threadGroupMenuIdForGroup(groupId: ThreadGroupId): `group:${string}` { + return `${THREAD_GROUP_MENU_ID_PREFIX}${groupId}`; +} + +/** Group id from a `group:id:` menu id, or null for the new/none choices. */ +export function threadGroupIdFromMenuId(id: string): ThreadGroupId | null { + if (!id.startsWith(THREAD_GROUP_MENU_ID_PREFIX)) return null; + return id.slice(THREAD_GROUP_MENU_ID_PREFIX.length) as ThreadGroupId; } /** @@ -98,6 +122,33 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(state.groups + ? [ + { + id: "move-to-group" as const, + label: "Move to group", + icon: "folder", + children: [ + { id: "group:new" as const, label: "New group…" }, + ...state.groups.options + .filter((group) => group.id !== state.groups?.currentGroupId) + .map((group) => ({ + id: threadGroupMenuIdForGroup(group.id), + label: group.name, + })), + ...(state.groups.currentGroupId !== null + ? [ + { + id: "group:none" as const, + label: "Remove from group", + separatorBefore: true, + }, + ] + : []), + ], + }, + ] + : []), { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, ...(state.supports.titleRegeneration ? [ diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 7a33b336839e..2bea439a1d2a 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -9,11 +9,12 @@ import { import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, + threadGroupIdFromMenuId, type ThreadActionMenuId, } from "../components/threadActionMenu.logic"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; @@ -23,10 +24,14 @@ import { readEnvironmentSupportsPinning, readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, + readEnvironmentSupportsThreadGroups, readEnvironmentSupportsTitleRegeneration, readThreadShell, + readThreadShells, useProjects, + useThreadGroups, } from "../state/entities"; +import { newThreadGroupId } from "~/lib/utils"; import { usePrimaryEnvironmentId } from "../state/environments"; import { readLocalApi } from "../localApi"; import { @@ -94,6 +99,14 @@ export function useThreadActionMenu(input: { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const threadGroups = useThreadGroups(); + // Read at menu-open time so the callback does not churn on every group change. + const threadGroupsRef = useRef(threadGroups); + threadGroupsRef.current = threadGroups; + const createThreadGroup = useAtomCommand(threadEnvironment.createGroup, { + reportFailure: false, + }); + const setThreadGroup = useAtomCommand(threadEnvironment.setGroup, { reportFailure: false }); const handleNewThread = useNewThreadHandler(); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); @@ -138,6 +151,8 @@ export function useThreadActionMenu(input: { }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const supportsGroups = readEnvironmentSupportsThreadGroups(threadRef.environmentId); + const threadShells = supportsGroups ? readThreadShells() : []; const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, isPinned: thread.pinnedAt != null, @@ -148,6 +163,28 @@ export function useThreadActionMenu(input: { isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, supports, snoozePresets, + ...(supportsGroups + ? { + groups: { + currentGroupId: thread.groupId ?? null, + // Only folders the sidebar shows: a group whose members are + // all archived is kept for unarchive but is not a target. + options: threadGroupsRef.current + .filter( + (group) => + group.environmentId === thread.environmentId && + group.projectId === thread.projectId && + threadShells.some( + (candidate) => + candidate.environmentId === group.environmentId && + candidate.groupId === group.id && + candidate.archivedAt === null, + ), + ) + .map((group) => ({ id: group.id, name: group.name })), + }, + } + : {}), }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -193,6 +230,32 @@ export function useThreadActionMenu(input: { failureToast(title, squashAtomCommandFailure(result)); } }; + if (action === "group:new") { + await reportFailure("Failed to group thread", () => + createThreadGroup({ + environmentId: threadRef.environmentId, + input: { + groupId: newThreadGroupId(), + projectId: thread.projectId, + threadIds: [thread.id], + generateName: true, + }, + }), + ); + return; + } + if (action === "group:none" || action.startsWith("group:")) { + const groupId = action === "group:none" ? null : threadGroupIdFromMenuId(action); + await reportFailure( + groupId === null ? "Failed to remove thread from group" : "Failed to move thread", + () => + setThreadGroup({ + environmentId: threadRef.environmentId, + input: { threadId: thread.id, groupId }, + }), + ); + return; + } switch (action) { case "project-settings": { const project = projects.find( @@ -357,6 +420,8 @@ export function useThreadActionMenu(input: { unsettleThread, unsnoozeThread, updateThreadMetadata, + createThreadGroup, + setThreadGroup, ], ); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 3445a06bbb62..317cef5cbf69 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -1,4 +1,4 @@ -import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { MessageId, ProjectId, ThreadGroupId, ThreadId } from "@t3tools/contracts"; import { type CxOptions, cx } from "class-variance-authority"; import * as Encoding from "effect/Encoding"; import { twMerge } from "tailwind-merge"; @@ -46,6 +46,8 @@ export const newProjectId = (): ProjectId => ProjectId.make(randomUUID()); export const newThreadId = (): ThreadId => ThreadId.make(randomUUID()); +export const newThreadGroupId = (): ThreadGroupId => ThreadGroupId.make(randomUUID()); + export const newDraftId = (): DraftId => DraftId.make(randomUUID()); export const newMessageId = (): MessageId => MessageId.make(randomUUID()); diff --git a/apps/web/src/projectIconColors.ts b/apps/web/src/projectIconColors.ts index 0cf071b4debe..91c4f5d014f7 100644 --- a/apps/web/src/projectIconColors.ts +++ b/apps/web/src/projectIconColors.ts @@ -5,114 +5,134 @@ export const PROJECT_ICON_COLORS: ReadonlyArray<{ readonly label: string; readonly className: string; readonly swatchClassName: string; + /** Soft surface tint for containers that carry this color (thread groups). */ + readonly tintClassName: string; }> = [ { value: "gray", label: "Gray", className: "text-gray-600 dark:text-gray-400", swatchClassName: "bg-gray-500", + tintClassName: "bg-gray-500/[0.09] dark:bg-gray-400/[0.11]", }, { value: "red", label: "Red", className: "text-red-600 dark:text-red-400", swatchClassName: "bg-red-500", + tintClassName: "bg-red-500/[0.09] dark:bg-red-400/[0.11]", }, { value: "orange", label: "Orange", className: "text-orange-600 dark:text-orange-400", swatchClassName: "bg-orange-500", + tintClassName: "bg-orange-500/[0.09] dark:bg-orange-400/[0.11]", }, { value: "amber", label: "Amber", className: "text-amber-600 dark:text-amber-400", swatchClassName: "bg-amber-500", + tintClassName: "bg-amber-500/[0.09] dark:bg-amber-400/[0.11]", }, { value: "yellow", label: "Yellow", className: "text-yellow-600 dark:text-yellow-400", swatchClassName: "bg-yellow-500", + tintClassName: "bg-yellow-500/[0.09] dark:bg-yellow-400/[0.11]", }, { value: "lime", label: "Lime", className: "text-lime-600 dark:text-lime-400", swatchClassName: "bg-lime-500", + tintClassName: "bg-lime-500/[0.09] dark:bg-lime-400/[0.11]", }, { value: "green", label: "Green", className: "text-green-600 dark:text-green-400", swatchClassName: "bg-green-500", + tintClassName: "bg-green-500/[0.09] dark:bg-green-400/[0.11]", }, { value: "emerald", label: "Emerald", className: "text-emerald-600 dark:text-emerald-400", swatchClassName: "bg-emerald-500", + tintClassName: "bg-emerald-500/[0.09] dark:bg-emerald-400/[0.11]", }, { value: "teal", label: "Teal", className: "text-teal-600 dark:text-teal-400", swatchClassName: "bg-teal-500", + tintClassName: "bg-teal-500/[0.09] dark:bg-teal-400/[0.11]", }, { value: "cyan", label: "Cyan", className: "text-cyan-600 dark:text-cyan-400", swatchClassName: "bg-cyan-500", + tintClassName: "bg-cyan-500/[0.09] dark:bg-cyan-400/[0.11]", }, { value: "sky", label: "Sky", className: "text-sky-600 dark:text-sky-400", swatchClassName: "bg-sky-500", + tintClassName: "bg-sky-500/[0.09] dark:bg-sky-400/[0.11]", }, { value: "blue", label: "Blue", className: "text-blue-600 dark:text-blue-400", swatchClassName: "bg-blue-500", + tintClassName: "bg-blue-500/[0.09] dark:bg-blue-400/[0.11]", }, { value: "indigo", label: "Indigo", className: "text-indigo-600 dark:text-indigo-400", swatchClassName: "bg-indigo-500", + tintClassName: "bg-indigo-500/[0.09] dark:bg-indigo-400/[0.11]", }, { value: "violet", label: "Violet", className: "text-violet-600 dark:text-violet-400", swatchClassName: "bg-violet-500", + tintClassName: "bg-violet-500/[0.09] dark:bg-violet-400/[0.11]", }, { value: "purple", label: "Purple", className: "text-purple-600 dark:text-purple-400", swatchClassName: "bg-purple-500", + tintClassName: "bg-purple-500/[0.09] dark:bg-purple-400/[0.11]", }, { value: "fuchsia", label: "Fuchsia", className: "text-fuchsia-600 dark:text-fuchsia-400", swatchClassName: "bg-fuchsia-500", + tintClassName: "bg-fuchsia-500/[0.09] dark:bg-fuchsia-400/[0.11]", }, { value: "pink", label: "Pink", className: "text-pink-600 dark:text-pink-400", swatchClassName: "bg-pink-500", + tintClassName: "bg-pink-500/[0.09] dark:bg-pink-400/[0.11]", }, { value: "rose", label: "Rose", className: "text-rose-600 dark:text-rose-400", swatchClassName: "bg-rose-500", + tintClassName: "bg-rose-500/[0.09] dark:bg-rose-400/[0.11]", }, ]; @@ -123,3 +143,11 @@ const PROJECT_ICON_COLOR_CLASSES = Object.fromEntries( export function projectIconColorClassName(color: ProjectIconColor): string { return PROJECT_ICON_COLOR_CLASSES[color]; } + +const PROJECT_ICON_TINT_CLASSES = Object.fromEntries( + PROJECT_ICON_COLORS.map(({ value, tintClassName }) => [value, tintClassName]), +) as Record; + +export function projectIconTintClassName(color: ProjectIconColor): string { + return PROJECT_ICON_TINT_CLASSES[color]; +} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index d9610e20717f..f3fdbe999272 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentProject, EnvironmentThread, + EnvironmentThreadGroup, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { @@ -19,7 +20,11 @@ import { allEnvironmentProjectSnapshotsReadyAtom, allEnvironmentShellsBootstrappedAtom, } from "./shell"; -import { environmentThreadDetails, environmentThreadShells } from "./threads"; +import { + environmentThreadDetails, + environmentThreadGroups, + environmentThreadShells, +} from "./threads"; const EMPTY_THREAD_REFS: ReadonlyArray = Object.freeze([]); @@ -78,6 +83,10 @@ export function useThreadShells(): ReadonlyArray { return useAtomValue(environmentThreadShells.threadShellsAtom); } +export function useThreadGroups(): ReadonlyArray { + return useAtomValue(environmentThreadGroups.threadGroupsAtom); +} + export function useAllEnvironmentShellsBootstrapped(): boolean { return useAtomValue(allEnvironmentShellsBootstrappedAtom); } @@ -220,6 +229,15 @@ export function readEnvironmentSupportsTitleRegeneration(environmentId: Environm ); } +/** Whether the environment's server understands thread groups. + Same version-skew contract as settlement. */ +export function readEnvironmentSupportsThreadGroups(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadGroups === true + ); +} + /** Whether the environment's server understands thread.pin.reorder (and orderKey on thread.pin). Same version-skew contract as settlement. */ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean { diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index deda3ca29e9a..07254cd36d64 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentThreadDetailAtoms, + createEnvironmentThreadGroupAtoms, createEnvironmentThreadShellAtoms, createEnvironmentThreadStateAtoms, EMPTY_ENVIRONMENT_THREAD_STATE, @@ -27,6 +28,10 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: threadEnvironment.snapshotAtom, }); +export const environmentThreadGroups = createEnvironmentThreadGroupAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: threadEnvironment.snapshotAtom, +}); const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( Atom.withLabel("web-environment-thread:empty"), diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index f9284811de3a..f77198286a84 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -30,6 +30,11 @@ on macOS and Ctrl on Windows and Linux, including GNOME, KDE Plasma, Niri, and Hyprland. If a custom desktop shortcut takes the same keys, choose another binding in Settings. +## Group threads + +Use `mod+g` to group the selected sidebar threads, or the open thread when +nothing is selected. See [Group threads](./thread-sidebar.md#group-threads). + ## Copy pull request references With a PR open in the right panel or on the Pull Requests page, use `mod+shift+c` @@ -58,7 +63,7 @@ It is a JSON array of rules: ```json [ - { "key": "mod+g", "command": "terminal.toggle" }, + { "key": "mod+alt+t", "command": "terminal.toggle" }, { "key": "mod+shift+g", "command": "terminal.new", "when": "terminalFocus" } ] ``` diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 67574f0b0286..d59a53233204 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -73,6 +73,22 @@ If dragging is unavailable for one environment, update the T3 Code server runnin environment. Pinned and active reordering require server support. Threads from older servers keep their default order until the server is updated. +## Group threads + +On web and desktop, select one or more threads with `Cmd/Ctrl+Click` or `Shift+Click` +and press `mod+g` to put them in a group. With nothing selected, `mod+g` groups the +open thread. A group is a collapsible folder above +the thread list. T3 Code names it from its threads with your text generation +model; double-click the name to rename it, or choose **Rename group** from the +group's menu. **Generate name** asks for a new suggestion. + +Right-click a group to change its icon and color, settle all of its active +threads, or **Ungroup**, which returns its threads to the list. A thread's menu +offers **Move to group** to file it under an existing group, start a new one, or +remove it from its group. Snoozed and settled threads stay inside their group, +so they fold away with it instead of filling the shared shelves. Groups belong +to one project; threads from different projects cannot share a group. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 8f313c1632ff..50b85b56d907 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -44,6 +44,10 @@ export type UnpinThreadInput = CommandInput<"thread.unpin">; export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; +export type CreateThreadGroupInput = CommandInput<"thread-group.create">; +export type UpdateThreadGroupInput = CommandInput<"thread-group.meta.update">; +export type DeleteThreadGroupInput = CommandInput<"thread-group.delete">; +export type SetThreadGroupInput = CommandInput<"thread.group.set">; export type LinkThreadPullRequestInput = CommandInput<"thread.pull-request.link">; export type UnlinkThreadPullRequestInput = CommandInput<"thread.pull-request.unlink">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; @@ -256,6 +260,48 @@ export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => Command }); }); +export const createThreadGroup: (input: CreateThreadGroupInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.createThreadGroup", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread-group.create", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const updateThreadGroup: (input: UpdateThreadGroupInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.updateThreadGroup", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread-group.meta.update", + commandId: yield* commandId(input), + }); +}); + +export const deleteThreadGroup: (input: DeleteThreadGroupInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.deleteThreadGroup", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread-group.delete", + commandId: yield* commandId(input), + }); +}); + +export const setThreadGroup: (input: SetThreadGroupInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.setThreadGroup", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.group.set", + commandId: yield* commandId(input), + }); +}); + export const linkThreadPullRequest: (input: LinkThreadPullRequestInput) => CommandEffect = Effect.fn("EnvironmentCommands.linkThreadPullRequest")(function* (input) { return yield* dispatch({ diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 9f4c83609cac..c8b3ca4aafcb 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -3,6 +3,7 @@ import type { OrchestrationMessage, OrchestrationProjectShell, OrchestrationThread, + OrchestrationThreadGroup, OrchestrationThreadShell, } from "@t3tools/contracts"; @@ -14,6 +15,10 @@ export interface EnvironmentThreadShell extends OrchestrationThreadShell { readonly environmentId: EnvironmentId; } +export interface EnvironmentThreadGroup extends OrchestrationThreadGroup { + readonly environmentId: EnvironmentId; +} + export type EnvironmentMessage = OrchestrationMessage; export interface EnvironmentThread extends OrchestrationThread { @@ -34,6 +39,13 @@ export function scopeThreadShell( return { ...thread, environmentId }; } +export function scopeThreadGroup( + environmentId: EnvironmentId, + group: OrchestrationThreadGroup, +): EnvironmentThreadGroup { + return { ...group, environmentId }; +} + export function scopeThread( environmentId: EnvironmentId, thread: OrchestrationThread, diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index b7f39b509462..4899248a8a93 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -236,7 +236,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") // If the authoritative refresh failed, omit the cached cursor so the // socket fallback sends a complete snapshot for this new session. if (!canResume || Option.isNone(current.snapshot)) { - return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + return { + threadGroups: true as const, + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; } if (!supportsCompletionMarker) { // Without a completion marker there is no synchronized signal for a @@ -249,6 +252,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } return { afterSequence: current.snapshot.value.snapshotSequence, + threadGroups: true as const, ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), }; }), diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 6ed44ce62e7c..03a82629adf2 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ProviderInstanceId, ThreadGroupId, ThreadId } from "@t3tools/contracts"; import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; import { applyShellStreamEvent } from "./shellReducer.ts"; @@ -9,9 +9,20 @@ const baseSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 0, projects: [], threads: [], + threadGroups: [], updatedAt: "2026-04-01T00:00:00.000Z", }; +const stubThreadGroup = { + id: ThreadGroupId.make("group-1"), + projectId: ProjectId.make("project-1"), + name: "Auth work", + icon: null, + nameGeneration: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", +} as const; + const stubProject = { id: ProjectId.make("project-1"), title: "Test Project", @@ -178,6 +189,44 @@ describe("applyShellStreamEvent", () => { }); }); + describe("thread-group-upserted and thread-group-removed", () => { + it("adds, replaces, and removes a thread group by id", () => { + const added = applyShellStreamEvent(baseSnapshot, { + kind: "thread-group-upserted", + sequence: 1, + threadGroup: stubThreadGroup, + }); + expect(added.threadGroups).toEqual([stubThreadGroup]); + expect(added.snapshotSequence).toBe(1); + + const renamed = applyShellStreamEvent(added, { + kind: "thread-group-upserted", + sequence: 2, + threadGroup: { ...stubThreadGroup, name: "Login flow" }, + }); + expect(renamed.threadGroups).toHaveLength(1); + expect(renamed.threadGroups?.[0]?.name).toBe("Login flow"); + + const removed = applyShellStreamEvent(renamed, { + kind: "thread-group-removed", + sequence: 3, + threadGroupId: stubThreadGroup.id, + }); + expect(removed.threadGroups).toHaveLength(0); + expect(removed.snapshotSequence).toBe(3); + }); + + it("drops a stale group upsert", () => { + const snapshot: OrchestrationShellSnapshot = { ...baseSnapshot, snapshotSequence: 5 }; + const next = applyShellStreamEvent(snapshot, { + kind: "thread-group-upserted", + sequence: 4, + threadGroup: stubThreadGroup, + }); + expect(next).toBe(snapshot); + }); + }); + it("returns original snapshot for unrecognized event kinds", () => { const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any; const next = applyShellStreamEvent(baseSnapshot, unknownEvent); diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index 3d3b22a1289f..216a2e1d0f61 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -40,6 +40,19 @@ export function applyShellStreamEvent( threads: Arr.filter(snapshot.threads, (t) => t.id !== event.threadId), snapshotSequence: event.sequence, }; + case "thread-group-upserted": { + const current = snapshot.threadGroups ?? []; + const threadGroups = current.some((g) => g.id === event.threadGroup.id) + ? Arr.map(current, (g) => (g.id === event.threadGroup.id ? event.threadGroup : g)) + : Arr.append(current, event.threadGroup); + return { ...snapshot, threadGroups, snapshotSequence: event.sequence }; + } + case "thread-group-removed": + return { + ...snapshot, + threadGroups: Arr.filter(snapshot.threadGroups ?? [], (g) => g.id !== event.threadGroupId), + snapshotSequence: event.sequence, + }; default: return snapshot; } diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 93e22cfd0c70..9eec4896517a 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -63,6 +63,14 @@ import { unsettleThread, unsnoozeThread, updateThreadMetadata, + createThreadGroup, + updateThreadGroup, + deleteThreadGroup, + setThreadGroup, + type CreateThreadGroupInput, + type UpdateThreadGroupInput, + type DeleteThreadGroupInput, + type SetThreadGroupInput, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -91,6 +99,10 @@ export type { UnsettleThreadInput, UnsnoozeThreadInput, UpdateThreadMetadataInput, + CreateThreadGroupInput, + UpdateThreadGroupInput, + DeleteThreadGroupInput, + SetThreadGroupInput, } from "../operations/commands.ts"; export function createThreadEnvironmentAtoms( @@ -103,6 +115,11 @@ export function createThreadEnvironmentAtoms( key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => JSON.stringify([environmentId, input.threadId]), }; + const groupConcurrency = { + mode: "serial" as const, + key: ({ environmentId, input }: { environmentId: string; input: { groupId: string } }) => + JSON.stringify([environmentId, "thread-group", input.groupId]), + }; const commands = { create: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:create", @@ -182,6 +199,32 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + // Group commands serialize per group rather than per thread: a create + // touches several threads at once and must not interleave with a rename. + createGroup: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread-group:create", + execute: (input: CreateThreadGroupInput) => createThreadGroup(input), + scheduler, + concurrency: groupConcurrency, + }), + updateGroup: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread-group:update", + execute: (input: UpdateThreadGroupInput) => updateThreadGroup(input), + scheduler, + concurrency: groupConcurrency, + }), + deleteGroup: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread-group:delete", + execute: (input: DeleteThreadGroupInput) => deleteThreadGroup(input), + scheduler, + concurrency: groupConcurrency, + }), + setGroup: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:set-group", + execute: (input: SetThreadGroupInput) => setThreadGroup(input), + scheduler, + concurrency, + }), linkPullRequest: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:link-pull-request", execute: (input: LinkThreadPullRequestInput) => linkThreadPullRequest(input), @@ -329,5 +372,9 @@ export function createThreadEnvironmentAtoms( ...thread, activeOrderKey: input.orderKey, })), + setGroup: optimistic.wrap(commands.setGroup, (thread, input) => ({ + ...thread, + groupId: input.groupId, + })), }; } diff --git a/packages/client-runtime/src/state/threadGroupEntities.ts b/packages/client-runtime/src/state/threadGroupEntities.ts new file mode 100644 index 000000000000..7a0b70141fe4 --- /dev/null +++ b/packages/client-runtime/src/state/threadGroupEntities.ts @@ -0,0 +1,63 @@ +import type { + EnvironmentId, + OrchestrationShellSnapshot, + OrchestrationThreadGroup, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentThreadGroup } from "./models.ts"; +import { scopeThreadGroup } from "./models.ts"; +import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; +import { arrayElementsEqual } from "./entities.ts"; + +const EMPTY_GROUPS: ReadonlyArray = Object.freeze([]); + +/** Thread groups across every enabled environment, scoped with their environment id. */ +export function createEnvironmentThreadGroupAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly snapshotAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; +}) { + const scopedGroups = new WeakMap< + OrchestrationThreadGroup, + Map + >(); + const scopedGroup = (environmentId: EnvironmentId, group: OrchestrationThreadGroup) => { + let byEnvironment = scopedGroups.get(group); + if (byEnvironment === undefined) { + byEnvironment = new Map(); + scopedGroups.set(group, byEnvironment); + } + let value = byEnvironment.get(environmentId); + if (value === undefined) { + value = scopeThreadGroup(environmentId, group); + byEnvironment.set(environmentId, value); + } + return value; + }; + + const environmentThreadGroupsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): ReadonlyArray => + get(input.snapshotAtom(environmentId))?.threadGroups ?? EMPTY_GROUPS, + ).pipe(Atom.withLabel(`environment-thread-groups:${environmentId}`)), + ); + + let previousGroups: ReadonlyArray = []; + const threadGroupsAtom = Atom.make((get) => { + const next: EnvironmentThreadGroup[] = []; + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { + for (const group of get(environmentThreadGroupsAtom(environmentId))) { + next.push(scopedGroup(environmentId, group)); + } + } + if (arrayElementsEqual(previousGroups, next)) { + return previousGroups; + } + previousGroups = next; + return previousGroups; + }).pipe(Atom.withLabel("environment-thread-group-list")); + + return { environmentThreadGroupsAtom, threadGroupsAtom }; +} diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index b7074b9304bb..72d4764804eb 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -966,4 +966,5 @@ export * from "./threadFeedback.ts"; export * from "./threadDetail.ts"; export * from "./threadReducer.ts"; export * from "./threadShell.ts"; +export * from "./threadGroupEntities.ts"; export * from "./threadState.ts"; diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index e55e0af7ca02..cfa04e6eeb85 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -109,6 +109,8 @@ export const ThreadId = makeEntityId("ThreadId"); export type ThreadId = typeof ThreadId.Type; export const ProjectId = makeEntityId("ProjectId"); export type ProjectId = typeof ProjectId.Type; +export const ThreadGroupId = makeEntityId("ThreadGroupId"); +export type ThreadGroupId = typeof ThreadGroupId.Type; export const EnvironmentId = makeEntityId("EnvironmentId"); export type EnvironmentId = typeof EnvironmentId.Type; export const CommandId = makeEntityId("CommandId"); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 6d5690187104..f10a82ccde27 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -133,6 +133,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), + /** Server understands thread-group.* and thread.group.set commands and + streams thread groups in the shell. Same version-skew contract as + threadSettlement. */ + threadGroups: Schema.optionalKey(Schema.Boolean), /** Server supports legacy linkedPullRequest updates through thread.meta.update. Independent of threadPullRequests; servers supporting both advertise both. */ threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 44d76ded1703..c4f9983c36bc 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -42,6 +42,7 @@ const THREAD_KEYBINDING_COMMANDS = [ "thread.copyReference", "thread.settle", "thread.pin", + "thread.group", ...THREAD_JUMP_KEYBINDING_COMMANDS, ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cdcc5b7e437a..41953ec37a98 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -18,6 +18,7 @@ import { PositiveInt, ProjectId, ProviderItemId, + ThreadGroupId, ThreadId, TrimmedNonEmptyString, TrimmedString, @@ -682,6 +683,34 @@ export const ThreadTitleRegeneration = Schema.Struct({ }); export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; +/** Pending AI naming for a thread group. Cleared when the request lands or a + * manual rename supersedes it; a late completion for another request is dropped. */ +export const ThreadGroupNameGeneration = Schema.Struct({ + requestId: CommandId, + startedAt: IsoDateTime, +}); +export type ThreadGroupNameGeneration = typeof ThreadGroupNameGeneration.Type; + +export const DEFAULT_THREAD_GROUP_NAME = "New group"; + +/** + * A user-made folder of threads inside one project. Membership lives on the + * thread (`groupId`), so archiving or settling a member never has to touch the + * group; the group only carries its own identity and presentation. + */ +export const OrchestrationThreadGroup = Schema.Struct({ + id: ThreadGroupId, + projectId: ProjectId, + name: TrimmedNonEmptyString, + // Same picker as project icons: a lucide glyph or monogram carries the + // color, an emoji has none. Null means the default folder glyph. + icon: Schema.NullOr(ProjectIconOverride), + nameGeneration: Schema.optional(Schema.NullOr(ThreadGroupNameGeneration)), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type OrchestrationThreadGroup = typeof OrchestrationThreadGroup.Type; + /** * Legacy single-PR link. Still emitted as the thread's derived current pull * request (see `@t3tools/shared/threadPullRequests`) so clients from before @@ -820,6 +849,9 @@ export const OrchestrationThread = Schema.Struct({ // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), titleState: Schema.optional(Schema.NullOr(ThreadTitleState)), + // The sidebar group this thread files under. Optional so payloads from + // pre-group servers still decode; null is ungrouped. + groupId: Schema.optional(Schema.NullOr(ThreadGroupId)), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -835,6 +867,9 @@ export const OrchestrationReadModel = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProject), threads: Schema.Array(OrchestrationThread), + // Optional so cached snapshots from older servers still decode and existing + // read-model fixtures stay valid; readers treat absent as empty. + threadGroups: Schema.optional(Schema.Array(OrchestrationThreadGroup)), updatedAt: IsoDateTime, }); export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; @@ -889,6 +924,7 @@ export const OrchestrationThreadShell = Schema.Struct({ activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), titleState: Schema.optional(Schema.NullOr(ThreadTitleState)), + groupId: Schema.optional(Schema.NullOr(ThreadGroupId)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -921,6 +957,9 @@ export const OrchestrationShellSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProjectShell), threads: Schema.Array(OrchestrationThreadShell), + // Optional so cached snapshots from older servers still decode and existing + // read-model fixtures stay valid; readers treat absent as empty. + threadGroups: Schema.optional(Schema.Array(OrchestrationThreadGroup)), updatedAt: IsoDateTime, }); export type OrchestrationShellSnapshot = typeof OrchestrationShellSnapshot.Type; @@ -946,6 +985,16 @@ export const OrchestrationShellStreamEvent = Schema.Union([ sequence: NonNegativeInt, threadId: ThreadId, }), + Schema.Struct({ + kind: Schema.Literal("thread-group-upserted"), + sequence: NonNegativeInt, + threadGroup: OrchestrationThreadGroup, + }), + Schema.Struct({ + kind: Schema.Literal("thread-group-removed"), + sequence: NonNegativeInt, + threadGroupId: ThreadGroupId, + }), ]); export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type; @@ -976,6 +1025,13 @@ export const OrchestrationSubscribeShellInput = Schema.Struct({ * snapshot or catch-up replay and before it begins emitting live events. */ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * Opt in to thread groups: the snapshot's `threadGroups` list and the + * `thread-group-upserted` / `thread-group-removed` stream items. Clients + * from before groups cannot decode those kinds, so the server keeps them + * off the stream unless asked. + */ + threadGroups: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShellInput.Type; @@ -1223,6 +1279,54 @@ const ThreadMetaUpdateCommand = Schema.Struct({ ), ); +// Groups are created with their first members in one command so a fresh +// group never exists empty, and so the AI name can be requested from the +// member titles at creation time. +const ThreadGroupCreateCommand = Schema.Struct({ + type: Schema.Literal("thread-group.create"), + commandId: CommandId, + groupId: ThreadGroupId, + projectId: ProjectId, + name: Schema.optional(TrimmedNonEmptyString), + icon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + threadIds: Schema.Array(ThreadId).check(Schema.isMinLength(1)), + // Ask the server to name the group from its members' titles. The group + // starts with the placeholder name (or `name`) until generation lands. + generateName: Schema.optional(Schema.Literal(true)), + createdAt: IsoDateTime, +}); + +const ThreadGroupMetaUpdateCommand = Schema.Struct({ + type: Schema.Literal("thread-group.meta.update"), + commandId: CommandId, + groupId: ThreadGroupId, + name: Schema.optional(TrimmedNonEmptyString), + icon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + regenerateName: Schema.optional(Schema.Literal(true)), +}).check( + Schema.makeFilter( + (input) => + !((input.name !== undefined || input.icon !== undefined) && input.regenerateName === true) || + "name or icon cannot be specified together with regenerateName", + ), +); + +// Deleting a group ungroups its members; the threads themselves are untouched. +const ThreadGroupDeleteCommand = Schema.Struct({ + type: Schema.Literal("thread-group.delete"), + commandId: CommandId, + groupId: ThreadGroupId, +}); + +// Null moves the thread out of its group. A group left empty by this move +// is deleted in the same command so the sidebar never shows a hollow folder. +const ThreadGroupSetCommand = Schema.Struct({ + type: Schema.Literal("thread.group.set"), + commandId: CommandId, + threadId: ThreadId, + groupId: Schema.NullOr(ThreadGroupId), +}); + const ThreadPullRequestLinkCommand = Schema.Struct({ type: Schema.Literal("thread.pull-request.link"), commandId: CommandId, @@ -1406,6 +1510,10 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadPinReorderCommand, ThreadActiveReorderCommand, ThreadMetaUpdateCommand, + ThreadGroupCreateCommand, + ThreadGroupMetaUpdateCommand, + ThreadGroupDeleteCommand, + ThreadGroupSetCommand, ThreadPullRequestLinkCommand, ThreadPullRequestUnlinkCommand, ThreadRuntimeModeSetCommand, @@ -1439,6 +1547,10 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadPinReorderCommand, ThreadActiveReorderCommand, ThreadMetaUpdateCommand, + ThreadGroupCreateCommand, + ThreadGroupMetaUpdateCommand, + ThreadGroupDeleteCommand, + ThreadGroupSetCommand, ThreadPullRequestLinkCommand, ThreadPullRequestUnlinkCommand, ThreadRuntimeModeSetCommand, @@ -1595,6 +1707,16 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), }); +const ThreadGroupNameGenerateCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread-group.name.generate.complete"), + commandId: CommandId, + groupId: ThreadGroupId, + requestId: CommandId, + // Absent when generation failed or produced nothing usable: the pending + // marker clears and the current name stays. + name: Schema.optional(TrimmedNonEmptyString), +}); + const ThreadPullRequestSyncCommand = Schema.Struct({ type: Schema.Literal("thread.pull-request.sync"), commandId: CommandId, @@ -1639,6 +1761,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadTitleRegenerationCompleteCommand, ThreadTitleGenerateCompleteCommand, ThreadTitleRefineCommand, + ThreadGroupNameGenerateCompleteCommand, ThreadPullRequestSyncCommand, ThreadPullRequestLinkSyncCommand, ]); @@ -1666,6 +1789,10 @@ export const OrchestrationEventType = Schema.Literals([ "thread.unpinned", "thread.pin-reordered", "thread.meta-updated", + "thread.group-set", + "thread-group.created", + "thread-group.meta-updated", + "thread-group.deleted", "thread.pull-request-linked", "thread.pull-request-unlinked", "thread.pull-request-synced", @@ -1686,7 +1813,7 @@ export const OrchestrationEventType = Schema.Literals([ ]); export type OrchestrationEventType = typeof OrchestrationEventType.Type; -export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]); +export const OrchestrationAggregateKind = Schema.Literals(["project", "thread", "thread-group"]); export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type; export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]); @@ -1827,6 +1954,38 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadGroupSetPayload = Schema.Struct({ + threadId: ThreadId, + groupId: Schema.NullOr(ThreadGroupId), + updatedAt: IsoDateTime, +}); + +export const ThreadGroupCreatedPayload = Schema.Struct({ + groupId: ThreadGroupId, + projectId: ProjectId, + name: TrimmedNonEmptyString, + icon: Schema.NullOr(ProjectIconOverride), + nameGeneration: Schema.optional(Schema.NullOr(ThreadGroupNameGeneration)), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadGroupMetaUpdatedPayload = Schema.Struct({ + groupId: ThreadGroupId, + name: Schema.optional(TrimmedNonEmptyString), + icon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + /** Intent marker consumed by the naming reactor. */ + regenerateName: Schema.optional(Schema.Literal(true)), + /** Pending state shared with clients. Null clears a matching request. */ + nameGeneration: Schema.optional(Schema.NullOr(ThreadGroupNameGeneration)), + updatedAt: IsoDateTime, +}); + +export const ThreadGroupDeletedPayload = Schema.Struct({ + groupId: ThreadGroupId, + deletedAt: IsoDateTime, +}); + export const ThreadPullRequestLinkedPayload = Schema.Struct({ threadId: ThreadId, link: ThreadPullRequestLink, @@ -1987,7 +2146,7 @@ const EventBaseFields = { sequence: NonNegativeInt, eventId: EventId, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([ProjectId, ThreadId, ThreadGroupId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), @@ -2071,6 +2230,26 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.meta-updated"), payload: ThreadMetaUpdatedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.group-set"), + payload: ThreadGroupSetPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread-group.created"), + payload: ThreadGroupCreatedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread-group.meta-updated"), + payload: ThreadGroupMetaUpdatedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread-group.deleted"), + payload: ThreadGroupDeletedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.pull-request-linked"), diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 948212a9aac6..fc4bf689bf61 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -62,6 +62,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, + { key: "mod+g", command: "thread.group", when: "!terminalFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command,