Skip to content
11 changes: 11 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
threadListV2BranchGroupKeys,
buildThreadListV2ListItems,
threadListV2ListItemsAreEqual,
threadListInboxReturns,
Expand Down Expand Up @@ -490,6 +491,7 @@ export function HomeScreen(props: HomeScreenProps) {
settledShelfExpanded,
snoozedShelfExpanded,
workingShelfEnabled,
branchGroupingEnabled,
workingShelfExpanded,
toggleSettledShelf,
toggleSnoozedShelf,
Expand Down Expand Up @@ -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,
Expand All @@ -539,6 +545,7 @@ export function HomeScreen(props: HomeScreenProps) {
ordered: getThreadListV2OrderedSection({
threads: props.threads,
section,
branchGroupingEnabled,
pendingOrder,
now: new Date().toISOString(),
settlementEnvironmentIds,
Expand All @@ -553,6 +560,7 @@ export function HomeScreen(props: HomeScreenProps) {
]);
}, [
workingShelfEnabled,
branchGroupingEnabled,
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
props.threads,
Expand Down Expand Up @@ -580,6 +588,7 @@ export function HomeScreen(props: HomeScreenProps) {
settledLimit: settledVisibleCount,
now: new Date().toISOString(),
workingShelfEnabled,
branchGroupingEnabled,
workingShelfExpanded,
inboxReturnAt: threadListInboxReturns.returnedAt,
snoozedShelfExpanded,
Expand All @@ -588,6 +597,7 @@ export function HomeScreen(props: HomeScreenProps) {
});
}, [
workingShelfEnabled,
branchGroupingEnabled,
workingShelfExpanded,
pendingOrder,
selectedThreadKey,
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/home/useThreadListActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(),
Expand All @@ -641,6 +651,10 @@ export function useThreadListActions(): {
),
});
const assignments = createThreadMovePlanner({
groupById: threadListV2BranchGroupKeys(
shells,
branchGroupingEnabled && section === "active",
),
allThreads: shells,
ordered,
section,
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function SettingsThreadsRouteScreen() {
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
>
<AutoSettleSettingsRows />
<BranchGroupingSettingsSection />
<BetaSettingsSection />
<LegacySettingsSection />
</ScrollView>
Expand Down Expand Up @@ -260,6 +261,30 @@ function AutoSettleSettingsRows() {
);
}

function BranchGroupingSettingsSection() {
const preferences = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
return (
<View className="gap-3">
<SettingsSection title="Sidebar grouping">
<SettingsSwitchRow
icon="folder"
label="Group by branch and worktree"
value={
AsyncResult.isSuccess(preferences) &&
preferences.value.sidebarBranchGroupingEnabled === true
}
onValueChange={(value) => savePreferences({ sidebarBranchGroupingEnabled: value })}
/>
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
Group threads by project, branch and worktree within each section. Pinned threads keep their
saved order.
</Text>
</View>
);
}

/**
* Device-local beta toggles, the counterpart of web's Working section (beta)
* in Settings → General.
Expand Down
25 changes: 21 additions & 4 deletions apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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] : [],
Expand All @@ -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,
Expand All @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
threadListV2BranchGroupKeys,
buildThreadListV2ListItems,
isThreadListV2ListItem,
threadListV2ListItemsAreEqual,
Expand Down Expand Up @@ -294,6 +295,7 @@ function ThreadNavigationSidebarPane(
settledShelfExpanded,
snoozedShelfExpanded,
workingShelfEnabled,
branchGroupingEnabled,
workingShelfExpanded,
toggleSettledShelf,
toggleSnoozedShelf,
Expand Down Expand Up @@ -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,
Expand All @@ -341,6 +347,7 @@ function ThreadNavigationSidebarPane(
ordered: getThreadListV2OrderedSection({
threads,
section,
branchGroupingEnabled,
pendingOrder,
now: new Date().toISOString(),
settlementEnvironmentIds,
Expand All @@ -355,6 +362,7 @@ function ThreadNavigationSidebarPane(
]);
}, [
workingShelfEnabled,
branchGroupingEnabled,
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
threads,
Expand All @@ -380,6 +388,7 @@ function ThreadNavigationSidebarPane(
settledLimit: settledVisibleCount,
now: new Date().toISOString(),
workingShelfEnabled,
branchGroupingEnabled,
workingShelfExpanded,
inboxReturnAt: threadListInboxReturns.returnedAt,
snoozedShelfExpanded,
Expand All @@ -388,6 +397,7 @@ function ThreadNavigationSidebarPane(
});
}, [
workingShelfEnabled,
branchGroupingEnabled,
workingShelfExpanded,
pendingOrder,
queuedThreadKeys,
Expand Down Expand Up @@ -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}
Expand Down
22 changes: 21 additions & 1 deletion apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -1295,10 +1297,28 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
</RowPressable>
);

if (!canOperateThread) return rowContent(() => {});
const groupHeading = props.branchGroupStart ? (
<Text
accessibilityRole="header"
className="px-5 pb-1 pt-3 text-xs font-medium text-foreground-muted"
numberOfLines={1}
>
{props.projectTitle ?? props.project?.title ?? "Project"} / {threadBranchGroupLabel(thread)}
{props.environmentLabel ? ` · ${props.environmentLabel}` : null}
</Text>
) : null;

if (!canOperateThread)
return (
<>
{groupHeading}
{rowContent(() => {})}
</>
);

return (
<View collapsable={false}>
{groupHeading}
{customSnoozeOpen && (
<CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} />
)}
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<typeof buildThreadListV2Items>[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"]);
});
});
Loading
Loading