From 65ecc59aa2ff83eb89d5377aebbc548afa6b69a1 Mon Sep 17 00:00:00 2001 From: pandec Date: Tue, 22 Sep 2026 07:59:57 +0200 Subject: [PATCH 1/3] feat: complete custom thread group workflows --- README.md | 2 +- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 6 + .../Layers/ProjectionPipeline.ts | 1 + .../Services/TurnStartBootstrap.test.ts | 11 +- .../Services/TurnStartBootstrap.ts | 3 + .../decider.custom-groups.test.ts | 58 +++- .../src/orchestration/decider.fork.test.ts | 37 +++ apps/server/src/orchestration/decider.ts | 2 + apps/server/src/orchestration/projector.ts | 1 + .../web/src/components/ChatView.logic.test.ts | 23 ++ apps/web/src/components/ChatView.logic.ts | 26 ++ apps/web/src/components/ChatView.tsx | 22 ++ .../src/components/CommandPalette.logic.ts | 3 +- apps/web/src/components/CommandPalette.tsx | 266 ++++++++++++++---- apps/web/src/components/Sidebar.tsx | 51 ++++ .../src/components/chat/DraftHeroHeadline.tsx | 93 +++++- .../sidebar/ThreadGroupsDialog.logic.test.ts | 12 + .../components/sidebar/ThreadGroupsDialog.tsx | 235 ++++++++++++---- apps/web/src/composerDraftStore.test.ts | 23 ++ apps/web/src/composerDraftStore.ts | 25 ++ apps/web/src/hooks/useHandleNewThread.ts | 10 + apps/web/src/lib/threadGroupMove.test.ts | 44 +++ apps/web/src/lib/threadGroupMove.ts | 58 ++++ docs/user/thread-sidebar.md | 12 +- .../src/state/threadReducer.test.ts | 65 +++-- .../client-runtime/src/state/threadReducer.ts | 1 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestration.test.ts | 2 + packages/contracts/src/orchestration.ts | 3 + 31 files changed, 930 insertions(+), 169 deletions(-) create mode 100644 apps/web/src/lib/threadGroupMove.test.ts create mode 100644 apps/web/src/lib/threadGroupMove.ts 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..0c24048b0f5d 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, @@ -2734,3 +2735,25 @@ describe("worktree setup visibility", () => { }); }); }); + +describe("resolveDraftCreationGroup", () => { + const groups = [{ id: "g1" }]; + + it("blocks the send when the picked group targets a server that cannot create grouped threads", () => { + expect( + resolveDraftCreationGroup({ customGroupId: "g1", groups, supportsGroupCreation: false }), + ).toMatchObject({ customGroupId: "g1", blockReason: expect.stringContaining("Choose Active") }); + expect( + resolveDraftCreationGroup({ customGroupId: "g1", groups, 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", groups, supportsGroupCreation: false }), + ).toEqual({ customGroupId: null, blockReason: null }); + expect( + resolveDraftCreationGroup({ customGroupId: undefined, groups, supportsGroupCreation: false }), + ).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..5d6b1a185c2f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -490,6 +490,32 @@ export function resolveThreadMetadataUpdateForNextTurn(input: { }; } +/** + * Which custom group a draft joins when its first send creates the thread. + * A group deleted since the pick falls back to Active. A live pick sent to a + * server that cannot create grouped threads blocks the send instead of + * creating the thread ungrouped: the choice was explicit, so it is never + * dropped silently. + */ +export function resolveDraftCreationGroup(input: { + readonly customGroupId: string | null | undefined; + readonly groups: ReadonlyArray<{ readonly id: string }>; + readonly supportsGroupCreation: boolean; +}): { readonly customGroupId: string | null; readonly blockReason: string | null } { + const customGroupId = + input.customGroupId != null && input.groups.some((group) => group.id === input.customGroupId) + ? input.customGroupId + : null; + 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..6ae3d881f4ee 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, + groups: customGroupCatalog.groups, + 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.ts b/apps/web/src/components/CommandPalette.logic.ts index 074e8870b710..83c25ac641dc 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -489,7 +489,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..9527fef96308 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,84 @@ 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 === null ? undefined : { customGroupId }, + ); + }, + }); + if (customGroupId === null) return enumerateCommandPaletteItems(items); + return enumerateCommandPaletteItems( + items.map((item, index) => { + const project = pickerProjects[index]!; + const supported = + serverConfigs.get(resolveTargetRef(project).environmentId)?.environment.capabilities + .threadCustomGroupCreation === true; + return supported + ? item + : { ...item, disabled: true, description: "Environment cannot create grouped threads" }; }), - ), + ); + }, [ contextualProjectRef, handleNewThread, @@ -1525,8 +1553,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 +2122,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 +2169,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 +2379,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 ( + selectedThreads.length === 0 && + openThreadCapabilities?.threadCustomGroups === true && + openUnarchivedThreadRef !== null + ) { const threadRef = openUnarchivedThreadRef; actionItems.push({ kind: "submenu", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 448d1f438ff4..12a8596fc124 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, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 76ff0967074b..bffa758d30eb 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,81 @@ 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 activeCustomGroup = + customGroups.groups.find((group) => group.id === draftCustomGroupId) ?? null; + const showGroupLine = + draftId !== null && hasResolvedProject && (supportsGroupCreation || activeCustomGroup !== null); + const groupSelector = ( + + + {activeCustomGroup?.name ?? "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..900769df2cfd 100644 --- a/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx +++ b/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx @@ -1,11 +1,23 @@ import { newThreadGroupOrderKey, planThreadGroupMove } from "./ThreadGroupsDialog.logic"; import { threadGroupSections } from "@t3tools/shared/threadGroups"; import { randomUUID } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { useMemo, useRef, useState } from "react"; import type { ThreadGroup } from "@t3tools/contracts"; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + 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 +31,8 @@ import { type GroupEdit = Omit; +const ACTIVE_DIVIDER_ID = "active-divider"; + /** Mounted once at the app root; opened through `openThreadGroupsDialog`. */ export function ThreadGroupsDialogHost() { const request = useThreadGroupsDialog((state) => state.request); @@ -59,12 +73,29 @@ export function ThreadGroupsDialog(props: { setSaving(false); } }; + const editsDisabled = props.disabled || saving; const move = (index: number, delta: number) => { + if (editsDisabled) return; const group = planThreadGroupMove(props.groups, index, delta); if (group) void save([group]); }; - // Groups render around a fixed Active divider; arrows move across it. + // 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; + move(from, to - from); + }; return ( {/* "none" focuses the popup itself so Tab starts at the first group's name; @@ -81,65 +112,33 @@ export function ThreadGroupsDialog(props: { - {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} + onMove={(delta) => move(index, delta)} + onRename={(next) => save([{ ...group, name: next }])} + onRemove={() => void save([{ ...group, deleted: true }])} + /> + ), + )}
- ), - )} +
+
{ @@ -164,9 +163,9 @@ export function ThreadGroupsDialog(props: { value={name} maxLength={80} onChange={(event) => setName(event.target.value)} - disabled={props.disabled || saving} + disabled={editsDisabled} /> -
@@ -175,3 +174,115 @@ 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 GroupRow(props: { + group: ThreadGroup; + disabled: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + onMove: (delta: number) => void; + onRename: (name: string) => Promise; + onRemove: () => void; +}) { + const { group, disabled } = props; + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: group.id, disabled: { draggable: disabled } }); + return ( +
+ + { + const next = event.target.value.trim(); + if (next && next !== group.name) { + const input = event.target; + void props.onRename(next).then((success) => { + if (!success) input.value = group.name; + }); + } else event.target.value = group.name; + }} + /> + + + +
+ ); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index a90f60e63e04..83151c0f0cfd 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 ordinary new-thread request states Active explicitly and wins. + 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..ffbb994dd39c 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -171,6 +171,8 @@ export function useNewThreadHandler() { worktreePath?: string | null; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + /** Custom group the new thread joins on first send. Absent means Active. */ + 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 +269,10 @@ export function useNewThreadHandler() { const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const hasStartFromOriginOption = options?.startFromOrigin !== undefined; + // Every new-thread request states its group explicitly: an ordinary + // "New thread" resets a reused draft to Active so a group picked for + // an earlier draft never leaks into the next one. + const customGroupId = options?.customGroupId ?? null; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -396,6 +402,7 @@ export function useNewThreadHandler() { ...workspaceContext, ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), + customGroupId, }, ); const opened = { @@ -444,6 +451,7 @@ export function useNewThreadHandler() { runtimeMode: latestActiveDraftThread.runtimeMode, interactionMode: latestActiveDraftThread.interactionMode, ...pickExplicitWorkspaceOptions(options), + customGroupId, }); return Promise.resolve({ draftId: currentRouteTarget.draftId, @@ -487,6 +495,7 @@ export function useNewThreadHandler() { runtimeMode: racedDraft.runtimeMode, interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), + customGroupId, }); await router.navigate({ to: "/draft/$draftId", @@ -509,6 +518,7 @@ export function useNewThreadHandler() { }), runtimeMode: defaultRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), + customGroupId, }); seedNewDraftModelState({ draftId, 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, From b44e109535dba1b28c26416f58f81a4f5cfd8eb8 Mon Sep 17 00:00:00 2001 From: pandec Date: Tue, 22 Sep 2026 08:38:49 +0200 Subject: [PATCH 2/3] fix(web): refine thread group interactions --- .../web/src/components/ChatView.logic.test.ts | 8 +- apps/web/src/components/ChatView.logic.ts | 8 +- .../components/CommandPalette.logic.test.ts | 16 +++ .../src/components/CommandPalette.logic.ts | 4 + apps/web/src/components/CommandPalette.tsx | 27 ++--- apps/web/src/components/LegacySidebar.tsx | 9 +- apps/web/src/components/Sidebar.tsx | 1 + .../components/sidebar/ThreadGroupsDialog.tsx | 107 +++++++++++++----- apps/web/src/composerDraftStore.test.ts | 2 +- apps/web/src/hooks/useHandleNewThread.ts | 22 ++-- apps/web/src/hooks/useThreadActionMenu.ts | 1 + apps/web/src/lib/chatThreadActions.test.ts | 4 +- apps/web/src/lib/chatThreadActions.ts | 5 +- 13 files changed, 148 insertions(+), 66 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0c24048b0f5d..66dd7e9024b6 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1984,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", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 5d6b1a185c2f..1c6686c439ec 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; } 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 83c25ac641dc..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); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 9527fef96308..bb3b4da72ecb 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1527,24 +1527,19 @@ function OpenCommandPaletteDialog(props: { projectAccentColorByTargetKey.get(`${project.environmentId}:${project.id}`) ?? null, icon: projectFavicon, runProject: async (project) => { - await handleNewThread( - resolveTargetRef(project), - customGroupId === null ? undefined : { customGroupId }, - ); + await handleNewThread(resolveTargetRef(project), { customGroupId }); }, + ...(customGroupId === null + ? {} + : { + disabledReason: (project) => + serverConfigs.get(resolveTargetRef(project).environmentId)?.environment.capabilities + .threadCustomGroupCreation === true + ? null + : "Environment cannot create grouped threads", + }), }); - if (customGroupId === null) return enumerateCommandPaletteItems(items); - return enumerateCommandPaletteItems( - items.map((item, index) => { - const project = pickerProjects[index]!; - const supported = - serverConfigs.get(resolveTargetRef(project).environmentId)?.environment.capabilities - .threadCustomGroupCreation === true; - return supported - ? item - : { ...item, disabled: true, description: "Environment cannot create grouped threads" }; - }), - ); + return enumerateCommandPaletteItems(items); }, [ contextualProjectRef, 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 12a8596fc124..b4650a2cb274 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5328,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/sidebar/ThreadGroupsDialog.tsx b/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx index 900769df2cfd..2a42d289e509 100644 --- a/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx +++ b/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx @@ -2,7 +2,7 @@ import { newThreadGroupOrderKey, planThreadGroupMove } from "./ThreadGroupsDialo import { threadGroupSections } from "@t3tools/shared/threadGroups"; import { randomUUID } from "~/lib/utils"; import { cn } from "~/lib/utils"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ThreadGroup } from "@t3tools/contracts"; import { DndContext, @@ -10,6 +10,7 @@ import { closestCenter, useSensor, useSensors, + type Active, type DragEndEvent, } from "@dnd-kit/core"; import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; @@ -33,6 +34,20 @@ 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); @@ -64,21 +79,23 @@ 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 editsDisabled = props.disabled || saving; - const move = (index: number, delta: number) => { - if (editsDisabled) return; - const group = planThreadGroupMove(props.groups, index, delta); - if (group) void save([group]); - }; + // 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( @@ -94,12 +111,12 @@ export function ThreadGroupsDialog(props: { const from = rowIds.indexOf(String(active.id)); const to = rowIds.indexOf(String(over.id)); if (from === -1 || to === -1) return; - move(from, to - from); + 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. */} 0} canMoveDown={index < sections.length - 1} - onMove={(delta) => move(index, delta)} - onRename={(next) => save([{ ...group, name: next }])} - onRemove={() => void save([{ ...group, deleted: true }])} + planMove={(delta) => planMove(index, delta)} + save={save} /> ), )} @@ -197,16 +214,35 @@ function ActiveDividerRow() { ); } +function rowDragData(active: Active): GroupRowDragData | undefined { + return active.data.current as GroupRowDragData | undefined; +} + function GroupRow(props: { group: ThreadGroup; disabled: boolean; canMoveUp: boolean; canMoveDown: boolean; - onMove: (delta: number) => void; - onRename: (name: string) => Promise; - onRemove: () => void; + 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; + }); + }; const { attributes, listeners, @@ -215,7 +251,11 @@ function GroupRow(props: { transform, transition, isDragging, - } = useSortable({ id: group.id, disabled: { draggable: disabled } }); + } = useSortable({ + id: group.id, + disabled: { draggable: disabled }, + data: { reorder } satisfies GroupRowDragData, + }); return (
{ - event.preventDefault(); listeners?.onPointerDown?.(event); + if (document.activeElement === inputRef.current) event.preventDefault(); }} > { - const next = event.target.value.trim(); + const input = event.target; + const next = input.value.trim(); + if (next === submittedName.current) return; if (next && next !== group.name) { - const input = event.target; - void props.onRename(next).then((success) => { + void props.save([{ ...group, name: next }]).then((success) => { if (!success) input.value = group.name; }); - } else event.target.value = group.name; + } else input.value = group.name; }} /> @@ -270,7 +316,10 @@ function GroupRow(props: { size="icon" aria-label={`Move ${group.name} down`} disabled={disabled || !props.canMoveDown} - onClick={() => props.onMove(1)} + onClick={() => { + const planned = props.planMove(1); + if (planned) reorder(planned); + }} > @@ -279,7 +328,7 @@ function GroupRow(props: { size="icon" aria-label={`Remove ${group.name}`} disabled={disabled} - onClick={props.onRemove} + onClick={() => void props.save([{ ...group, deleted: true }])} > diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 83151c0f0cfd..6b2a906d0401 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1972,7 +1972,7 @@ describe("composerDraftStore project draft thread mapping", () => { useComposerDraftStore.setState(options.merge!(saved, useComposerDraftStore.getState())); expect(useComposerDraftStore.getState().getDraftThread(draftId)?.customGroupId).toBe("group-1"); - // An ordinary new-thread request states Active explicitly and wins. + // An explicit Active pick replaces it. useComposerDraftStore .getState() .setProjectDraftThreadId(projectRef, draftId, { threadId, customGroupId: null }); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ffbb994dd39c..d9b3863b17ef 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -171,7 +171,8 @@ export function useNewThreadHandler() { worktreePath?: string | null; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; - /** Custom group the new thread joins on first send. Absent means Active. */ + /** 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; }, @@ -269,10 +270,13 @@ export function useNewThreadHandler() { const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const hasStartFromOriginOption = options?.startFromOrigin !== undefined; - // Every new-thread request states its group explicitly: an ordinary - // "New thread" resets a reused draft to Active so a group picked for - // an earlier draft never leaks into the next one. - const customGroupId = options?.customGroupId ?? null; + // 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) @@ -402,7 +406,7 @@ export function useNewThreadHandler() { ...workspaceContext, ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), - customGroupId, + ...customGroupFields, }, ); const opened = { @@ -451,7 +455,7 @@ export function useNewThreadHandler() { runtimeMode: latestActiveDraftThread.runtimeMode, interactionMode: latestActiveDraftThread.interactionMode, ...pickExplicitWorkspaceOptions(options), - customGroupId, + ...customGroupFields, }); return Promise.resolve({ draftId: currentRouteTarget.draftId, @@ -495,7 +499,7 @@ export function useNewThreadHandler() { runtimeMode: racedDraft.runtimeMode, interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), - customGroupId, + ...customGroupFields, }); await router.navigate({ to: "/draft/$draftId", @@ -518,7 +522,7 @@ export function useNewThreadHandler() { }), runtimeMode: defaultRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), - customGroupId, + ...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; } From 467383dd75d964312d56dc541526de5f24b93995 Mon Sep 17 00:00:00 2001 From: pandec Date: Tue, 22 Sep 2026 08:59:45 +0200 Subject: [PATCH 3/3] fix(web): preserve group choices during incomplete state --- .../web/src/components/ChatView.logic.test.ts | 29 +++++++++++++++---- apps/web/src/components/ChatView.logic.ts | 22 ++++++++------ apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/CommandPalette.tsx | 2 +- .../src/components/chat/DraftHeroHeadline.tsx | 15 ++++++---- .../components/sidebar/ThreadGroupsDialog.tsx | 7 ++++- 6 files changed, 54 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 66dd7e9024b6..e45f1c3457f8 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -2737,23 +2737,42 @@ describe("worktree setup visibility", () => { }); describe("resolveDraftCreationGroup", () => { - const groups = [{ id: "g1" }]; + 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", groups, supportsGroupCreation: false }), + resolveDraftCreationGroup({ customGroupId: "g1", catalog, supportsGroupCreation: false }), ).toMatchObject({ customGroupId: "g1", blockReason: expect.stringContaining("Choose Active") }); expect( - resolveDraftCreationGroup({ customGroupId: "g1", groups, supportsGroupCreation: true }), + 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", groups, supportsGroupCreation: false }), + resolveDraftCreationGroup({ customGroupId: "gone", catalog, supportsGroupCreation: false }), ).toEqual({ customGroupId: null, blockReason: null }); expect( - resolveDraftCreationGroup({ customGroupId: undefined, groups, supportsGroupCreation: false }), + 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 1c6686c439ec..729097d5bbd1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -496,20 +496,24 @@ export function resolveThreadMetadataUpdateForNextTurn(input: { /** * Which custom group a draft joins when its first send creates the thread. - * A group deleted since the pick falls back to Active. A live pick sent to a - * server that cannot create grouped threads blocks the send instead of - * creating the thread ungrouped: the choice was explicit, so it is never - * dropped silently. + * 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 groups: ReadonlyArray<{ readonly id: string }>; + readonly catalog: ReadonlyArray<{ readonly id: string; readonly deleted?: boolean }>; readonly supportsGroupCreation: boolean; }): { readonly customGroupId: string | null; readonly blockReason: string | null } { - const customGroupId = - input.customGroupId != null && input.groups.some((group) => group.id === input.customGroupId) - ? input.customGroupId - : 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, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6ae3d881f4ee..c546be440214 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8015,7 +8015,7 @@ export default function ChatView(props: ChatViewProps) { const creationGroup = isLocalDraftThread ? resolveDraftCreationGroup({ customGroupId: draftThread?.customGroupId, - groups: customGroupCatalog.groups, + catalog: customGroupCatalog.catalog, supportsGroupCreation: appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment .capabilities.threadCustomGroupCreation === true, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index bb3b4da72ecb..cdc774661418 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2426,7 +2426,7 @@ function OpenCommandPaletteDialog(props: { ], }); } else if ( - selectedThreads.length === 0 && + selectedThreadKeys.size === 0 && openThreadCapabilities?.threadCustomGroups === true && openUnarchivedThreadRef !== null ) { diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index bffa758d30eb..393eee7b0dcd 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -268,21 +268,24 @@ export function DraftHeroHeadline({ activeProjectRef !== null && environments.find((environment) => environment.environmentId === activeProjectRef.environmentId) ?.serverConfig?.environment.capabilities.threadCustomGroupCreation === true; - const activeCustomGroup = - customGroups.groups.find((group) => group.id === draftCustomGroupId) ?? null; + 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 || activeCustomGroup !== null); + draftId !== null && + hasResolvedProject && + (supportsGroupCreation || draftCustomGroupId !== null); const groupSelector = ( - {activeCustomGroup?.name ?? "Active"} + {activeCustomGroup?.name ?? (missingCustomGroup ? "Unavailable" : "Active")} { if (!draftId) return; setDraftThreadContext(draftId, { diff --git a/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx b/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx index 2a42d289e509..f7748afcb810 100644 --- a/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx +++ b/apps/web/src/components/sidebar/ThreadGroupsDialog.tsx @@ -240,7 +240,12 @@ function GroupRow(props: { 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 (!success && submittedName.current === name) { + submittedName.current = null; + if (name !== null && inputRef.current?.value.trim() === name) { + inputRef.current.value = group.name; + } + } }); }; const {