diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 245c5509d84a..65813fe5d779 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -56,6 +56,7 @@ import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
+ threadListV2BranchGroupKeys,
buildThreadListV2ListItems,
threadListV2ListItemsAreEqual,
threadListInboxReturns,
@@ -490,6 +491,7 @@ export function HomeScreen(props: HomeScreenProps) {
settledShelfExpanded,
snoozedShelfExpanded,
workingShelfEnabled,
+ branchGroupingEnabled,
workingShelfExpanded,
toggleSettledShelf,
toggleSnoozedShelf,
@@ -531,6 +533,10 @@ export function HomeScreen(props: HomeScreenProps) {
const threadMoveAvailability = useMemo(() => {
const sectionAvailability = (section: "pinned" | "active") =>
computeThreadMoveAvailability({
+ groupById: threadListV2BranchGroupKeys(
+ props.threads,
+ branchGroupingEnabled && section === "active",
+ ),
allThreads: props.threads,
section,
pendingOrder,
@@ -539,6 +545,7 @@ export function HomeScreen(props: HomeScreenProps) {
ordered: getThreadListV2OrderedSection({
threads: props.threads,
section,
+ branchGroupingEnabled,
pendingOrder,
now: new Date().toISOString(),
settlementEnvironmentIds,
@@ -553,6 +560,7 @@ export function HomeScreen(props: HomeScreenProps) {
]);
}, [
workingShelfEnabled,
+ branchGroupingEnabled,
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
props.threads,
@@ -580,6 +588,7 @@ export function HomeScreen(props: HomeScreenProps) {
settledLimit: settledVisibleCount,
now: new Date().toISOString(),
workingShelfEnabled,
+ branchGroupingEnabled,
workingShelfExpanded,
inboxReturnAt: threadListInboxReturns.returnedAt,
snoozedShelfExpanded,
@@ -588,6 +597,7 @@ export function HomeScreen(props: HomeScreenProps) {
});
}, [
workingShelfEnabled,
+ branchGroupingEnabled,
workingShelfExpanded,
pendingOrder,
selectedThreadKey,
@@ -743,6 +753,7 @@ export function HomeScreen(props: HomeScreenProps) {
onNewThreadOnBranch={props.onNewThreadOnBranch}
thread={thread}
variant={item.item.variant}
+ branchGroupStart={item.item.branchGroupStart === true}
hasQueuedMessages={item.hasQueuedMessages}
snoozed={item.item.snoozed}
pinned={item.item.pinned}
diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts
index 4fffe5f36c10..9cb513fd3bea 100644
--- a/apps/mobile/src/features/home/useThreadListActions.test.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.test.ts
@@ -92,6 +92,8 @@ vi.mock("../../state/use-atom-command", () => ({
// Stubbed at the direct dependency: the real outbox pulls the Expo file-system
// storage into a test that only reads which threads are queued.
vi.mock("../../state/use-thread-outbox", () => ({ queuedThreadKeysAtom: "queued-thread-keys" }));
+// Same for preferences, which pull in Expo storage; grouping stays off here.
+vi.mock("../../state/preferences", () => ({ mobilePreferencesAtom: "mobile-preferences" }));
// The real hold lives in a module-level atom that would leak between cases.
vi.mock("../../state/thread-order", () => ({
threadDropBusyAtom: "thread-drop-busy",
diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts
index 964c12a7189f..5fe42ca570b9 100644
--- a/apps/mobile/src/features/home/useThreadListActions.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.ts
@@ -28,7 +28,12 @@ import {
createThreadMovePlanner,
threadDropLifecycle,
} from "../threads/threadOrder";
-import { getThreadListV2OrderedSection } from "../threads/threadListV2";
+import { mobilePreferencesAtom } from "../../state/preferences";
+import { AsyncResult } from "effect/reactivity";
+import {
+ getThreadListV2OrderedSection,
+ threadListV2BranchGroupKeys,
+} from "../threads/threadListV2";
import { threadCanArchive } from "./threadArchive";
import { resolveThreadTitleRename } from "../threads/thread-title-rename";
@@ -624,7 +629,12 @@ export function useThreadListActions(): {
);
return false;
}
+ const preferences = appAtomRegistry.get(mobilePreferencesAtom);
+ const branchGroupingEnabled =
+ AsyncResult.isSuccess(preferences) &&
+ preferences.value.sidebarBranchGroupingEnabled === true;
const ordered = getThreadListV2OrderedSection({
+ branchGroupingEnabled,
threads: shells,
section,
now: new Date().toISOString(),
@@ -641,6 +651,10 @@ export function useThreadListActions(): {
),
});
const assignments = createThreadMovePlanner({
+ groupById: threadListV2BranchGroupKeys(
+ shells,
+ branchGroupingEnabled && section === "active",
+ ),
allThreads: shells,
ordered,
section,
diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
index 047f28e5fb99..10dc165eef83 100644
--- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
@@ -47,6 +47,7 @@ export function SettingsThreadsRouteScreen() {
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
>
+
@@ -260,6 +261,30 @@ function AutoSettleSettingsRows() {
);
}
+function BranchGroupingSettingsSection() {
+ const preferences = useAtomValue(mobilePreferencesAtom);
+ const savePreferences = useAtomSet(updateMobilePreferencesAtom);
+ return (
+
+
+ savePreferences({ sidebarBranchGroupingEnabled: value })}
+ />
+
+
+ Group threads by project, branch and worktree within each section. Pinned threads keep their
+ saved order.
+
+
+ );
+}
+
/**
* Device-local beta toggles, the counterpart of web's Working section (beta)
* in Settings → General.
diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
index 97dc6cf85eca..1648e53e2816 100644
--- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
@@ -27,7 +27,11 @@ import {
threadDragAction,
type ThreadMoveDestination,
} from "./threadOrder";
-import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2";
+import {
+ getThreadListV2OrderedSection,
+ threadListV2BranchGroupKeys,
+ threadListInboxReturns,
+} from "./threadListV2";
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
const ROW_HEIGHT = 56;
@@ -156,7 +160,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
const pendingOrder = useAtomValue(pendingThreadOrderAtom);
const dropBusy = useAtomValue(threadDropBusyAtom);
const { moveThread } = useThreadListActions();
- const { workingShelfEnabled } = useThreadListV2ShelfPreferences();
+ const { workingShelfEnabled, branchGroupingEnabled } = useThreadListV2ShelfPreferences();
const [now, setNow] = useState(() => new Date().toISOString());
const [expanded, setExpanded] = useState({ snoozed: false, settled: false });
useEffect(() => {
@@ -179,6 +183,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
now,
queuedThreadKeys,
pendingOrder,
+ branchGroupingEnabled,
settlementEnvironmentIds: new Set(
[...configs].flatMap(([id, config]) =>
config.environment.capabilities.threadSettlement ? [id] : [],
@@ -205,10 +210,22 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })),
settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })),
};
- }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]);
+ }, [
+ threads,
+ configs,
+ now,
+ queuedThreadKeys,
+ pendingOrder,
+ workingShelfEnabled,
+ branchGroupingEnabled,
+ ]);
const planners = useMemo(() => {
const planner = (section: "pinned" | "active") =>
createThreadMovePlanner({
+ groupById: threadListV2BranchGroupKeys(
+ threads,
+ branchGroupingEnabled && section === "active",
+ ),
ordered: sections[section],
allThreads: threads,
section,
@@ -227,7 +244,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
),
});
return { pinned: planner("pinned"), active: planner("active") };
- }, [sections, threads, configs, workingShelfEnabled]);
+ }, [sections, threads, configs, workingShelfEnabled, branchGroupingEnabled]);
const rows = useMemo(() => {
const result: Row[] = [];
let offset = 0;
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 27d23b0a0b15..0c90616f4f8e 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -67,6 +67,7 @@ import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
+ threadListV2BranchGroupKeys,
buildThreadListV2ListItems,
isThreadListV2ListItem,
threadListV2ListItemsAreEqual,
@@ -294,6 +295,7 @@ function ThreadNavigationSidebarPane(
settledShelfExpanded,
snoozedShelfExpanded,
workingShelfEnabled,
+ branchGroupingEnabled,
workingShelfExpanded,
toggleSettledShelf,
toggleSnoozedShelf,
@@ -333,6 +335,10 @@ function ThreadNavigationSidebarPane(
const threadMoveAvailability = useMemo(() => {
const sectionAvailability = (section: "pinned" | "active") =>
computeThreadMoveAvailability({
+ groupById: threadListV2BranchGroupKeys(
+ threads,
+ branchGroupingEnabled && section === "active",
+ ),
allThreads: threads,
section,
pendingOrder,
@@ -341,6 +347,7 @@ function ThreadNavigationSidebarPane(
ordered: getThreadListV2OrderedSection({
threads,
section,
+ branchGroupingEnabled,
pendingOrder,
now: new Date().toISOString(),
settlementEnvironmentIds,
@@ -355,6 +362,7 @@ function ThreadNavigationSidebarPane(
]);
}, [
workingShelfEnabled,
+ branchGroupingEnabled,
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
threads,
@@ -380,6 +388,7 @@ function ThreadNavigationSidebarPane(
settledLimit: settledVisibleCount,
now: new Date().toISOString(),
workingShelfEnabled,
+ branchGroupingEnabled,
workingShelfExpanded,
inboxReturnAt: threadListInboxReturns.returnedAt,
snoozedShelfExpanded,
@@ -388,6 +397,7 @@ function ThreadNavigationSidebarPane(
});
}, [
workingShelfEnabled,
+ branchGroupingEnabled,
workingShelfExpanded,
pendingOrder,
queuedThreadKeys,
@@ -693,6 +703,7 @@ function ThreadNavigationSidebarPane(
onNewThreadOnBranch={props.onNewThreadOnBranch}
thread={thread}
variant={item.item.variant}
+ branchGroupStart={item.item.branchGroupStart === true}
hasQueuedMessages={item.hasQueuedMessages}
snoozed={item.item.snoozed}
pinned={item.item.pinned}
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index 88ad0d43ea65..d08d0319db9a 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -11,6 +11,7 @@ import { RowPressable } from "../../components/RowPressable";
import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
import { appAtomRegistry } from "../../state/atom-registry";
import { threadArrangementOpenAtom } from "../../state/thread-order";
+import { threadBranchGroupLabel } from "@t3tools/client-runtime/state/thread-sort";
import type { ThreadMoveDestination } from "./threadOrder";
import type {
EnvironmentProject,
@@ -488,6 +489,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
});
export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
+ readonly branchGroupStart?: boolean;
readonly thread: EnvironmentThreadShell;
readonly variant: "card" | "slim";
/** A message for this thread is waiting in the outbox. */
@@ -1295,10 +1297,28 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
);
- if (!canOperateThread) return rowContent(() => {});
+ const groupHeading = props.branchGroupStart ? (
+
+ {props.projectTitle ?? props.project?.title ?? "Project"} / {threadBranchGroupLabel(thread)}
+ {props.environmentLabel ? ` · ${props.environmentLabel}` : null}
+
+ ) : null;
+
+ if (!canOperateThread)
+ return (
+ <>
+ {groupHeading}
+ {rowContent(() => {})}
+ >
+ );
return (
+ {groupHeading}
{customSnoozeOpen && (
setCustomSnoozeOpen(false)} onSnooze={handleSnooze} />
)}
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
index d5946e62ca15..1b19ce9cfefa 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -2437,3 +2437,30 @@ describe("Working section beta", () => {
]);
});
});
+
+describe("branch and worktree grouping", () => {
+ it("groups unpinned rows and keeps search results and pins in their usual order", () => {
+ const rows = [
+ makeThread({ id: ThreadId.make("a1"), title: "Thread A1", branch: "a", activeOrderKey: "f" }),
+ makeThread({ id: ThreadId.make("b"), title: "Thread B", branch: "b", activeOrderKey: "n" }),
+ makeThread({ id: ThreadId.make("a2"), title: "Thread A2", branch: "a", activeOrderKey: "t" }),
+ ];
+ const build = (overrides: Partial[0]> = {}) =>
+ buildThreadListV2Items({
+ threads: rows,
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ branchGroupingEnabled: true,
+ ...overrides,
+ }).items.map(({ thread, branchGroupStart }) => [thread.id, branchGroupStart]);
+ expect(build()).toEqual([
+ ["a1", true],
+ ["a2", false],
+ ["b", true],
+ ]);
+ expect(build({ searchQuery: "Thread" }).map(([id]) => id)).toEqual(["a1", "b", "a2"]);
+ const pinned = rows.map((row) => ({ ...row, pinnedAt: NOW, pinOrderKey: row.activeOrderKey }));
+ expect(build({ threads: pinned }).map(([id]) => id)).toEqual(["a1", "b", "a2"]);
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index b5aee8fb4b22..928097bb68be 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -18,6 +18,8 @@ import {
sortWorkingThreadsBySend,
} from "@t3tools/client-runtime/state/thread-inbox";
import {
+ groupThreadsByBranch,
+ threadBranchGroupKey,
sortActiveThreadsByOrderKey,
resolveSettledThreadTimestamp,
sortPinnedThreadsByOrderKey,
@@ -227,10 +229,22 @@ export function sortThreadsForListV2<
return sortActiveThreadsByOrderKey(threads);
}
+/** Move planners keep grouped rows inside their branch group. */
+export function threadListV2BranchGroupKeys(
+ threads: readonly EnvironmentThreadShell[],
+ grouped: boolean,
+) {
+ if (!grouped) return undefined;
+ return new Map(
+ threads.map((thread) => [`${thread.environmentId}:${thread.id}`, threadBranchGroupKey(thread)]),
+ );
+}
+
/** Canonical card section for Move up/down, independent of search or scope. */
export function getThreadListV2OrderedSection(input: {
readonly threads: readonly EnvironmentThreadShell[];
readonly section: "pinned" | "active";
+ readonly branchGroupingEnabled?: boolean;
readonly pendingOrder?: PendingThreadOrder | null;
readonly now: string;
readonly settlementEnvironmentIds?: ReadonlySet;
@@ -265,10 +279,14 @@ export function getThreadListV2OrderedSection(input: {
input.pendingOrder?.section === input.section
? reconcilePendingThreadOrder(input.pendingOrder, ordered)
: null;
- return applyPendingThreadOrder(ordered, input.section, pending);
+ const arranged = applyPendingThreadOrder(ordered, input.section, pending);
+ return input.branchGroupingEnabled && input.section !== "pinned"
+ ? groupThreadsByBranch(arranged)
+ : arranged;
}
export interface ThreadListV2Item {
+ readonly branchGroupStart?: boolean;
readonly thread: EnvironmentThreadShell;
readonly variant: "card" | "slim";
/** Snoozed-shelf row: shows the wake countdown and offers Wake. */
@@ -413,6 +431,7 @@ export function threadListV2ListItemsAreEqual(
previous.item.variant === item.item.variant &&
previous.item.snoozed === item.item.snoozed &&
previous.item.pinned === item.item.pinned &&
+ previous.item.branchGroupStart === item.item.branchGroupStart &&
previous.snoozeWakeLabelText === item.snoozeWakeLabelText &&
previous.timeLabel === item.timeLabel &&
previous.snoozePresetMinute === item.snoozePresetMinute &&
@@ -640,6 +659,7 @@ function sortSettledThreadsReusingLast(
* the settled recency tail, matching the web v2 list.
*/
export function buildThreadListV2Items(input: {
+ readonly branchGroupingEnabled?: boolean;
readonly pendingOrder?: PendingThreadOrder | null;
readonly threads: ReadonlyArray;
readonly environmentId: EnvironmentId | null;
@@ -780,7 +800,19 @@ export function buildThreadListV2Items(input: {
: orderedSnoozed.filter(
(thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
);
- const orderedSettled = sortSettledThreadsReusingLast(settled);
+ // Grouping keeps each section's order inside its groups; search stays flat.
+ const grouped = input.branchGroupingEnabled === true && query.length === 0;
+ const arrange = (threads: readonly EnvironmentThreadShell[]) =>
+ grouped ? groupThreadsByBranch(threads) : threads;
+ let previousGroup: string | null = null;
+ const groupStart = (section: string, thread: EnvironmentThreadShell) => {
+ if (!grouped) return {};
+ const group = `${section}:${threadBranchGroupKey(thread)}`;
+ const branchGroupStart = group !== previousGroup;
+ previousGroup = group;
+ return { branchGroupStart };
+ };
+ const orderedSettled = arrange(sortSettledThreadsReusingLast(settled));
const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
const limitedSettled =
orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;
@@ -813,8 +845,9 @@ export function buildThreadListV2Items(input: {
isLast: false,
});
}
- for (const thread of orderedActive) {
+ for (const thread of arrange(orderedActive)) {
items.push({
+ ...groupStart("active", thread),
thread,
variant: "card",
snoozed: false,
@@ -823,8 +856,9 @@ export function buildThreadListV2Items(input: {
});
}
const workingShelfHeaderIndex = orderedWorking.length > 0 ? items.length : null;
- for (const thread of visibleWorking) {
+ for (const thread of arrange(visibleWorking)) {
items.push({
+ ...groupStart("working", thread),
thread,
variant: "card",
snoozed: false,
@@ -833,8 +867,9 @@ export function buildThreadListV2Items(input: {
});
}
const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null;
- for (const thread of visibleSnoozed) {
+ for (const thread of arrange(visibleSnoozed)) {
items.push({
+ ...groupStart("snoozed", thread),
thread,
variant: "slim",
snoozed: true,
@@ -843,8 +878,9 @@ export function buildThreadListV2Items(input: {
});
}
const settledShelfHeaderIndex = orderedSettled.length > 0 ? items.length : null;
- for (const thread of visibleSettled) {
+ for (const thread of arrange(visibleSettled)) {
items.push({
+ ...groupStart("settled", thread),
thread,
variant: "slim",
snoozed: false,
diff --git a/apps/mobile/src/features/threads/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts
index 5943b200eb0a..9a0ae2e0ef79 100644
--- a/apps/mobile/src/features/threads/threadOrder.ts
+++ b/apps/mobile/src/features/threads/threadOrder.ts
@@ -1,6 +1,7 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import {
generateSpreadPinOrderKeys,
+ groupedThreadOrderKeys,
pinOrderKeyBetween,
planPinnedReorder,
} from "@t3tools/client-runtime/state/thread-sort";
@@ -81,6 +82,8 @@ function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) {
* are supported. Menu availability and execution use this same planner. */
export function createThreadMovePlanner(input: {
readonly ordered: readonly OrderRow[];
+ /** An opt-in view can constrain arrangement without changing lifecycle actions. */
+ readonly groupById?: ReadonlyMap | undefined;
readonly allThreads?: readonly OrderRow[];
readonly section: PendingThreadOrder["section"];
readonly reorderableEnvironmentIds: ReadonlySet;
@@ -99,9 +102,25 @@ export function createThreadMovePlanner(input: {
);
return (movedId: string, direction: ThreadMoveDestination) => {
if (!writableIds.has(movedId)) return null;
+ if (input.groupById && orderedIds.includes(movedId)) {
+ const targetId =
+ typeof direction === "string"
+ ? orderedIds[orderedIds.indexOf(movedId) + (direction === "up" ? -1 : 1)]
+ : direction.targetId;
+ if (targetId == null || input.groupById.get(movedId) !== input.groupById.get(targetId))
+ return null;
+ }
const nextIds = threadOrderAfterMove(orderedIds, movedId, direction);
if (nextIds === null) return null;
- const assignments = planPinnedReorder({ orderedIds: nextIds, keysById, movedId });
+ const assignments = planPinnedReorder({
+ orderedIds: nextIds,
+ keysById:
+ input.groupById && !orderedIds.includes(movedId)
+ ? new Map(keysById).set(movedId, null)
+ : keysById,
+ movedId,
+ ...(input.groupById ? { groupById: input.groupById } : {}),
+ });
return assignments === null ||
assignments.length === 0 ||
assignments.some((assignment) => !writableIds.has(assignment.id))
@@ -125,6 +144,8 @@ export interface ThreadMoveAvailability {
*/
export function computeThreadMoveAvailability(input: {
readonly ordered: readonly OrderRow[];
+ /** An opt-in view can constrain arrangement without changing lifecycle actions. */
+ readonly groupById?: ReadonlyMap | undefined;
readonly allThreads?: readonly OrderRow[];
readonly section: PendingThreadOrder["section"];
readonly reorderableEnvironmentIds: ReadonlySet;
@@ -155,6 +176,46 @@ export function computeThreadMoveAvailability(input: {
keysById.set(id, rowOrder(row, input.section).key);
if (input.reorderableEnvironmentIds.has(row.environmentId)) writableIds.add(id);
}
+ if (input.groupById) {
+ const retainedKeys = new Set(
+ [...keysById.values()].flatMap((key) => (key == null ? [] : [key])),
+ );
+ const groups = new Map();
+ for (const id of orderedIds) {
+ const group = input.groupById.get(id) ?? id;
+ const ids = groups.get(group);
+ if (ids) ids.push(id);
+ else groups.set(group, [id]);
+ }
+ for (const ids of groups.values()) {
+ const keys = groupedThreadOrderKeys(ids, keysById, retainedKeys);
+ let writes = 0;
+ let deniedWrites = 0;
+ ids.forEach((id, index) => {
+ if (keysById.get(id) !== keys[index]) writes += 1;
+ if (!writableIds.has(id) && keysById.get(id) !== keys[index]) deniedWrites += 1;
+ });
+ ids.forEach((id, index) => {
+ const available = (neighbor: number) => {
+ if (!writableIds.has(id) || neighbor < 0 || neighbor >= ids.length) return false;
+ let denied = deniedWrites;
+ let nextWrites = writes;
+ for (const position of [index, neighbor]) {
+ const other = position === index ? neighbor : index;
+ const row = ids[position]!;
+ if (keysById.get(row) !== keys[position]) nextWrites -= 1;
+ if (keysById.get(row) !== keys[other]) nextWrites += 1;
+ if (writableIds.has(row)) continue;
+ if (keysById.get(row) !== keys[position]) denied -= 1;
+ if (keysById.get(row) !== keys[other]) denied += 1;
+ }
+ return denied === 0 && nextWrites > 0;
+ };
+ result.set(id, { canMoveUp: available(index - 1), canMoveDown: available(index + 1) });
+ });
+ }
+ return result;
+ }
// Read after the loop so a duplicate id reserves only its final key.
const reservedKeys = new Set();
keysById.forEach((key, id) => {
diff --git a/apps/mobile/src/features/threads/threadOrderAvailability.test.ts b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts
index fdd90c14dcbe..6ed68b33848d 100644
--- a/apps/mobile/src/features/threads/threadOrderAvailability.test.ts
+++ b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts
@@ -120,6 +120,36 @@ function randomCase(rng: () => number) {
const WRITABLE = new Set(["env-w" as EnvironmentId]);
describe("computeThreadMoveAvailability matches the reference planner", () => {
+ it("allows a writable group's move beside an unrelated read-only group", () => {
+ const ordered = [
+ makeRow("a1", "env-w", "f", false),
+ makeRow("a2", "env-w", "t", false),
+ makeRow("a3", "env-w", "w", false),
+ makeRow("b", "env-x", "n", false),
+ ];
+ const input = {
+ ordered,
+ section: "active" as const,
+ reorderableEnvironmentIds: WRITABLE,
+ groupById: new Map(
+ ordered.map((row) => [`${row.environmentId}:${row.id}`, row.environmentId]),
+ ),
+ };
+ const planner = createThreadMovePlanner(input);
+ expect(planner("env-w:a1", "down")).toEqual([
+ { id: "env-w:a2", orderKey: "f" },
+ { id: "env-w:a1", orderKey: "t" },
+ ]);
+ expect(computeThreadMoveAvailability(input).get("env-w:a1")).toEqual({
+ canMoveUp: false,
+ canMoveDown: true,
+ });
+ expect(computeThreadMoveAvailability(input).get("env-w:a3")).toEqual({
+ canMoveUp: true,
+ canMoveDown: false,
+ });
+ });
+
it("agrees across randomized sections (keys, holes, non-writable rows, hidden keys)", () => {
for (let seed = 1; seed <= 4_000; seed += 1) {
const rng = makeRng(seed);
@@ -139,6 +169,27 @@ describe("computeThreadMoveAvailability matches the reference planner", () => {
.join(",")}`,
).toEqual(answer);
}
+ const groupedInput = {
+ ordered,
+ allThreads,
+ section: "pinned" as const,
+ reorderableEnvironmentIds: WRITABLE,
+ groupById: new Map(
+ ordered.map((row, index) => [
+ `${row.environmentId}:${row.id}`,
+ String(Math.floor(index / 3)),
+ ]),
+ ),
+ };
+ const groupedBatch = computeThreadMoveAvailability(groupedInput);
+ const groupedPlanner = createThreadMovePlanner(groupedInput);
+ for (const row of ordered) {
+ const id = `${row.environmentId}:${row.id}`;
+ expect(groupedBatch.get(id), `grouped seed ${seed} row ${id}`).toEqual({
+ canMoveUp: groupedPlanner(id, "up") !== null,
+ canMoveDown: groupedPlanner(id, "down") !== null,
+ });
+ }
}
});
diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts
index 1ee46cbff89d..fedc5dea8c48 100644
--- a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts
+++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts
@@ -18,6 +18,8 @@ export function useThreadListV2ShelfPreferences() {
const settledShelfExpanded =
loaded && preferencesResult.value.threadListSettledShelfExpanded === true;
// Working section beta: off until the preference loads and is enabled.
+ const branchGroupingEnabled =
+ loaded && preferencesResult.value.sidebarBranchGroupingEnabled === true;
const workingShelfEnabled = loaded && preferencesResult.value.workingShelfEnabled === true;
const workingShelfExpanded =
loaded && preferencesResult.value.threadListWorkingShelfExpanded === true;
@@ -52,6 +54,7 @@ export function useThreadListV2ShelfPreferences() {
settledShelfExpanded,
snoozedShelfExpanded,
workingShelfEnabled,
+ branchGroupingEnabled,
workingShelfExpanded,
toggleSettledShelf,
toggleSnoozedShelf,
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index ee96bce2d5ce..19ee9f3c0852 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -43,6 +43,7 @@ export interface Preferences {
readonly planModeEnabled?: boolean;
/** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */
readonly workingShelfEnabled?: boolean;
+ readonly sidebarBranchGroupingEnabled?: boolean;
/** Model favorites belong to this device, like the web client setting. */
readonly modelFavorites?: ReadonlyArray<{
readonly provider: ProviderInstanceId;
@@ -111,6 +112,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingMode?: SidebarProjectGroupingMode;
planModeEnabled?: boolean;
workingShelfEnabled?: boolean;
+ sidebarBranchGroupingEnabled?: boolean;
modelFavorites?: Preferences["modelFavorites"];
threadListSettledShelfExpanded?: boolean;
threadListSnoozedShelfExpanded?: boolean;
@@ -185,6 +187,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.planModeEnabled === "boolean") {
preferences.planModeEnabled = parsed.planModeEnabled;
}
+ if (typeof parsed.sidebarBranchGroupingEnabled === "boolean") {
+ preferences.sidebarBranchGroupingEnabled = parsed.sidebarBranchGroupingEnabled;
+ }
if (typeof parsed.workingShelfEnabled === "boolean") {
preferences.workingShelfEnabled = parsed.workingShelfEnabled;
}
diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts
index 10ff985da3ba..60f994c81324 100644
--- a/apps/web/src/components/Sidebar.drag.test.ts
+++ b/apps/web/src/components/Sidebar.drag.test.ts
@@ -1,15 +1,25 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { closestCenter, type CollisionDetection } from "@dnd-kit/core";
import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
+import { ThreadId } from "@t3tools/contracts";
+import {
+ groupThreadsByBranch,
+ sortSettledThreads,
+ threadBranchGroupKey,
+} from "@t3tools/client-runtime/state/thread-sort";
+import { makeThreadFixture } from "../test-fixtures";
import {
createSidebarCollisionDetection,
createSidebarSortingStrategy,
restrictBelowSidebarLabel,
} from "./Sidebar.drag";
import {
+ applySidebarThreadDrop,
resolveSidebarDropTarget,
sidebarListItemId,
sidebarMarkerId,
+ sortSnoozedThreadsForSidebar,
+ type SidebarDropTarget,
type SidebarListItem,
type SidebarListMarker,
type SidebarSection,
@@ -253,6 +263,172 @@ describe("sidebar collision detection", () => {
});
describe("sidebar drag projection", () => {
+ it("keeps settled groups in committed order during a pinned reorder", () => {
+ const rows = ["a", "b", "p2"].map((id, index) =>
+ makeThreadFixture({
+ id: ThreadId.make(id),
+ branch: id === "p2" ? "b" : id,
+ settledOverride: id === "p2" ? null : "settled",
+ settledAt: id === "p2" ? null : `2026-10-10T0${9 - index}:00:00.000Z`,
+ }),
+ );
+ const branchGroupByKey = new Map(
+ rows.map((row) => [String(row.id), threadBranchGroupKey(row)]),
+ );
+ const aHeader = marker(`branch:settled:${branchGroupByKey.get("a")!}`);
+ const bHeader = marker(`branch:settled:${branchGroupByKey.get("b")!}`);
+ const items = [
+ pinnedHeader,
+ thread("p1", "pinned"),
+ thread("p2", "pinned"),
+ divider,
+ thread("inbox", "active"),
+ settledHeader,
+ aHeader,
+ thread("a", "settled"),
+ bHeader,
+ thread("b", "settled"),
+ ];
+ const settledOrder = (target: SidebarDropTarget) =>
+ groupThreadsByBranch(
+ sortSettledThreads(
+ target.section === "settled"
+ ? [
+ ...rows.slice(0, 2),
+ applySidebarThreadDrop(rows[2]!, "settled", "2026-10-10T10:00:00.000Z"),
+ ]
+ : rows.slice(0, 2),
+ ),
+ ).map((row) => row.id);
+ const result = preview(
+ { items, settledOrder, settledExpanded: true, branchGroupByKey },
+ "p2",
+ "p1",
+ );
+ expect(result.get(sidebarListItemId(aHeader))).toEqual(stationary);
+ expect(result.get("a")).toEqual(stationary);
+ expect(result.get(sidebarListItemId(bHeader))).toEqual(stationary);
+ expect(result.get("b")).toEqual(stationary);
+ });
+
+ it("regroups snoozed rows in wake order after the leading row leaves", () => {
+ const rows = ["a1", "b", "a2"].map((id, index) =>
+ makeThreadFixture({
+ id: ThreadId.make(id),
+ branch: id.startsWith("a") ? "a" : "b",
+ snoozedUntil: `2099-01-01T0${index + 1}:00:00.000Z`,
+ }),
+ );
+ const branchGroupByKey = new Map(
+ rows.map((row) => [String(row.id), threadBranchGroupKey(row)]),
+ );
+ const aHeader = marker(`branch:snoozed:${branchGroupByKey.get("a1")!}`);
+ const bHeader = marker(`branch:snoozed:${branchGroupByKey.get("b")!}`);
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ thread("inbox", "active"),
+ marker("snoozed-header"),
+ aHeader,
+ thread("a1", "snoozed"),
+ thread("a2", "snoozed"),
+ bHeader,
+ thread("b", "snoozed"),
+ settledHeader,
+ marker("settled-placeholder"),
+ ];
+ const input = {
+ items,
+ settledOrder: [],
+ settledExpanded: false,
+ branchGroupByKey,
+ snoozedOrder: groupThreadsByBranch(
+ sortSnoozedThreadsForSidebar(rows.filter((row) => row.id !== "a1")),
+ ).map((row) => row.id),
+ };
+ const result = preview(input, "a1", "p");
+ expect(result.get(sidebarListItemId(bHeader))?.y).toBe(-24);
+ expect(result.get("b")?.y).toBe(-24);
+ expect(result.get(sidebarListItemId(aHeader))?.y).toBe(153);
+ expect(result.get("a2")?.y).toBe(116);
+ });
+
+ it.each([
+ { active: "a1", over: "a2", aHeader: 48, a2: -35, bHeader: 48 },
+ { active: "a1", over: "p", aHeader: 247, a2: 164, bHeader: -68 },
+ { active: "p", over: "b", aHeader: -35, a2: -35, bHeader: 48 },
+ ])(
+ "keeps headings aligned when dragging $active over $over without Working",
+ ({ active, over, aHeader, a2, bHeader }) => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ marker("branch:active:a"),
+ thread("a1", "active"),
+ thread("a2", "active"),
+ marker("branch:active:b"),
+ thread("b", "active"),
+ settledHeader,
+ marker("settled-placeholder"),
+ ];
+ const transforms = preview(
+ {
+ items,
+ settledOrder: [],
+ settledExpanded: false,
+ boundaryLabelHeight: 24,
+ activeOrder: (target) =>
+ target.section !== "active"
+ ? ["b", "a2"]
+ : target.activeOrder.includes("p")
+ ? ["a1", "a2", "p", "b"]
+ : target.activeOrder,
+ branchGroupByKey: new Map([
+ ["p", "a"],
+ ["a1", "a"],
+ ["a2", "a"],
+ ["b", "b"],
+ ]),
+ },
+ active,
+ over,
+ );
+ expect(transforms.get(sidebarMarkerId("branch:active:a"))?.y).toBe(aHeader);
+ expect(transforms.get("a2")?.y).toBe(a2);
+ expect(transforms.get(sidebarMarkerId("branch:active:b"))?.y).toBe(bHeader);
+ expect(transforms.get("b")?.y).toBe(bHeader);
+ },
+ );
+
+ it("hides a departing group's heading and reserves an arriving group's heading", () => {
+ const items = [
+ pinnedHeader,
+ thread("p", "pinned"),
+ divider,
+ marker("branch:active:a"),
+ thread("a", "active"),
+ marker("branch:active:b"),
+ thread("b", "active"),
+ settledHeader,
+ marker("settled-placeholder"),
+ ];
+ const input = {
+ items,
+ settledOrder: [],
+ settledExpanded: false,
+ branchGroupByKey: new Map([
+ ["p", "new"],
+ ["a", "a"],
+ ["b", "b"],
+ ]),
+ };
+ expect(preview(input, "a", "p").get(sidebarMarkerId("branch:active:a"))?.scaleY).toBe(0);
+ // The arriving pin adds its 82px card and a 32px heading, with list gaps.
+ expect(preview(input, "p", "b").get(sidebarMarkerId("settled-header"))?.y).toBe(33);
+ });
+
it.each([
["a2", "a1"],
["p", "a1"],
diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts
index 6664476e8c69..66dca1bb0377 100644
--- a/apps/web/src/components/Sidebar.drag.ts
+++ b/apps/web/src/components/Sidebar.drag.ts
@@ -2,11 +2,14 @@ import { closestCenter, type CollisionDetection, type Modifier } from "@dnd-kit/
import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
import {
resolveSidebarDropTarget,
+ groupSidebarOrder,
+ sidebarBranchGroupMarker,
sidebarListItemId,
sidebarMarkerId,
type SidebarListItem,
type SidebarListMarker,
type SidebarSection,
+ type SidebarDropTarget,
} from "./Sidebar.logic";
const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
@@ -101,9 +104,11 @@ export function createSidebarSortingStrategy(input: {
items: readonly SidebarListItem[];
/** Suspend the reorder preview while the thread is dragged out as context. */
enabled?: boolean;
- settledOrder: readonly string[];
- /** Time-ordered inbox (Working beta): where the lifted row would land. */
- activeOrder?: readonly string[];
+ settledOrder: readonly string[] | ((target: SidebarDropTarget) => readonly string[]);
+ snoozedOrder?: readonly string[];
+ /** Canonical landing order; grouped manual drops depend on the target. */
+ activeOrder?: readonly string[] | ((target: SidebarDropTarget) => readonly string[]);
+ branchGroupByKey?: ReadonlyMap;
settledExpanded: boolean;
settledVisibleCount?: number;
routeThreadKey?: string | null;
@@ -155,21 +160,43 @@ export function createSidebarSortingStrategy(input: {
cardHeight ??= 82 * scale;
slimHeight ??= 36 * scale;
const labelHeight = (input.boundaryLabelHeight ?? 0) * scale;
+ const projectedSettledOrder =
+ typeof input.settledOrder === "function" ? input.settledOrder(target) : input.settledOrder;
const group = groups[target.section];
- const order =
+ let order =
target.section === "pinned"
? target.pinnedOrder
: target.section === "settled"
- ? input.settledOrder
- : (input.activeOrder ?? target.activeOrder);
+ ? projectedSettledOrder
+ : typeof input.activeOrder === "function"
+ ? input.activeOrder(target)
+ : (input.activeOrder ?? target.activeOrder);
+ if (input.branchGroupByKey && target.section !== "pinned")
+ order = groupSidebarOrder(order, input.branchGroupByKey);
const ranks = new Map(order.map((key, index) => [key, index]));
const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY;
const index = group.findIndex(
(item) => (ranks.get(item.key) ?? Number.POSITIVE_INFINITY) > rank,
);
group.splice(index < 0 ? group.length : index, 0, { ...active, section: target.section });
+ if (input.branchGroupByKey) {
+ const activeOrder =
+ target.section === "active"
+ ? order
+ : typeof input.activeOrder === "function"
+ ? input.activeOrder(target)
+ : groups.active.map((item) => item.key);
+ const byKey = new Map(groups.active.map((item) => [item.key, item]));
+ groups.active = activeOrder.flatMap((key) => byKey.get(key) ?? []);
+ }
+ if (input.snoozedOrder) {
+ const byKey = new Map(groups.snoozed.map((item) => [item.key, item]));
+ groups.snoozed = input.snoozedOrder.flatMap((key) => byKey.get(key) ?? []);
+ }
const settledOrder = (
- input.settledOrder.length > 0 ? input.settledOrder : groups.settled.map((item) => item.key)
+ projectedSettledOrder.length > 0
+ ? projectedSettledOrder
+ : groups.settled.map((item) => item.key)
).filter((key) => key !== active.key || target.section === "settled");
const visible = input.settledExpanded
? settledOrder.slice(0, input.settledVisibleCount ?? settledOrder.length)
@@ -181,9 +208,17 @@ export function createSidebarSortingStrategy(input: {
groups.settled = visible.map((key) => ({ kind: "thread", key, section: "settled" }));
const projected: SidebarListItem[] = [];
const marker = (name: SidebarListMarker) => projected.push({ kind: "marker", marker: name });
- const section = (name: "active" | "settled") => {
- if (groups[name].length > 0) projected.push(...groups[name]);
- else marker(`${name}-placeholder`);
+ const section = (name: Exclude) => {
+ let previousGroup: string | undefined;
+ for (const item of groups[name]) {
+ const group = input.branchGroupByKey?.get(item.key);
+ if (group !== undefined && group !== previousGroup)
+ marker(sidebarBranchGroupMarker(name, group));
+ previousGroup = group;
+ projected.push(item);
+ }
+ if (groups[name].length === 0 && (name === "active" || name === "settled"))
+ marker(`${name}-placeholder`);
};
marker("pinned-header");
projected.push(...groups.pinned);
@@ -191,7 +226,7 @@ export function createSidebarSortingStrategy(input: {
section("active");
if (items.some((item) => item.kind === "marker" && item.marker === "working-header")) {
marker("working-header");
- projected.push(...groups.working);
+ section("working");
}
if (
groups.snoozed.length > 0 ||
@@ -199,7 +234,7 @@ export function createSidebarSortingStrategy(input: {
items.some((item) => item.kind === "marker" && item.marker === "snoozed-header"))
) {
marker("snoozed-header");
- projected.push(...groups.snoozed);
+ section("snoozed");
}
marker("settled-header");
section("settled");
@@ -207,10 +242,12 @@ export function createSidebarSortingStrategy(input: {
const index = indices.get(sidebarListItemId(item));
const rect = index === undefined ? undefined : rects[index];
const fallback =
- item.kind === "thread" &&
- (item.section === "pinned" || item.section === "active" || item.section === "working")
- ? cardHeight
- : slimHeight;
+ item.kind === "marker" && item.marker.startsWith("branch:")
+ ? 32 * scale
+ : item.kind === "thread" &&
+ (item.section === "pinned" || item.section === "active" || item.section === "working")
+ ? cardHeight
+ : slimHeight;
const moved = item.kind === "thread" && item.key === active.key;
return item.kind === "marker" &&
(item.marker === "pinned-header" || item.marker === "pinned-divider")
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
index bf2a0c0cbd6f..565ce7813cea 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -49,6 +49,7 @@ import {
resolveSidebarDropTarget,
planSidebarThreadDrop,
sortPinnedThreadsForSidebar,
+ sortSnoozedThreadsForSidebar,
sortProjectsForSidebar,
sortScopedProjectsForSidebar,
sortSidebarV2ProjectGroups,
@@ -1982,6 +1983,20 @@ describe("pinOrderKeyBetween", () => {
});
});
+describe("sortSnoozedThreadsForSidebar", () => {
+ it("orders by soonest wake and preserves the source order for equal wake times", () => {
+ const rows = [
+ { id: "b", snoozedUntil: "2099-01-01T02:00:00.000Z" },
+ { id: "a2", snoozedUntil: "2099-01-01T02:00:00.000Z" },
+ { id: "a1", snoozedUntil: "2099-01-01T01:00:00.000Z" },
+ ];
+ expect(sortSnoozedThreadsForSidebar(rows).map((row) => row.id)).toEqual(["a1", "b", "a2"]);
+ expect(
+ sortSnoozedThreadsForSidebar(rows.filter((row) => row.id !== "a1")).map((row) => row.id),
+ ).toEqual(["b", "a2"]);
+ });
+});
+
describe("sortPinnedThreadsForSidebar", () => {
const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({
id: input.id,
@@ -2314,5 +2329,41 @@ describe("Working shelf (beta)", () => {
unsnooze: false,
});
});
+
+ it.each(["active", "pinned"] as const)(
+ "keeps grouped %s drops inside a writable group",
+ (activeSection) => {
+ const plan = planSidebarThreadDrop({
+ activeKey: "a1",
+ activeSection,
+ target: { section: "active", pinnedOrder: [], activeOrder: ["a2", "a3", "b", "a1"] },
+ pinnedOrder: activeSection === "pinned" ? ["a1"] : [],
+ pinnedKeysById: new Map(),
+ activeOrder: activeSection === "pinned" ? ["a2", "a3", "b"] : ["a1", "a2", "a3", "b"],
+ activeKeysById: new Map([
+ ["a1", "f"],
+ ["a2", "t"],
+ ["a3", "w"],
+ ["b", "x"],
+ ]),
+ activeReorderableKeys: new Set(["a1", "a2", "a3"]),
+ activeGroupByKey: new Map([
+ ["a1", "a"],
+ ["a2", "a"],
+ ["a3", "a"],
+ ["b", "b"],
+ ]),
+ });
+ expect(plan.kind).toBe("move-active");
+ if (plan.kind !== "move-active") return;
+ expect(plan.order).toEqual(["a2", "a3", "a1", "b"]);
+ expect(plan.assignments.every(({ id }) => id !== "b")).toBe(true);
+ // Re-entry ignores a retained active key; it keeps the destination group's anchor.
+ const key = plan.assignments.find(({ id }) => id === "a1")!.orderKey;
+ if (activeSection === "pinned") expect(key > "w").toBe(true);
+ else expect(key).toBe("w");
+ expect(plan.unpin).toBe(activeSection === "pinned");
+ },
+ );
});
});
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index a8743024832f..351514e4b5c2 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -160,10 +160,33 @@ export type SidebarListMarker =
| "pinned-divider"
| "working-header"
| "snoozed-header"
- | "settled-header";
+ | "settled-header"
+ | `branch:${Exclude}:${string}`;
+
+export function sidebarBranchGroupMarker(
+ section: Exclude,
+ group: string,
+): SidebarListMarker {
+ return `branch:${section}:${group}`;
+}
+
+/** Project the same contiguous groups that the committed sidebar renders. */
+export function groupSidebarOrder(
+ order: readonly string[],
+ groupByKey: ReadonlyMap,
+): string[] {
+ const groups = new Map();
+ for (const key of order) {
+ const group = groupByKey.get(key) ?? key;
+ const rows = groups.get(group);
+ if (rows) rows.push(key);
+ else groups.set(group, [key]);
+ }
+ return [...groups.values()].flat();
+}
export function sidebarMarkerId(marker: SidebarListMarker): string {
- return `${SIDEBAR_MARKER_PREFIX}${marker}`;
+ return `${SIDEBAR_MARKER_PREFIX}${encodeURIComponent(marker)}`;
}
export type SidebarListItem =
@@ -319,6 +342,8 @@ export function planSidebarThreadDrop(input: {
readonly activeReorderableKeys?: ReadonlySet;
/** Working beta: the inbox sorts by time, so drops only change lifecycle. */
readonly activeTimeOrdered?: boolean;
+ /** Branch grouping can show active rows outside their saved key order. */
+ readonly activeGroupByKey?: ReadonlyMap;
}): SidebarThreadDropPlan {
const {
activeKey,
@@ -332,6 +357,7 @@ export function planSidebarThreadDrop(input: {
activeOrder,
activeKeysById,
activeReorderableKeys,
+ activeGroupByKey,
} = input;
if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) {
return { kind: "none" };
@@ -344,13 +370,14 @@ export function planSidebarThreadDrop(input: {
order: readonly string[],
keysById: ReadonlyMap,
writable: ReadonlySet | undefined,
+ groupById?: ReadonlyMap,
) => {
- if (!writable) return planPinnedReorder({ orderedIds: order, keysById, movedId: activeKey });
+ const plan = { keysById, movedId: activeKey, ...(groupById ? { groupById } : {}) };
+ if (!writable) return planPinnedReorder({ ...plan, orderedIds: order });
if (!writable.has(activeKey)) return null;
const assignments = planPinnedReorder({
+ ...plan,
orderedIds: order.filter((key) => writable.has(key) || keysById.get(key) != null),
- keysById,
- movedId: activeKey,
});
return assignments.every(({ id }) => writable.has(id)) ? assignments : null;
};
@@ -370,7 +397,9 @@ export function planSidebarThreadDrop(input: {
unsnooze: activeSection === "snoozed",
};
}
- const order = target.activeOrder;
+ const order = activeGroupByKey
+ ? groupSidebarOrder(target.activeOrder, activeGroupByKey)
+ : target.activeOrder;
if (
activeSection === "active" &&
order.length === activeOrder.length &&
@@ -378,7 +407,11 @@ export function planSidebarThreadDrop(input: {
) {
return { kind: "none" };
}
- const assignments = arrange(order, activeKeysById, activeReorderableKeys);
+ const keys =
+ activeGroupByKey && activeSection !== "active"
+ ? new Map(activeKeysById).set(activeKey, null)
+ : activeKeysById;
+ const assignments = arrange(order, keys, activeReorderableKeys, activeGroupByKey);
if (assignments === null) return { kind: "none" };
return {
kind: "move-active",
@@ -1045,7 +1078,7 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea
/** First VALID timestamp wins: `a ?? b` falls through on null, but a present-
yet-malformed string must also fall through to the next candidate rather
than sink the row to the epoch. */
-export function firstValidTimestampMs(
+function firstValidTimestampMs(
...candidates: ReadonlyArray
): number {
for (const candidate of candidates) {
@@ -1056,6 +1089,16 @@ export function firstValidTimestampMs(
return 0;
}
+/** Soonest wake first, before grouping the snoozed shelf. */
+export function sortSnoozedThreadsForSidebar(
+ threads: readonly T[],
+): T[] {
+ return threads.toSorted(
+ (left, right) =>
+ firstValidTimestampMs(left.snoozedUntil) - firstValidTimestampMs(right.snoozedUntil),
+ );
+}
+
export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
// The Working section beta folds and orders the inbox the same way on mobile.
export {
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 8a5db463884e..c7190a21a0f9 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -34,6 +34,10 @@ import {
} from "@t3tools/client-runtime/state/thread-settled";
import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox";
import {
+ groupThreadsByBranch,
+ threadBranchGroupKey,
+ threadBranchGroupLabel,
+ planPinnedReorder,
resolveSettledThreadTimestamp,
sortSettledThreads,
} from "@t3tools/client-runtime/state/thread-sort";
@@ -189,7 +193,6 @@ import {
deleteSelectedThreadEntries,
filterSidebarProjectScopeItems,
formatWorkingDurationLabel,
- firstValidTimestampMs,
hasUnseenCompletion,
isSidebarNestedLinkClick,
isSidebarThreadWorking,
@@ -204,6 +207,7 @@ import {
resolveSidebarThreadSection,
resolveSidebarRowAccessibility,
type SidebarDropVerb,
+ type SidebarDropTarget,
resolveSidebarThreadStatus,
resolveThreadLastVisitedAt,
searchSidebarThreads,
@@ -212,10 +216,12 @@ import {
shouldRecedeSidebarThread,
resolveWorkingStartedAt,
sidebarListItemId,
+ sidebarBranchGroupMarker,
sidebarMarkerId,
sidebarThreadKeyAtY,
sortInboxThreadsByReturn,
sortPinnedThreadsForSidebar,
+ sortSnoozedThreadsForSidebar,
sortSidebarV2ProjectGroups,
sortThreadsForSidebar,
sortWorkingThreadsBySend,
@@ -689,6 +695,7 @@ function SortableSidebarMarker(props: {
className?: string;
children?: ReactNode;
"data-testid"?: string;
+ role?: "presentation";
}) {
const { setNodeRef, transform, transition } = useSortable({
id: sidebarMarkerId(props.marker),
@@ -698,6 +705,7 @@ function SortableSidebarMarker(props: {
return (
s.sidebarProjectSortOrder);
const timestampFormat = useClientSettings((s) => s.timestampFormat);
const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled);
+ const branchGroupingEnabled = useClientSettings((s) => s.sidebarBranchGroupingEnabled);
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const {
settleThread,
@@ -2763,6 +2772,24 @@ export default function Sidebar() {
override holds until all of them appear in canonical state. */
readonly assignedKeys: ReadonlyMap;
} | null>(null);
+ const threadSearchInputRef = useRef(null);
+ const [threadSearchQuery, setThreadSearchQuery] = useState("");
+ const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
+ const isSearchingThreads = threadSearchQuery.trim().length > 0;
+ // Search results stay in their usual flat order.
+ const groupByBranch = branchGroupingEnabled && !isSearchingThreads;
+ const branchGroupByKey = useMemo(
+ () =>
+ groupByBranch
+ ? new Map(
+ threads.map((thread) => [
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ threadBranchGroupKey(thread),
+ ]),
+ )
+ : undefined,
+ [groupByBranch, threads],
+ );
const {
pinnedThreads,
draggableThreadKeys,
@@ -2848,6 +2875,8 @@ export default function Sidebar() {
// sort, or mixed-version fleets would render different pinned orders on
// web and mobile from the same data.
const sortedPinned = sortPinnedThreadsForSidebar(pinned);
+ const arrange = (rows: EnvironmentThreadShell[]) =>
+ groupByBranch ? groupThreadsByBranch(rows) : rows;
const sortedActive = workingShelfEnabled
? sortInboxThreadsByReturn(active, inboxReturns.returnedAt)
: sortThreadsForSidebar(active);
@@ -2862,7 +2891,7 @@ export default function Sidebar() {
}),
draggableThreadKeys: draggable,
activeReorderableThreadKeys: activeReorderable,
- activeThreads:
+ activeThreads: arrange(
optimisticDrop?.section !== "active" || optimisticDrop.order === null
? sortedActive
: orderItemsByPreferredIds({
@@ -2870,18 +2899,16 @@ export default function Sidebar() {
preferredIds: optimisticDrop.order,
getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
}),
+ ),
// Newest send first; finishing and waking again do not move a row.
- workingThreads: sortWorkingThreadsBySend(working),
+ workingThreads: arrange(sortWorkingThreadsBySend(working)),
// Soonest wake first: "what comes back next" is the shelf's question.
- snoozedThreads: snoozed.toSorted(
- (left, right) =>
- firstValidTimestampMs(left.snoozedUntil ?? null) -
- firstValidTimestampMs(right.snoozedUntil ?? null),
- ),
- settledThreads: sortSettledThreads(settled),
+ snoozedThreads: arrange(sortSnoozedThreadsForSidebar(snoozed)),
+ settledThreads: arrange(sortSettledThreads(settled)),
snoozeNow: preciseNow,
};
}, [
+ groupByBranch,
nowMinute,
optimisticDrop,
scopedProjectKeys,
@@ -2891,10 +2918,6 @@ export default function Sidebar() {
workingShelfEnabled,
]);
- const threadSearchInputRef = useRef(null);
- const [threadSearchQuery, setThreadSearchQuery] = useState("");
- const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
- const isSearchingThreads = threadSearchQuery.trim().length > 0;
const searchableThreads = useMemo(
() => [
...pinnedThreads,
@@ -2938,14 +2961,13 @@ export default function Sidebar() {
}, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]);
// Arm a timeout for the earliest upcoming wake so the shelf empties the
- // moment a snooze expires instead of on the next minute tick. Sorted
- // soonest-first, so entry 0 is the boundary.
+ // moment a snooze expires instead of on the next minute tick. Branch
+ // grouping can reorder the shelf, so scan every entry for the boundary.
useEffect(() => {
- const nextWakeAtMs =
- snoozedThreads.length > 0 && snoozedThreads[0]?.snoozedUntil != null
- ? Date.parse(snoozedThreads[0].snoozedUntil)
- : Number.NaN;
- if (Number.isNaN(nextWakeAtMs)) return;
+ const nextWakeAtMs = Math.min(
+ ...snoozedThreads.map((thread) => Date.parse(thread.snoozedUntil ?? "") || Infinity),
+ );
+ if (!Number.isFinite(nextWakeAtMs)) return;
// setTimeout delays are signed 32-bit: anything larger overflows and
// fires immediately, turning a far-future wake (event-condition snoozes
// synced from elsewhere) into a tight re-arm loop. Clamped, the timer
@@ -3783,11 +3805,19 @@ export default function Sidebar() {
const rowsOf = (
list: readonly EnvironmentThreadShell[],
section: SidebarSection,
- ): SidebarListItem[] =>
- list.map((thread) => {
+ ): SidebarListItem[] => {
+ const rows: SidebarListItem[] = [];
+ let previousGroup: string | undefined;
+ for (const thread of list) {
const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
- return { kind: "thread", key, section };
- });
+ const group = branchGroupByKey?.get(key);
+ if (section !== "pinned" && group !== undefined && group !== previousGroup)
+ rows.push({ kind: "marker", marker: sidebarBranchGroupMarker(section, group) });
+ previousGroup = group;
+ rows.push({ kind: "thread", key, section });
+ }
+ return rows;
+ };
if (
pinnedThreads.length +
activeThreads.length +
@@ -3820,6 +3850,7 @@ export default function Sidebar() {
return items;
}, [
activeThreads,
+ branchGroupByKey,
pinnedThreads,
renderedSettledThreads,
settledThreads.length,
@@ -3882,26 +3913,83 @@ export default function Sidebar() {
if (dragState === null || thread === undefined) return [];
const key = (candidate: EnvironmentThreadShell) =>
scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
- return sortSettledThreads([
- ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
- applySidebarThreadDrop(thread, "settled", dragState.occurredAt),
- ]).map(key);
- }, [dragState, settledThreads, threadByKey]);
+ const remaining = settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey);
+ return (target: SidebarDropTarget) => {
+ const sorted = sortSettledThreads(
+ target.section === "settled"
+ ? [...remaining, applySidebarThreadDrop(thread, "settled", dragState.occurredAt)]
+ : remaining,
+ );
+ return (groupByBranch ? groupThreadsByBranch(sorted) : sorted).map(key);
+ };
+ }, [groupByBranch, dragState, settledThreads, threadByKey]);
+ const draggedSnoozedOrder = useMemo(() => {
+ if (!groupByBranch || dragState?.activeSection !== "snoozed") return undefined;
+ const key = (candidate: EnvironmentThreadShell) =>
+ scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
+ // Equal wake times retain the original shell order, before grouping.
+ const snoozedKeys = new Set(snoozedThreads.map(key));
+ const remaining = threads.filter(
+ (candidate) => snoozedKeys.has(key(candidate)) && key(candidate) !== dragState.activeKey,
+ );
+ return groupThreadsByBranch(sortSnoozedThreadsForSidebar(remaining)).map(key);
+ }, [groupByBranch, dragState, snoozedThreads, threads]);
// Working beta: the inbox is time-ordered too, so the preview shows the
// slot a drop will land in, not the slot under the pointer.
const draggedActiveOrder = useMemo(() => {
const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey);
- if (!workingShelfEnabled || dragState === null || thread === undefined) return undefined;
+ if ((!workingShelfEnabled && !groupByBranch) || dragState === null || thread === undefined)
+ return undefined;
const key = (candidate: EnvironmentThreadShell) =>
scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
- return sortInboxThreadsByReturn(
- [
- ...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
- applySidebarThreadDrop(thread, "active", dragState.occurredAt),
- ],
- inboxReturns.returnedAt,
- ).map(key);
- }, [activeThreads, dragState, threadByKey, workingShelfEnabled]);
+ const remaining = activeThreads.filter((row) => key(row) !== dragState.activeKey);
+ if (!workingShelfEnabled && branchGroupByKey) {
+ const keysById = new Map(threads.map((row) => [key(row), row.activeOrderKey ?? null]));
+ if (dragState.activeSection !== "active") keysById.set(dragState.activeKey, null);
+ const currentOrder = activeThreads.map(key);
+ return (target: SidebarDropTarget) => {
+ if (target.section !== "active")
+ return groupThreadsByBranch(sortThreadsForSidebar(remaining)).map(key);
+ if (
+ dragState.activeSection === "active" &&
+ target.activeOrder.every((id, index) => id === currentOrder[index])
+ )
+ return currentOrder;
+ const assignments = new Map(
+ planPinnedReorder({
+ orderedIds: target.activeOrder,
+ keysById,
+ movedId: dragState.activeKey,
+ groupById: branchGroupByKey,
+ }).map(({ id, orderKey }) => [id, orderKey]),
+ );
+ const rows = [
+ ...remaining,
+ applySidebarThreadDrop(thread, "active", dragState.occurredAt),
+ ].map((row) =>
+ assignments.has(key(row)) ? { ...row, activeOrderKey: assignments.get(key(row))! } : row,
+ );
+ return groupThreadsByBranch(sortThreadsForSidebar(rows)).map(key);
+ };
+ }
+ return (target: SidebarDropTarget) => {
+ const sorted = sortInboxThreadsByReturn(
+ target.section === "active"
+ ? [...remaining, applySidebarThreadDrop(thread, "active", dragState.occurredAt)]
+ : remaining,
+ inboxReturns.returnedAt,
+ );
+ return (groupByBranch ? groupThreadsByBranch(sorted) : sorted).map(key);
+ };
+ }, [
+ groupByBranch,
+ branchGroupByKey,
+ activeThreads,
+ dragState,
+ threadByKey,
+ threads,
+ workingShelfEnabled,
+ ]);
const sidebarSortingStrategy = useMemo(
() =>
createSidebarSortingStrategy({
@@ -3909,6 +3997,8 @@ export default function Sidebar() {
enabled: !isContextDrag,
boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,
settledOrder: draggedSettledOrder,
+ ...(draggedSnoozedOrder === undefined ? {} : { snoozedOrder: draggedSnoozedOrder }),
+ ...(branchGroupByKey ? { branchGroupByKey } : {}),
...(draggedActiveOrder === undefined ? {} : { activeOrder: draggedActiveOrder }),
settledExpanded: settledShelfExpanded,
settledVisibleCount,
@@ -3916,8 +4006,10 @@ export default function Sidebar() {
snoozedThreadCount: snoozedThreads.length,
}),
[
+ branchGroupByKey,
draggedActiveOrder,
draggedSettledOrder,
+ draggedSnoozedOrder,
isContextDrag,
routeThreadKey,
settledShelfExpanded,
@@ -3957,6 +4049,15 @@ export default function Sidebar() {
(id) => {
const target = resolveSidebarDropTarget(sidebarListItems, draggedThreadKey, id);
if (target === null) return false;
+ if (
+ branchGroupingEnabled &&
+ draggedFromSection === "active" &&
+ target.section === "active"
+ ) {
+ const overThread = threadByKey.get(id);
+ if (!overThread || threadBranchGroupKey(source) !== threadBranchGroupKey(overThread))
+ return false;
+ }
return (
planSidebarThreadDrop({
activeKey: draggedThreadKey,
@@ -3974,6 +4075,7 @@ export default function Sidebar() {
activeKeysById,
activeReorderableKeys: activeReorderableThreadKeys,
activeTimeOrdered: workingShelfEnabled,
+ ...(branchGroupByKey ? { activeGroupByKey: branchGroupByKey } : {}),
}).kind !== "none"
);
},
@@ -3983,6 +4085,8 @@ export default function Sidebar() {
},
);
}, [
+ branchGroupByKey,
+ branchGroupingEnabled,
activeKeysById,
pinnedKeysById,
serverConfigs,
@@ -4001,12 +4105,21 @@ export default function Sidebar() {
(event: DragEndEvent) => {
const activeKey = String(event.active.id);
const activeSection = sectionByThreadKey.get(activeKey);
- const target =
+ const rawTarget =
event.over === null
? null
: resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id));
+ const target =
+ rawTarget?.section === "active" && typeof draggedActiveOrder === "function"
+ ? { ...rawTarget, activeOrder: draggedActiveOrder(rawTarget) }
+ : rawTarget;
const activeThread = threadByKey.get(activeKey);
if (activeSection === undefined || target === null || activeThread === undefined) return;
+ if (branchGroupingEnabled && activeSection === "active" && target.section === "active") {
+ const overThread = event.over === null ? undefined : threadByKey.get(String(event.over.id));
+ if (!overThread || threadBranchGroupKey(activeThread) !== threadBranchGroupKey(overThread))
+ return;
+ }
const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id);
const plan = planSidebarThreadDrop({
activeKey,
@@ -4024,6 +4137,7 @@ export default function Sidebar() {
activeKeysById,
activeReorderableKeys: activeReorderableThreadKeys,
activeTimeOrdered: workingShelfEnabled,
+ ...(branchGroupByKey ? { activeGroupByKey: branchGroupByKey } : {}),
});
if (plan.kind === "none") return;
if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return;
@@ -4150,12 +4264,15 @@ export default function Sidebar() {
})();
},
[
+ branchGroupByKey,
+ branchGroupingEnabled,
activeKeysById,
pinnedKeysById,
serverConfigs,
activeKeys,
activeReorderableThreadKeys,
draggableThreadKeys,
+ draggedActiveOrder,
pinThread,
pinnedKeys,
planForwardNavigation,
@@ -5403,9 +5520,43 @@ export default function Sidebar() {
onDraftContextMenu={handleDraftContextMenu}
/>,
];
- for (const item of sidebarListItems) {
+ for (const [itemIndex, item] of sidebarListItems.entries()) {
if (item.kind === "thread") {
- items.push(renderThreadRow(threadByKey.get(item.key)!, item.section));
+ const thread = threadByKey.get(item.key)!;
+ items.push(renderThreadRow(thread, item.section));
+ continue;
+ }
+ if (item.marker.startsWith("branch:")) {
+ const next = sidebarListItems[itemIndex + 1];
+ const thread =
+ next?.kind === "thread" ? threadByKey.get(next.key) : undefined;
+ if (thread) {
+ const projectKey =
+ `${thread.environmentId}:${thread.projectId}` as const;
+ const projectTitle =
+ projectDisplayNameByKey.get(projectKey) ??
+ projectByKey.get(projectKey)?.title ??
+ "Project";
+ const environment = environmentLabelById.get(thread.environmentId);
+ items.push(
+
+
+ }>
+ {projectTitle} / {threadBranchGroupLabel(thread)}
+ {environmentLabelById.size > 1 && environment
+ ? ` · ${environment}`
+ : null}
+
+ {thread.worktreePath}
+
+ ,
+ );
+ }
continue;
}
switch (item.marker) {
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index bee57d21b253..28d97af76088 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -573,6 +573,10 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.sidebarProjectSortOrder !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder
? ["Project order"]
: []),
+ ...(settings.sidebarBranchGroupingEnabled !==
+ DEFAULT_UNIFIED_SETTINGS.sidebarBranchGroupingEnabled
+ ? ["Branch and worktree grouping"]
+ : []),
...(settings.sidebarWorkingShelfEnabled !==
DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled
? ["Working section"]
@@ -713,6 +717,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.sidebarProjectGroupingMode,
settings.sidebarProjectSortOrder,
settings.sidebarWorkingShelfEnabled,
+ settings.sidebarBranchGroupingEnabled,
settings.sidebarThreadPreviewCount,
settings.showSkillsInSlashMenu,
settings.timestampFormat,
@@ -813,6 +818,7 @@ export function useSettingsRestore(onRestored?: () => void) {
sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
+ sidebarBranchGroupingEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarBranchGroupingEnabled,
sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads,
@@ -2399,6 +2405,34 @@ export function GeneralSettingsPanel() {
}
/>
+
+ updateSettings({
+ sidebarBranchGroupingEnabled:
+ DEFAULT_UNIFIED_SETTINGS.sidebarBranchGroupingEnabled,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ updateSettings({ sidebarBranchGroupingEnabled: Boolean(checked) })
+ }
+ aria-label="Group threads by branch and worktree"
+ />
+ }
+ />
+
{supportsAutoSettlement ? (
<>
{
expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(orderedIds);
});
});
+
+describe("branch and worktree grouping", () => {
+ it("keeps section order inside groups without combining environments, projects or checkouts", () => {
+ const first = {
+ id: "first",
+ environmentId: EnvironmentId.make("a"),
+ projectId: ProjectId.make("p"),
+ branch: "main",
+ worktreePath: null,
+ };
+ const rows = [
+ first,
+ { ...first, id: "other-branch", branch: "feature" },
+ { ...first, id: "same-checkout" },
+ { ...first, id: "other-project", projectId: ProjectId.make("q") },
+ { ...first, id: "other-environment", environmentId: EnvironmentId.make("b") },
+ { ...first, id: "worktree", worktreePath: "/repo/one" },
+ ];
+ expect(groupThreadsByBranch(rows).map((row) => row.id)).toEqual([
+ "first",
+ "same-checkout",
+ "other-branch",
+ "other-project",
+ "other-environment",
+ "worktree",
+ ]);
+ expect(threadBranchGroupLabel({ branch: null, worktreePath: null })).toBe("Local checkout");
+ expect(threadBranchGroupLabel({ branch: "feature", worktreePath: "C:\\repo\\task" })).toBe(
+ "feature · task",
+ );
+ });
+
+ it("keeps interleaved groups stable without writing outside the moved group", () => {
+ const keysById = new Map([
+ ["a1", "f"],
+ ["b", "n"],
+ ["a2", "t"],
+ ["a3", "w"],
+ ]);
+ const desired = ["a2", "a1", "a3", "b"];
+ const plan = planPinnedReorder({
+ orderedIds: desired,
+ keysById,
+ movedId: "a1",
+ groupById: new Map([
+ ["a1", "a"],
+ ["a2", "a"],
+ ["a3", "a"],
+ ["b", "b"],
+ ]),
+ });
+ expect(plan).toEqual([
+ { id: "a2", orderKey: "f" },
+ { id: "a1", orderKey: "t" },
+ ]);
+ for (const { id, orderKey } of plan) keysById.set(id, orderKey);
+ const rows = desired.map((id) => ({
+ id,
+ activeOrderKey: keysById.get(id),
+ createdAt: "2026-01-01T00:00:00Z",
+ environmentId: EnvironmentId.make("env"),
+ projectId: ProjectId.make("project"),
+ branch: id === "b" ? "b" : "a",
+ worktreePath: null,
+ }));
+ expect(groupThreadsByBranch(sortActiveThreadsByOrderKey(rows)).map((row) => row.id)).toEqual(
+ desired,
+ );
+ expect(keysById.get("b")).toBe("n");
+ });
+
+ it("extends a keyed group for a new row without taking another group's reserved keys", () => {
+ const reserved = `t${generateSpreadPinOrderKeys(1)[0]}`;
+ const keysById = new Map([
+ ["a1", "f"],
+ ["a2", "t"],
+ ["new", null],
+ ["b", "w"],
+ ["hidden", reserved],
+ ]);
+ const plan = planPinnedReorder({
+ orderedIds: ["a1", "a2", "new", "b"],
+ keysById,
+ movedId: "new",
+ groupById: new Map([
+ ["a1", "a"],
+ ["a2", "a"],
+ ["new", "a"],
+ ["b", "b"],
+ ]),
+ });
+ expect(plan).toHaveLength(1);
+ expect(plan[0]!.id).toBe("new");
+ expect(plan[0]!.orderKey > "t").toBe(true);
+ expect(plan[0]!.orderKey).not.toBe("w");
+ expect(plan[0]!.orderKey).not.toBe(reserved);
+ });
+});
diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts
index 1dcd3d370cd8..625b0bd16325 100644
--- a/packages/client-runtime/src/state/threadSort.ts
+++ b/packages/client-runtime/src/state/threadSort.ts
@@ -4,6 +4,42 @@ import type { EnvironmentThreadShell } from "./models.ts";
import * as Arr from "effect/Array";
import * as Order from "effect/Order";
+type BranchGroupThread = Pick<
+ EnvironmentThreadShell,
+ "environmentId" | "projectId" | "branch" | "worktreePath"
+>;
+
+/** Branch names and paths only identify work inside their project and environment. */
+export function threadBranchGroupKey(thread: BranchGroupThread): string {
+ return JSON.stringify([
+ thread.environmentId,
+ thread.projectId,
+ thread.worktreePath,
+ thread.branch,
+ ]);
+}
+
+export function threadBranchGroupLabel(
+ thread: Pick,
+): string {
+ const worktree = thread.worktreePath?.split(/[\\/]/).filter(Boolean).at(-1);
+ if (thread.branch && worktree && thread.branch !== worktree)
+ return `${thread.branch} · ${worktree}`;
+ return thread.branch || worktree || "Local checkout";
+}
+
+/** Keep each shelf's existing order within groups; its leading row orders the groups. */
+export function groupThreadsByBranch(threads: readonly T[]): T[] {
+ const groups = new Map();
+ for (const thread of threads) {
+ const key = threadBranchGroupKey(thread);
+ const group = groups.get(key);
+ if (group) group.push(thread);
+ else groups.set(key, [thread]);
+ }
+ return [...groups.values()].flat();
+}
+
export interface ThreadSortInput {
readonly createdAt: string;
readonly updatedAt: string;
@@ -254,13 +290,41 @@ export function generateSpreadPinOrderKeys(count: number): string[] {
return keys;
}
+/** Reuse a group's key range so its leading key, and shelf position, stay put.
+ * Legacy keyless/corrupt groups materialize only their own rows. */
+export function groupedThreadOrderKeys(
+ orderedIds: readonly string[],
+ keysById: ReadonlyMap,
+ retainedKeys?: ReadonlySet,
+): string[] {
+ const keys = [
+ ...new Set(
+ orderedIds.flatMap((id) => {
+ const key = keysById.get(id);
+ return key != null && isValidPinOrderKey(key) ? [key] : [];
+ }),
+ ),
+ ].sort();
+ if (keys.length === orderedIds.length) return keys;
+ const reserved =
+ retainedKeys ?? new Set([...keysById.values()].flatMap((key) => (key == null ? [] : [key])));
+ const prefix = keys.at(-1) ?? "";
+ for (const suffix of generateSpreadPinOrderKeys(orderedIds.length - keys.length)) {
+ let key = prefix + suffix;
+ while (reserved.has(key)) key += "n";
+ keys.push(key);
+ }
+ return keys;
+}
+
/**
* Assignments needed to realize a new pinned order. When the moved thread
* sits between two keyed (or absent) neighbors, this is a single write to
* the moved thread. When a neighbor is keyless (threads pinned before
* reordering shipped), the whole section gets fresh spread keys — a
* one-time materialization; every move after that is single-write. Active
- * reordering uses the same planner with activeOrderKey values.
+ * reordering uses the same planner with activeOrderKey values. Grouped
+ * moves permute only that group's keys to preserve its shelf position.
*/
export function planPinnedReorder(input: {
/** Thread ids in the desired visual order (after the move). */
@@ -268,8 +332,20 @@ export function planPinnedReorder(input: {
/** Include retained keys from hidden rows; only orderedIds receive writes. */
readonly keysById: ReadonlyMap;
readonly movedId: string;
+ /** Grouped arrangements write only the moved row's project/checkout. */
+ readonly groupById?: ReadonlyMap;
}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> {
const { orderedIds, keysById, movedId } = input;
+ if (input.groupById) {
+ if (!orderedIds.includes(movedId)) return [];
+ const group = input.groupById.get(movedId);
+ if (group === undefined) return [];
+ const ids = orderedIds.filter((id) => input.groupById!.get(id) === group);
+ const keys = groupedThreadOrderKeys(ids, keysById);
+ return ids.flatMap((id, index) =>
+ keysById.get(id) === keys[index] ? [] : [{ id, orderKey: keys[index]! }],
+ );
+ }
const visibleIds = new Set(orderedIds);
const reservedKeys = new Set(
[...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])),
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 0e8027b9f5fd..b54ed7d5cbf2 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -471,6 +471,9 @@ export const ClientSettingsSchema = Schema.Struct({
sidebarWorkingShelfEnabled: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(false)),
),
+ sidebarBranchGroupingEnabled: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(false)),
+ ),
sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
),
@@ -1618,6 +1621,7 @@ export const ClientSettingsPatch = Schema.Struct({
showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean),
+ sidebarBranchGroupingEnabled: Schema.optionalKey(Schema.Boolean),
sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
sidebarProjectGroupingOverrides: Schema.optionalKey(
Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),