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),