diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts
index 6c6cbac49..57bd3065d 100644
--- a/apps/web/src/appSettings.test.ts
+++ b/apps/web/src/appSettings.test.ts
@@ -5,6 +5,8 @@ import {
getSlashModelOptions,
normalizeCustomModelSlugs,
resolveAppModelSelection,
+ resolveSidebarThreadOrder,
+ SIDEBAR_THREAD_ORDER_OPTIONS,
} from "./appSettings";
describe("normalizeCustomModelSlugs", () => {
@@ -82,3 +84,18 @@ describe("getSlashModelOptions", () => {
expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
});
});
+
+describe("resolveSidebarThreadOrder", () => {
+ it("defaults invalid values to recent-activity", () => {
+ expect(resolveSidebarThreadOrder("something-else")).toBe("recent-activity");
+ });
+
+ it("keeps the supported thread ordering preferences", () => {
+ expect(resolveSidebarThreadOrder("recent-activity")).toBe("recent-activity");
+ expect(resolveSidebarThreadOrder("created-at")).toBe("created-at");
+ expect(SIDEBAR_THREAD_ORDER_OPTIONS.map((option) => option.value)).toEqual([
+ "recent-activity",
+ "created-at",
+ ]);
+ });
+});
diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts
index 5ed218fb2..88ef4e10a 100644
--- a/apps/web/src/appSettings.ts
+++ b/apps/web/src/appSettings.ts
@@ -6,6 +6,20 @@ import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/s
const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
const MAX_CUSTOM_MODEL_COUNT = 32;
export const MAX_CUSTOM_MODEL_LENGTH = 256;
+export const SIDEBAR_THREAD_ORDER_OPTIONS = [
+ {
+ value: "recent-activity",
+ label: "Recent activity",
+ description: "Sort chats by the latest user message, final assistant message, plan, or assistant question.",
+ },
+ {
+ value: "created-at",
+ label: "Created time",
+ description: "Sort chats by when the thread was originally created.",
+ },
+] as const;
+export type SidebarThreadOrder = (typeof SIDEBAR_THREAD_ORDER_OPTIONS)[number]["value"];
+const SidebarThreadOrderSchema = Schema.Literals(["recent-activity", "created-at"]);
const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = {
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
};
@@ -21,6 +35,9 @@ const AppSettingsSchema = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
+ sidebarThreadOrder: SidebarThreadOrderSchema.pipe(
+ Schema.withConstructorDefault(() => Option.some("recent-activity")),
+ ),
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
@@ -32,6 +49,10 @@ export interface AppModelOption {
isCustom: boolean;
}
+export function resolveSidebarThreadOrder(value: string | null | undefined): SidebarThreadOrder {
+ return value === "created-at" ? "created-at" : "recent-activity";
+}
+
const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
let listeners: Array<() => void> = [];
@@ -70,6 +91,7 @@ export function normalizeCustomModelSlugs(
function normalizeAppSettings(settings: AppSettings): AppSettings {
return {
...settings,
+ sidebarThreadOrder: resolveSidebarThreadOrder(settings.sidebarThreadOrder),
customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
};
}
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index 0f0250e48..eba15a62f 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -8,6 +8,7 @@ import {
type ProjectId,
type ServerConfig,
type ThreadId,
+ type TurnId,
type WsWelcomePayload,
WS_CHANNELS,
WS_METHODS,
@@ -256,6 +257,69 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
};
}
+function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
+ const snapshot = createSnapshotForTargetUser({
+ targetMessageId: "msg-user-pending-input" as MessageId,
+ targetText: "pending input thread",
+ });
+ const turnId = "turn-pending-user-input" as TurnId;
+ const pendingUserInputActivity: OrchestrationReadModel["threads"][number]["activities"][number] = {
+ id: "activity-pending-user-input" as OrchestrationReadModel["threads"][number]["activities"][number]["id"],
+ createdAt: isoAt(102),
+ tone: "info",
+ kind: "user-input.requested",
+ summary: "User input requested",
+ turnId,
+ payload: {
+ requestId: "req-user-input-browser",
+ questions: [
+ {
+ id: "affected_course",
+ header: "Affected Course",
+ question: "Which student calendar is broken?",
+ options: [
+ {
+ label: "The Combine (Recommended)",
+ description: "Matches the report.",
+ },
+ {
+ label: "The Invitational",
+ description: "Alternative calendar.",
+ },
+ ],
+ },
+ ],
+ },
+ };
+ return {
+ ...snapshot,
+ threads: snapshot.threads.map((thread) =>
+ thread.id !== THREAD_ID
+ ? thread
+ : Object.assign({}, thread, {
+ latestTurn: {
+ turnId,
+ state: "running",
+ requestedAt: isoAt(100),
+ startedAt: isoAt(101),
+ completedAt: null,
+ assistantMessageId: null,
+ },
+ session: {
+ threadId: THREAD_ID,
+ status: "running",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: turnId,
+ lastError: null,
+ updatedAt: isoAt(101),
+ },
+ activities: [pendingUserInputActivity],
+ }),
+ ),
+ };
+}
+
function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-plan-target" as MessageId,
@@ -858,6 +922,43 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});
+ it("submits structured user-input answers from a running thread", async () => {
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: createSnapshotWithPendingUserInput(),
+ });
+
+ try {
+ const optionButton = page.getByRole("button", { name: /The Combine \(Recommended\)/i });
+ await expect.element(optionButton).toBeVisible();
+ await optionButton.click();
+
+ const submitButton = page.getByRole("button", { name: /Submit answers/i });
+ await expect.element(submitButton).toBeEnabled();
+ await submitButton.click();
+
+ await vi.waitFor(
+ () => {
+ const dispatches = wsRequests.filter(
+ (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand,
+ );
+ const userInputCommand = dispatches
+ .map((request) => request.command as { type?: string; requestId?: string } | undefined)
+ .find((command) => command?.type === "thread.user-input.respond");
+ expect(userInputCommand).toEqual(
+ expect.objectContaining({
+ type: "thread.user-input.respond",
+ requestId: "req-user-input-browser",
+ }),
+ );
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
it("toggles plan mode with Shift+Tab only while the composer is focused", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 1023b37e1..cea66a10f 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -873,8 +873,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
[threadActivities],
);
const pendingUserInputs = useMemo(
- () => derivePendingUserInputs(threadActivities),
- [threadActivities],
+ () =>
+ derivePendingUserInputs(threadActivities, {
+ latestTurn: activeLatestTurn,
+ session: activeThread?.session ?? null,
+ }),
+ [activeLatestTurn, activeThread?.session, threadActivities],
);
const activePendingUserInput = pendingUserInputs[0] ?? null;
const activePendingDraftAnswers = useMemo(
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
index d0216d0e4..8c2d9972a 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -72,10 +72,23 @@ describe("resolveThreadStatusPill", () => {
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
+ ).toMatchObject({ label: "Planning", pulse: true });
+ });
+
+ it("shows working for non-plan threads that are actively running", () => {
+ expect(
+ resolveThreadStatusPill({
+ thread: {
+ ...baseThread,
+ interactionMode: "default",
+ },
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ }),
).toMatchObject({ label: "Working", pulse: true });
});
- it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => {
+ it("shows plan submitted when a settled plan turn has a proposed plan ready for follow-up", () => {
expect(
resolveThreadStatusPill({
thread: {
@@ -99,7 +112,34 @@ describe("resolveThreadStatusPill", () => {
hasPendingApprovals: false,
hasPendingUserInput: false,
}),
- ).toMatchObject({ label: "Plan Ready", pulse: false });
+ ).toMatchObject({ label: "Plan Submitted", pulse: false });
+ });
+
+ it("shows errored when the latest turn failed after the thread was last visited", () => {
+ expect(
+ resolveThreadStatusPill({
+ thread: {
+ ...baseThread,
+ latestTurn: {
+ turnId: "turn-1" as never,
+ state: "error",
+ assistantMessageId: null,
+ requestedAt: "2026-03-09T10:00:00.000Z",
+ startedAt: "2026-03-09T10:00:00.000Z",
+ completedAt: "2026-03-09T10:05:00.000Z",
+ },
+ lastVisitedAt: "2026-03-09T10:04:00.000Z",
+ session: {
+ ...baseThread.session,
+ status: "error",
+ updatedAt: "2026-03-09T10:05:00.000Z",
+ orchestrationStatus: "error",
+ },
+ },
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ }),
+ ).toMatchObject({ label: "Errored", pulse: false });
});
it("shows completed when there is an unseen completion and no active blocker", () => {
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index e950d8de6..0d81b740f 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -4,11 +4,13 @@ import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
export interface ThreadStatusPill {
label:
| "Working"
+ | "Planning"
| "Connecting"
| "Completed"
| "Pending Approval"
| "Awaiting Input"
- | "Plan Ready";
+ | "Plan Submitted"
+ | "Errored";
colorClass: string;
dotClass: string;
pulse: boolean;
@@ -19,6 +21,28 @@ type ThreadStatusInput = Pick<
"interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
>;
+function hasUnreadAt(timestamp: string | undefined, lastVisitedAt: string | undefined): boolean {
+ if (!timestamp) {
+ return false;
+ }
+
+ const updatedAt = Date.parse(timestamp);
+ if (Number.isNaN(updatedAt)) {
+ return false;
+ }
+
+ if (!lastVisitedAt) {
+ return true;
+ }
+
+ const visitedAt = Date.parse(lastVisitedAt);
+ if (Number.isNaN(visitedAt)) {
+ return true;
+ }
+
+ return updatedAt > visitedAt;
+}
+
export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
if (!thread.latestTurn?.completedAt) return false;
const completedAt = Date.parse(thread.latestTurn.completedAt);
@@ -55,6 +79,15 @@ export function resolveThreadStatusPill(input: {
};
}
+ if (thread.session?.status === "running" && thread.interactionMode === "plan") {
+ return {
+ label: "Planning",
+ colorClass: "text-cyan-600 dark:text-cyan-300/90",
+ dotClass: "bg-cyan-500 dark:bg-cyan-300/90",
+ pulse: true,
+ };
+ }
+
if (thread.session?.status === "running") {
return {
label: "Working",
@@ -80,9 +113,33 @@ export function resolveThreadStatusPill(input: {
findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null) !== null;
if (hasPlanReadyPrompt) {
return {
- label: "Plan Ready",
- colorClass: "text-violet-600 dark:text-violet-300/90",
- dotClass: "bg-violet-500 dark:bg-violet-300/90",
+ label: "Plan Submitted",
+ colorClass: "text-teal-600 dark:text-teal-300/90",
+ dotClass: "bg-teal-500 dark:bg-teal-300/90",
+ pulse: false,
+ };
+ }
+
+ if (
+ thread.latestTurn?.state === "error" &&
+ hasUnreadAt(thread.latestTurn.completedAt ?? undefined, thread.lastVisitedAt)
+ ) {
+ return {
+ label: "Errored",
+ colorClass: "text-rose-600 dark:text-rose-300/90",
+ dotClass: "bg-rose-500 dark:bg-rose-300/90",
+ pulse: false,
+ };
+ }
+
+ if (
+ thread.session?.status === "error" &&
+ hasUnreadAt(thread.session.updatedAt, thread.lastVisitedAt)
+ ) {
+ return {
+ label: "Errored",
+ colorClass: "text-rose-600 dark:text-rose-300/90",
+ dotClass: "bg-rose-500 dark:bg-rose-300/90",
pulse: false,
};
}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 284ed0da9..955ac9626 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1,7 +1,9 @@
import {
ArrowLeftIcon,
ChevronRightIcon,
+ CircleDotIcon,
FolderIcon,
+ GitBranchIcon,
GitPullRequestIcon,
PlusIcon,
RocketIcon,
@@ -10,22 +12,7 @@ import {
TerminalIcon,
TriangleAlertIcon,
} from "lucide-react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import {
- DndContext,
- type DragCancelEvent,
- type CollisionDetection,
- PointerSensor,
- type DragStartEvent,
- closestCorners,
- pointerWithin,
- useSensor,
- useSensors,
- type DragEndEvent,
-} from "@dnd-kit/core";
-import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
-import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
-import { CSS } from "@dnd-kit/utilities";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
DEFAULT_RUNTIME_MODE,
DEFAULT_MODEL_BY_PROVIDER,
@@ -42,14 +29,48 @@ import { isElectron } from "../env";
import { APP_STAGE_LABEL } from "../branding";
import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
import { useStore } from "../store";
-import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
+import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
+import { type Thread } from "../types";
import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { readNativeApi } from "../nativeApi";
import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
+import { getSidebarThreadSortTimestamp, sortSidebarThreadEntries } from "../sidebarThreadOrder";
+import { buildSidebarGroupContextMenuItems } from "../sidebarGroupContextMenu";
+import {
+ orderProjectsByIds,
+ shouldClearOptimisticProjectOrder,
+ reorderProjectOrder,
+} from "../projectOrder";
+import {
+ animateSidebarReorder,
+ buildSidebarReorderDeltas,
+ collectElementTopPositions,
+ hasSidebarReorderChanged,
+} from "../sidebarReorderAnimation";
+import { preferredTerminalEditor } from "../terminal-links";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { toastManager } from "./ui/toast";
+import {
+ buildProjectChildrenClassName,
+ buildProjectGroupCollapseKey,
+ buildThreadGroupChildrenClassName,
+ buildThreadGroupDragCursorClassName,
+ buildSidebarInteractionClassName,
+ buildThreadGroupChevronClassName,
+ buildThreadGroupComposeButtonClassName,
+ buildThreadGroupDropIndicatorClassName,
+ buildThreadGroupHeaderClassName,
+ buildThreadRowClassName,
+ hasCrossedThreadGroupDragThreshold,
+ isProjectGroupOpen,
+ resolveThreadGroupDropEffect,
+ shouldIgnoreSidebarDragPointerDown,
+ shouldSnapThreadGroupDropToEnd,
+ setProjectGroupCollapsed,
+ shouldRenderProjectComposeButton,
+} from "./sidebarGroupInteractions";
import {
getArm64IntelBuildWarningDescription,
getDesktopUpdateActionError,
@@ -63,7 +84,6 @@ import {
} from "./desktopUpdate.logic";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
-import { Collapsible, CollapsibleContent } from "./ui/collapsible";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import {
SidebarContent,
@@ -82,10 +102,16 @@ import {
} from "./ui/sidebar";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
import { isNonEmpty as isNonEmptyString } from "effect/String";
+import {
+ MAIN_THREAD_GROUP_ID,
+ buildThreadGroupId,
+ orderProjectThreadGroups,
+ resolveProjectThreadGroupPrById,
+ reorderProjectThreadGroupOrder,
+} from "../threadGroups";
import { resolveThreadStatusPill } from "./Sidebar.logic";
const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
-const THREAD_PREVIEW_LIMIT = 6;
async function copyTextToClipboard(text: string): Promise {
if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) {
@@ -118,6 +144,15 @@ interface PrStatusIndicator {
}
type ThreadPr = GitStatusResult["pr"];
+type SidebarGroupEntry = {
+ id: ThreadId;
+ title: string;
+ createdAt: string;
+ branch: string | null;
+ worktreePath: string | null;
+ thread: Thread | null;
+ isDraft: boolean;
+};
function terminalStatusFromRunningIds(
runningTerminalIds: string[],
@@ -222,52 +257,31 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
);
}
-type SortableProjectHandleProps = Pick, "attributes" | "listeners">;
-
-function SortableProjectItem({
- projectId,
- children,
-}: {
- projectId: ProjectId;
- children: (handleProps: SortableProjectHandleProps) => React.ReactNode;
-}) {
- const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } =
- useSortable({ id: projectId });
- return (
-
- {children({ attributes, listeners })}
-
- );
-}
-
export default function Sidebar() {
const projects = useStore((store) => store.projects);
const threads = useStore((store) => store.threads);
+ const syncServerReadModel = useStore((store) => store.syncServerReadModel);
const markThreadUnread = useStore((store) => store.markThreadUnread);
const toggleProject = useStore((store) => store.toggleProject);
- const reorderProjects = useStore((store) => store.reorderProjects);
+ const setProjectExpanded = useStore((store) => store.setProjectExpanded);
const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
- const getDraftThreadByProjectId = useComposerDraftStore(
- (store) => store.getDraftThreadByProjectId,
+ const draftsByThreadId = useComposerDraftStore((store) => store.draftsByThreadId);
+ const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId);
+ const projectGroupDraftThreadIdById = useComposerDraftStore(
+ (store) => store.projectGroupDraftThreadIdById,
+ );
+ const getDraftThreadByProjectGroupId = useComposerDraftStore(
+ (store) => store.getDraftThreadByProjectGroupId,
);
const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
- const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
+ const setProjectGroupDraftThreadId = useComposerDraftStore(
+ (store) => store.setProjectGroupDraftThreadId,
+ );
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
- const clearProjectDraftThreadId = useComposerDraftStore(
- (store) => store.clearProjectDraftThreadId,
+ const clearProjectGroupDraftThreadId = useComposerDraftStore(
+ (store) => store.clearProjectGroupDraftThreadId,
);
const clearProjectDraftThreadById = useComposerDraftStore(
(store) => store.clearProjectDraftThreadById,
@@ -293,16 +307,63 @@ export default function Sidebar() {
const addProjectInputRef = useRef(null);
const [renamingThreadId, setRenamingThreadId] = useState(null);
const [renamingTitle, setRenamingTitle] = useState("");
- const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
- ReadonlySet
- >(() => new Set());
const renamingCommittedRef = useRef(false);
const renamingInputRef = useRef(null);
- const dragInProgressRef = useRef(false);
- const suppressProjectClickAfterDragRef = useRef(false);
const [desktopUpdateState, setDesktopUpdateState] = useState(null);
- const shouldBrowseForProjectImmediately = isElectron;
- const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
+ const [optimisticProjectOrder, setOptimisticProjectOrder] = useState(null);
+ const [optimisticGroupOrderByProjectId, setOptimisticGroupOrderByProjectId] = useState<
+ Record
+ >({});
+ const [draggedProjectId, setDraggedProjectId] = useState(null);
+ const [projectDropTarget, setProjectDropTarget] = useState<{ beforeProjectId: ProjectId | null } | null>(
+ null,
+ );
+ const [draggedGroup, setDraggedGroup] = useState<{ projectId: ProjectId; groupId: string } | null>(
+ null,
+ );
+ const [dropTarget, setDropTarget] = useState<{ projectId: ProjectId; beforeGroupId: string | null } | null>(
+ null,
+ );
+ const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set());
+ const pendingProjectDragRef = useRef<{
+ projectId: ProjectId;
+ startX: number;
+ startY: number;
+ pointerId: number;
+ element: HTMLElement;
+ } | null>(null);
+ const projectRowRefs = useRef(new Map());
+ const threadGroupRowRefs = useRef(new Map());
+ const previousProjectOrderRef = useRef>([]);
+ const previousProjectTopsRef = useRef
+
+
+
Sidebar
+
+ Choose how chats are ordered in each project.
+
+
+
+
+
+
Codex App Server
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index 3d1d269d4..498d489e0 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -220,6 +220,120 @@ describe("derivePendingUserInputs", () => {
},
]);
});
+
+ it("removes prompts when the provider reports an unknown pending user input request", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "user-input-open",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "user-input.requested",
+ summary: "User input requested",
+ tone: "info",
+ payload: {
+ requestId: "req-user-input-1",
+ questions: [
+ {
+ id: "sandbox_mode",
+ header: "Sandbox",
+ question: "Which mode should be used?",
+ options: [
+ {
+ label: "workspace-write",
+ description: "Allow workspace writes only",
+ },
+ ],
+ },
+ ],
+ },
+ }),
+ makeActivity({
+ id: "user-input-failed",
+ createdAt: "2026-02-23T00:00:02.000Z",
+ kind: "provider.user-input.respond.failed",
+ summary: "Provider user input response failed",
+ tone: "error",
+ payload: {
+ requestId: "req-user-input-1",
+ detail:
+ "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending user input request: req-user-input-1",
+ },
+ }),
+ ];
+
+ expect(derivePendingUserInputs(activities)).toEqual([]);
+ });
+
+ it("removes prompts when the thread session is already in error", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "user-input-open",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "user-input.requested",
+ summary: "User input requested",
+ tone: "info",
+ payload: {
+ requestId: "req-user-input-1",
+ questions: [
+ {
+ id: "sandbox_mode",
+ header: "Sandbox",
+ question: "Which mode should be used?",
+ options: [
+ {
+ label: "workspace-write",
+ description: "Allow workspace writes only",
+ },
+ ],
+ },
+ ],
+ },
+ }),
+ ];
+
+ expect(
+ derivePendingUserInputs(activities, {
+ session: { status: "error" },
+ }),
+ ).toEqual([]);
+ });
+
+ it("removes prompts when their turn has already settled", () => {
+ const activities: OrchestrationThreadActivity[] = [
+ makeActivity({
+ id: "user-input-open",
+ createdAt: "2026-02-23T00:00:01.000Z",
+ kind: "user-input.requested",
+ summary: "User input requested",
+ tone: "info",
+ turnId: "turn-1",
+ payload: {
+ requestId: "req-user-input-1",
+ questions: [
+ {
+ id: "sandbox_mode",
+ header: "Sandbox",
+ question: "Which mode should be used?",
+ options: [
+ {
+ label: "workspace-write",
+ description: "Allow workspace writes only",
+ },
+ ],
+ },
+ ],
+ },
+ }),
+ ];
+
+ expect(
+ derivePendingUserInputs(activities, {
+ latestTurn: {
+ turnId: TurnId.makeUnsafe("turn-1"),
+ state: "error",
+ },
+ }),
+ ).toEqual([]);
+ });
});
describe("deriveActivePlanState", () => {
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index e9351ca2b..263193470 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -45,6 +45,11 @@ export interface PendingUserInput {
questions: ReadonlyArray
;
}
+interface PendingUserInputContext {
+ latestTurn?: Pick | null;
+ session?: Pick | null;
+}
+
export interface ActivePlanState {
createdAt: string;
turnId: TurnId | null;
@@ -262,8 +267,12 @@ function parseUserInputQuestions(
export function derivePendingUserInputs(
activities: ReadonlyArray,
+ context?: PendingUserInputContext,
): PendingUserInput[] {
- const openByRequestId = new Map();
+ const openByRequestId = new Map<
+ ApprovalRequestId,
+ PendingUserInput & { turnId: TurnId | null }
+ >();
const ordered = [...activities].toSorted(compareActivitiesByOrder);
for (const activity of ordered) {
@@ -285,18 +294,42 @@ export function derivePendingUserInputs(
requestId,
createdAt: activity.createdAt,
questions,
+ turnId: activity.turnId,
});
continue;
}
if (activity.kind === "user-input.resolved" && requestId) {
openByRequestId.delete(requestId);
+ continue;
+ }
+
+ const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
+ if (
+ activity.kind === "provider.user-input.respond.failed" &&
+ requestId &&
+ detail?.includes("Unknown pending user input request")
+ ) {
+ openByRequestId.delete(requestId);
}
}
- return [...openByRequestId.values()].toSorted((left, right) =>
- left.createdAt.localeCompare(right.createdAt),
- );
+ if (context?.session?.status === "error" || context?.session?.status === "closed") {
+ return [];
+ }
+
+ const latestTurn = context?.latestTurn;
+ if (latestTurn && latestTurn.state !== "running") {
+ for (const [requestId, pending] of openByRequestId.entries()) {
+ if (pending.turnId === latestTurn.turnId) {
+ openByRequestId.delete(requestId);
+ }
+ }
+ }
+
+ return [...openByRequestId.values()]
+ .map(({ turnId: _turnId, ...pending }) => pending)
+ .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
}
export function deriveActivePlanState(
diff --git a/apps/web/src/sidebarGroupContextMenu.test.ts b/apps/web/src/sidebarGroupContextMenu.test.ts
new file mode 100644
index 000000000..a263176bf
--- /dev/null
+++ b/apps/web/src/sidebarGroupContextMenu.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+
+import { buildSidebarGroupContextMenuItems } from "./sidebarGroupContextMenu";
+
+describe("sidebarGroupContextMenu", () => {
+ it("builds a light menu for Main", () => {
+ expect(
+ buildSidebarGroupContextMenuItems({
+ isMainGroup: true,
+ hasBranch: false,
+ hasWorktreePath: false,
+ hasPr: false,
+ }),
+ ).toEqual([
+ { id: "open-workspace", label: "Open workspace" },
+ { id: "copy-workspace-path", label: "Copy workspace path" },
+ { id: "new-chat", label: "New chat" },
+ ]);
+ });
+
+ it("builds a branch-only menu without destructive actions", () => {
+ expect(
+ buildSidebarGroupContextMenuItems({
+ isMainGroup: false,
+ hasBranch: true,
+ hasWorktreePath: false,
+ hasPr: true,
+ }),
+ ).toEqual([
+ { id: "open-workspace", label: "Open workspace" },
+ { id: "copy-branch-name", label: "Copy branch name" },
+ { id: "copy-project-path", label: "Copy project path" },
+ { id: "open-pr", label: "Open PR" },
+ { id: "new-chat", label: "New chat" },
+ ]);
+ });
+
+ it("builds a full worktree menu with destructive action", () => {
+ expect(
+ buildSidebarGroupContextMenuItems({
+ isMainGroup: false,
+ hasBranch: true,
+ hasWorktreePath: true,
+ hasPr: true,
+ }),
+ ).toEqual([
+ { id: "open-workspace", label: "Open workspace" },
+ { id: "copy-branch-name", label: "Copy branch name" },
+ { id: "copy-workspace-path", label: "Copy worktree path" },
+ { id: "open-pr", label: "Open PR" },
+ { id: "new-chat", label: "New chat" },
+ {
+ id: "delete-group-worktree-and-chats",
+ label: "Delete chats and worktree",
+ destructive: true,
+ },
+ ]);
+ });
+});
diff --git a/apps/web/src/sidebarGroupContextMenu.ts b/apps/web/src/sidebarGroupContextMenu.ts
new file mode 100644
index 000000000..8cab8b3ce
--- /dev/null
+++ b/apps/web/src/sidebarGroupContextMenu.ts
@@ -0,0 +1,55 @@
+import type { ContextMenuItem } from "@t3tools/contracts";
+
+export type SidebarGroupContextMenuAction =
+ | "open-workspace"
+ | "copy-workspace-path"
+ | "copy-project-path"
+ | "copy-branch-name"
+ | "open-pr"
+ | "new-chat"
+ | "delete-group-worktree-and-chats";
+
+export function buildSidebarGroupContextMenuItems(input: {
+ isMainGroup: boolean;
+ hasBranch: boolean;
+ hasWorktreePath: boolean;
+ hasPr: boolean;
+}): ContextMenuItem[] {
+ if (input.isMainGroup) {
+ return [
+ { id: "open-workspace", label: "Open workspace" },
+ { id: "copy-workspace-path", label: "Copy workspace path" },
+ { id: "new-chat", label: "New chat" },
+ ];
+ }
+
+ const items: ContextMenuItem[] = [
+ { id: "open-workspace", label: "Open workspace" },
+ ];
+
+ if (input.hasBranch) {
+ items.push({ id: "copy-branch-name", label: "Copy branch name" });
+ }
+
+ items.push(
+ input.hasWorktreePath
+ ? { id: "copy-workspace-path", label: "Copy worktree path" }
+ : { id: "copy-project-path", label: "Copy project path" },
+ );
+
+ if (input.hasPr) {
+ items.push({ id: "open-pr", label: "Open PR" });
+ }
+
+ items.push({ id: "new-chat", label: "New chat" });
+
+ if (input.hasWorktreePath) {
+ items.push({
+ id: "delete-group-worktree-and-chats",
+ label: "Delete chats and worktree",
+ destructive: true,
+ });
+ }
+
+ return items;
+}
diff --git a/apps/web/src/sidebarReorderAnimation.test.ts b/apps/web/src/sidebarReorderAnimation.test.ts
new file mode 100644
index 000000000..c76df03ad
--- /dev/null
+++ b/apps/web/src/sidebarReorderAnimation.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ animateSidebarReorder,
+ buildSidebarReorderDeltas,
+ collectElementTopPositions,
+ hasSidebarReorderChanged,
+ SIDEBAR_REORDER_TRANSITION,
+} from "./sidebarReorderAnimation";
+
+function makeElement(top: number) {
+ return {
+ style: {
+ transition: "",
+ transform: "",
+ willChange: "",
+ },
+ getBoundingClientRect: () => ({ top }),
+ };
+}
+
+describe("sidebarReorderAnimation", () => {
+ it("detects when the visible order changes", () => {
+ expect(
+ hasSidebarReorderChanged(["project-a", "project-b"], ["project-a", "project-b"]),
+ ).toBe(false);
+ expect(
+ hasSidebarReorderChanged(["project-a", "project-b"], ["project-b", "project-a"]),
+ ).toBe(true);
+ expect(hasSidebarReorderChanged(["project-a"], ["project-a", "project-b"])).toBe(true);
+ });
+
+ it("builds vertical animation deltas from previous and next row positions", () => {
+ expect(
+ buildSidebarReorderDeltas(
+ new Map([
+ ["project-a", 20],
+ ["project-b", 60],
+ ]),
+ new Map([
+ ["project-a", 60],
+ ["project-b", 20],
+ ]),
+ ),
+ ).toEqual([
+ { id: "project-a", deltaY: -40 },
+ { id: "project-b", deltaY: 40 },
+ ]);
+ });
+
+ it("ignores rows without meaningful movement", () => {
+ expect(
+ buildSidebarReorderDeltas(
+ new Map([
+ ["project-a", 20],
+ ["project-b", 60],
+ ]),
+ new Map([
+ ["project-a", 20.2],
+ ["project-b", 60],
+ ]),
+ ),
+ ).toEqual([]);
+ });
+
+ it("collects top positions from the current rendered row map", () => {
+ expect(
+ collectElementTopPositions(
+ new Map([
+ ["project-a", makeElement(20)],
+ ["project-b", makeElement(60)],
+ ]),
+ ),
+ ).toEqual(
+ new Map([
+ ["project-a", 20],
+ ["project-b", 60],
+ ]),
+ );
+ });
+
+ it("does not let a stale cleanup cancel a newer reorder animation", () => {
+ const project = makeElement(20);
+ const animationFrames: Array<() => void> = [];
+ const timeouts: Array<() => void> = [];
+
+ animateSidebarReorder(
+ new Map([["project-a", project]]),
+ [{ id: "project-a", deltaY: 40 }],
+ {
+ requestAnimationFrame: (callback) => animationFrames.push(callback),
+ setTimeout: (callback) => timeouts.push(callback),
+ },
+ );
+
+ animationFrames.shift()?.();
+ expect(project.style.transition).toBe(SIDEBAR_REORDER_TRANSITION);
+ expect(project.style.transform).toBe("translateY(0)");
+
+ animateSidebarReorder(
+ new Map([["project-a", project]]),
+ [{ id: "project-a", deltaY: -40 }],
+ {
+ requestAnimationFrame: (callback) => animationFrames.push(callback),
+ setTimeout: (callback) => timeouts.push(callback),
+ },
+ );
+
+ animationFrames.shift()?.();
+ expect(project.style.transition).toBe(SIDEBAR_REORDER_TRANSITION);
+ expect(project.style.transform).toBe("translateY(0)");
+
+ timeouts.shift()?.();
+ expect(project.style.transition).toBe(SIDEBAR_REORDER_TRANSITION);
+ expect(project.style.transform).toBe("translateY(0)");
+ expect(project.style.willChange).toBe("transform");
+
+ timeouts.shift()?.();
+ expect(project.style.transition).toBe("");
+ expect(project.style.transform).toBe("");
+ expect(project.style.willChange).toBe("");
+ });
+});
diff --git a/apps/web/src/sidebarReorderAnimation.ts b/apps/web/src/sidebarReorderAnimation.ts
new file mode 100644
index 000000000..7d2ea5ada
--- /dev/null
+++ b/apps/web/src/sidebarReorderAnimation.ts
@@ -0,0 +1,117 @@
+export interface SidebarReorderDelta {
+ id: T;
+ deltaY: number;
+}
+
+export const SIDEBAR_REORDER_TRANSITION = "transform 200ms ease-out";
+
+interface SidebarAnimatableElement {
+ style: {
+ transition: string;
+ transform: string;
+ willChange: string;
+ };
+ getBoundingClientRect(): { top: number };
+}
+
+interface SidebarAnimationScheduler {
+ requestAnimationFrame: (callback: () => void) => void;
+ setTimeout: (callback: () => void, delayMs: number) => void;
+}
+
+const activeSidebarAnimationTokenByElement = new WeakMap();
+
+export function hasSidebarReorderChanged(
+ previousOrder: ReadonlyArray,
+ nextOrder: ReadonlyArray,
+): boolean {
+ if (previousOrder.length !== nextOrder.length) {
+ return true;
+ }
+
+ return previousOrder.some((id, index) => id !== nextOrder[index]);
+}
+
+export function buildSidebarReorderDeltas(
+ previousTops: ReadonlyMap,
+ nextTops: ReadonlyMap,
+): Array> {
+ const deltas: Array> = [];
+
+ for (const [id, nextTop] of nextTops.entries()) {
+ const previousTop = previousTops.get(id);
+ if (previousTop === undefined) {
+ continue;
+ }
+
+ const deltaY = previousTop - nextTop;
+ if (Math.abs(deltaY) < 0.5) {
+ continue;
+ }
+
+ deltas.push({ id, deltaY });
+ }
+
+ return deltas;
+}
+
+export function collectElementTopPositions(
+ elements: ReadonlyMap,
+): Map {
+ return new Map(
+ [...elements.entries()].map(([id, element]) => [id, element.getBoundingClientRect().top] as const),
+ );
+}
+
+export function animateSidebarReorder(
+ elements: ReadonlyMap,
+ deltas: ReadonlyArray>,
+ scheduler?: SidebarAnimationScheduler,
+): void {
+ if (deltas.length === 0) {
+ return;
+ }
+
+ const resolvedScheduler = scheduler ?? {
+ requestAnimationFrame: (callback: () => void) => window.requestAnimationFrame(callback),
+ setTimeout: (callback: () => void, delayMs: number) => {
+ window.setTimeout(callback, delayMs);
+ },
+ };
+
+ for (const { id, deltaY } of deltas) {
+ const element = elements.get(id);
+ if (!element) {
+ continue;
+ }
+
+ const nextToken = (activeSidebarAnimationTokenByElement.get(element) ?? 0) + 1;
+ activeSidebarAnimationTokenByElement.set(element, nextToken);
+
+ element.style.transition = "none";
+ element.style.transform = `translateY(${deltaY}px)`;
+ element.style.willChange = "transform";
+ element.getBoundingClientRect();
+
+ const cleanup = () => {
+ if (activeSidebarAnimationTokenByElement.get(element) !== nextToken) {
+ return;
+ }
+
+ element.style.transition = "";
+ element.style.transform = "";
+ element.style.willChange = "";
+ activeSidebarAnimationTokenByElement.delete(element);
+ };
+
+ resolvedScheduler.requestAnimationFrame(() => {
+ if (activeSidebarAnimationTokenByElement.get(element) !== nextToken) {
+ return;
+ }
+
+ element.style.transition = SIDEBAR_REORDER_TRANSITION;
+ element.style.transform = "translateY(0)";
+ resolvedScheduler.setTimeout(cleanup, 220);
+ });
+ }
+}
diff --git a/apps/web/src/sidebarThreadOrder.test.ts b/apps/web/src/sidebarThreadOrder.test.ts
new file mode 100644
index 000000000..ee4a04c07
--- /dev/null
+++ b/apps/web/src/sidebarThreadOrder.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it } from "vitest";
+
+import { sortSidebarThreadEntries } from "./sidebarThreadOrder";
+import { type Thread } from "./types";
+
+function makeThread(overrides: Partial): Thread {
+ return {
+ id: "thread" as Thread["id"],
+ codexThreadId: null,
+ projectId: "project" as Thread["projectId"],
+ title: "Thread",
+ model: "gpt-5.4",
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ session: null,
+ messages: [],
+ turnDiffSummaries: [],
+ activities: [],
+ proposedPlans: [],
+ error: null,
+ createdAt: "2026-03-09T12:00:00.000Z",
+ latestTurn: null,
+ branch: null,
+ worktreePath: null,
+ ...overrides,
+ };
+}
+
+describe("sortSidebarThreadEntries", () => {
+ it("sorts by recent activity when configured", () => {
+ const olderThread = makeThread({
+ id: "thread-older" as Thread["id"],
+ createdAt: "2026-03-09T11:00:00.000Z",
+ messages: [
+ {
+ id: "older-user" as Thread["messages"][number]["id"],
+ role: "user",
+ text: "hello",
+ streaming: false,
+ createdAt: "2026-03-09T11:01:00.000Z",
+ },
+ ],
+ });
+ const newerActivityThread = makeThread({
+ id: "thread-new-activity" as Thread["id"],
+ createdAt: "2026-03-09T10:00:00.000Z",
+ messages: [
+ {
+ id: "new-user" as Thread["messages"][number]["id"],
+ role: "user",
+ text: "latest",
+ streaming: false,
+ createdAt: "2026-03-09T12:30:00.000Z",
+ },
+ ],
+ });
+
+ const ordered = sortSidebarThreadEntries(
+ [
+ { id: olderThread.id, createdAt: olderThread.createdAt, thread: olderThread },
+ {
+ id: newerActivityThread.id,
+ createdAt: newerActivityThread.createdAt,
+ thread: newerActivityThread,
+ },
+ ],
+ "recent-activity",
+ );
+
+ expect(ordered.map((entry) => entry.id)).toEqual([
+ newerActivityThread.id,
+ olderThread.id,
+ ]);
+ });
+
+ it("sorts by created-at when configured", () => {
+ const olderThread = makeThread({
+ id: "thread-older" as Thread["id"],
+ createdAt: "2026-03-09T11:00:00.000Z",
+ messages: [
+ {
+ id: "older-user" as Thread["messages"][number]["id"],
+ role: "user",
+ text: "latest",
+ streaming: false,
+ createdAt: "2026-03-09T12:30:00.000Z",
+ },
+ ],
+ });
+ const newerThread = makeThread({
+ id: "thread-newer" as Thread["id"],
+ createdAt: "2026-03-09T12:00:00.000Z",
+ });
+
+ const ordered = sortSidebarThreadEntries(
+ [
+ { id: olderThread.id, createdAt: olderThread.createdAt, thread: olderThread },
+ { id: newerThread.id, createdAt: newerThread.createdAt, thread: newerThread },
+ ],
+ "created-at",
+ );
+
+ expect(ordered.map((entry) => entry.id)).toEqual([newerThread.id, olderThread.id]);
+ });
+
+ it("treats proposed plans and pending questions as recent activity", () => {
+ const planThread = makeThread({
+ id: "thread-plan" as Thread["id"],
+ createdAt: "2026-03-09T08:00:00.000Z",
+ proposedPlans: [
+ {
+ id: "plan-1" as Thread["proposedPlans"][number]["id"],
+ turnId: null,
+ planMarkdown: "# plan",
+ createdAt: "2026-03-09T09:00:00.000Z",
+ updatedAt: "2026-03-09T12:15:00.000Z",
+ },
+ ],
+ });
+ const questionThread = makeThread({
+ id: "thread-question" as Thread["id"],
+ createdAt: "2026-03-09T07:00:00.000Z",
+ activities: [
+ {
+ id: "act-1" as Thread["activities"][number]["id"],
+ createdAt: "2026-03-09T12:20:00.000Z",
+ kind: "user-input.requested",
+ summary: "Need input",
+ tone: "info",
+ payload: {
+ requestId: "req-1",
+ questions: [
+ {
+ id: "answer",
+ header: "Answer",
+ question: "Need input",
+ options: [
+ {
+ label: "continue",
+ description: "Continue execution",
+ },
+ ],
+ },
+ ],
+ },
+ turnId: null,
+ sequence: 1,
+ },
+ ],
+ });
+
+ const ordered = sortSidebarThreadEntries(
+ [
+ { id: planThread.id, createdAt: planThread.createdAt, thread: planThread },
+ { id: questionThread.id, createdAt: questionThread.createdAt, thread: questionThread },
+ ],
+ "recent-activity",
+ );
+
+ expect(ordered.map((entry) => entry.id)).toEqual([questionThread.id, planThread.id]);
+ });
+});
diff --git a/apps/web/src/sidebarThreadOrder.ts b/apps/web/src/sidebarThreadOrder.ts
new file mode 100644
index 000000000..5d4551ba6
--- /dev/null
+++ b/apps/web/src/sidebarThreadOrder.ts
@@ -0,0 +1,73 @@
+import { derivePendingUserInputs } from "./session-logic";
+import type { Thread } from "./types";
+import type { SidebarThreadOrder } from "./appSettings";
+
+export interface SidebarThreadEntryLike {
+ id: string;
+ createdAt: string;
+ thread: Thread | null;
+}
+
+function latestIso(left: string, right: string): string {
+ return left.localeCompare(right) >= 0 ? left : right;
+}
+
+export function getSidebarThreadRecentActivityAt(entry: SidebarThreadEntryLike): string {
+ const thread = entry.thread;
+ if (thread === null) {
+ return entry.createdAt;
+ }
+
+ let latestActivityAt = latestIso(entry.createdAt, thread.createdAt);
+
+ for (const message of thread.messages) {
+ if (message.role === "user") {
+ latestActivityAt = latestIso(latestActivityAt, message.createdAt);
+ continue;
+ }
+
+ if (message.role === "assistant" && !message.streaming && message.completedAt) {
+ latestActivityAt = latestIso(latestActivityAt, message.completedAt);
+ }
+ }
+
+ for (const proposedPlan of thread.proposedPlans) {
+ latestActivityAt = latestIso(latestActivityAt, proposedPlan.updatedAt);
+ }
+
+ for (
+ const pendingUserInput of derivePendingUserInputs(thread.activities, {
+ latestTurn: thread.latestTurn,
+ session: thread.session,
+ })
+ ) {
+ latestActivityAt = latestIso(latestActivityAt, pendingUserInput.createdAt);
+ }
+
+ return latestActivityAt;
+}
+
+export function getSidebarThreadSortTimestamp(
+ entry: SidebarThreadEntryLike,
+ order: SidebarThreadOrder,
+): string {
+ return order === "created-at" ? entry.createdAt : getSidebarThreadRecentActivityAt(entry);
+}
+
+export function sortSidebarThreadEntries(
+ entries: readonly T[],
+ order: SidebarThreadOrder,
+): T[] {
+ return [...entries].toSorted((left, right) => {
+ const bySortTimestamp =
+ getSidebarThreadSortTimestamp(right, order).localeCompare(
+ getSidebarThreadSortTimestamp(left, order),
+ );
+ if (bySortTimestamp !== 0) return bySortTimestamp;
+
+ const byCreatedAt = right.createdAt.localeCompare(left.createdAt);
+ if (byCreatedAt !== 0) return byCreatedAt;
+
+ return right.id.localeCompare(left.id);
+ });
+}
diff --git a/apps/web/src/threadGroups.test.ts b/apps/web/src/threadGroups.test.ts
new file mode 100644
index 000000000..dcf222323
--- /dev/null
+++ b/apps/web/src/threadGroups.test.ts
@@ -0,0 +1,371 @@
+import { ProjectId, ThreadId, type GitStatusResult } from "@t3tools/contracts";
+import { describe, expect, it } from "vitest";
+
+import {
+ MAIN_THREAD_GROUP_ID,
+ buildThreadGroupId,
+ orderProjectThreadGroups,
+ resolveProjectThreadGroupPrById,
+ reorderProjectThreadGroupOrder,
+} from "./threadGroups";
+import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
+
+function makeThread(overrides: Partial = {}): Thread {
+ return {
+ id: ThreadId.makeUnsafe("thread-1"),
+ codexThreadId: null,
+ projectId: ProjectId.makeUnsafe("project-1"),
+ title: "Thread",
+ model: "gpt-5-codex",
+ runtimeMode: DEFAULT_RUNTIME_MODE,
+ interactionMode: DEFAULT_INTERACTION_MODE,
+ session: null,
+ messages: [],
+ turnDiffSummaries: [],
+ activities: [],
+ proposedPlans: [],
+ error: null,
+ createdAt: "2026-03-01T00:00:00.000Z",
+ latestTurn: null,
+ branch: null,
+ worktreePath: null,
+ ...overrides,
+ };
+}
+
+function makeGitStatus(overrides: Partial = {}): GitStatusResult {
+ return {
+ branch: "feature/a",
+ hasWorkingTreeChanges: false,
+ workingTree: {
+ files: [],
+ insertions: 0,
+ deletions: 0,
+ },
+ hasUpstream: true,
+ aheadCount: 0,
+ behindCount: 0,
+ pr: null,
+ ...overrides,
+ };
+}
+
+describe("threadGroups", () => {
+ it("uses worktree identity before branch identity", () => {
+ expect(
+ buildThreadGroupId({
+ branch: "feature/a",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ }),
+ ).toBe("worktree:/tmp/project/.t3/worktrees/feature-a");
+ expect(buildThreadGroupId({ branch: "feature/a", worktreePath: null })).toBe("branch:feature/a");
+ expect(buildThreadGroupId({ branch: null, worktreePath: null })).toBe(MAIN_THREAD_GROUP_ID);
+ });
+
+ it("labels worktree groups from the path when branch metadata is missing", () => {
+ const [mainGroup, worktreeGroup] = orderProjectThreadGroups({
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-main"),
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree"),
+ branch: null,
+ worktreePath: "/tmp/project/.t3/worktrees/feature-draft-only",
+ }),
+ ],
+ });
+
+ expect(mainGroup?.label).toBe("Main");
+ expect(worktreeGroup?.label).toBe("feature-draft-only");
+ });
+
+ it("normalizes stored metadata used for PR resolution", () => {
+ const groups = orderProjectThreadGroups({
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree-spaced"),
+ branch: " feature/a ",
+ worktreePath: " /tmp/project/.t3/worktrees/feature-a ",
+ }),
+ ],
+ });
+
+ expect(groups[1]).toMatchObject({
+ id: "worktree:/tmp/project/.t3/worktrees/feature-a",
+ branch: "feature/a",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ label: "feature/a",
+ });
+
+ const prByGroupId = resolveProjectThreadGroupPrById({
+ groups,
+ projectCwd: "/tmp/project",
+ statusByCwd: new Map([
+ [
+ "/tmp/project/.t3/worktrees/feature-a",
+ makeGitStatus({
+ branch: "feature/a",
+ pr: {
+ number: 12,
+ title: "Feature A",
+ url: "https://example.com/pr/12",
+ baseBranch: "main",
+ headBranch: "feature/a",
+ state: "open",
+ },
+ }),
+ ],
+ ]),
+ });
+
+ expect(prByGroupId.get("worktree:/tmp/project/.t3/worktrees/feature-a")?.number).toBe(12);
+ });
+
+ it("refreshes group metadata when a newer thread wins the same normalized group", () => {
+ const groups = orderProjectThreadGroups({
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree-older"),
+ branch: null,
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ createdAt: "2026-03-01T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree-newer"),
+ branch: "feature/a",
+ worktreePath: " /tmp/project/.t3/worktrees/feature-a ",
+ createdAt: "2026-03-05T00:00:00.000Z",
+ }),
+ ],
+ });
+
+ expect(groups[1]).toMatchObject({
+ id: "worktree:/tmp/project/.t3/worktrees/feature-a",
+ branch: "feature/a",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ label: "feature/a",
+ latestActivityAt: "2026-03-05T00:00:00.000Z",
+ });
+ });
+
+ it("pins Main first, inserts new groups next, then keeps shared project order", () => {
+ const groups = orderProjectThreadGroups({
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-main"),
+ createdAt: "2026-03-01T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-feature-b"),
+ branch: "feature/b",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-b",
+ createdAt: "2026-03-05T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-feature-a"),
+ branch: "feature/a",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ createdAt: "2026-03-04T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-release"),
+ branch: "release/1.0",
+ worktreePath: null,
+ createdAt: "2026-03-03T00:00:00.000Z",
+ }),
+ ],
+ orderedGroupIds: ["worktree:/tmp/project/.t3/worktrees/feature-a", "branch:release/1.0"],
+ });
+
+ expect(groups.map((group) => group.id)).toEqual([
+ MAIN_THREAD_GROUP_ID,
+ "worktree:/tmp/project/.t3/worktrees/feature-b",
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ "branch:release/1.0",
+ ]);
+ });
+
+ it("reorders non-main groups without losing unknown entries", () => {
+ expect(
+ reorderProjectThreadGroupOrder({
+ currentOrder: ["worktree:/tmp/project/.t3/worktrees/feature-a", "branch:release/1.0"],
+ movedGroupId: "branch:release/1.0",
+ beforeGroupId: "worktree:/tmp/project/.t3/worktrees/feature-a",
+ }),
+ ).toEqual(["branch:release/1.0", "worktree:/tmp/project/.t3/worktrees/feature-a"]);
+ });
+
+ it("treats dropping a group on itself as a no-op", () => {
+ expect(
+ reorderProjectThreadGroupOrder({
+ currentOrder: ["worktree:/tmp/project/.t3/worktrees/feature-a", "branch:release/1.0"],
+ movedGroupId: "branch:release/1.0",
+ beforeGroupId: "branch:release/1.0",
+ }),
+ ).toEqual(["worktree:/tmp/project/.t3/worktrees/feature-a", "branch:release/1.0"]);
+ });
+
+ it("ignores Main and duplicate ids from shared project order", () => {
+ const groups = orderProjectThreadGroups({
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-main"),
+ createdAt: "2026-03-01T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-release"),
+ branch: "release/1.0",
+ worktreePath: null,
+ createdAt: "2026-03-03T00:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-feature-a"),
+ branch: "feature/a",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-a",
+ createdAt: "2026-03-04T00:00:00.000Z",
+ }),
+ ],
+ orderedGroupIds: [
+ MAIN_THREAD_GROUP_ID,
+ "branch:release/1.0",
+ "branch:release/1.0",
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ ],
+ });
+
+ expect(groups.map((group) => group.id)).toEqual([
+ MAIN_THREAD_GROUP_ID,
+ "branch:release/1.0",
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ ]);
+ });
+
+ it("keeps reordered shared group order non-main and unique", () => {
+ expect(
+ reorderProjectThreadGroupOrder({
+ currentOrder: [
+ MAIN_THREAD_GROUP_ID,
+ "branch:release/1.0",
+ "branch:release/1.0",
+ ],
+ movedGroupId: "worktree:/tmp/project/.t3/worktrees/feature-a",
+ beforeGroupId: MAIN_THREAD_GROUP_ID,
+ }),
+ ).toEqual([
+ "worktree:/tmp/project/.t3/worktrees/feature-a",
+ "branch:release/1.0",
+ ]);
+ });
+
+ it("includes draft-only groups in ordering", () => {
+ const groups = orderProjectThreadGroups({
+ threads: [
+ {
+ branch: "feature/draft-only",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-draft-only",
+ createdAt: "2026-03-06T00:00:00.000Z",
+ },
+ ],
+ });
+
+ expect(groups.map((group) => group.id)).toEqual([
+ MAIN_THREAD_GROUP_ID,
+ "worktree:/tmp/project/.t3/worktrees/feature-draft-only",
+ ]);
+ });
+
+ it("resolves PR state for worktree and branch groups but never Main", () => {
+ const groups = orderProjectThreadGroups({
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-main"),
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-branch"),
+ branch: "feature/a",
+ worktreePath: null,
+ }),
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree"),
+ branch: "feature/b",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-b",
+ }),
+ ],
+ });
+
+ const prByGroupId = resolveProjectThreadGroupPrById({
+ groups,
+ projectCwd: "/tmp/project",
+ statusByCwd: new Map([
+ [
+ "/tmp/project",
+ makeGitStatus({
+ branch: "feature/a",
+ pr: {
+ number: 12,
+ title: "Feature A",
+ url: "https://example.com/pr/12",
+ baseBranch: "main",
+ headBranch: "feature/a",
+ state: "open",
+ },
+ }),
+ ],
+ [
+ "/tmp/project/.t3/worktrees/feature-b",
+ makeGitStatus({
+ branch: "feature/b",
+ pr: {
+ number: 34,
+ title: "Feature B",
+ url: "https://example.com/pr/34",
+ baseBranch: "main",
+ headBranch: "feature/b",
+ state: "merged",
+ },
+ }),
+ ],
+ ]),
+ });
+
+ expect(prByGroupId.get(MAIN_THREAD_GROUP_ID)).toBeNull();
+ expect(prByGroupId.get("branch:feature/a")?.number).toBe(12);
+ expect(prByGroupId.get("worktree:/tmp/project/.t3/worktrees/feature-b")?.number).toBe(34);
+ });
+
+ it("omits group PR state when the git status branch does not match the group branch", () => {
+ const groups = orderProjectThreadGroups({
+ threads: [
+ makeThread({
+ id: ThreadId.makeUnsafe("thread-worktree"),
+ branch: "feature/b",
+ worktreePath: "/tmp/project/.t3/worktrees/feature-b",
+ }),
+ ],
+ });
+
+ const prByGroupId = resolveProjectThreadGroupPrById({
+ groups,
+ projectCwd: "/tmp/project",
+ statusByCwd: new Map([
+ [
+ "/tmp/project/.t3/worktrees/feature-b",
+ makeGitStatus({
+ branch: "feature/c",
+ pr: {
+ number: 99,
+ title: "Wrong Branch",
+ url: "https://example.com/pr/99",
+ baseBranch: "main",
+ headBranch: "feature/c",
+ state: "closed",
+ },
+ }),
+ ],
+ ]),
+ });
+
+ expect(prByGroupId.get("worktree:/tmp/project/.t3/worktrees/feature-b")).toBeNull();
+ });
+});
diff --git a/apps/web/src/threadGroups.ts b/apps/web/src/threadGroups.ts
new file mode 100644
index 000000000..20fbeb3a0
--- /dev/null
+++ b/apps/web/src/threadGroups.ts
@@ -0,0 +1,180 @@
+import type { GitStatusResult } from "@t3tools/contracts";
+import { formatWorktreePathForDisplay } from "./worktreeCleanup";
+
+export type ThreadGroupId = string;
+export const MAIN_THREAD_GROUP_ID: ThreadGroupId = "main";
+
+export interface ThreadGroupIdentity {
+ branch: string | null;
+ worktreePath: string | null;
+}
+
+export interface ThreadGroupSeed extends ThreadGroupIdentity {
+ createdAt: string;
+}
+
+export interface OrderedProjectThreadGroup {
+ id: ThreadGroupId;
+ branch: string | null;
+ worktreePath: string | null;
+ label: string;
+ latestActivityAt: string;
+}
+
+export type ThreadGroupPrStatus = GitStatusResult["pr"];
+
+function normalizeWorktreePath(worktreePath: string): string {
+ return worktreePath.trim();
+}
+
+function normalizeBranchName(branch: string): string {
+ return branch.trim();
+}
+
+function normalizeThreadGroupIdentity(input: ThreadGroupIdentity): ThreadGroupIdentity {
+ const branch = input.branch ? normalizeBranchName(input.branch) : null;
+ const worktreePath = input.worktreePath ? normalizeWorktreePath(input.worktreePath) : null;
+ return {
+ branch: branch && branch.length > 0 ? branch : null,
+ worktreePath: worktreePath && worktreePath.length > 0 ? worktreePath : null,
+ };
+}
+
+function normalizeProjectThreadGroupOrder(threadGroupOrder: readonly ThreadGroupId[]): ThreadGroupId[] {
+ const seen = new Set();
+ const next: ThreadGroupId[] = [];
+ for (const groupId of threadGroupOrder) {
+ if (groupId === MAIN_THREAD_GROUP_ID || seen.has(groupId)) {
+ continue;
+ }
+ seen.add(groupId);
+ next.push(groupId);
+ }
+ return next;
+}
+
+export function buildThreadGroupId(input: ThreadGroupIdentity): ThreadGroupId {
+ const normalized = normalizeThreadGroupIdentity(input);
+ if (normalized.worktreePath) {
+ return `worktree:${normalized.worktreePath}`;
+ }
+ if (normalized.branch) {
+ return `branch:${normalized.branch}`;
+ }
+ return MAIN_THREAD_GROUP_ID;
+}
+
+function threadGroupLabel(input: ThreadGroupIdentity): string {
+ if (input.worktreePath) {
+ return input.branch ?? formatWorktreePathForDisplay(input.worktreePath);
+ }
+ if (input.branch) {
+ return input.branch;
+ }
+ return "Main";
+}
+
+export function orderProjectThreadGroups(input: {
+ threads: T[];
+ orderedGroupIds?: readonly ThreadGroupId[] | null | undefined;
+}): OrderedProjectThreadGroup[] {
+ const groups = new Map();
+ for (const thread of input.threads) {
+ const identity = normalizeThreadGroupIdentity({
+ branch: thread.branch,
+ worktreePath: thread.worktreePath,
+ });
+ const id = buildThreadGroupId(identity);
+ const existing = groups.get(id);
+ if (!existing) {
+ groups.set(id, {
+ id,
+ branch: identity.branch,
+ worktreePath: identity.worktreePath,
+ label: threadGroupLabel(identity),
+ latestActivityAt: thread.createdAt,
+ });
+ continue;
+ }
+ if (thread.createdAt > existing.latestActivityAt) {
+ existing.latestActivityAt = thread.createdAt;
+ existing.branch = identity.branch;
+ existing.worktreePath = identity.worktreePath;
+ existing.label = threadGroupLabel(identity);
+ }
+ }
+
+ const mainGroup =
+ groups.get(MAIN_THREAD_GROUP_ID) ??
+ ({
+ id: MAIN_THREAD_GROUP_ID,
+ branch: null,
+ worktreePath: null,
+ label: "Main",
+ latestActivityAt: "",
+ } satisfies OrderedProjectThreadGroup);
+
+ const nonMainGroups = [...groups.values()].filter((group) => group.id !== MAIN_THREAD_GROUP_ID);
+ const normalizedProjectThreadGroupOrder = normalizeProjectThreadGroupOrder(input.orderedGroupIds ?? []);
+ const orderedKnownIds = new Set(normalizedProjectThreadGroupOrder);
+ const newGroups = nonMainGroups
+ .filter((group) => !orderedKnownIds.has(group.id))
+ .toSorted((left, right) => right.latestActivityAt.localeCompare(left.latestActivityAt));
+ const knownGroups = normalizedProjectThreadGroupOrder
+ .map((groupId) => groups.get(groupId))
+ .filter((group): group is OrderedProjectThreadGroup => group !== undefined);
+
+ return [mainGroup, ...newGroups, ...knownGroups];
+}
+
+export function reorderProjectThreadGroupOrder(input: {
+ currentOrder: ThreadGroupId[];
+ movedGroupId: ThreadGroupId;
+ beforeGroupId: ThreadGroupId | null;
+}): ThreadGroupId[] {
+ const normalizedCurrentOrder = normalizeProjectThreadGroupOrder(input.currentOrder);
+ if (input.movedGroupId === MAIN_THREAD_GROUP_ID) {
+ return normalizedCurrentOrder;
+ }
+ if (input.beforeGroupId === input.movedGroupId) {
+ return normalizedCurrentOrder;
+ }
+ const withoutMoved = normalizedCurrentOrder.filter((groupId) => groupId !== input.movedGroupId);
+ if (input.beforeGroupId === MAIN_THREAD_GROUP_ID) {
+ return [input.movedGroupId, ...withoutMoved];
+ }
+ if (!input.beforeGroupId) {
+ return [...withoutMoved, input.movedGroupId];
+ }
+ const insertIndex = withoutMoved.indexOf(input.beforeGroupId);
+ if (insertIndex === -1) {
+ return [input.movedGroupId, ...withoutMoved];
+ }
+ return [
+ ...withoutMoved.slice(0, insertIndex),
+ input.movedGroupId,
+ ...withoutMoved.slice(insertIndex),
+ ];
+}
+
+export function resolveProjectThreadGroupPrById(input: {
+ groups: readonly OrderedProjectThreadGroup[];
+ projectCwd: string;
+ statusByCwd: ReadonlyMap;
+}): Map {
+ const prByGroupId = new Map();
+
+ for (const group of input.groups) {
+ if (group.id === MAIN_THREAD_GROUP_ID || group.branch === null) {
+ prByGroupId.set(group.id, null);
+ continue;
+ }
+
+ const cwd = group.worktreePath ?? input.projectCwd;
+ const status = input.statusByCwd.get(cwd);
+ const branchMatches = status?.branch !== null && status?.branch === group.branch;
+ prByGroupId.set(group.id, branchMatches ? (status?.pr ?? null) : null);
+ }
+
+ return prByGroupId;
+}