Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export const make = Effect.gen(function* () {
threadPinReorder: true,
threadActiveReorder: true,
threadCustomGroups: true,
threadCustomGroupCreation: true,
threadGroupPlacement: true,
threadTitleRegeneration: true,
threadPullRequests: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/Services/TurnStartBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 57 additions & 1 deletion apps/server/src/orchestration/decider.custom-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -61,6 +61,62 @@ function makeReadModel(overrides: Partial<OrchestrationThread> = {}): 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({
Expand Down
37 changes: 37 additions & 0 deletions apps/server/src/orchestration/decider.fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 46 additions & 4 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
rememberCheckoutIsRepo,
resolveBackgroundDraftWorkspaceOptions,
resolveComposerInteractionMode,
resolveDraftCreationGroup,
restorePlanFollowUpComposer,
resolveComposerProviderSelection,
resolveDraftPromotionNavigationTarget,
Expand Down Expand Up @@ -1983,16 +1984,16 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => {

describe("startNewThreadForProject", () => {
it("starts a thread through the supplied shared handler for the active project", () => {
const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = [];
const calls: Array<[{ environmentId: EnvironmentId; projectId: ProjectId }, unknown]> = [];
const projectRef = { environmentId, projectId };

expect(
startNewThreadForProject(projectRef, (nextProjectRef) => {
calls.push(nextProjectRef);
startNewThreadForProject(projectRef, (nextProjectRef, options) => {
calls.push([nextProjectRef, options]);
return Promise.resolve();
}),
).toBe(true);
expect(calls).toEqual([projectRef]);
expect(calls).toEqual([[projectRef, { customGroupId: null }]]);
});

it("does nothing when the active project is unavailable", () => {
Expand Down Expand Up @@ -2734,3 +2735,44 @@ describe("worktree setup visibility", () => {
});
});
});

describe("resolveDraftCreationGroup", () => {
const catalog = [{ id: "g1" }, { id: "gone", deleted: true }];

it("blocks the send when the picked group targets a server that cannot create grouped threads", () => {
expect(
resolveDraftCreationGroup({ customGroupId: "g1", catalog, supportsGroupCreation: false }),
).toMatchObject({ customGroupId: "g1", blockReason: expect.stringContaining("Choose Active") });
expect(
resolveDraftCreationGroup({ customGroupId: "g1", catalog, supportsGroupCreation: true }),
).toEqual({ customGroupId: "g1", blockReason: null });
});

it("falls back to Active for a deleted group and for no pick at all", () => {
expect(
resolveDraftCreationGroup({ customGroupId: "gone", catalog, supportsGroupCreation: false }),
).toEqual({ customGroupId: null, blockReason: null });
expect(
resolveDraftCreationGroup({
customGroupId: undefined,
catalog,
supportsGroupCreation: false,
}),
).toEqual({ customGroupId: null, blockReason: null });
});

it.each([{ catalog: [] }, { catalog: [{ id: "other" }] }])(
"preserves a pick missing from an incomplete catalog",
({ catalog }) => {
expect(
resolveDraftCreationGroup({ customGroupId: "g1", catalog, supportsGroupCreation: true }),
).toEqual({ customGroupId: "g1", blockReason: expect.stringContaining("unavailable") });
},
);

it("allows Active even before the catalog is available", () => {
expect(
resolveDraftCreationGroup({ customGroupId: null, catalog: [], supportsGroupCreation: true }),
).toEqual({ customGroupId: null, blockReason: null });
});
});
38 changes: 36 additions & 2 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,14 @@ export function hasEnvironmentReconnectWarningGraceElapsed(

export function startNewThreadForProject(
projectRef: ScopedProjectRef | null,
handleNewThread: (projectRef: ScopedProjectRef) => Promise<unknown>,
handleNewThread: (
projectRef: ScopedProjectRef,
options: { customGroupId: null },
) => Promise<unknown>,
): boolean {
if (projectRef === null) return false;
void handleNewThread(projectRef);
// An explicit New thread starts in Active.
void handleNewThread(projectRef, { customGroupId: null });

return true;
}
Expand Down Expand Up @@ -490,6 +494,36 @@ export function resolveThreadMetadataUpdateForNextTurn(input: {
};
}

/**
* Which custom group a draft joins when its first send creates the thread.
* An explicit deletion falls back to Active. Missing catalog entries can
* still be loading, so keep the pick and block until it can be checked.
* A live pick also blocks on servers that cannot create grouped threads.
*/
export function resolveDraftCreationGroup(input: {
readonly customGroupId: string | null | undefined;
readonly catalog: ReadonlyArray<{ readonly id: string; readonly deleted?: boolean }>;
readonly supportsGroupCreation: boolean;
}): { readonly customGroupId: string | null; readonly blockReason: string | null } {
const selectedGroup = input.catalog.find((group) => group.id === input.customGroupId);
const customGroupId = selectedGroup?.deleted ? null : (input.customGroupId ?? null);
if (customGroupId !== null && !selectedGroup) {
return {
customGroupId,
blockReason:
"The selected group is unavailable. Wait for its environment to connect, or choose Active before sending.",
};
}
if (customGroupId !== null && !input.supportsGroupCreation) {
return {
customGroupId,
blockReason:
"This environment cannot create threads in a group. Choose Active or update the environment before sending.",
};
}
return { customGroupId, blockReason: null };
}

export function buildLocalDraftThread(
threadId: ThreadId,
draftThread: DraftThreadState,
Expand Down
Loading