diff --git a/README.md b/README.md index b86429042412..758cc8b9a6d4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod - **Move a thread to the top.** The command palette can move the current thread to the top of Pinned, Active, or its custom group, including members hidden by filters or collapsed groups. It preserves group membership and excludes drafts, archived, snoozed, and settled threads. New or reopened threads still follow the sidebar's usual placement rules. -- **Custom thread groups.** Create named sidebar groups across projects and environments, drag threads between them, and arrange the same groups on mobile. Pinning, snoozing, and settlement retain group membership. New groups start below Active; in the Thread groups dialog, move a group up or down across the Active divider to place each one above or below, and mobile follows the same placement. Settings → Extras can show a Thread groups toolbar button; the command palette always offers New thread group, Manage thread groups, and Move current thread to group. +- **Custom thread groups.** Organize threads across projects and environments. Choose a group on the new-thread view or use New thread in group in the command palette. Move selected threads together from their context menu or the palette. Drag groups in the Thread groups dialog, or use its arrows, to arrange them above or below Active; mobile follows the same order and membership. Forked threads inherit their source group. Pinning, snoozing, and settlement retain the group. Settings → Extras can show a Thread groups toolbar button; the palette always offers group management. - **Rename and snooze from the command palette.** Rename current thread edits the title inside the palette input, and Snooze current thread lists the snooze presets with their wake times and also parses a typed time such as `45m`, `2pm`, or `fri 9am`. Both are available as unbound keybinding commands (Thread: Rename, Thread: Snooze) that open the palette straight into that step. diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index bbf4614e468f..825445645723 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -187,6 +187,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.usagePriceOverrides).toBe(true); expect(second.capabilities.threadActiveReorder).toBe(true); expect(second.capabilities.threadCustomGroups).toBe(true); + expect(second.capabilities.threadCustomGroupCreation).toBe(true); expect(second.capabilities.threadGroupPlacement).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequests).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b5e6a7a71575..f39cdfba93d0 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -262,6 +262,7 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadActiveReorder: true, threadCustomGroups: true, + threadCustomGroupCreation: true, threadGroupPlacement: true, threadTitleRegeneration: true, threadPullRequests: true, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 32e1940fb246..c052d8bbf962 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1003,6 +1003,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { threadId: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), title: "Thread 1", + customGroupId: "initial-group", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -1040,6 +1041,11 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* projectionPipeline.bootstrap; + const createdGroupRows = yield* sql<{ customGroupId: string | null }>` + SELECT custom_group_id AS "customGroupId" FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(createdGroupRows, [{ customGroupId: "initial-group" }]); + const projectRows = yield* sql<{ readonly projectId: string; readonly title: string; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 8ba78fe0d4a4..f41ea80864c6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -767,6 +767,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti projectId: event.payload.projectId, title: event.payload.title, titleState: event.payload.titleState ?? null, + customGroupId: event.payload.customGroupId ?? null, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, diff --git a/apps/server/src/orchestration/Services/TurnStartBootstrap.test.ts b/apps/server/src/orchestration/Services/TurnStartBootstrap.test.ts index e6279350a3e0..923e69ab8817 100644 --- a/apps/server/src/orchestration/Services/TurnStartBootstrap.test.ts +++ b/apps/server/src/orchestration/Services/TurnStartBootstrap.test.ts @@ -939,7 +939,11 @@ describe("TurnStartBootstrap", () => { const caller = yield* Effect.forkChild( bootstrap.dispatchTurnStart( makeTurnStartCommand({ - createThread: { ...createThreadBootstrap, titleSource: "manual" }, + createThread: { + ...createThreadBootstrap, + titleSource: "manual", + customGroupId: "research", + }, prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", branch: "test" }, runSetupScript: true, }), @@ -953,7 +957,10 @@ describe("TurnStartBootstrap", () => { ); assert.isTrue( dispatched.some( - (command) => command.type === "thread.create" && command.titleSource === "manual", + (command) => + command.type === "thread.create" && + command.titleSource === "manual" && + command.customGroupId === "research", ), ); yield* Fiber.interrupt(caller); diff --git a/apps/server/src/orchestration/Services/TurnStartBootstrap.ts b/apps/server/src/orchestration/Services/TurnStartBootstrap.ts index 01b6dc9ddbef..f0c782944290 100644 --- a/apps/server/src/orchestration/Services/TurnStartBootstrap.ts +++ b/apps/server/src/orchestration/Services/TurnStartBootstrap.ts @@ -547,6 +547,9 @@ export const make = Effect.gen(function* () { if (bootstrap?.createThread) { const created = yield* dispatchFromClient({ type: "thread.create", + ...(bootstrap.createThread.customGroupId !== undefined + ? { customGroupId: bootstrap.createThread.customGroupId } + : {}), commandId: yield* serverCommandId("bootstrap-thread-create"), threadId: command.threadId, projectId: bootstrap.createThread.projectId, diff --git a/apps/server/src/orchestration/decider.custom-groups.test.ts b/apps/server/src/orchestration/decider.custom-groups.test.ts index bf8fe3fb6560..ca4d80e26380 100644 --- a/apps/server/src/orchestration/decider.custom-groups.test.ts +++ b/apps/server/src/orchestration/decider.custom-groups.test.ts @@ -11,7 +11,7 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { decideOrchestrationCommand } from "./decider.ts"; -import { projectEvent } from "./projector.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z"; // The Effect test clock starts at the epoch. @@ -61,6 +61,62 @@ function makeReadModel(overrides: Partial = {}): Orchestrat } it.layer(NodeServices.layer)("custom thread groups", (it) => { + it.effect( + "creates a thread directly in its chosen group and defaults legacy creation to Active", + () => + Effect.gen(function* () { + let readModel = createEmptyReadModel(NOW); + const project = yield* decideOrchestrationCommand({ + readModel, + command: { + type: "project.create", + commandId: CommandId.make("create-project"), + projectId: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + createdAt: NOW, + }, + }); + for (const event of Array.isArray(project) ? project : [project]) { + readModel = yield* projectEvent(readModel, { + ...event, + sequence: readModel.snapshotSequence + 1, + }); + } + for (const customGroupId of ["research", null, undefined]) { + const threadId = ThreadId.make(`thread-${customGroupId}`); + const decided = yield* decideOrchestrationCommand({ + readModel, + command: { + type: "thread.create", + commandId: CommandId.make(`create-${customGroupId}`), + threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: NOW, + ...(customGroupId !== undefined ? { customGroupId } : {}), + }, + }); + for (const event of Array.isArray(decided) ? decided : [decided]) { + expect(event.type).toBe("thread.created"); + readModel = yield* projectEvent(readModel, { + ...event, + sequence: readModel.snapshotSequence + 1, + }); + } + expect(readModel.threads.find((thread) => thread.id === threadId)).toMatchObject({ + customGroupId: customGroupId ?? null, + settledOverride: null, + }); + } + }), + ); + it.effect("assigns, moves and clears a group without changing lifecycle or activity", () => Effect.gen(function* () { let readModel = makeReadModel({ diff --git a/apps/server/src/orchestration/decider.fork.test.ts b/apps/server/src/orchestration/decider.fork.test.ts index 926820cb4efb..0bf01c3ec633 100644 --- a/apps/server/src/orchestration/decider.fork.test.ts +++ b/apps/server/src/orchestration/decider.fork.test.ts @@ -121,11 +121,48 @@ it.layer(NodeServices.layer)("thread fork decider", (it) => { branch: "dev", worktreePath: "/tmp/project", runtimeMode: "full-access", + customGroupId: null, }); } }), ); + it.effect("keeps the source custom group without inheriting its pin or manual order", () => + Effect.gen(function* () { + const seeded = yield* seedReadModel; + const readModel = { + ...seeded, + threads: seeded.threads.map((thread) => ({ + ...thread, + customGroupId: "research", + pinnedAt: now, + activeOrderKey: "m", + })), + }; + const result = yield* decideOrchestrationCommand({ + readModel, + command: { + type: "thread.fork", + commandId: CommandId.make("command-group-fork"), + sourceThreadId, + threadId: ThreadId.make("destination-group-fork"), + createdAt: now, + }, + }); + let projected: typeof seeded = readModel; + for (const event of Array.isArray(result) ? result : [result]) { + projected = yield* projectEvent(projected, { + ...event, + sequence: projected.snapshotSequence + 1, + }); + } + const fork = projected.threads.find((thread) => thread.id === "destination-group-fork"); + expect(fork?.customGroupId).toBe("research"); + expect(fork?.pinnedAt ?? null).toBeNull(); + expect(fork?.activeOrderKey).toBeNull(); + }), + ); + it.effect("does not stack the fork prefix on an already prefixed title", () => Effect.gen(function* () { const readModel = yield* projectEvent(yield* seedReadModel, { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c3e3bad2d7e1..edc6deaa9e81 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -441,6 +441,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" })), type: "thread.created", payload: { + ...(command.customGroupId !== undefined ? { customGroupId: command.customGroupId } : {}), threadId: command.threadId, projectId: command.projectId, title: command.title, @@ -488,6 +489,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: source.projectId, title: formatForkedThreadTitle(source.title), + customGroupId: source.customGroupId ?? null, modelSelection: source.modelSelection, runtimeMode: source.runtimeMode, interactionMode: source.interactionMode, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 3d2324d0e750..922972b25a8f 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -437,6 +437,7 @@ export function projectEvent( projectId: payload.projectId, title: payload.title, titleState: payload.titleState ?? null, + customGroupId: payload.customGroupId ?? null, modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index dec9a9a7f5a0..e45f1c3457f8 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -57,6 +57,7 @@ import { rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, + resolveDraftCreationGroup, restorePlanFollowUpComposer, resolveComposerProviderSelection, resolveDraftPromotionNavigationTarget, @@ -1983,16 +1984,16 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => { describe("startNewThreadForProject", () => { it("starts a thread through the supplied shared handler for the active project", () => { - const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; + const calls: Array<[{ environmentId: EnvironmentId; projectId: ProjectId }, unknown]> = []; const projectRef = { environmentId, projectId }; expect( - startNewThreadForProject(projectRef, (nextProjectRef) => { - calls.push(nextProjectRef); + startNewThreadForProject(projectRef, (nextProjectRef, options) => { + calls.push([nextProjectRef, options]); return Promise.resolve(); }), ).toBe(true); - expect(calls).toEqual([projectRef]); + expect(calls).toEqual([[projectRef, { customGroupId: null }]]); }); it("does nothing when the active project is unavailable", () => { @@ -2734,3 +2735,44 @@ describe("worktree setup visibility", () => { }); }); }); + +describe("resolveDraftCreationGroup", () => { + const catalog = [{ id: "g1" }, { id: "gone", deleted: true }]; + + it("blocks the send when the picked group targets a server that cannot create grouped threads", () => { + expect( + resolveDraftCreationGroup({ customGroupId: "g1", catalog, supportsGroupCreation: false }), + ).toMatchObject({ customGroupId: "g1", blockReason: expect.stringContaining("Choose Active") }); + expect( + resolveDraftCreationGroup({ customGroupId: "g1", catalog, supportsGroupCreation: true }), + ).toEqual({ customGroupId: "g1", blockReason: null }); + }); + + it("falls back to Active for a deleted group and for no pick at all", () => { + expect( + resolveDraftCreationGroup({ customGroupId: "gone", catalog, supportsGroupCreation: false }), + ).toEqual({ customGroupId: null, blockReason: null }); + expect( + resolveDraftCreationGroup({ + customGroupId: undefined, + catalog, + supportsGroupCreation: false, + }), + ).toEqual({ customGroupId: null, blockReason: null }); + }); + + it.each([{ catalog: [] }, { catalog: [{ id: "other" }] }])( + "preserves a pick missing from an incomplete catalog", + ({ catalog }) => { + expect( + resolveDraftCreationGroup({ customGroupId: "g1", catalog, supportsGroupCreation: true }), + ).toEqual({ customGroupId: "g1", blockReason: expect.stringContaining("unavailable") }); + }, + ); + + it("allows Active even before the catalog is available", () => { + expect( + resolveDraftCreationGroup({ customGroupId: null, catalog: [], supportsGroupCreation: true }), + ).toEqual({ customGroupId: null, blockReason: null }); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 7805f928b434..729097d5bbd1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -455,10 +455,14 @@ export function hasEnvironmentReconnectWarningGraceElapsed( export function startNewThreadForProject( projectRef: ScopedProjectRef | null, - handleNewThread: (projectRef: ScopedProjectRef) => Promise, + handleNewThread: ( + projectRef: ScopedProjectRef, + options: { customGroupId: null }, + ) => Promise, ): boolean { if (projectRef === null) return false; - void handleNewThread(projectRef); + // An explicit New thread starts in Active. + void handleNewThread(projectRef, { customGroupId: null }); return true; } @@ -490,6 +494,36 @@ export function resolveThreadMetadataUpdateForNextTurn(input: { }; } +/** + * Which custom group a draft joins when its first send creates the thread. + * An explicit deletion falls back to Active. Missing catalog entries can + * still be loading, so keep the pick and block until it can be checked. + * A live pick also blocks on servers that cannot create grouped threads. + */ +export function resolveDraftCreationGroup(input: { + readonly customGroupId: string | null | undefined; + readonly catalog: ReadonlyArray<{ readonly id: string; readonly deleted?: boolean }>; + readonly supportsGroupCreation: boolean; +}): { readonly customGroupId: string | null; readonly blockReason: string | null } { + const selectedGroup = input.catalog.find((group) => group.id === input.customGroupId); + const customGroupId = selectedGroup?.deleted ? null : (input.customGroupId ?? null); + if (customGroupId !== null && !selectedGroup) { + return { + customGroupId, + blockReason: + "The selected group is unavailable. Wait for its environment to connect, or choose Active before sending.", + }; + } + if (customGroupId !== null && !input.supportsGroupCreation) { + return { + customGroupId, + blockReason: + "This environment cannot create threads in a group. Choose Active or update the environment before sending.", + }; + } + return { customGroupId, blockReason: null }; +} + export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e4fdafbdc93e..c546be440214 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -281,6 +281,7 @@ import { import { useNowMinute } from "../hooks/useNowMinute"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useThreadGroupCatalog } from "../hooks/useThreadGroups"; import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { useThreadActions } from "../hooks/useThreadActions"; @@ -459,6 +460,7 @@ import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildLoadingThreadFromShell, + resolveDraftCreationGroup, buildRunningThreadTurnInterruptInput, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, @@ -1614,6 +1616,7 @@ export default function ChatView(props: ChatViewProps) { const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); const { environments } = useEnvironments(); const serverConfigs = useServerConfigs(); + const customGroupCatalog = useThreadGroupCatalog(); const primaryEnvironment = usePrimaryEnvironment(); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { @@ -8007,6 +8010,23 @@ export default function ChatView(props: ChatViewProps) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } + // The send environment can differ from the one the group was picked in + // (load balancing), so the capability is read live for this send. + const creationGroup = isLocalDraftThread + ? resolveDraftCreationGroup({ + customGroupId: draftThread?.customGroupId, + catalog: customGroupCatalog.catalog, + supportsGroupCreation: + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment + .capabilities.threadCustomGroupCreation === true, + }) + : null; + if (creationGroup?.blockReason) { + setThreadError(threadIdForSend, creationGroup.blockReason); + return; + } + const creationGroupFields = + creationGroup?.customGroupId != null ? { customGroupId: creationGroup.customGroupId } : {}; const composerImagesSnapshot = [...composerImages]; const composerFilesSnapshot = [...composerFiles]; @@ -8446,6 +8466,7 @@ export default function ChatView(props: ChatViewProps) { branch: activeThreadBranch, worktreePath: null, createdAt: messageCreatedAt, + ...creationGroupFields, }, prepareWorktree: { projectCwd: activeProject.workspaceRoot, @@ -8790,6 +8811,7 @@ export default function ChatView(props: ChatViewProps) { branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, + ...creationGroupFields, }, } : {}), diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 327726106a44..53672c0bc126 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -807,6 +807,22 @@ describe("buildProjectActionItems", () => { expect(items[0]).not.toHaveProperty("projectAccentColor"); }); + it("disables a project with a reason and leaves the others runnable", () => { + const items = buildProjectActionItems({ + projects: [ + makeProject({ id: ProjectId.make("blocked") }), + makeProject({ id: ProjectId.make("ok") }), + ], + valuePrefix: "new-thread-in-group:g1", + icon: () => null, + runProject: async (_project) => undefined, + disabledReason: (project) => (project.id === "blocked" ? "Environment cannot" : null), + }); + + expect(items[0]).toMatchObject({ disabled: true, description: "Environment cannot" }); + expect(items[1]).not.toHaveProperty("disabled"); + }); + it("leaves rows accent-free when no resolver is supplied", () => { const items = buildProjectActionItems({ projects: [makeProject()], diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 074e8870b710..132b4fc15bc7 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -282,9 +282,12 @@ export function buildProjectActionItems(input: { renderDescription?: (project: CommandPaletteProject) => ReactNode; projectAccentColor?: (project: CommandPaletteProject) => SidebarProjectAccentColor | null; shortcutCommand?: KeybindingCommand; + /** Reason a project cannot be picked; it replaces the description and disables the row. */ + disabledReason?: (project: CommandPaletteProject) => string | null; }): CommandPaletteActionItem[] { return input.projects.map((project) => { const projectAccentColor = input.projectAccentColor?.(project) ?? null; + const disabledReason = input.disabledReason?.(project) ?? null; return { kind: "action", value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, @@ -298,6 +301,7 @@ export function buildProjectActionItems(input: { description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), ...(projectAccentColor !== null ? { projectAccentColor } : {}), + ...(disabledReason !== null ? { disabled: true, description: disabledReason } : {}), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { await input.runProject(project); @@ -489,7 +493,8 @@ export function resolveThreadUtilityOpenTarget(input: { * is disabled, so the list doubles as a "which group is this in" answer. */ export function buildMoveToGroupItems(input: { readonly groups: ReadonlyArray<{ readonly id: string; readonly name: string }>; - readonly currentGroupId: string | null; + /** Group the moved threads share; undefined when they differ, so every row stays enabled. */ + readonly currentGroupId: string | null | undefined; readonly icon: ReactNode; readonly move: (groupId: string | null) => Promise; }): CommandPaletteActionItem[] { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3e0cfd2623dc..cdc774661418 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -3,7 +3,11 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + parseScopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { canCreateProjectInEnvironment, getCloneDestinationBrowsePath, @@ -96,6 +100,8 @@ import { useSavedPromptList } from "../hooks/useSavedPrompts"; import { savedPromptPreview } from "./chat/composerPromptPicker"; import { useThreadActions } from "../hooks/useThreadActions"; import { useThreadGroupCatalog } from "../hooks/useThreadGroups"; +import { groupMovableThreads, moveThreadsToGroup } from "../lib/threadGroupMove"; +import { useThreadSelectionStore } from "../threadSelectionStore"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { openThreadGroupsDialog } from "./sidebar/threadGroupsDialogStore"; import { resolveSnoozePresets } from "./Sidebar.snooze"; @@ -204,6 +210,7 @@ import { type CommandPaletteActionItem, type CommandPaletteThreadActionId, type CommandPaletteOpenIntent, + type CommandPaletteProject, type CommandPaletteSubmenuItem, type CommandPaletteView, filterCommandPaletteGroups, @@ -867,6 +874,7 @@ function OpenCommandPaletteDialog(props: { reportFailure: false, }); const customGroupCatalog = useThreadGroupCatalog(); + const selectedThreadKeys = useThreadSelectionStore((state) => state.selectedThreadKeys); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projects = useProjects(); const projectAccentColors = useProjectAccentColors(); @@ -1460,64 +1468,79 @@ function OpenCommandPaletteDialog(props: { ], ); - const projectThreadItems = useMemo( - () => - enumerateCommandPaletteItems( - buildProjectActionItems({ - projects: pickerProjects, - valuePrefix: "new-thread-in", - searchTerms: (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const location = projectEnvironmentLocationById.get(project.environmentId); - return [ - ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? - []), - ...(location ? [location.label] : []), - ]; - }, - renderDescription: (project) => { - const location = projectEnvironmentLocationById.get(project.environmentId) ?? { - kind: "remote", - label: "Remote", - machine: "server" as const, - }; - return ( - - - {location.kind === "remote" ? ( - - ) : null} - {location.label} - - - {project.workspaceRoot} + // One project list serves "New thread in..." and each group of "New + // thread in group...": the group only changes the draft the pick lands in. + // A grouped pick lands on the contextual member when the project is part + // of the viewed logical project, so capability is checked on that target. + const buildNewThreadProjectItems = useCallback( + (customGroupId: string | null) => { + const resolveTargetRef = (project: CommandPaletteProject) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const contextualRefBelongsToGroup = + contextualProjectRef !== null && + group?.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === contextualProjectRef.environmentId && + projectRef.projectId === contextualProjectRef.projectId, + ); + return contextualRefBelongsToGroup + ? contextualProjectRef + : scopeProjectRef(project.environmentId, project.id); + }; + const items = buildProjectActionItems({ + projects: pickerProjects, + valuePrefix: + customGroupId === null ? "new-thread-in" : `new-thread-in-group:${customGroupId}`, + searchTerms: (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const location = projectEnvironmentLocationById.get(project.environmentId); + return [ + ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? + []), + ...(location ? [location.label] : []), + ]; + }, + renderDescription: (project) => { + const location = projectEnvironmentLocationById.get(project.environmentId) ?? { + kind: "remote", + label: "Remote", + machine: "server" as const, + }; + return ( + + + {location.kind === "remote" ? ( + + ) : null} + {location.label} - ); - }, - projectAccentColor: (project) => - projectAccentColorByTargetKey.get(`${project.environmentId}:${project.id}`) ?? null, - icon: projectFavicon, - runProject: async (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const contextualRefBelongsToGroup = - contextualProjectRef !== null && - group?.memberProjectRefs.some( - (projectRef) => - projectRef.environmentId === contextualProjectRef.environmentId && - projectRef.projectId === contextualProjectRef.projectId, - ); - await handleNewThread( - contextualRefBelongsToGroup - ? contextualProjectRef - : scopeProjectRef(project.environmentId, project.id), - ); - }, - }), - ), + + {project.workspaceRoot} + + ); + }, + projectAccentColor: (project) => + projectAccentColorByTargetKey.get(`${project.environmentId}:${project.id}`) ?? null, + icon: projectFavicon, + runProject: async (project) => { + await handleNewThread(resolveTargetRef(project), { customGroupId }); + }, + ...(customGroupId === null + ? {} + : { + disabledReason: (project) => + serverConfigs.get(resolveTargetRef(project).environmentId)?.environment.capabilities + .threadCustomGroupCreation === true + ? null + : "Environment cannot create grouped threads", + }), + }); + return enumerateCommandPaletteItems(items); + }, [ contextualProjectRef, handleNewThread, @@ -1525,8 +1548,21 @@ function OpenCommandPaletteDialog(props: { projectAccentColorByTargetKey, projectEnvironmentLocationById, projectGroupByTargetKey, + serverConfigs, ], ); + const projectThreadItems = useMemo( + () => buildNewThreadProjectItems(null), + [buildNewThreadProjectItems], + ); + const groupedProjectThreadItems = useMemo( + () => + customGroupCatalog.groups.map((group) => ({ + group, + items: buildNewThreadProjectItems(group.id), + })), + [buildNewThreadProjectItems, customGroupCatalog.groups], + ); const allThreadItems = useMemo( () => @@ -2081,6 +2117,34 @@ function OpenCommandPaletteDialog(props: { addonIcon: , groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); + // Group first, then project: the group is the rarer, more deliberate + // choice. Projects on servers without creation support are listed but + // disabled, so the pick cannot land where the send would be blocked. + if (customGroupCatalog.groups.length > 0) { + actionItems.push({ + kind: "submenu", + value: "action:new-thread-in-group", + searchTerms: ["new thread", "group", "thread group", "new thread in group"], + title: "New thread in group...", + icon: , + addonIcon: , + groups: [ + { + value: "thread-groups", + label: "Groups", + items: groupedProjectThreadItems.map(({ group, items }) => ({ + kind: "submenu", + value: `new-thread-in-group:${group.id}`, + searchTerms: [group.name], + title: group.name, + icon: , + addonIcon: , + groups: [{ value: "projects", label: "Projects", items }], + })), + }, + ], + }); + } } const savedPromptsSubmenu = buildSavedPromptsSubmenu({ @@ -2100,6 +2164,32 @@ function OpenCommandPaletteDialog(props: { actionItems.push(savedPromptsSubmenu); } + // Selection keys can outlive their threads; only loaded shells count. + const selectedThreads = useMemo( + () => + [...selectedThreadKeys].flatMap((threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + const thread = + threadRef === null + ? undefined + : threads.find( + (candidate) => + candidate.environmentId === threadRef.environmentId && + candidate.id === threadRef.threadId, + ); + return thread ? [thread] : []; + }), + [selectedThreadKeys, threads], + ); + const groupMovableSelectedThreads = useMemo( + () => + groupMovableThreads( + selectedThreads, + (environmentId) => + serverConfigs.get(environmentId)?.environment.capabilities.threadCustomGroups === true, + ), + [selectedThreads, serverConfigs], + ); const currentThread = currentThreadRef === null ? null @@ -2284,7 +2374,62 @@ function OpenCommandPaletteDialog(props: { } } - if (openThreadCapabilities?.threadCustomGroups === true && openUnarchivedThreadRef !== null) { + // A sidebar multi-selection takes over the group move; rows on servers + // without group support are left out of the count and the move. A + // selection with nothing movable offers no move at all: falling back to + // the current thread would move something the user did not select. + if (groupMovableSelectedThreads.length > 0) { + const selectionCount = groupMovableSelectedThreads.length; + const sharedGroupIds = new Set( + groupMovableSelectedThreads.map((thread) => threadGroupId(thread, customGroupCatalog.groups)), + ); + actionItems.push({ + kind: "submenu", + value: "action:move-selected-to-group", + searchTerms: ["move", "group", "move to group", "thread group", "selected threads"], + title: `Move ${selectionCount} selected thread${selectionCount === 1 ? "" : "s"} to group...`, + icon: , + addonIcon: , + groups: [ + { + value: "move-to-group", + label: "Groups", + items: enumerateCommandPaletteItems( + buildMoveToGroupItems({ + groups: customGroupCatalog.groups, + currentGroupId: sharedGroupIds.size === 1 ? [...sharedGroupIds][0] : undefined, + icon: , + move: async (groupId) => { + const outcome = await moveThreadsToGroup({ + threads: groupMovableSelectedThreads, + customGroupId: groupId, + move: (threadRef, customGroupId) => + updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, customGroupId }, + }), + }); + useThreadSelectionStore.getState().removeFromSelection(outcome.movedThreadKeys); + if (outcome.failedCount > 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to move ${outcome.failedCount} thread${outcome.failedCount === 1 ? "" : "s"} to group`, + description: errorMessage(outcome.firstError), + }), + ); + } + }, + }), + ), + }, + ], + }); + } else if ( + selectedThreadKeys.size === 0 && + openThreadCapabilities?.threadCustomGroups === true && + openUnarchivedThreadRef !== null + ) { const threadRef = openUnarchivedThreadRef; actionItems.push({ kind: "submenu", diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index cccdc77434d6..e04bed63d8d9 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2150,10 +2150,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec setOpenMobile(false); } void (async () => { - // No options: branch, worktree, and env mode come from the user's - // configured defaults, never from the currently viewed thread. + // Branch, worktree, and env mode come from the user's configured + // defaults, never from the currently viewed thread. const result = await settlePromise(() => - handleNewThread(scopeProjectRef(member.environmentId, member.id)), + handleNewThread(scopeProjectRef(member.environmentId, member.id), { + customGroupId: null, + }), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); @@ -2420,6 +2422,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec worktreePath: thread.worktreePath, envMode: thread.worktreePath ? "worktree" : "local", startFromOrigin: false, + customGroupId: null, }), ); if (result._tag === "Failure") { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 448d1f438ff4..b4650a2cb274 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { useThreadGroupCatalog } from "../hooks/useThreadGroups"; import { threadGroupId, threadGroupSections } from "@t3tools/shared/threadGroups"; +import { groupMovableThreads, moveThreadsToGroup } from "~/lib/threadGroupMove"; import { openThreadGroupsDialog } from "./sidebar/threadGroupsDialogStore"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; @@ -4915,6 +4916,13 @@ export default function Sidebar() { const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, }); + // Move to group (k) counts only rows whose server accepts group + // membership; a mixed selection moves what it can. + const groupMovableSelectedThreads = groupMovableThreads( + selectedThreads, + (environmentId) => + serverConfigs.get(environmentId)?.environment.capabilities.threadCustomGroups === true, + ); // The indefinite preset needs every selected environment to support // it; a mixed selection would half-apply the same way blocked work // would. @@ -4952,6 +4960,21 @@ export default function Sidebar() { ] : []), ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), + ...(groupMovableSelectedThreads.length > 0 + ? [ + { + id: "move-to-group", + label: `Move to group (${groupMovableSelectedThreads.length})`, + children: [ + { id: "group:none", label: "No group" }, + ...customGroups.groups.map((group) => ({ + id: `group:${group.id}`, + label: group.name, + })), + ], + }, + ] + : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], @@ -4959,6 +4982,33 @@ export default function Sidebar() { ), ); if (clicked._tag === "Failure") return; + if (clicked.value?.startsWith("group:")) { + // Moved rows leave the selection; failed ones stay so a retry + // targets exactly what did not move. + const outcome = await moveThreadsToGroup({ + threads: groupMovableSelectedThreads, + customGroupId: clicked.value === "group:none" ? null : clicked.value.slice(6), + move: (threadRef, customGroupId) => + updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, customGroupId }, + }), + }); + removeFromSelection(outcome.movedThreadKeys); + if (outcome.failedCount > 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to move ${outcome.failedCount} thread${outcome.failedCount === 1 ? "" : "s"} to group`, + description: + outcome.firstError instanceof Error + ? outcome.firstError.message + : "An error occurred.", + }), + ); + } + return; + } if (clicked.value?.startsWith("snooze:")) { const preset = clicked.value === "snooze:custom" @@ -5115,6 +5165,7 @@ export default function Sidebar() { clearSelection, confirmThreadDelete, confirmThreadUnpin, + customGroups.groups, deleteThread, markThreadUnread, performSnooze, @@ -5277,6 +5328,7 @@ export default function Sidebar() { worktreePath: thread.worktreePath, envMode: thread.worktreePath ? "worktree" : "local", startFromOrigin: false, + customGroupId: null, }), ); if (result._tag === "Failure") { diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 76ff0967074b..393eee7b0dcd 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -8,6 +8,7 @@ import { useCallback, useMemo } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; import { useProjectAccentColors } from "~/hooks/useProjectAccentColors"; import { useAccentTintSettings, useClientSettings } from "~/hooks/useSettings"; +import { useThreadGroupCatalog } from "~/hooks/useThreadGroups"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; import { selectProjectGroupingSettings } from "~/logicalProject"; import { projectAccentTintStyle } from "~/projectAccentTint"; @@ -60,6 +61,11 @@ export function DraftHeroHeadline({ const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const draftCustomGroupId = useComposerDraftStore((store) => + draftId ? (store.getDraftSession(draftId)?.customGroupId ?? null) : null, + ); + const customGroups = useThreadGroupCatalog(); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const environmentLabelById = useMemo( @@ -254,18 +260,84 @@ export function DraftHeroHeadline({ ? `${activeProjectDisplayName ?? "Choose a project"} to start` : "Add a project to start"; + // The group line appears when the draft's environment can create the + // thread inside a group, and stays while the draft holds a group on an + // environment that cannot, so the user can switch back to Active. A group + // deleted since the pick reads as Active, matching what the send will do. + const supportsGroupCreation = + activeProjectRef !== null && + environments.find((environment) => environment.environmentId === activeProjectRef.environmentId) + ?.serverConfig?.environment.capabilities.threadCustomGroupCreation === true; + const selectedCustomGroup = customGroups.catalog.find((group) => group.id === draftCustomGroupId); + const activeCustomGroup = selectedCustomGroup?.deleted ? null : selectedCustomGroup; + const missingCustomGroup = draftCustomGroupId !== null && selectedCustomGroup === undefined; + const showGroupLine = + draftId !== null && + hasResolvedProject && + (supportsGroupCreation || draftCustomGroupId !== null); + const groupSelector = ( + + + {activeCustomGroup?.name ?? (missingCustomGroup ? "Unavailable" : "Active")} + + + { + if (!draftId) return; + setDraftThreadContext(draftId, { + customGroupId: typeof value === "string" && value.length > 0 ? value : null, + }); + }} + > + + Active + + {customGroups.groups.map((group) => ( + + {group.name} + + ))} + + {supportsGroupCreation ? null : ( + <> + +
+ This environment cannot create threads in a group. +
+ + )} +
+
+ ); + return ( -

- {hasResolvedProject ? ( - <>What should we build in {projectSelector}? - ) : canChooseProject ? ( - <>{projectSelector} to start - ) : ( - <>Add a project to start - )} -

+ <> +

+ {hasResolvedProject ? ( + <>What should we build in {projectSelector}? + ) : canChooseProject ? ( + <>{projectSelector} to start + ) : ( + <>Add a project to start + )} +

+ {showGroupLine ? ( +

+ New thread in {groupSelector} group +

+ ) : null} + ); } diff --git a/apps/web/src/components/sidebar/ThreadGroupsDialog.logic.test.ts b/apps/web/src/components/sidebar/ThreadGroupsDialog.logic.test.ts index aa29bbcd107e..33a12e78ae13 100644 --- a/apps/web/src/components/sidebar/ThreadGroupsDialog.logic.test.ts +++ b/apps/web/src/components/sidebar/ThreadGroupsDialog.logic.test.ts @@ -54,6 +54,18 @@ it("moves groups across the Active divider and back", () => { expect(rowsAfter(down, above)).toEqual([null, "0", "1", "2"]); }); +it("drags a group several rows at once across the Active divider", () => { + // Rows: Active, 0, 1, 2. Dropping "2" on the Active slot moves it above. + const up = planThreadGroupMove(groups, 3, -3)!; + expect(up).toMatchObject({ id: "2", aboveActive: true }); + expect(rowsAfter(up)).toEqual(["2", null, "0", "1"]); + // Rows: 2, Active, 0, 1. Dropping "2" on the last row crosses back down. + const above = visibleThreadGroups(mergeThreadGroups(groups, [{ ...up, revision: "3" }])); + const down = planThreadGroupMove(above, 0, 3)!; + expect(down).toMatchObject({ id: "2", aboveActive: false }); + expect(rowsAfter(down, above)).toEqual([null, "0", "1", "2"]); +}); + it("rejects moves past the first or last row", () => { expect(planThreadGroupMove(groups, 1, -2)).toBeNull(); expect(planThreadGroupMove(groups, 3, 1)).toBeNull(); diff --git a/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx b/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx index 10e19cd38d31..f7748afcb810 100644 --- a/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx +++ b/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx @@ -1,11 +1,24 @@ import { newThreadGroupOrderKey, planThreadGroupMove } from "./ThreadGroupsDialog.logic"; import { threadGroupSections } from "@t3tools/shared/threadGroups"; import { randomUUID } from "~/lib/utils"; -import { useMemo, useRef, useState } from "react"; +import { cn } from "~/lib/utils"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ThreadGroup } from "@t3tools/contracts"; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type Active, + type DragEndEvent, +} from "@dnd-kit/core"; +import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { useThreadGroups } from "~/hooks/useThreadGroups"; import { useThreadGroupsDialog } from "./threadGroupsDialogStore"; -import { ArrowDownIcon, ArrowUpIcon, TrashIcon } from "lucide-react"; +import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, TrashIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { @@ -19,6 +32,22 @@ import { type GroupEdit = Omit; +const ACTIVE_DIVIDER_ID = "active-divider"; + +// The handle is pointer-only; the arrow buttons are the keyboard path, so the +// sortable's default "press space to pick up" instructions would mislead. +const DRAG_ACCESSIBILITY = { + screenReaderInstructions: { + draggable: "Drag with a pointer to reorder, or use the Move up and Move down buttons.", + }, +}; + +/** Attached to each group row's sortable so a drop is applied by the row + * that owns the name input, letting it fold an unsaved rename into the move. */ +interface GroupRowDragData { + readonly reorder: (planned: ThreadGroup) => void; +} + /** Mounted once at the app root; opened through `openThreadGroupsDialog`. */ export function ThreadGroupsDialogHost() { const request = useThreadGroupsDialog((state) => state.request); @@ -50,25 +79,44 @@ export function ThreadGroupsDialog(props: { const [name, setName] = useState(""); const newGroupInputRef = useRef(null); const popupRef = useRef(null); - const [saving, setSaving] = useState(false); + // Outstanding save count: a drop can be saving while a rename still is. + const [pendingSaves, setPendingSaves] = useState(0); + const saving = pendingSaves > 0; const save = async (entries: readonly GroupEdit[]) => { - setSaving(true); + setPendingSaves((count) => count + 1); try { return await props.update(entries); } finally { - setSaving(false); + setPendingSaves((count) => count - 1); } }; - const move = (index: number, delta: number) => { - const group = planThreadGroupMove(props.groups, index, delta); - if (group) void save([group]); - }; - // Groups render around a fixed Active divider; arrows move across it. + const editsDisabled = props.disabled || saving; + // Only a read-only catalog blocks a move. A drop can land while another + // row's rename is still saving; each save writes its own group record, so + // the two never overwrite each other. + const planMove = (index: number, delta: number) => + props.disabled ? null : planThreadGroupMove(props.groups, index, delta); + // Groups render around a fixed Active divider; arrows and drags move across it. const sections = useMemo(() => threadGroupSections(props.groups), [props.groups]); + const rowIds = useMemo( + () => sections.map((group) => (group === null ? ACTIVE_DIVIDER_ID : group.id)), + [sections], + ); + // A small distance threshold keeps a plain click on the handle from + // starting a drag. + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const from = rowIds.indexOf(String(active.id)); + const to = rowIds.indexOf(String(over.id)); + if (from === -1 || to === -1) return; + const planned = planMove(from, to - from); + if (planned) rowDragData(active)?.reorder(planned); + }; return ( - {/* "none" focuses the popup itself so Tab starts at the first group's name; - the default would land in that input and select its text. */} + {/* Focus the popup so Tab starts at the first group control instead of selecting a name. */} - {sections.map((group, index) => - group === null ? ( -
-
- Active -
-
- ) : ( -
- { - const next = event.target.value.trim(); - if (next && next !== group.name) { - const input = event.target; - void save([{ ...group, name: next }]).then((success) => { - if (!success) input.value = group.name; - }); - } else event.target.value = group.name; - }} - /> - - - + + +
+ {sections.map((group, index) => + group === null ? ( + + ) : ( + 0} + canMoveDown={index < sections.length - 1} + planMove={(delta) => planMove(index, delta)} + save={save} + /> + ), + )}
- ), - )} +
+
{ @@ -164,9 +180,9 @@ export function ThreadGroupsDialog(props: { value={name} maxLength={80} onChange={(event) => setName(event.target.value)} - disabled={props.disabled || saving} + disabled={editsDisabled} /> -
@@ -175,3 +191,152 @@ export function ThreadGroupsDialog(props: {
); } + +/** The divider is a sortable slot that cannot be picked up, so dragged + * groups shift it like any other row and crossing it changes sides. */ +function ActiveDividerRow() { + const { setNodeRef, transform, transition } = useSortable({ + id: ACTIVE_DIVIDER_ID, + disabled: { draggable: true }, + }); + return ( +
+
+ Active +
+
+ ); +} + +function rowDragData(active: Active): GroupRowDragData | undefined { + return active.data.current as GroupRowDragData | undefined; +} + +function GroupRow(props: { + group: ThreadGroup; + disabled: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + planMove: (delta: number) => ThreadGroup | null; + save: (entries: readonly GroupEdit[]) => Promise; +}) { + const { group, disabled } = props; + const inputRef = useRef(null); + // A rename folded into a reorder. The name input keeps that text until the + // catalog catches up, so its blur must not resend it on the pre-move record + // and undo the reorder. + const submittedName = useRef(null); + useEffect(() => { + if (submittedName.current === group.name) submittedName.current = null; + }, [group.name]); + const reorder = (planned: ThreadGroup) => { + const next = inputRef.current?.value.trim() ?? ""; + const name = next && next !== group.name ? next : null; + submittedName.current = name; + void props.save([name === null ? planned : { ...planned, name }]).then((success) => { + if (!success && submittedName.current === name) { + submittedName.current = null; + if (name !== null && inputRef.current?.value.trim() === name) { + inputRef.current.value = group.name; + } + } + }); + }; + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: group.id, + disabled: { draggable: disabled }, + data: { reorder } satisfies GroupRowDragData, + }); + return ( +
+ + { + const input = event.target; + const next = input.value.trim(); + if (next === submittedName.current) return; + if (next && next !== group.name) { + void props.save([{ ...group, name: next }]).then((success) => { + if (!success) input.value = group.name; + }); + } else input.value = group.name; + }} + /> + + + +
+ ); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index a90f60e63e04..6b2a906d0401 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1956,6 +1956,29 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("keeps a picked group across project changes and reload until it is explicitly reset", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId, customGroupId: "group-1" }); + // Groups span environments, so a project or environment change keeps the pick. + store.setDraftThreadContext(draftId, { projectRef: remoteProjectRef }); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + expect(store.getDraftThread(draftId)?.customGroupId).toBe("group-1"); + + const options = useComposerDraftStore.persist.getOptions(); + const saved = JSON.parse( + JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())), + ) as unknown; + resetComposerDraftStore(); + useComposerDraftStore.setState(options.merge!(saved, useComposerDraftStore.getState())); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.customGroupId).toBe("group-1"); + + // An explicit Active pick replaces it. + useComposerDraftStore + .getState() + .setProjectDraftThreadId(projectRef, draftId, { threadId, customGroupId: null }); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.customGroupId).toBeNull(); + }); + it("stores the start-from-origin choice with the draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 7c8bb0125b5a..db1754b0ed17 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -325,6 +325,7 @@ const PersistedDraftThreadState = Schema.Struct({ worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + customGroupId: Schema.optionalKey(Schema.NullOr(Schema.String)), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -457,6 +458,8 @@ export interface DraftSessionState { worktreePath: string | null; envMode: DraftThreadEnvMode; startFromOrigin: boolean; + /** Custom thread group the thread joins on first send; null or absent is Active. */ + customGroupId?: string | null; promotedTo?: ScopedThreadRef | null; } @@ -532,6 +535,7 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; environmentSelection?: "auto" | "manual"; loadBalancedEnvironmentId?: EnvironmentId | null; + customGroupId?: string | null; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ @@ -549,6 +553,7 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; environmentSelection?: "auto" | "manual"; loadBalancedEnvironmentId?: EnvironmentId | null; + customGroupId?: string | null; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ @@ -565,6 +570,7 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; environmentSelection?: "auto" | "manual"; loadBalancedEnvironmentId?: EnvironmentId | null; + customGroupId?: string | null; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; @@ -1521,6 +1527,7 @@ function createDraftThreadState( interactionMode?: ProviderInteractionMode; environmentSelection?: "auto" | "manual"; loadBalancedEnvironmentId?: EnvironmentId | null; + customGroupId?: string | null; }, ): DraftThreadState { // A project change (including switching environments within a logical @@ -1573,6 +1580,11 @@ function createDraftThreadState( envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + // Groups span environments, so the choice survives project changes. + customGroupId: + options?.customGroupId === undefined + ? (existingThread?.customGroupId ?? null) + : options.customGroupId, promotedTo: null, }; } @@ -1607,6 +1619,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.worktreePath === right.worktreePath && left.envMode === right.envMode && left.startFromOrigin === right.startFromOrigin && + (left.customGroupId ?? null) === (right.customGroupId ?? null) && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1755,6 +1768,10 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, + ...(typeof candidateDraftThread.customGroupId === "string" && + candidateDraftThread.customGroupId.length > 0 + ? { customGroupId: candidateDraftThread.customGroupId } + : {}), ...(candidateDraftThread.environmentSelection === "manual" || candidateDraftThread.environmentSelection === "auto" ? { environmentSelection: candidateDraftThread.environmentSelection } @@ -2509,6 +2526,9 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + ...(persistedDraftThread.customGroupId != null + ? { customGroupId: persistedDraftThread.customGroupId } + : {}), ...(persistedDraftThread.environmentSelection ? { environmentSelection: persistedDraftThread.environmentSelection } : {}), @@ -2812,6 +2832,10 @@ const composerDraftStore = create()( envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + customGroupId: + options.customGroupId === undefined + ? (existing.customGroupId ?? null) + : options.customGroupId, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2827,6 +2851,7 @@ const composerDraftStore = create()( nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && nextDraftThread.startFromOrigin === existing.startFromOrigin && + nextDraftThread.customGroupId === (existing.customGroupId ?? null) && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ba435c57a4ba..d9b3863b17ef 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -171,6 +171,9 @@ export function useNewThreadHandler() { worktreePath?: string | null; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + /** Custom group the new thread joins on first send; null is Active. + * Absent keeps whatever the reused draft already holds. */ + customGroupId?: string | null; replace?: boolean; }, // Which draft the thread ended up in, so a caller that has something to put in it — a @@ -267,6 +270,13 @@ export function useNewThreadHandler() { const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const hasStartFromOriginOption = options?.startFromOrigin !== undefined; + // A group pick belongs to the unsent draft, like its branch and env + // mode: landing on that draft again (app start, post-settle + // navigation) keeps it, and only an explicit option + // or the picker changes it. A promoted draft is left behind, so the + // next draft starts in Active. + const customGroupFields = + options?.customGroupId === undefined ? {} : { customGroupId: options.customGroupId }; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -396,6 +406,7 @@ export function useNewThreadHandler() { ...workspaceContext, ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), + ...customGroupFields, }, ); const opened = { @@ -444,6 +455,7 @@ export function useNewThreadHandler() { runtimeMode: latestActiveDraftThread.runtimeMode, interactionMode: latestActiveDraftThread.interactionMode, ...pickExplicitWorkspaceOptions(options), + ...customGroupFields, }); return Promise.resolve({ draftId: currentRouteTarget.draftId, @@ -487,6 +499,7 @@ export function useNewThreadHandler() { runtimeMode: racedDraft.runtimeMode, interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), + ...customGroupFields, }); await router.navigate({ to: "/draft/$draftId", @@ -509,6 +522,7 @@ export function useNewThreadHandler() { }), runtimeMode: defaultRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), + ...customGroupFields, }); seedNewDraftModelState({ draftId, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 3536e4b7a54f..5ae74f356361 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -216,6 +216,7 @@ export function useThreadActionMenu(input: { worktreePath: thread.worktreePath, envMode: thread.worktreePath ? "worktree" : "local", startFromOrigin: false, + customGroupId: null, }), ); if (result._tag === "Failure") { diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index c145404a74e2..ed386ccc254e 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -152,7 +152,9 @@ describe("chatThreadActions", () => { ); expect(didStart).toBe(true); - expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + customGroupId: null, + }); }); it("does not start a thread when there is no project context", async () => { diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index c14a26d03d1c..eb3dff643a57 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -25,6 +25,7 @@ interface NewThreadHandler { worktreePath?: string | null; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + customGroupId?: string | null; }, // The opened draft's identity, which most callers have no use for. ): Promise; @@ -97,6 +98,8 @@ export async function startNewThreadFromContext( return false; } - await context.handleNewThread(projectRef); + // An explicit New thread starts in Active; only the group picker or the + // palette's grouped entry chooses a group. + await context.handleNewThread(projectRef, { customGroupId: null }); return true; } diff --git a/apps/web/src/lib/threadGroupMove.test.ts b/apps/web/src/lib/threadGroupMove.test.ts new file mode 100644 index 000000000000..44c4e95f8e84 --- /dev/null +++ b/apps/web/src/lib/threadGroupMove.test.ts @@ -0,0 +1,44 @@ +import { AsyncResult } from "effect/unstable/reactivity"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { describe, expect, it } from "vite-plus/test"; +import { groupMovableThreads, moveThreadsToGroup } from "./threadGroupMove"; + +const thread = (id: string, environmentId = "env-a") => ({ + id: id as ThreadId, + environmentId: environmentId as EnvironmentId, +}); + +describe("groupMovableThreads", () => { + it("keeps only threads whose environment supports groups", () => { + const threads = [thread("t1", "env-a"), thread("t2", "env-b"), thread("t3", "env-a")]; + expect(groupMovableThreads(threads, (environmentId) => environmentId === "env-a")).toEqual([ + threads[0], + threads[2], + ]); + }); +}); + +describe("moveThreadsToGroup", () => { + it("moves every thread and reports the failures without stopping early", async () => { + const calls: Array<[string, string | null]> = []; + const outcome = await moveThreadsToGroup({ + threads: [thread("ok-1"), thread("fails"), thread("ok-2")], + customGroupId: "group-1", + move: async (threadRef, customGroupId) => { + calls.push([threadRef.threadId, customGroupId]); + return threadRef.threadId === "fails" + ? AsyncResult.failure(Cause.fail(new Error("boom"))) + : AsyncResult.success(undefined); + }, + }); + expect(calls).toEqual([ + ["ok-1", "group-1"], + ["fails", "group-1"], + ["ok-2", "group-1"], + ]); + expect(outcome.movedThreadKeys).toEqual(["env-a:ok-1", "env-a:ok-2"]); + expect(outcome.failedCount).toBe(1); + expect(outcome.firstError).toBeInstanceOf(Error); + }); +}); diff --git a/apps/web/src/lib/threadGroupMove.ts b/apps/web/src/lib/threadGroupMove.ts new file mode 100644 index 000000000000..40af08a6bfb0 --- /dev/null +++ b/apps/web/src/lib/threadGroupMove.ts @@ -0,0 +1,58 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; + +export interface GroupMoveThread { + readonly id: ThreadId; + readonly environmentId: EnvironmentId; +} + +/** Threads a bulk move can act on: only those whose server accepts group + * membership. Mixed selections move what they can; the caller labels the + * action with this count so it never promises more than it will touch. */ +export function groupMovableThreads( + threads: ReadonlyArray, + supportsCustomGroups: (environmentId: EnvironmentId) => boolean, +): T[] { + return threads.filter((thread) => supportsCustomGroups(thread.environmentId)); +} + +export interface MoveThreadsToGroupOutcome { + /** Scoped keys of threads whose move succeeded, for dropping them from a selection. */ + readonly movedThreadKeys: string[]; + /** First non-interrupted failure, already squashed to an Error-like value. */ + readonly firstError: unknown; + readonly failedCount: number; +} + +/** Moves each thread independently so one failure never blocks the rest; + * failed threads stay behind for the caller to keep selected and retry. */ +export async function moveThreadsToGroup(input: { + readonly threads: ReadonlyArray; + readonly customGroupId: string | null; + readonly move: ( + threadRef: ScopedThreadRef, + customGroupId: string | null, + ) => Promise>; +}): Promise { + const results = await Promise.all( + input.threads.map(async (thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const result = await input.move(threadRef, input.customGroupId); + return { threadKey: scopedThreadKey(threadRef), result }; + }), + ); + const movedThreadKeys = results.flatMap(({ threadKey, result }) => + result._tag === "Success" ? [threadKey] : [], + ); + const failures = results.flatMap(({ result }) => + result._tag === "Failure" && !isAtomCommandInterrupted(result) + ? [squashAtomCommandFailure(result)] + : [], + ); + return { movedThreadKeys, firstError: failures[0] ?? null, failedCount: failures.length }; +} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 08ed6a778b11..9c24ecac3b57 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -116,9 +116,17 @@ On web and desktop, open the command palette (`Cmd/Ctrl+K`) and choose **New thr **Manage thread groups** to create, rename, reorder, or remove groups; Settings → Extras → Sidebar can add a **Thread groups** button to the sidebar toolbar for the same dialog. Drag a thread onto a group header or between its rows, use **Move current thread to group** in the palette, or use **Move to group** -from the thread menu. Each thread belongs to one group. New threads start in Active. +from the thread menu. Cmd/Ctrl-click multiple threads, then use **Move to group** from their context +menu or **Move selected threads to group** in the palette to move them together. If a move fails, +failed threads remain selected so you can retry. Each thread belongs to one group. Removing a group keeps its threads and returns its active threads to Active. +New threads default to Active, which means no custom group. Choose an existing group below the +new-thread heading, or use **New thread in group…** in the palette to choose the group and then the +project. The palette's choice appears in the new-thread picker and is saved with the draft until +its first send. Forked threads inherit the source thread's group. Creating or forking threads into +a group requires an updated server. + Groups can contain threads from any project or connected environment. Group definitions propagate when a web or mobile client connects to the environments together. An environment that was offline catches up when it shares a connected client with an updated environment. Thread assignments stay @@ -133,7 +141,7 @@ group. Unpinning, waking, or reopening returns them to the group when no other s Grouped threads still auto-settle. New groups start below Active. The Thread groups dialog shows Active as a divider between the -groups; move a group up or down across it to place that group above or below Active. Placement is +groups; drag a group across it, or use the up/down arrows, to place that group above or below Active. Placement is part of the group definition, so every connected client shows the same arrangement. Editing and syncing groups requires an updated server. Older environments' existing groups remain visible, but their catalogs are read-only until those servers are upgraded. diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 5fdb0028c041..01e1c1b9a95b 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -409,37 +409,42 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.created", () => { - it("creates a fresh thread", () => { - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 1, - occurredAt: "2026-04-01T01:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-2"), - type: "thread.created", - payload: { - threadId: ThreadId.make("thread-2"), - projectId: ProjectId.make("project-1"), - title: "New Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - createdAt: "2026-04-01T01:00:00.000Z", - updatedAt: "2026-04-01T01:00:00.000Z", - }, - }); + it.each([undefined, null, "research"])( + "creates a fresh thread in group %s", + (customGroupId) => { + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 1, + occurredAt: "2026-04-01T01:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-2"), + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-2"), + projectId: ProjectId.make("project-1"), + title: "New Thread", + customGroupId, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + }); - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.id).toBe("thread-2"); - expect(result.thread.title).toBe("New Thread"); - expect(result.thread.branch).toBe("main"); - expect(result.thread.messages).toEqual([]); - expect(result.thread.session).toBeNull(); - } - }); + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.id).toBe("thread-2"); + expect(result.thread.title).toBe("New Thread"); + expect(result.thread.customGroupId).toBe(customGroupId ?? null); + expect(result.thread.branch).toBe("main"); + expect(result.thread.messages).toEqual([]); + expect(result.thread.session).toBeNull(); + } + }, + ); }); describe("thread.deleted", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index da2d74875bb2..bfa2b4877aae 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -317,6 +317,7 @@ function applyThreadDetailEventUnretained( projectId: event.payload.projectId, title: event.payload.title, titleState: event.payload.titleState ?? null, + customGroupId: event.payload.customGroupId ?? null, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 0de89f87d69e..3d32cbd61ac7 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -184,6 +184,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server persists manual Active order through thread.active.reorder. */ threadActiveReorder: Schema.optionalKey(Schema.Boolean), threadCustomGroups: Schema.optionalKey(Schema.Boolean), + /** Server accepts customGroupId during thread creation, including first-send bootstrap. */ + threadCustomGroupCreation: Schema.optionalKey(Schema.Boolean), /** Server preserves each group's placement around Active. Older group servers strip it on decode, so clients must not replicate catalogs to them. */ threadGroupPlacement: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 7ee403e417cb..ba2884926571 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -567,6 +567,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => createThread: { projectId: "project-1", title: "Bootstrap thread", + customGroupId: "research", modelSelection: { provider: "codex", model: "gpt-5.4", @@ -589,6 +590,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => createdAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); + assert.strictEqual(parsed.bootstrap?.createThread?.customGroupId, "research"); assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true); assert.strictEqual(parsed.bootstrap?.prepareWorktree?.requireWorktree, true); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b54283e920f1..cdfbe351b8d1 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1227,6 +1227,7 @@ const ProjectDeleteCommand = Schema.Struct({ const ThreadCreateCommand = Schema.Struct({ type: Schema.Literal("thread.create"), + customGroupId: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), commandId: CommandId, threadId: ThreadId, projectId: ProjectId, @@ -1505,6 +1506,7 @@ const ThreadMessageSpeechRequestCommand = Schema.Struct({ }); const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ + customGroupId: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), projectId: ProjectId, title: TrimmedNonEmptyString, titleSource: Schema.optional(Schema.Literal("manual")), @@ -2044,6 +2046,7 @@ export const ProjectDeletedPayload = Schema.Struct({ }); export const ThreadCreatedPayload = Schema.Struct({ + customGroupId: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString,