authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 20:40:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 20:40:32-07:00
log0c91f687de606f62e157209f34e6aa74744b555a
tree5094490b84e743b84efd8fa2a371193ec4fc075d
parent151324b2c0bf0ace789820b8ea4ec8ceb55753d9
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): retire the legacy grouped thread list (#13183)


24 files changed, 75 insertions(+), 3211 deletions(-)

apps/mobile/src/features/home/HomeHeader.android.tsx+6-58
......@@ -1,13 +1,7 @@
11import type { MenuAction } from "@react-native-menu/menu";
22import { useCallback, useMemo } from "react";
33import { NativeStackScreenOptions } from "../../native/StackHeader";
4import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
54import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar";
6import {
7 hasCustomHomeListOptions,
8 PROJECT_SORT_OPTIONS,
9 THREAD_SORT_OPTIONS,
10} from "./home-list-options";
115import type { HomeHeaderProps } from "./HomeHeader.types";
126
137export type { HomeHeaderEnvironment } from "./HomeHeader.types";
......@@ -17,13 +11,11 @@ function checkedMenuState(checked: boolean) {
1711}
1812
1913export function HomeHeader(props: HomeHeaderProps) {
20 // Thread List v2 lays the list out in fixed creation order, so the
21 // sort/group filter controls would be silently ignored — hide them and
22 // key the "customized" icon state off the environment filter alone.
23 const threadListV2Enabled = useThreadListV2Enabled();
24 const hasCustomListOptions = threadListV2Enabled
25 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
26 : hasCustomHomeListOptions(props);
14 // The list uses a fixed creation order and ignores sort/group options, so
15 // the filter menu only carries the filters and the "customized" icon state
16 // keys off those alone.
17 const hasCustomListOptions =
18 props.selectedEnvironmentId !== null || props.selectedProjectKey !== null;
2719 const menuActions = useMemo<MenuAction[]>(
2820 () => [
2921 {
......@@ -62,38 +54,8 @@ export function HomeHeader(props: HomeHeaderProps) {
6254 ],
6355 },
6456 ] satisfies MenuAction[])),
65 ...(threadListV2Enabled
66 ? []
67 : ([
68 {
69 id: "project-sort",
70 title: "Sort projects",
71 subactions: PROJECT_SORT_OPTIONS.map((option) => ({
72 id: `project-sort:${option.value}`,
73 title: option.label,
74 state: checkedMenuState(props.projectSortOrder === option.value),
75 })),
76 },
77 {
78 id: "thread-sort",
79 title: "Sort threads",
80 subactions: THREAD_SORT_OPTIONS.map((option) => ({
81 id: `thread-sort:${option.value}`,
82 title: option.label,
83 state: checkedMenuState(props.threadSortOrder === option.value),
84 })),
85 },
86 ] satisfies MenuAction[])),
87 ],
88 [
89 props.environments,
90 props.projectSortOrder,
91 props.projects,
92 props.selectedEnvironmentId,
93 props.selectedProjectKey,
94 props.threadSortOrder,
95 threadListV2Enabled,
9657 ],
58 [props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey],
9759 );
9860 const handleMenuAction = useCallback(
9961 (event: { nativeEvent: { event: string } }) => {
......@@ -126,20 +88,6 @@ export function HomeHeader(props: HomeHeaderProps) {
12688 }
12789 return;
12890 }
129
130 const projectSort = PROJECT_SORT_OPTIONS.find(
131 (option) => id === `project-sort:${option.value}`,
132 );
133 if (projectSort) {
134 props.onProjectSortOrderChange(projectSort.value);
135 return;
136 }
137
138 const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`);
139 if (threadSort) {
140 props.onThreadSortOrderChange(threadSort.value);
141 return;
142 }
14391 },
14492 [props],
14593 );
apps/mobile/src/features/home/HomeHeader.tsx+7-48
......@@ -2,7 +2,6 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
22import { useCallback, useRef } from "react";
33import type { SearchBarCommands } from "react-native-screens";
44import { useUniwindTheme } from "../../lib/useUniwindTheme";
5import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
65import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
76import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
87import {
......@@ -10,11 +9,6 @@ import {
109 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
1110} from "../layout/native-mail-search-toolbar";
1211import { buildHomeListFilterMenu } from "./home-list-filter-menu";
13import {
14 hasCustomHomeListOptions,
15 PROJECT_SORT_OPTIONS,
16 THREAD_SORT_OPTIONS,
17} from "./home-list-options";
1812import type { HomeHeaderProps } from "./HomeHeader.types";
1913
2014export type { HomeHeaderEnvironment } from "./HomeHeader.types";
......@@ -22,22 +16,17 @@ export type { HomeHeaderEnvironment } from "./HomeHeader.types";
2216export function HomeHeader(props: HomeHeaderProps) {
2317 const searchBarRef = useRef<SearchBarCommands>(null);
2418 const iconColor = useUniwindTheme()["--color-icon"];
25 // Thread List v2 lays the list out in fixed creation order, so the
26 // sort/group filter controls would be silently ignored — hide them and
27 // key the "customized" icon state off the environment filter alone.
28 const threadListV2Enabled = useThreadListV2Enabled();
29 const hasCustomListOptions = threadListV2Enabled
30 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
31 : hasCustomHomeListOptions(props);
19 // The list uses a fixed creation order and ignores sort/group options, so
20 // the filter menu only carries the filters and the "customized" icon state
21 // keys off those alone.
22 const hasCustomListOptions =
23 props.selectedEnvironmentId !== null || props.selectedProjectKey !== null;
3224 const focusSearch = useCallback(() => {
3325 searchBarRef.current?.focus();
3426 return searchBarRef.current !== null;
3527 }, []);
3628 useHardwareKeyboardCommand("focusSearch", focusSearch);
37 const filterMenu = buildHomeListFilterMenu({
38 ...props,
39 listOrganization: !threadListV2Enabled,
40 });
29 const filterMenu = buildHomeListFilterMenu(props);
4130
4231 return (
4332 <>
......@@ -100,7 +89,7 @@ export function HomeHeader(props: HomeHeaderProps) {
10089 {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (
10190 <NativeHeaderToolbar placement="bottom">
10291 <NativeHeaderToolbar.Menu
103 accessibilityLabel="Filter and sort threads"
92 accessibilityLabel="Filter threads"
10493 icon={
10594 hasCustomListOptions
10695 ? "line.3.horizontal.decrease.circle.fill"
......@@ -150,36 +139,6 @@ export function HomeHeader(props: HomeHeaderProps) {
150139 ))}
151140 </NativeHeaderToolbar.Menu>
152141 ) : null}
153
154 {threadListV2Enabled ? null : (
155 <NativeHeaderToolbar.Menu title="Sort projects">
156 <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label>
157 {PROJECT_SORT_OPTIONS.map((option) => (
158 <NativeHeaderToolbar.MenuAction
159 key={option.value}
160 isOn={props.projectSortOrder === option.value}
161 onPress={() => props.onProjectSortOrderChange(option.value)}
162 >
163 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
164 </NativeHeaderToolbar.MenuAction>
165 ))}
166 </NativeHeaderToolbar.Menu>
167 )}
168
169 {threadListV2Enabled ? null : (
170 <NativeHeaderToolbar.Menu title="Sort threads">
171 <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label>
172 {THREAD_SORT_OPTIONS.map((option) => (
173 <NativeHeaderToolbar.MenuAction
174 key={option.value}
175 isOn={props.threadSortOrder === option.value}
176 onPress={() => props.onThreadSortOrderChange(option.value)}
177 >
178 <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label>
179 </NativeHeaderToolbar.MenuAction>
180 ))}
181 </NativeHeaderToolbar.Menu>
182 )}
183142 </NativeHeaderToolbar.Menu>
184143 <NativeHeaderToolbar.Spacer flexible />
185144 <NativeHeaderToolbar.Button
apps/mobile/src/features/home/HomeHeader.types.ts+1-6
......@@ -1,5 +1,4 @@
1import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
2import type { HomeProjectSortOrder } from "./homeThreadList";
1import type { EnvironmentId } from "@t3tools/contracts";
32import type {
43 HomeListFilterMenuEnvironment,
54 HomeListFilterMenuProject,
......@@ -13,13 +12,9 @@ export interface HomeHeaderProps {
1312 readonly searchQuery: string;
1413 readonly selectedEnvironmentId: EnvironmentId | null;
1514 readonly selectedProjectKey: string | null;
16 readonly projectSortOrder: HomeProjectSortOrder;
17 readonly threadSortOrder: SidebarThreadSortOrder;
1815 readonly onSearchQueryChange: (query: string) => void;
1916 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
2017 readonly onProjectChange: (projectKey: string | null) => void;
21 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
22 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
2318 readonly onOpenEnvironments: () => void;
2419 readonly onOpenSettings: () => void;
2520 readonly onStartNewTask: () => void;
apps/mobile/src/features/home/HomeRouteScreen.tsx+2-13
......@@ -76,12 +76,8 @@ export function HomeRouteScreen() {
7676 () => new Set(environments.map((environment) => environment.environmentId)),
7777 [environments],
7878 );
79 const {
80 options: listOptions,
81 setSelectedEnvironmentId,
82 setProjectSortOrder,
83 setThreadSortOrder,
84 } = useHomeListOptions(availableEnvironmentIds);
79 const { options: listOptions, setSelectedEnvironmentId } =
80 useHomeListOptions(availableEnvironmentIds);
8581 const selectedEnvironmentId = listOptions.selectedEnvironmentId;
8682 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
8783 const projectFilterOptions = useMemo(
......@@ -175,8 +171,6 @@ export function HomeRouteScreen() {
175171 searchQuery={searchQuery}
176172 selectedEnvironmentId={selectedEnvironmentId}
177173 selectedProjectKey={selectedProjectKey}
178 projectSortOrder={listOptions.projectSortOrder}
179 threadSortOrder={listOptions.threadSortOrder}
180174 onEnvironmentChange={setSelectedEnvironmentId}
181175 onProjectChange={setSelectedProjectKey}
182176 onOpenEnvironments={() =>
......@@ -191,10 +185,8 @@ export function HomeRouteScreen() {
191185 params: { screen: "Settings" },
192186 })
193187 }
194 onProjectSortOrderChange={setProjectSortOrder}
195188 onSearchQueryChange={setSearchQuery}
196189 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
197 onThreadSortOrderChange={setThreadSortOrder}
198190 />
199191
200192 <HomeScreen
......@@ -225,7 +217,6 @@ export function HomeRouteScreen() {
225217 params: { screen: "Settings" },
226218 })
227219 }
228 onProjectSortOrderChange={setProjectSortOrder}
229220 onSearchQueryChange={setSearchQuery}
230221 onSelectThread={handleSelectThread}
231222 onSelectPendingTask={openPendingTask}
......@@ -252,7 +243,6 @@ export function HomeRouteScreen() {
252243 });
253244 }}
254245 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
255 onThreadSortOrderChange={setThreadSortOrder}
256246 pendingTasks={pendingTasks}
257247 projectGroupingMode={listOptions.projectGroupingMode}
258248 projects={projects}
......@@ -262,7 +252,6 @@ export function HomeRouteScreen() {
262252 selectedEnvironmentId={selectedEnvironmentId}
263253 selectedProjectKey={selectedProjectKey}
264254 threads={threads}
265 threadSortOrder={listOptions.threadSortOrder}
266255 />
267256 </>
268257 </AndroidHomeFabLayout>
apps/mobile/src/features/home/HomeScreen.tsx+36-424
......@@ -1,10 +1,6 @@
11import type { ThreadMoveDestination } from "../threads/threadOrder";
22import { computeThreadMoveAvailability } from "../threads/threadOrder";
3import {
4 LegendList,
5 type LegendListRef,
6 type LegendListRenderItemProps,
7} from "@legendapp/list/react-native";
3import { LegendList } from "@legendapp/list/react-native";
84import {
95 type EnvironmentProject,
106 type EnvironmentThreadShell,
......@@ -17,10 +13,8 @@ import {
1713 type EnvironmentId,
1814 resolveEnvironmentMachineKind,
1915 type SidebarProjectGroupingMode,
20 type SidebarThreadSortOrder,
2116} from "@t3tools/contracts";
22import { useAtomSet, useAtomValue } from "@effect/atom-react";
23import { AsyncResult } from "effect/unstable/reactivity";
17import { useAtomValue } from "@effect/atom-react";
2418import { useFocusEffect } from "@react-navigation/native";
2519import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2620import { ActivityIndicator, Platform, View } from "react-native";
......@@ -34,20 +28,12 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace
3428import type { SavedRemoteConnection } from "../../lib/connection";
3529import { scopedProjectKey } from "../../lib/scopedEntities";
3630import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
37import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3831import { useThreadSearch } from "../../state/queries";
3932import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
40import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
4133import { usePendingThreadOrder } from "../../state/thread-order";
4234import { environmentServerConfigsAtom } from "../../state/server";
4335import type { PendingNewTask } from "../../state/use-pending-new-tasks";
4436import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
45import {
46 PendingTaskListRow,
47 ThreadListGroupHeader,
48 ThreadListRow,
49 ThreadListShowMoreRow,
50} from "../threads/thread-list-items";
5137import {
5238 ThreadListV2PendingRow,
5339 ThreadListV2Row,
......@@ -67,19 +53,8 @@ import {
6753} from "../threads/threadListV2";
6854import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
6955import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
70import {
71 buildHomeListLayout,
72 DEFAULT_GROUP_DISPLAY_STATE,
73 EMPTY_HOME_LIST_LAYOUT,
74 homeListItemsAreEqual,
75 nextGroupDisplayState,
76 type HomeGroupDisplayAction,
77 type HomeGroupDisplayState,
78 type HomeListItem,
79} from "./homeListItems";
8056import {
8157 buildHomeProjectScopes,
82 buildHomeThreadGroups,
8358 sortHomeProjectScopes,
8459 type HomeProjectSortOrder,
8560} from "./homeThreadList";
......@@ -101,13 +76,10 @@ interface HomeScreenProps {
10176 readonly selectedEnvironmentId: EnvironmentId | null;
10277 readonly selectedProjectKey: string | null;
10378 readonly projectSortOrder: HomeProjectSortOrder;
104 readonly threadSortOrder: SidebarThreadSortOrder;
10579 readonly projectGroupingMode: SidebarProjectGroupingMode;
10680 readonly onSearchQueryChange: (query: string) => void;
10781 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
10882 readonly onProjectChange: (projectKey: string | null) => void;
109 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
110 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
11183 readonly onAddConnection: () => void;
11284 readonly onOpenSettings: () => void;
11385 readonly onStartNewTask: () => void;
......@@ -138,7 +110,6 @@ interface HomeScreenProps {
138110
139111/* ─── Layout constants ───────────────────────────────────────────────── */
140112
141const ESTIMATED_THREAD_ROW_HEIGHT = 72;
142113// v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards
143114// measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line
144115// cards ~94dp. The estimate seeds the recycler's initial container count,
......@@ -236,15 +207,8 @@ function HomeTopContentSpacer() {
236207/* ─── Main screen ────────────────────────────────────────────────────── */
237208
238209export function HomeScreen(props: HomeScreenProps) {
239 const [groupDisplayStates, setGroupDisplayStates] = useState<
240 ReadonlyMap<string, HomeGroupDisplayState>
241 >(() => new Map());
242 const preferencesResult = useAtomValue(mobilePreferencesAtom);
243 const threadListV2Enabled = useThreadListV2Enabled();
244210 const queuedThreadKeys = useQueuedThreadKeys();
245 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
246211 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
247 const listRef = useRef<LegendListRef | null>(null);
248212 const insets = useSafeAreaInsets();
249213 const iosBottomToolbarClearance =
250214 Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED
......@@ -279,42 +243,6 @@ export function HomeScreen(props: HomeScreenProps) {
279243 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),
280244 [threadSearch.matches],
281245 );
282 const effectiveGroupDisplayStates = useMemo(() => {
283 const next = new Map(groupDisplayStates);
284 if (!AsyncResult.isSuccess(preferencesResult)) {
285 return next;
286 }
287 for (const key of preferencesResult.value.collapsedProjectGroups ?? []) {
288 const existing = next.get(key);
289 next.set(key, {
290 ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE),
291 collapsed: true,
292 });
293 }
294 return next;
295 }, [groupDisplayStates, preferencesResult]);
296 const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates);
297 effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates;
298
299 const updateGroupDisplay = useCallback(
300 (key: string, action: HomeGroupDisplayAction) => {
301 const next = new Map(effectiveGroupDisplayStatesRef.current);
302 next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action));
303 effectiveGroupDisplayStatesRef.current = next;
304 setGroupDisplayStates(next);
305 if (action === "toggle-collapsed") {
306 const collapsedProjectGroups: string[] = [];
307 for (const [groupKey, state] of next) {
308 if (state.collapsed) {
309 collapsedProjectGroups.push(groupKey);
310 }
311 }
312 savePreferences({ collapsedProjectGroups });
313 }
314 },
315 [savePreferences],
316 );
317
318246 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
319247 if (openSwipeableRef.current !== methods) {
320248 openSwipeableRef.current?.close();
......@@ -346,112 +274,7 @@ export function HomeScreen(props: HomeScreenProps) {
346274 }),
347275 [props.projectGroupingMode, props.projects, props.selectedEnvironmentId],
348276 );
349 const selectedProjectScope = useMemo(
350 () =>
351 props.selectedProjectKey === null
352 ? null
353 : (projectScopes.find(
354 (scope) =>
355 scope.key === props.selectedProjectKey ||
356 scope.projectRefs.some(
357 (projectRef) =>
358 scopedProjectKey(projectRef.environmentId, projectRef.projectId) ===
359 props.selectedProjectKey,
360 ),
361 ) ?? null),
362 [projectScopes, props.selectedProjectKey],
363 );
364 const selectedProjectRefKeys = useMemo(
365 () =>
366 selectedProjectScope === null
367 ? null
368 : new Set(
369 selectedProjectScope.projectRefs.map((projectRef) =>
370 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
371 ),
372 ),
373 [selectedProjectScope],
374 );
375 const scopedProjects = useMemo(
376 () =>
377 threadListV2Enabled
378 ? []
379 : selectedProjectRefKeys === null
380 ? props.projects
381 : props.projects.filter((project) =>
382 selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)),
383 ),
384 [threadListV2Enabled, props.projects, selectedProjectRefKeys],
385 );
386 const scopedThreads = useMemo(
387 () =>
388 threadListV2Enabled
389 ? []
390 : selectedProjectRefKeys === null
391 ? props.threads
392 : props.threads.filter((thread) =>
393 selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)),
394 ),
395 [threadListV2Enabled, props.threads, selectedProjectRefKeys],
396 );
397 const scopedPendingTasks = useMemo(
398 () =>
399 threadListV2Enabled
400 ? []
401 : selectedProjectRefKeys === null
402 ? props.pendingTasks
403 : props.pendingTasks.filter((pendingTask) =>
404 selectedProjectRefKeys.has(
405 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
406 ),
407 ),
408 [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys],
409 );
410
411 const projectGroups = useMemo(
412 () =>
413 threadListV2Enabled
414 ? []
415 : buildHomeThreadGroups({
416 projects: scopedProjects,
417 threads: scopedThreads,
418 pendingTasks: scopedPendingTasks,
419 queuedThreadKeys,
420 environmentId: props.selectedEnvironmentId,
421 searchQuery: props.searchQuery,
422 matchedThreadKeys,
423 projectSortOrder: props.projectSortOrder,
424 threadSortOrder: props.threadSortOrder,
425 projectGroupingMode: props.projectGroupingMode,
426 }),
427 [
428 threadListV2Enabled,
429 queuedThreadKeys,
430 props.projectGroupingMode,
431 props.projectSortOrder,
432 props.searchQuery,
433 props.selectedEnvironmentId,
434 props.threadSortOrder,
435 matchedThreadKeys,
436 scopedPendingTasks,
437 scopedProjects,
438 scopedThreads,
439 ],
440 );
441
442277 const hasSearchQuery = props.searchQuery.trim().length > 0;
443 const listLayout = useMemo(
444 () =>
445 threadListV2Enabled
446 ? EMPTY_HOME_LIST_LAYOUT
447 : buildHomeListLayout({
448 groups: projectGroups,
449 displayStates: effectiveGroupDisplayStates,
450 showAllThreads: hasSearchQuery,
451 }),
452 [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery],
453 );
454
455278 const projectByKey = useMemo(() => {
456279 const map = new Map<string, EnvironmentProject>();
457280 for (const project of props.projects) {
......@@ -597,12 +420,11 @@ export function HomeScreen(props: HomeScreenProps) {
597420 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
598421 useFocusEffect(
599422 useCallback(() => {
600 if (!threadListV2Enabled) return;
601423 // Refresh immediately on enable or focus because the previous value can be hours old.
602424 setNowMinute(new Date().toISOString().slice(0, 16));
603425 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
604426 return () => clearInterval(id);
605 }, [threadListV2Enabled]),
427 }, []),
606428 );
607429 // Threads on servers without the settlement capability never classify as
608430 // settled (the user could neither un-settle nor pin them).
......@@ -715,16 +537,6 @@ export function HomeScreen(props: HomeScreenProps) {
715537 snoozeWakeTick,
716538 ]);
717539 const threadListV2Layout = useMemo(() => {
718 if (!threadListV2Enabled)
719 return {
720 items: [],
721 hiddenSettledCount: 0,
722 snoozedCount: 0,
723 snoozedShelfHeaderIndex: null,
724 settledCount: 0,
725 settledShelfHeaderIndex: null,
726 nextSnoozeWakeAt: null,
727 };
728540 // Settled threads are live shells; archived threads keep their original
729541 // "hidden from lists" meaning.
730542 return buildThreadListV2Items({
......@@ -757,7 +569,6 @@ export function HomeScreen(props: HomeScreenProps) {
757569 props.selectedEnvironmentId,
758570 props.threads,
759571 matchedThreadKeys,
760 threadListV2Enabled,
761572 v2ScopedProjectGroup,
762573 ]);
763574 // Re-partition the moment the earliest snooze expires (clamped to the
......@@ -824,10 +635,7 @@ export function HomeScreen(props: HomeScreenProps) {
824635 ],
825636 );
826637
827 useThreadJumpShortcuts(
828 threadListV2Enabled ? threadListV2Items : listLayout.items,
829 props.onSelectThread,
830 );
638 useThreadJumpShortcuts(threadListV2Items, props.onSelectThread);
831639
832640 const renderV2Item = useCallback(
833641 ({ item }: { readonly item: ThreadListV2ListItem }) => {
......@@ -996,126 +804,13 @@ export function HomeScreen(props: HomeScreenProps) {
996804 ],
997805 );
998806
999 const extraData = useMemo(
1000 () => ({
1001 savedConnectionsById: props.savedConnectionsById,
1002 searchQuery: props.searchQuery,
1003 threadSearchMatchByKey,
1004 }),
1005 [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
1006 );
1007
1008 const renderItem = useCallback(
1009 ({ item }: LegendListRenderItemProps<HomeListItem>) => {
1010 switch (item.type) {
1011 case "header":
1012 return (
1013 <ThreadListGroupHeader
1014 variant="compact"
1015 collapsed={item.collapsed}
1016 isFirst={item.isFirst}
1017 groupKey={item.group.key}
1018 onGroupAction={updateGroupDisplay}
1019 // Aggregated groups (same repo across machines) have no single
1020 // target project, and `pending-project:` groups hold a placeholder
1021 // built from queued-task metadata rather than a real project shell,
1022 // so the quick new-thread button is single-real-project only.
1023 newThreadTarget={item.group.newThreadTarget}
1024 onNewThread={props.onNewThreadInProject}
1025 project={item.group.representative}
1026 threadCount={item.group.threads.length + item.group.pendingTasks.length}
1027 title={item.group.title}
1028 />
1029 );
1030 case "pending-task":
1031 return (
1032 <PendingTaskListRow
1033 variant="compact"
1034 pendingTask={item.pendingTask}
1035 environmentLabel={
1036 props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
1037 }
1038 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
1039 isLast={item.isLast}
1040 onSelectPendingTask={props.onSelectPendingTask}
1041 onDeletePendingTask={props.onDeletePendingTask}
1042 />
1043 );
1044 case "thread": {
1045 const thread = item.thread;
1046 return (
1047 <ThreadListRow
1048 onNewThreadOnBranch={props.onNewThreadOnBranch}
1049 variant="compact"
1050 thread={thread}
1051 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
1052 environmentLabel={
1053 props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
1054 }
1055 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
1056 isLast={item.isLast}
1057 searchMatch={threadSearchMatchByKey.get(
1058 threadSearchMatchKey({
1059 environmentId: thread.environmentId,
1060 threadId: thread.id,
1061 }),
1062 )}
1063 searchQuery={props.searchQuery}
1064 onArchiveThread={props.onArchiveThread}
1065 onDeleteThread={props.onDeleteThread}
1066 onRenameThread={handleRenameThread}
1067 onRegenerateThreadTitle={handleRegenerateThreadTitle}
1068 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
1069 onSelectThread={props.onSelectThread}
1070 onSwipeableClose={handleSwipeableClose}
1071 onSwipeableWillOpen={handleSwipeableWillOpen}
1072 />
1073 );
1074 }
1075 case "show-more":
1076 return (
1077 <ThreadListShowMoreRow
1078 variant="compact"
1079 hiddenCount={item.hiddenCount}
1080 canShowLess={item.canShowLess}
1081 groupKey={item.groupKey}
1082 onGroupAction={updateGroupDisplay}
1083 />
1084 );
1085 }
1086 },
1087 [
1088 handleSwipeableClose,
1089 handleSwipeableWillOpen,
1090 handleRegenerateThreadTitle,
1091 handleRenameThread,
1092 machineByEnvironmentId,
1093 queuedThreadKeys,
1094 props.onArchiveThread,
1095 props.onDeletePendingTask,
1096 props.onDeleteThread,
1097 props.onNewThreadInProject,
1098 props.onSelectPendingTask,
1099 props.onSelectThread,
1100 props.onNewThreadOnBranch,
1101 props.searchQuery,
1102 props.savedConnectionsById,
1103 threadSearchMatchByKey,
1104 titleRegenerationEnvironmentIds,
1105 updateGroupDisplay,
1106 ],
1107 );
1108
1109 const keyExtractor = useCallback((item: HomeListItem) => item.key, []);
1110
1111807 /* Empty states */
1112808 // The signal must ignore the search/environment filters: an active query
1113809 // that matches nothing needs the in-list "No results" state, not the
1114810 // full-page "No threads yet". Settled threads are unarchived live shells,
1115 // so the v1 check already covers v2.
811 // so the archived-at check already covers the settled shelf.
1116812 const hasAnyThreads =
1117813 props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0;
1118 const hasResults = threadListV2Enabled ? threadListV2Items.length > 0 : projectGroups.length > 0;
1119814 const selectedEnvironmentLabel =
1120815 props.selectedEnvironmentId === null
1121816 ? null
......@@ -1178,16 +873,18 @@ export function HomeScreen(props: HomeScreenProps) {
1178873 // mobile — the menu is the one filter surface).
1179874 const v2ListHeader = listHeader;
1180875
1181 const listEmpty = !hasResults ? (
876 // Use the v2 project scope for its empty state. Snoozed threads need no
877 // special empty state: their shelf header is a list row even while collapsed.
878 const v2ListEmpty =
1182879 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (
1183880 <EmptyState
1184881 title="No results"
1185882 detail={`No threads matching "${props.searchQuery}".`}
1186883 variant={Platform.OS === "android" ? "plain" : undefined}
1187884 />
1188 ) : selectedProjectScope !== null ? (
885 ) : v2ScopedProjectGroup !== null ? (
1189886 <EmptyState
1190 title={`No threads in ${selectedProjectScope.title}`}
887 title={`No threads in ${v2ScopedProjectGroup.title}`}
1191888 detail="Choose another project or create a new task."
1192889 variant={Platform.OS === "android" ? "plain" : undefined}
1193890 />
......@@ -1203,93 +900,16 @@ export function HomeScreen(props: HomeScreenProps) {
1203900 detail="Create a task to start a new coding session."
1204901 variant={Platform.OS === "android" ? "plain" : undefined}
1205902 />
1206 )
1207 ) : null;
1208 // Use the v2 project scope for its empty state. Snoozed threads need no
1209 // special empty state: their shelf header is a list row even while collapsed.
1210 const v2ListEmpty =
1211 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (
1212 <EmptyState
1213 title="No results"
1214 detail={`No threads matching "${props.searchQuery}".`}
1215 variant={Platform.OS === "android" ? "plain" : undefined}
1216 />
1217 ) : v2ScopedProjectGroup !== null ? (
1218 <EmptyState
1219 title={`No threads in ${v2ScopedProjectGroup.title}`}
1220 detail="Choose another project or create a new task."
1221 variant={Platform.OS === "android" ? "plain" : undefined}
1222 />
1223 ) : (
1224 listEmpty
1225903 );
1226904
1227 if (
1228 Platform.OS === "android" &&
1229 (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0)
1230 ) {
905 if (Platform.OS === "android" && threadListV2Items.length === 0) {
1231906 return (
1232907 <View className="flex-1 bg-header">
1233908 <View
1234909 className="flex-1 items-center justify-center overflow-hidden rounded-t-[28px] bg-screen px-4"
1235910 style={{ paddingBottom: insets.bottom }}
1236911 >
1237 {threadListV2Enabled ? v2ListEmpty : listEmpty}
1238 </View>
1239 </View>
1240 );
1241 }
1242
1243 if (threadListV2Enabled) {
1244 return (
1245 <View className="flex-1 bg-screen android:bg-header">
1246 <View
1247 className={
1248 Platform.OS === "android"
1249 ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen"
1250 : "flex-1 bg-screen"
1251 }
1252 >
1253 {/* Same recycler the iPad sidebar and the legacy list use: cells are
1254 reused across data rebuilds and `itemsAreEqual` keeps a minute tick
1255 (or an unrelated shell update) from re-rendering untouched rows. */}
1256 <SwipeableScrollGateProvider enabled={swipeEnabled}>
1257 <LegendList
1258 data={threadListV2Items}
1259 renderItem={renderV2Item}
1260 keyExtractor={v2KeyExtractor}
1261 getItemType={(item) => item.type}
1262 itemsAreEqual={threadListV2ListItemsAreEqual}
1263 estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT}
1264 drawDistance={500}
1265 recycleItems
1266 extraData={v2ExtraData}
1267 ListHeaderComponent={v2ListHeader}
1268 ListFooterComponent={
1269 settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? (
1270 <ThreadListV2ShowMoreRow
1271 hiddenCount={threadListV2Layout.hiddenSettledCount}
1272 onPress={showMoreSettled}
1273 />
1274 ) : null
1275 }
1276 ListEmptyComponent={v2ListEmpty}
1277 style={{ flex: 1 }}
1278 automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
1279 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
1280 showsVerticalScrollIndicator={false}
1281 keyboardDismissMode="on-drag"
1282 keyboardShouldPersistTaps="handled"
1283 {...scrollGateHandlers}
1284 scrollEventThrottle={16}
1285 contentContainerStyle={{
1286 paddingBottom:
1287 Platform.OS === "ios"
1288 ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance
1289 : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88),
1290 }}
1291 />
1292 </SwipeableScrollGateProvider>
912 {v2ListEmpty}
1293913 </View>
1294914 </View>
1295915 );
......@@ -1304,52 +924,44 @@ export function HomeScreen(props: HomeScreenProps) {
1304924 : "flex-1 bg-screen"
1305925 }
1306926 >
1307 {/* Sticky headers are deliberately not wired up: LegendList's JS sticky
1308 implementation mispositions pinned headers at mount under iOS
1309 automatic content insets (headers render one nav-inset too low until
1310 the first scroll event) and blanks non-pinned headers after
1311 collapse/expand data changes. The flattened layout still exposes
1312 `stickyHeaderIndices` if this gets revisited. */}
927 {/* Same recycler the iPad sidebar and the legacy list use: cells are
928 reused across data rebuilds and `itemsAreEqual` keeps a minute tick
929 (or an unrelated shell update) from re-rendering untouched rows. */}
1313930 <SwipeableScrollGateProvider enabled={swipeEnabled}>
1314931 <LegendList
1315 ref={listRef}
1316 data={listLayout.items}
1317 renderItem={renderItem}
1318 keyExtractor={keyExtractor}
1319 itemsAreEqual={homeListItemsAreEqual}
932 data={threadListV2Items}
933 renderItem={renderV2Item}
934 keyExtractor={v2KeyExtractor}
935 getItemType={(item) => item.type}
936 itemsAreEqual={threadListV2ListItemsAreEqual}
937 estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT}
1320938 drawDistance={500}
1321 estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT}
1322 extraData={extraData}
1323 ListHeaderComponent={listHeader}
1324 ListEmptyComponent={listEmpty}
939 recycleItems
940 extraData={v2ExtraData}
941 ListHeaderComponent={v2ListHeader}
942 ListFooterComponent={
943 settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? (
944 <ThreadListV2ShowMoreRow
945 hiddenCount={threadListV2Layout.hiddenSettledCount}
946 onPress={showMoreSettled}
947 />
948 ) : null
949 }
950 ListEmptyComponent={v2ListEmpty}
1325951 style={{ flex: 1 }}
1326 automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED}
1327 contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"}
952 automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
953 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
1328954 showsVerticalScrollIndicator={false}
1329955 keyboardDismissMode="on-drag"
1330956 keyboardShouldPersistTaps="handled"
1331957 {...scrollGateHandlers}
1332 recycleItems
1333958 scrollEventThrottle={16}
1334959 contentContainerStyle={{
1335 // Android reserves room for the floating new-task FAB
1336 // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a
1337 // standard 44pt bottom toolbar that overlays the list and is not
1338 // reflected in insets while contentInsetAdjustmentBehavior is
1339 // "never".
1340960 paddingBottom:
1341961 Platform.OS === "ios"
1342 ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance
962 ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance
1343963 : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88),
1344964 }}
1345 scrollIndicatorInsets={
1346 Platform.OS === "ios"
1347 ? {
1348 bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance,
1349 top: 0,
1350 }
1351 : undefined
1352 }
1353965 />
1354966 </SwipeableScrollGateProvider>
1355967 </View>
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx+1-1
......@@ -130,7 +130,7 @@ export function MaterialThreadListToolbar(props: {
130130 <AndroidAnchoredMenu actions={props.filterActions} onPressAction={props.onFilterAction}>
131131 {(open) => (
132132 <MaterialFloatingActionButton
133 label="Filter and sort threads"
133 label="Filter threads"
134134 icon={filterIcon}
135135 onPress={open}
136136 />
apps/mobile/src/features/home/home-list-filter-menu.test.ts-4
......@@ -13,12 +13,8 @@ describe("buildHomeListFilterMenu", () => {
1313 ],
1414 selectedEnvironmentId: null,
1515 selectedProjectKey: "environment-1:project-1",
16 projectSortOrder: "updated_at",
17 threadSortOrder: "updated_at",
1816 onEnvironmentChange: vi.fn(),
1917 onProjectChange,
20 onProjectSortOrderChange: vi.fn(),
21 onThreadSortOrderChange: vi.fn(),
2218 });
2319
2420 const projectMenu = menu.items.find(
apps/mobile/src/features/home/home-list-filter-menu.ts+1-37
......@@ -1,7 +1,4 @@
1import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
2
3import type { HomeProjectSortOrder } from "./homeThreadList";
4import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options";
1import type { EnvironmentId } from "@t3tools/contracts";
52
63export interface HomeListFilterMenuEnvironment {
74 readonly environmentId: EnvironmentId;
......@@ -37,16 +34,8 @@ export function buildHomeListFilterMenu(props: {
3734 readonly projects: ReadonlyArray<HomeListFilterMenuProject>;
3835 readonly selectedEnvironmentId: EnvironmentId | null;
3936 readonly selectedProjectKey: string | null;
40 readonly projectSortOrder: HomeProjectSortOrder;
41 readonly threadSortOrder: SidebarThreadSortOrder;
4237 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
4338 readonly onProjectChange: (projectKey: string | null) => void;
44 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
45 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
46 /** False hides the sort/group submenus. Thread List v2 uses a fixed
47 creation-order layout, so offering those controls while it silently
48 ignores them would be a lie; the environment filter still applies. */
49 readonly listOrganization?: boolean;
5039}): HomeListFilterMenu {
5140 const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];
5241
......@@ -95,31 +84,6 @@ export function buildHomeListFilterMenu(props: {
9584 });
9685 }
9786
98 if (props.listOrganization !== false) {
99 items.push(
100 {
101 type: "submenu",
102 title: "Sort projects",
103 items: PROJECT_SORT_OPTIONS.map((option) => ({
104 type: "action",
105 title: option.label,
106 state: props.projectSortOrder === option.value ? "on" : "off",
107 onPress: () => props.onProjectSortOrderChange(option.value),
108 })),
109 },
110 {
111 type: "submenu",
112 title: "Sort threads",
113 items: THREAD_SORT_OPTIONS.map((option) => ({
114 type: "action",
115 title: option.label,
116 state: props.threadSortOrder === option.value ? "on" : "off",
117 onPress: () => props.onThreadSortOrderChange(option.value),
118 })),
119 },
120 );
121 }
122
12387 return {
12488 title: "Thread list options",
12589 items,
apps/mobile/src/features/home/home-list-options.test.ts deleted-31
......@@ -1,31 +0,0 @@
1import {
2 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
3 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
4} from "@t3tools/contracts";
5import { describe, expect, it } from "vite-plus/test";
6
7import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options";
8
9const defaults: HomeListOptions = {
10 selectedEnvironmentId: null,
11 projectSortOrder:
12 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
13 ? "updated_at"
14 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
15 threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
16};
17
18describe("home list options", () => {
19 it("recognizes default options", () => {
20 expect(hasCustomHomeListOptions(defaults)).toBe(false);
21 });
22
23 it("marks environment filters as customized", () => {
24 expect(
25 hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }),
26 ).toBe(true);
27 expect(
28 hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }),
29 ).toBe(true);
30 });
31});
apps/mobile/src/features/home/home-list-options.ts-33
......@@ -31,22 +31,6 @@ export interface ResolvedHomeListOptions extends HomeListOptions {
3131 readonly projectGroupingMode: SidebarProjectGroupingMode;
3232}
3333
34export const PROJECT_SORT_OPTIONS: ReadonlyArray<{
35 readonly value: HomeProjectSortOrder;
36 readonly label: string;
37}> = [
38 { value: "updated_at", label: "Last user message" },
39 { value: "created_at", label: "Created at" },
40];
41
42export const THREAD_SORT_OPTIONS: ReadonlyArray<{
43 readonly value: SidebarThreadSortOrder;
44 readonly label: string;
45}> = [
46 { value: "updated_at", label: "Last user message" },
47 { value: "created_at", label: "Created at" },
48];
49
5034function defaultHomeListOptions(): HomeListOptions {
5135 return {
5236 selectedEnvironmentId: null,
......@@ -81,23 +65,6 @@ export function HomeListOptionsProvider({
8165 return createElement(HomeListOptionsContext, { value }, children);
8266}
8367
84export function hasCustomHomeListOptions(
85 options: HomeListOptions & {
86 readonly selectedProjectKey?: string | null;
87 },
88): boolean {
89 const defaultProjectSortOrder =
90 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
91 ? "updated_at"
92 : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER;
93 return (
94 options.selectedEnvironmentId !== null ||
95 (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) ||
96 options.projectSortOrder !== defaultProjectSortOrder ||
97 options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER
98 );
99}
100
10168export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) {
10269 const shared = useContext(HomeListOptionsContext);
10370 const [localOptions, setLocalOptions] = useState<HomeListOptions>(defaultHomeListOptions);
apps/mobile/src/features/home/homeListItems.test.ts deleted-266
......@@ -1,266 +0,0 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import {
9 buildHomeListLayout,
10 DEFAULT_GROUP_DISPLAY_STATE,
11 HOME_INITIAL_VISIBLE_THREADS,
12 HOME_SHOW_MORE_STEP,
13 nextGroupDisplayState,
14 type HomeGroupDisplayState,
15 type HomeListItem,
16} from "./homeListItems";
17import type { HomeThreadGroup } from "./homeThreadList";
18import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
19
20const environmentId = EnvironmentId.make("environment-1");
21
22function makeProject(id: string, title: string): EnvironmentProject {
23 return {
24 environmentId,
25 id: ProjectId.make(id),
26 title,
27 workspaceRoot: `/workspaces/${id}`,
28 repositoryIdentity: null,
29 defaultModelSelection: null,
30 scripts: [],
31 createdAt: "2026-06-01T00:00:00.000Z",
32 updatedAt: "2026-06-01T00:00:00.000Z",
33 };
34}
35
36function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
37 return {
38 environmentId,
39 id: ThreadId.make(id),
40 projectId,
41 title: `Thread ${id}`,
42 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
43 runtimeMode: "full-access",
44 interactionMode: "default",
45 branch: null,
46 worktreePath: null,
47 pullRequests: [],
48 latestTurn: null,
49 createdAt: "2026-06-01T00:00:00.000Z",
50 updatedAt: "2026-06-01T00:00:00.000Z",
51 archivedAt: null,
52 settledOverride: null,
53 settledAt: null,
54 session: null,
55 latestUserMessageAt: null,
56 hasPendingApprovals: false,
57 hasPendingUserInput: false,
58 hasActionableProposedPlan: false,
59 };
60}
61
62function makeGroup(key: string, threadCount: number): HomeThreadGroup {
63 const project = makeProject(key, key);
64 const threads = Array.from({ length: threadCount }, (_, index) =>
65 makeThread(`${key}-thread-${index}`, project.id),
66 );
67 return {
68 key,
69 title: key,
70 representative: project,
71 projects: [project],
72 pendingTasks: [],
73 threads,
74 // All threads inside the recency window, so the baseline stays at the
75 // initial page size and the pagination expectations below hold.
76 recentThreads: threads,
77 newThreadTarget: project,
78 };
79}
80
81function itemTypes(items: ReadonlyArray<HomeListItem>): string[] {
82 return items.map((item) => item.type);
83}
84
85function displayStates(
86 entries: Record<string, HomeGroupDisplayState>,
87): ReadonlyMap<string, HomeGroupDisplayState> {
88 return new Map(Object.entries(entries));
89}
90
91describe("threadJumpTarget", () => {
92 it("numbers only displayed threads across groups, skipping collapsed groups and pagination rows", () => {
93 const layout = buildHomeListLayout({
94 groups: [makeGroup("collapsed", 3), makeGroup("alpha", 8), makeGroup("beta", 3)],
95 displayStates: displayStates({ collapsed: { collapsed: true, visibleCount: 6 } }),
96 });
97 expect(threadJumpTarget(layout.items, "thread.jump.1")?.id).toBe("alpha-thread-0");
98 expect(threadJumpTarget(layout.items, "thread.jump.7")?.id).toBe("beta-thread-0");
99 expect(threadJumpTarget(layout.items, "thread.jump.9")?.id).toBe("beta-thread-2");
100 });
101
102 it("ignores missing positions and unrelated commands", () => {
103 const layout = buildHomeListLayout({
104 groups: [makeGroup("alpha", 1)],
105 displayStates: displayStates({}),
106 });
107 expect(threadJumpTarget(layout.items, "thread.jump.2")).toBeNull();
108 expect(threadJumpTarget([], "thread.jump.1")).toBeNull();
109 expect(threadJumpTarget(layout.items, "commandPalette")).toBeNull();
110 });
111});
112
113describe("buildHomeListLayout", () => {
114 it("renders a header plus all threads for a small group without a show-more row", () => {
115 const layout = buildHomeListLayout({
116 groups: [makeGroup("alpha", 3)],
117 displayStates: displayStates({}),
118 });
119
120 expect(itemTypes(layout.items)).toEqual(["header", "thread", "thread", "thread"]);
121 expect(layout.stickyHeaderIndices).toEqual([0]);
122 expect(layout.items.at(-1)).toMatchObject({ type: "thread", isLast: true });
123 });
124
125 it("limits large groups to the initial visible count with a show-more row", () => {
126 const layout = buildHomeListLayout({
127 groups: [makeGroup("alpha", 133)],
128 displayStates: displayStates({}),
129 });
130
131 const threadItems = layout.items.filter((item) => item.type === "thread");
132 expect(threadItems).toHaveLength(HOME_INITIAL_VISIBLE_THREADS);
133 expect(layout.items.at(-1)).toMatchObject({
134 type: "show-more",
135 groupKey: "alpha",
136 hiddenCount: 133 - HOME_INITIAL_VISIBLE_THREADS,
137 canShowLess: false,
138 });
139 // The show-more row takes over the last slot, so no thread is marked last.
140 expect(threadItems.every((item) => item.type === "thread" && !item.isLast)).toBe(true);
141 });
142
143 it("reveals more threads per show-more step and offers show-less when exhausted", () => {
144 const group = makeGroup("alpha", 20);
145
146 const expandedOnce = buildHomeListLayout({
147 groups: [group],
148 displayStates: displayStates({
149 alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
150 }),
151 });
152 expect(expandedOnce.items.filter((item) => item.type === "thread")).toHaveLength(
153 HOME_INITIAL_VISIBLE_THREADS + HOME_SHOW_MORE_STEP,
154 );
155 expect(expandedOnce.items.at(-1)).toMatchObject({
156 type: "show-more",
157 hiddenCount: 4,
158 canShowLess: true,
159 });
160
161 const fullyExpanded = buildHomeListLayout({
162 groups: [group],
163 displayStates: displayStates({
164 alpha: nextGroupDisplayState(
165 nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
166 "show-more",
167 ),
168 }),
169 });
170 expect(fullyExpanded.items.filter((item) => item.type === "thread")).toHaveLength(20);
171 expect(fullyExpanded.items.at(-1)).toMatchObject({
172 type: "show-more",
173 hiddenCount: 0,
174 canShowLess: true,
175 });
176
177 const reset = nextGroupDisplayState(
178 nextGroupDisplayState(
179 nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
180 "show-more",
181 ),
182 "show-less",
183 );
184 expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS);
185 });
186
187 it("offers show-less after expanding a stale group whose baseline is below the page size", () => {
188 // Stale project: 10 threads total but only 3 within the recency window.
189 const project = makeProject("stale", "stale");
190 const threads = Array.from({ length: 10 }, (_, index) =>
191 makeThread(`stale-thread-${index}`, project.id),
192 );
193 const group: HomeThreadGroup = {
194 key: "stale",
195 title: "stale",
196 representative: project,
197 projects: [project],
198 pendingTasks: [],
199 threads,
200 recentThreads: threads.slice(0, 3),
201 newThreadTarget: project,
202 };
203
204 const collapsedToRecent = buildHomeListLayout({
205 groups: [group],
206 displayStates: displayStates({}),
207 });
208 expect(collapsedToRecent.items.filter((item) => item.type === "thread")).toHaveLength(3);
209 expect(collapsedToRecent.items.at(-1)).toMatchObject({
210 type: "show-more",
211 hiddenCount: 7,
212 canShowLess: false,
213 });
214
215 const expanded = buildHomeListLayout({
216 groups: [group],
217 displayStates: displayStates({
218 stale: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
219 }),
220 });
221 expect(expanded.items.filter((item) => item.type === "thread")).toHaveLength(10);
222 expect(expanded.items.at(-1)).toMatchObject({
223 type: "show-more",
224 hiddenCount: 0,
225 canShowLess: true,
226 });
227 });
228
229 it("hides threads and the show-more row for collapsed groups", () => {
230 const layout = buildHomeListLayout({
231 groups: [makeGroup("alpha", 12), makeGroup("beta", 2)],
232 displayStates: displayStates({
233 alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"),
234 }),
235 });
236
237 expect(itemTypes(layout.items)).toEqual(["header", "header", "thread", "thread"]);
238 expect(layout.items[0]).toMatchObject({ type: "header", collapsed: true, isFirst: true });
239 expect(layout.items[1]).toMatchObject({ type: "header", collapsed: false, isFirst: false });
240 expect(layout.stickyHeaderIndices).toEqual([0, 1]);
241 });
242
243 it("suspends collapse and pagination while searching", () => {
244 const layout = buildHomeListLayout({
245 groups: [makeGroup("alpha", 12)],
246 displayStates: displayStates({
247 alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"),
248 }),
249 showAllThreads: true,
250 });
251
252 expect(layout.items.filter((item) => item.type === "thread")).toHaveLength(12);
253 expect(layout.items.some((item) => item.type === "show-more")).toBe(false);
254 });
255
256 it("keeps sticky indices aligned across multiple expanded groups", () => {
257 const layout = buildHomeListLayout({
258 groups: [makeGroup("alpha", 8), makeGroup("beta", 1)],
259 displayStates: displayStates({}),
260 });
261
262 // header + 6 threads + show-more = 8 items, so beta's header is index 8.
263 expect(layout.stickyHeaderIndices).toEqual([0, 8]);
264 expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false });
265 });
266});
apps/mobile/src/features/home/homeListItems.ts deleted-209
......@@ -1,209 +0,0 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2
3import type { PendingNewTask } from "../../state/use-pending-new-tasks";
4import type { HomeThreadGroup } from "./homeThreadList";
5
6/** Threads shown per project before the "Show more" affordance appears. */
7export const HOME_INITIAL_VISIBLE_THREADS = 6;
8/** Additional threads revealed per "Show more" tap. */
9export const HOME_SHOW_MORE_STEP = 10;
10
11export interface HomeGroupDisplayState {
12 readonly collapsed: boolean;
13 /** How many threads are currently revealed (clamped to the group size). */
14 readonly visibleCount: number;
15}
16
17export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = {
18 collapsed: false,
19 visibleCount: HOME_INITIAL_VISIBLE_THREADS,
20};
21
22export interface HomeHeaderListItem {
23 readonly type: "header";
24 readonly key: string;
25 readonly group: HomeThreadGroup;
26 readonly collapsed: boolean;
27 readonly isFirst: boolean;
28}
29
30export interface HomeThreadListItem {
31 readonly type: "thread";
32 readonly key: string;
33 readonly thread: EnvironmentThreadShell;
34 readonly isLast: boolean;
35}
36
37export interface HomePendingTaskListItem {
38 readonly type: "pending-task";
39 readonly key: string;
40 readonly pendingTask: PendingNewTask;
41 readonly isLast: boolean;
42}
43
44export interface HomeShowMoreListItem {
45 readonly type: "show-more";
46 readonly key: string;
47 readonly groupKey: string;
48 /** Threads still hidden. 0 means the group is fully expanded. */
49 readonly hiddenCount: number;
50 /** Whether more than the initial count is revealed, so "Show less" applies. */
51 readonly canShowLess: boolean;
52}
53
54export type HomeListItem =
55 | HomeHeaderListItem
56 | HomePendingTaskListItem
57 | HomeThreadListItem
58 | HomeShowMoreListItem;
59
60export interface HomeListLayout {
61 readonly items: ReadonlyArray<HomeListItem>;
62 readonly stickyHeaderIndices: ReadonlyArray<number>;
63}
64
65export const EMPTY_HOME_LIST_LAYOUT: HomeListLayout = { items: [], stickyHeaderIndices: [] };
66
67export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less";
68
69export function nextGroupDisplayState(
70 current: HomeGroupDisplayState,
71 action: HomeGroupDisplayAction,
72): HomeGroupDisplayState {
73 switch (action) {
74 case "toggle-collapsed":
75 return { ...current, collapsed: !current.collapsed };
76 case "show-more":
77 return { ...current, visibleCount: current.visibleCount + HOME_SHOW_MORE_STEP };
78 case "show-less":
79 return { ...current, visibleCount: HOME_INITIAL_VISIBLE_THREADS };
80 }
81}
82
83/**
84 * Structural equality for list items. Item objects are rebuilt on every
85 * collapse/show-more toggle; without this the lists would consider every
86 * mounted row changed and re-render all of them (each carrying a swipeable +
87 * a vcs-status subscription). Group/thread references are stable across
88 * toggles.
89 */
90export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem): boolean {
91 switch (item.type) {
92 case "header":
93 return (
94 previous.type === "header" &&
95 previous.group === item.group &&
96 previous.collapsed === item.collapsed &&
97 previous.isFirst === item.isFirst
98 );
99 case "pending-task":
100 return (
101 previous.type === "pending-task" &&
102 previous.pendingTask === item.pendingTask &&
103 previous.isLast === item.isLast
104 );
105 case "thread":
106 return (
107 previous.type === "thread" &&
108 previous.thread === item.thread &&
109 previous.isLast === item.isLast
110 );
111 case "show-more":
112 return (
113 previous.type === "show-more" &&
114 previous.groupKey === item.groupKey &&
115 previous.hiddenCount === item.hiddenCount &&
116 previous.canShowLess === item.canShowLess
117 );
118 }
119}
120
121export function buildHomeListLayout(input: {
122 readonly groups: ReadonlyArray<HomeThreadGroup>;
123 readonly displayStates: ReadonlyMap<string, HomeGroupDisplayState>;
124 /**
125 * When searching, pagination is suspended so every match stays visible.
126 */
127 readonly showAllThreads?: boolean;
128}): HomeListLayout {
129 const items: HomeListItem[] = [];
130 const stickyHeaderIndices: number[] = [];
131
132 for (const [groupIndex, group] of input.groups.entries()) {
133 const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE;
134 const collapsed = display.collapsed && input.showAllThreads !== true;
135
136 stickyHeaderIndices.push(items.length);
137 items.push({
138 type: "header",
139 key: `header:${group.key}`,
140 group,
141 collapsed,
142 isFirst: groupIndex === 0,
143 });
144
145 if (collapsed) {
146 continue;
147 }
148
149 const totalCount = group.threads.length;
150 // Default to the group's recent-activity window (last few days, or a small
151 // fallback for stale projects), capped at the initial page size. Until the
152 // user taps "Show more", older threads stay hidden to save vertical space;
153 // "Show less" resets visibleCount to the initial constant, which lands back
154 // here at the recency baseline.
155 const baselineCount = Math.min(
156 group.recentThreads.length,
157 HOME_INITIAL_VISIBLE_THREADS,
158 totalCount,
159 );
160 const visibleCount = input.showAllThreads
161 ? totalCount
162 : Math.min(
163 display.visibleCount > HOME_INITIAL_VISIBLE_THREADS
164 ? display.visibleCount
165 : baselineCount,
166 totalCount,
167 );
168 const visibleThreads = group.threads.slice(0, visibleCount);
169 const hiddenCount = totalCount - visibleCount;
170 const hasShowMoreRow = !input.showAllThreads && totalCount > baselineCount;
171
172 // Pending (unsent) tasks lead the group and are never paginated away.
173 for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {
174 items.push({
175 type: "pending-task",
176 key: pendingTask.key,
177 pendingTask,
178 isLast:
179 pendingIndex === group.pendingTasks.length - 1 &&
180 visibleThreads.length === 0 &&
181 !hasShowMoreRow,
182 });
183 }
184
185 for (const [threadIndex, thread] of visibleThreads.entries()) {
186 items.push({
187 type: "thread",
188 key: `thread:${thread.environmentId}:${thread.id}`,
189 thread,
190 isLast: threadIndex === visibleThreads.length - 1 && !hasShowMoreRow,
191 });
192 }
193
194 if (hasShowMoreRow) {
195 items.push({
196 type: "show-more",
197 key: `show-more:${group.key}`,
198 groupKey: group.key,
199 hiddenCount,
200 // Compare against the group's own baseline, not the global page size:
201 // stale projects start below HOME_INITIAL_VISIBLE_THREADS, and "Show
202 // less" must be offered as soon as anything beyond the baseline shows.
203 canShowLess: visibleCount > baselineCount,
204 });
205 }
206 }
207
208 return { items, stickyHeaderIndices };
209}
apps/mobile/src/features/home/homeThreadList.test.ts+2-494
......@@ -6,11 +6,7 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-searc
66import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
77import { describe, expect, it } from "vite-plus/test";
88
9import {
10 buildHomeProjectScopes,
11 buildHomeThreadGroups,
12 sortHomeProjectScopes,
13} from "./homeThreadList";
9import { buildHomeProjectScopes, sortHomeProjectScopes } from "./homeThreadList";
1410
1511function makeProject(
1612 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
......@@ -54,25 +50,7 @@ function makeThread(
5450
5551const NOW = Date.parse("2026-06-29T00:00:00.000Z");
5652
57function buildGroups(
58 projects: ReadonlyArray<EnvironmentProject>,
59 threads: ReadonlyArray<EnvironmentThreadShell>,
60 overrides: Partial<Parameters<typeof buildHomeThreadGroups>[0]> = {},
61) {
62 return buildHomeThreadGroups({
63 projects,
64 threads,
65 environmentId: null,
66 searchQuery: "",
67 projectSortOrder: "updated_at",
68 threadSortOrder: "updated_at",
69 projectGroupingMode: "repository",
70 now: NOW,
71 ...overrides,
72 });
73}
74
75describe("buildHomeThreadGroups", () => {
53describe("home project scopes", () => {
7654 it("builds one v2 scope for the same repository across environments", () => {
7755 const localEnvironmentId = EnvironmentId.make("environment-local");
7856 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
......@@ -116,70 +94,6 @@ describe("buildHomeThreadGroups", () => {
11694 );
11795 });
11896
119 it("routes stale duplicate project refs through the canonical repository group", () => {
120 const localEnvironmentId = EnvironmentId.make("environment-local");
121 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
122 const repositoryIdentity = {
123 canonicalKey: "github.com/pingdotgg/t3code",
124 locator: {
125 source: "git-remote" as const,
126 remoteName: "origin",
127 remoteUrl: "git@github.com:pingdotgg/t3code.git",
128 },
129 };
130 const local = makeProject({
131 id: ProjectId.make("project-local"),
132 environmentId: localEnvironmentId,
133 title: "t3code",
134 workspaceRoot: "/workspaces/t3code",
135 repositoryIdentity,
136 });
137 const stale = makeProject({
138 environmentId: remoteEnvironmentId,
139 id: ProjectId.make("project-stale"),
140 title: "t3code",
141 workspaceRoot: "/remote/t3code",
142 updatedAt: "2026-06-01T00:00:00.000Z",
143 });
144 const canonicalRemote = makeProject({
145 environmentId: remoteEnvironmentId,
146 id: ProjectId.make("project-canonical-remote"),
147 title: "t3code",
148 workspaceRoot: "/remote/t3code/",
149 repositoryIdentity,
150 updatedAt: "2026-06-02T00:00:00.000Z",
151 });
152 const projects = [local, stale, canonicalRemote];
153 const staleThread = makeThread({
154 environmentId: remoteEnvironmentId,
155 id: ThreadId.make("thread-stale-project-ref"),
156 projectId: stale.id,
157 title: "Still visible",
158 updatedAt: "2026-06-03T00:00:00.000Z",
159 });
160
161 const scopes = buildHomeProjectScopes({
162 projects,
163 environmentId: null,
164 projectGroupingMode: "repository",
165 });
166 const groups = buildGroups(projects, [staleThread]);
167
168 expect(scopes).toHaveLength(1);
169 expect(scopes[0]?.projects.map((project) => project.id)).toEqual([
170 local.id,
171 canonicalRemote.id,
172 ]);
173 expect(scopes[0]?.projectRefs.map((projectRef) => projectRef.projectId)).toEqual([
174 local.id,
175 stale.id,
176 canonicalRemote.id,
177 ]);
178 expect(groups).toHaveLength(1);
179 expect(groups[0]?.threads.map((thread) => thread.id)).toEqual([staleThread.id]);
180 expect(groups[0]?.newThreadTarget?.id).toBe(canonicalRemote.id);
181 });
182
18397 it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => {
18498 const localEnvironmentId = EnvironmentId.make("environment-local");
18599 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
......@@ -375,410 +289,4 @@ describe("buildHomeThreadGroups", () => {
375289 }),
376290 ).toHaveLength(2);
377291 });
378
379 it("uses the physical project title for a singleton scope", () => {
380 const project = makeProject({
381 environmentId: EnvironmentId.make("environment-1"),
382 id: ProjectId.make("project-1"),
383 title: "local-worktree-name",
384 repositoryIdentity: {
385 canonicalKey: "github.com/pingdotgg/t3code",
386 displayName: "codething-mvp",
387 locator: {
388 source: "git-remote" as const,
389 remoteName: "origin",
390 remoteUrl: "git@github.com:pingdotgg/t3code.git",
391 },
392 },
393 });
394
395 const scopes = buildHomeProjectScopes({
396 projects: [project],
397 environmentId: null,
398 projectGroupingMode: "repository",
399 });
400 const groups = buildGroups(
401 [project],
402 [
403 makeThread({
404 environmentId: project.environmentId,
405 id: ThreadId.make("thread-1"),
406 projectId: project.id,
407 title: "Thread",
408 }),
409 ],
410 );
411
412 expect(scopes[0]?.title).toBe("local-worktree-name");
413 expect(groups[0]?.title).toBe("local-worktree-name");
414 });
415
416 it("sorts the newest thread first regardless of snapshot order", () => {
417 const environmentId = EnvironmentId.make("environment-1");
418 const project = makeProject({
419 environmentId,
420 id: ProjectId.make("project-1"),
421 title: "T3 Code",
422 });
423 const threads = [
424 makeThread({
425 environmentId,
426 id: ThreadId.make("thread-old"),
427 projectId: project.id,
428 title: "Older thread",
429 updatedAt: "2026-06-02T00:00:00.000Z",
430 }),
431 makeThread({
432 environmentId,
433 id: ThreadId.make("thread-new"),
434 projectId: project.id,
435 title: "Newer thread",
436 updatedAt: "2026-06-03T00:00:00.000Z",
437 }),
438 ];
439
440 expect(buildGroups([project], threads)[0]?.threads.map((thread) => thread.id)).toEqual([
441 "thread-new",
442 "thread-old",
443 ]);
444 });
445
446 it("supports independent project and thread creation-time sorting", () => {
447 const environmentId = EnvironmentId.make("environment-1");
448 const olderProject = makeProject({
449 environmentId,
450 id: ProjectId.make("project-older"),
451 title: "Older project",
452 });
453 const newerProject = makeProject({
454 environmentId,
455 id: ProjectId.make("project-newer"),
456 title: "Newer project",
457 });
458 const threads = [
459 makeThread({
460 environmentId,
461 id: ThreadId.make("old-created"),
462 projectId: olderProject.id,
463 title: "Updated recently",
464 createdAt: "2026-06-01T00:00:00.000Z",
465 updatedAt: "2026-06-05T00:00:00.000Z",
466 }),
467 makeThread({
468 environmentId,
469 id: ThreadId.make("new-created"),
470 projectId: olderProject.id,
471 title: "Created recently",
472 createdAt: "2026-06-04T00:00:00.000Z",
473 updatedAt: "2026-06-04T00:00:00.000Z",
474 }),
475 makeThread({
476 environmentId,
477 id: ThreadId.make("newest-project-thread"),
478 projectId: newerProject.id,
479 title: "Newest project",
480 createdAt: "2026-06-06T00:00:00.000Z",
481 }),
482 ];
483
484 const groups = buildGroups([olderProject, newerProject], threads, {
485 projectSortOrder: "created_at",
486 threadSortOrder: "created_at",
487 projectGroupingMode: "separate",
488 });
489
490 expect(groups.map((group) => group.representative.id)).toEqual([
491 "project-newer",
492 "project-older",
493 ]);
494 expect(groups[1]?.threads.map((thread) => thread.id)).toEqual(["new-created", "old-created"]);
495 });
496
497 it("filters both projects and threads to one environment", () => {
498 const localEnvironmentId = EnvironmentId.make("environment-local");
499 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
500 const projects = [
501 makeProject({
502 environmentId: localEnvironmentId,
503 id: ProjectId.make("project-local"),
504 title: "Local",
505 }),
506 makeProject({
507 environmentId: remoteEnvironmentId,
508 id: ProjectId.make("project-remote"),
509 title: "Remote",
510 }),
511 ];
512 const threads = projects.map((project) =>
513 makeThread({
514 environmentId: project.environmentId,
515 id: ThreadId.make(`thread-${project.id}`),
516 projectId: project.id,
517 title: project.title,
518 }),
519 );
520
521 const groups = buildGroups(projects, threads, { environmentId: remoteEnvironmentId });
522
523 expect(groups).toHaveLength(1);
524 expect(groups[0]?.representative.environmentId).toBe(remoteEnvironmentId);
525 expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]);
526 });
527
528 it("matches web repository, repository-path, and separate grouping modes", () => {
529 const environmentId = EnvironmentId.make("environment-1");
530 const repositoryIdentity = {
531 canonicalKey: "github.com/t3tools/t3code",
532 locator: {
533 source: "git-remote" as const,
534 remoteName: "origin",
535 remoteUrl: "git@github.com:t3tools/t3code.git",
536 },
537 provider: "github",
538 owner: "t3tools",
539 name: "t3code",
540 displayName: "T3 Code",
541 rootPath: "/workspaces/t3code",
542 };
543 const projects = [
544 makeProject({
545 environmentId,
546 id: ProjectId.make("project-web"),
547 title: "Web",
548 workspaceRoot: "/workspaces/t3code/apps/web",
549 repositoryIdentity,
550 }),
551 makeProject({
552 environmentId,
553 id: ProjectId.make("project-mobile"),
554 title: "Mobile",
555 workspaceRoot: "/workspaces/t3code/apps/mobile",
556 repositoryIdentity,
557 }),
558 ];
559 const threads = projects.map((project) =>
560 makeThread({
561 environmentId,
562 id: ThreadId.make(`thread-${project.id}`),
563 projectId: project.id,
564 title: project.title,
565 }),
566 );
567
568 expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1);
569 expect(
570 buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map(
571 (group) => group.title,
572 ),
573 ).toEqual(["Mobile", "Web"]);
574 expect(
575 buildGroups(projects, threads, { projectGroupingMode: "separate" }).map(
576 (group) => group.title,
577 ),
578 ).toEqual(["Mobile", "Web"]);
579 });
580
581 it("default view shows only threads from the last 5 days", () => {
582 const environmentId = EnvironmentId.make("environment-1");
583 const project = makeProject({
584 environmentId,
585 id: ProjectId.make("project-1"),
586 title: "T3 Code",
587 });
588 const threads = [
589 makeThread({
590 environmentId,
591 id: ThreadId.make("recent-1"),
592 projectId: project.id,
593 title: "Today",
594 updatedAt: "2026-06-28T00:00:00.000Z",
595 }),
596 makeThread({
597 environmentId,
598 id: ThreadId.make("recent-2"),
599 projectId: project.id,
600 title: "Within window",
601 updatedAt: "2026-06-25T00:00:00.000Z",
602 }),
603 makeThread({
604 environmentId,
605 id: ThreadId.make("old"),
606 projectId: project.id,
607 title: "Two weeks ago",
608 updatedAt: "2026-06-14T00:00:00.000Z",
609 }),
610 ];
611
612 const group = buildGroups([project], threads)[0];
613 // Default view trims to recent threads...
614 expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2"]);
615 // ...while full history stays available for the expanded view.
616 expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]);
617 });
618
619 it("keeps an old thread in the default view while a message waits in its outbox", () => {
620 const environmentId = EnvironmentId.make("environment-1");
621 const project = makeProject({
622 environmentId,
623 id: ProjectId.make("project-1"),
624 title: "T3 Code",
625 });
626 const threads = [
627 makeThread({
628 environmentId,
629 id: ThreadId.make("recent"),
630 projectId: project.id,
631 title: "Today",
632 updatedAt: "2026-06-28T00:00:00.000Z",
633 }),
634 makeThread({
635 environmentId,
636 id: ThreadId.make("old-queued"),
637 projectId: project.id,
638 title: "Two weeks ago, follow-up queued offline",
639 updatedAt: "2026-06-14T00:00:00.000Z",
640 }),
641 makeThread({
642 environmentId,
643 id: ThreadId.make("old"),
644 projectId: project.id,
645 title: "Two weeks ago",
646 updatedAt: "2026-06-13T00:00:00.000Z",
647 }),
648 ];
649
650 const group = buildGroups([project], threads, {
651 queuedThreadKeys: new Set([`${environmentId}:old-queued`]),
652 })[0];
653 expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent", "old-queued"]);
654 });
655
656 it("falls back to the most recent 3 threads when none are within 5 days", () => {
657 const environmentId = EnvironmentId.make("environment-1");
658 const project = makeProject({
659 environmentId,
660 id: ProjectId.make("project-1"),
661 title: "T3 Code",
662 });
663 const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map(
664 (day, index) =>
665 makeThread({
666 environmentId,
667 id: ThreadId.make(`thread-${index}`),
668 projectId: project.id,
669 title: `Thread ${index}`,
670 updatedAt: `${day}T00:00:00.000Z`,
671 }),
672 );
673
674 const group = buildGroups([project], threads)[0];
675 expect(group?.recentThreads.map((thread) => thread.id)).toEqual([
676 "thread-4",
677 "thread-3",
678 "thread-2",
679 ]);
680 expect(group?.threads).toHaveLength(5);
681 });
682
683 it("does not apply the recency window while searching", () => {
684 const environmentId = EnvironmentId.make("environment-1");
685 const project = makeProject({
686 environmentId,
687 id: ProjectId.make("project-1"),
688 title: "T3 Code",
689 });
690 const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map(
691 (day, index) =>
692 makeThread({
693 environmentId,
694 id: ThreadId.make(`thread-${index}`),
695 projectId: project.id,
696 title: `Thread ${index}`,
697 updatedAt: `${day}T00:00:00.000Z`,
698 }),
699 );
700
701 const group = buildGroups([project], threads, { searchQuery: "T3 Code" })[0];
702 // Search reaches the full history rather than the 3-thread fallback.
703 expect(group?.recentThreads).toHaveLength(5);
704 expect(group?.recentThreads.map((thread) => thread.id)).toEqual(
705 group?.threads.map((thread) => thread.id),
706 );
707 });
708
709 it("includes a thread matched by message content", () => {
710 const environmentId = EnvironmentId.make("environment-1");
711 const project = makeProject({
712 environmentId,
713 id: ProjectId.make("project-1"),
714 title: "T3 Code",
715 });
716 const thread = makeThread({
717 environmentId,
718 id: ThreadId.make("thread-content"),
719 projectId: project.id,
720 title: "Unrelated title",
721 });
722
723 const groups = buildGroups([project], [thread], {
724 searchQuery: "relay reconnect",
725 matchedThreadKeys: new Set([
726 threadSearchMatchKey({
727 environmentId,
728 threadId: thread.id,
729 }),
730 ]),
731 });
732
733 expect(groups[0]?.threads.map((candidate) => candidate.id)).toEqual(["thread-content"]);
734 });
735
736 it("targets quick new threads at the group member with the newest thread", () => {
737 const laptopEnv = EnvironmentId.make("environment-laptop");
738 const desktopEnv = EnvironmentId.make("environment-desktop");
739 const repositoryIdentity = {
740 canonicalKey: "github.com/pingdotgg/t3code",
741 locator: {
742 source: "git-remote" as const,
743 remoteName: "origin",
744 remoteUrl: "git@github.com:pingdotgg/t3code.git",
745 },
746 };
747 const laptopProject = makeProject({
748 environmentId: laptopEnv,
749 id: ProjectId.make("project-laptop"),
750 title: "t3code",
751 repositoryIdentity,
752 });
753 const desktopProject = makeProject({
754 environmentId: desktopEnv,
755 id: ProjectId.make("project-desktop"),
756 title: "t3code",
757 repositoryIdentity,
758 });
759 const threads = [
760 makeThread({
761 environmentId: laptopEnv,
762 id: ThreadId.make("thread-laptop"),
763 projectId: laptopProject.id,
764 title: "Older laptop thread",
765 updatedAt: "2026-06-27T00:00:00.000Z",
766 }),
767 makeThread({
768 environmentId: desktopEnv,
769 id: ThreadId.make("thread-desktop"),
770 projectId: desktopProject.id,
771 title: "Newest desktop thread",
772 updatedAt: "2026-06-28T00:00:00.000Z",
773 }),
774 ];
775
776 // Aggregated into one group by repository; the quick new-thread target
777 // must follow the newest thread (desktop), not the arbitrary first member.
778 const groups = buildGroups([laptopProject, desktopProject], threads);
779 expect(groups).toHaveLength(1);
780 expect(groups[0]?.projects).toHaveLength(2);
781 expect(groups[0]?.newThreadTarget?.environmentId).toBe(desktopEnv);
782 expect(groups[0]?.newThreadTarget?.id).toBe(desktopProject.id);
783 });
784292});
apps/mobile/src/features/home/homeThreadList.ts-249
......@@ -141,252 +141,3 @@ export function sortHomeProjectScopes(input: {
141141const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000;
142142/** Fallback when a project has no threads inside the recency window. */
143143const RECENT_THREAD_FALLBACK_COUNT = 3;
144
145export interface HomeThreadGroup {
146 readonly key: string;
147 readonly title: string;
148 readonly representative: EnvironmentProject;
149 readonly projects: ReadonlyArray<EnvironmentProject>;
150 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
151 /** Full sorted thread history for the group (revealed when expanded / searching). */
152 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
153 /** Subset shown by default: threads from the last few days, or the most recent few. */
154 readonly recentThreads: ReadonlyArray<EnvironmentThreadShell>;
155 /**
156 * Where a quick "new thread in this project" should land. For aggregated
157 * groups (same repo on several machines) this is the member that owns the
158 * group's most recent thread — the machine the user last worked on — rather
159 * than the arbitrary first member; the draft's computer picker covers
160 * switching from there. Null only for synthetic pending-project groups,
161 * whose single "project" is a placeholder built from queued-task metadata.
162 */
163 readonly newThreadTarget: EnvironmentProject | null;
164}
165
166interface MutableHomeThreadGroup {
167 readonly key: string;
168 readonly projects: EnvironmentProject[];
169 readonly pendingTasks: PendingNewTask[];
170 readonly threads: EnvironmentThreadShell[];
171}
172
173function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number {
174 const latestThread = group.threads.reduce(
175 (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),
176 Number.NEGATIVE_INFINITY,
177 );
178 return group.pendingTasks.reduce((latest, pendingTask) => {
179 const timestamp = Date.parse(pendingTask.createdAt);
180 return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp);
181 }, latestThread);
182}
183
184/**
185 * Trims a group's threads to recent activity for the default home view.
186 * `sortedThreads` must already be ordered newest-first for `threadSortOrder`.
187 * Keeps threads within {@link RECENT_THREAD_WINDOW_MS}; when none qualify, keeps
188 * the most recent {@link RECENT_THREAD_FALLBACK_COUNT} so a project never vanishes.
189 */
190function selectRecentThreads(
191 sortedThreads: ReadonlyArray<EnvironmentThreadShell>,
192 threadSortOrder: SidebarThreadSortOrder,
193 now: number,
194 queuedThreadKeys: ReadonlySet<string> | undefined,
195): ReadonlyArray<EnvironmentThreadShell> {
196 const cutoff = now - RECENT_THREAD_WINDOW_MS;
197 // A thread with a message waiting in the outbox has work the user is
198 // waiting on, however old its last activity; it never trims away.
199 const recent = sortedThreads.filter(
200 (thread) =>
201 getThreadSortTimestamp(thread, threadSortOrder) >= cutoff ||
202 queuedThreadKeys?.has(scopedThreadKey(thread.environmentId, thread.id)) === true,
203 );
204 return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT);
205}
206
207export function buildHomeThreadGroups(input: {
208 readonly projects: ReadonlyArray<EnvironmentProject>;
209 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
210 readonly pendingTasks?: ReadonlyArray<PendingNewTask>;
211 /** Thread keys with a message waiting in the outbox; kept in the default view. */
212 readonly queuedThreadKeys?: ReadonlySet<string>;
213 readonly environmentId: EnvironmentId | null;
214 readonly searchQuery: string;
215 readonly matchedThreadKeys?: ReadonlySet<string>;
216 readonly projectSortOrder: HomeProjectSortOrder;
217 readonly threadSortOrder: SidebarThreadSortOrder;
218 readonly projectGroupingMode: SidebarProjectGroupingMode;
219 /** Current time used for the recency window; defaults to now. Injectable for tests. */
220 readonly now?: number;
221}): ReadonlyArray<HomeThreadGroup> {
222 const now = input.now ?? Date.now();
223 const groups = new Map<string, MutableHomeThreadGroup>();
224 const groupTitleByKey = new Map<string, string>();
225 const groupKeyByProjectKey = new Map<string, string>();
226
227 for (const scope of buildHomeProjectScopes(input)) {
228 groupTitleByKey.set(scope.key, scope.title);
229 groups.set(scope.key, {
230 key: scope.key,
231 projects: [...scope.projects],
232 pendingTasks: [],
233 threads: [],
234 });
235 for (const projectRef of scope.projectRefs) {
236 groupKeyByProjectKey.set(
237 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
238 scope.key,
239 );
240 }
241 }
242
243 for (const pendingTask of input.pendingTasks ?? []) {
244 if (input.environmentId !== null && pendingTask.environmentId !== input.environmentId) {
245 continue;
246 }
247
248 const physicalKey = scopedProjectKey(pendingTask.environmentId, pendingTask.projectId);
249 let groupKey = groupKeyByProjectKey.get(physicalKey);
250 if (!groupKey) {
251 // The project shell is not loaded (environment offline / project gone).
252 // A queued task must stay visible and deletable regardless, so build a
253 // standalone group from the metadata snapshotted at enqueue time.
254 groupKey = `pending-project:${physicalKey}`;
255 groupKeyByProjectKey.set(physicalKey, groupKey);
256 groups.set(groupKey, {
257 key: groupKey,
258 projects: [
259 {
260 environmentId: pendingTask.environmentId,
261 id: pendingTask.projectId,
262 title: pendingTask.projectTitle ?? "Unknown project",
263 workspaceRoot: pendingTask.projectCwd ?? String(pendingTask.projectId),
264 repositoryIdentity: null,
265 defaultModelSelection: null,
266 scripts: [],
267 createdAt: pendingTask.createdAt,
268 updatedAt: pendingTask.createdAt,
269 },
270 ],
271 pendingTasks: [],
272 threads: [],
273 });
274 }
275 groups.get(groupKey)?.pendingTasks.push(pendingTask);
276 }
277
278 for (const thread of input.threads) {
279 if (thread.archivedAt !== null) {
280 continue;
281 }
282 if (input.environmentId !== null && thread.environmentId !== input.environmentId) {
283 continue;
284 }
285
286 const physicalKey = scopedProjectKey(thread.environmentId, thread.projectId);
287 const groupKey = groupKeyByProjectKey.get(physicalKey);
288 if (!groupKey) {
289 continue;
290 }
291 groups.get(groupKey)?.threads.push(thread);
292 }
293
294 const query = input.searchQuery.trim().toLocaleLowerCase();
295 const result: HomeThreadGroup[] = [];
296
297 for (const group of groups.values()) {
298 const representative = group.projects[0];
299 if (!representative || (group.threads.length === 0 && group.pendingTasks.length === 0)) {
300 continue;
301 }
302
303 const title =
304 groupTitleByKey.get(group.key) ??
305 deriveProjectGroupLabel({ representative, members: group.projects });
306 const groupMatches =
307 query.length === 0 ||
308 title.toLocaleLowerCase().includes(query) ||
309 group.projects.some((project) => project.title.toLocaleLowerCase().includes(query));
310 const matchingThreads = groupMatches
311 ? group.threads
312 : group.threads.filter(
313 (thread) =>
314 thread.title.toLocaleLowerCase().includes(query) ||
315 threadPullRequestSearchTerms(thread).some((term) =>
316 term.toLocaleLowerCase().includes(query),
317 ) ||
318 input.matchedThreadKeys?.has(
319 threadSearchMatchKey({
320 environmentId: thread.environmentId,
321 threadId: thread.id,
322 }),
323 ) === true,
324 );
325 const matchingPendingTasks = groupMatches
326 ? group.pendingTasks
327 : group.pendingTasks.filter((pendingTask) =>
328 pendingTask.title.toLocaleLowerCase().includes(query),
329 );
330
331 if (matchingThreads.length === 0 && matchingPendingTasks.length === 0) {
332 continue;
333 }
334
335 const sortedThreads = sortThreads(matchingThreads, input.threadSortOrder);
336 // An active search should reach the full history, so the recency window
337 // only trims the default (no-query) view.
338 const recentThreads =
339 query.length === 0
340 ? selectRecentThreads(sortedThreads, input.threadSortOrder, now, input.queuedThreadKeys)
341 : sortedThreads;
342
343 // A stale project id still resolves to the canonical member with the same
344 // environment/path, so quick creation follows the machine with the newest activity.
345 const lastActiveProject = Arr.head(sortedThreads).pipe(
346 Option.flatMap((thread) =>
347 Arr.findFirst(
348 input.projects,
349 (project) =>
350 project.environmentId === thread.environmentId && project.id === thread.projectId,
351 ),
352 ),
353 Option.flatMap((threadProject) =>
354 Arr.findFirst(
355 group.projects,
356 (project) =>
357 derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject),
358 ),
359 ),
360 Option.getOrNull,
361 );
362
363 result.push({
364 key: group.key,
365 title,
366 representative,
367 projects: group.projects,
368 pendingTasks: matchingPendingTasks,
369 threads: sortedThreads,
370 recentThreads,
371 newThreadTarget: group.key.startsWith("pending-project:")
372 ? null
373 : (lastActiveProject ?? representative),
374 });
375 }
376
377 return Arr.sort(
378 result,
379 Order.mapInput(
380 Order.Struct({
381 timestamp: Order.flip(Order.Number),
382 title: Order.String,
383 key: Order.String,
384 }),
385 (group: HomeThreadGroup) => ({
386 timestamp: groupSortTimestamp(group, input.projectSortOrder),
387 title: group.title,
388 key: group.key,
389 }),
390 ),
391 );
392}
apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts+3-8
......@@ -2,23 +2,19 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell
22import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
33import { useCallback } from "react";
44
5import type { HomeListItem } from "../home/homeListItems";
65import type { ThreadListV2ListItem } from "../threads/threadListV2";
76import {
87 useHardwareKeyboardCommand,
98 type HardwareKeyboardCommand,
109} from "./hardwareKeyboardCommands";
1110
12type ThreadShortcutListItem =
13 | HomeListItem
14 | ThreadListV2ListItem
15 | { readonly type: "v2-show-more" };
11type ThreadShortcutListItem = ThreadListV2ListItem | { readonly type: "v2-show-more" };
1612
1713export function threadJumpIndex(command: HardwareKeyboardCommand) {
1814 return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command);
1915}
2016
21/** Uses the rendered list so filters, collapsed groups and shelves keep their order. */
17/** Uses the rendered list so filters and shelves keep their order. */
2218export function threadJumpTarget(
2319 items: ReadonlyArray<ThreadShortcutListItem>,
2420 command: HardwareKeyboardCommand,
......@@ -26,8 +22,7 @@ export function threadJumpTarget(
2622 let index = threadJumpIndex(command);
2723 if (index < 0) return null;
2824 for (const item of items) {
29 const thread =
30 item.type === "thread" ? item.thread : item.type === "v2-thread" ? item.item.thread : null;
25 const thread = item.type === "v2-thread" ? item.item.thread : null;
3126 if (thread !== null && index-- === 0) return thread;
3227 }
3328 return null;
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx-8
......@@ -12,7 +12,6 @@ import { AppText as Text } from "../../components/AppText";
1212import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
1313import { serverEnvironment } from "../../state/server";
1414import { useAtomCommand } from "../../state/use-atom-command";
15import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
1615import { SettingsSection } from "./components/SettingsSection";
1716import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";
1817import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
......@@ -224,19 +223,12 @@ function AutoSettleSettingsRows() {
224223function LegacySettingsSection() {
225224 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
226225 const preferences = useAtomValue(mobilePreferencesAtom);
227 const threadListV2Enabled = useThreadListV2Enabled();
228226 const planModeEnabled =
229227 AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true;
230228
231229 return (
232230 <View className="gap-3">
233231 <SettingsSection title="Legacy">
234 <SettingsSwitchRow
235 icon="sidebar.left"
236 label="Legacy Thread List"
237 value={!threadListV2Enabled}
238 onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })}
239 />
240232 <SettingsSwitchRow
241233 icon="hammer"
242234 label="Plan Mode"
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+15-295
......@@ -29,7 +29,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
2929import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
3030import { useProjects, useThreadShells } from "../../state/entities";
3131import { useThreadSearch } from "../../state/queries";
32import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
3332import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
3433import { usePendingThreadOrder } from "../../state/thread-order";
3534import { environmentServerConfigsAtom } from "../../state/server";
......@@ -39,24 +38,9 @@ import { useWorkspaceState } from "../../state/workspace";
3938import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
4039import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
4140import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
42import {
43 hasCustomHomeListOptions,
44 PROJECT_SORT_OPTIONS,
45 THREAD_SORT_OPTIONS,
46 useHomeListOptions,
47} from "../home/home-list-options";
41import { useHomeListOptions } from "../home/home-list-options";
4842import { buildHomeListFilterMenu } from "../home/home-list-filter-menu";
49import {
50 buildHomeListLayout,
51 DEFAULT_GROUP_DISPLAY_STATE,
52 EMPTY_HOME_LIST_LAYOUT,
53 homeListItemsAreEqual,
54 nextGroupDisplayState,
55 type HomeGroupDisplayAction,
56 type HomeGroupDisplayState,
57 type HomeListItem,
58} from "../home/homeListItems";
59import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList";
43import { buildHomeProjectScopes } from "../home/homeThreadList";
6044import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
6145import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
6246import { useThreadListActions } from "../home/useThreadListActions";
......@@ -71,12 +55,6 @@ import { useMaterialFabScroll } from "../home/MaterialFabScrollContext";
7155import { SidebarFilterButton } from "./sidebar-filter-button";
7256import { createSidebarHeaderItems } from "./sidebar-native-header-items";
7357import { SidebarNavigationShell } from "./sidebar-navigation-shell";
74import {
75 PendingTaskListRow,
76 ThreadListGroupHeader,
77 ThreadListRow,
78 ThreadListShowMoreRow,
79} from "./thread-list-items";
8058import {
8159 ThreadListV2PendingRow,
8260 ThreadListV2Row,
......@@ -96,11 +74,9 @@ import {
9674 type ThreadListV2ListItem,
9775} from "./threadListV2";
9876
99/** The sidebar list serves both lists: v1 grouped items or, when the Thread
100 List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled
101 "Show more" pager. */
77/** The sidebar list: flat v2 rows with queued tasks spliced in, plus a
78 settled "Show more" pager row. */
10279type SidebarListItem =
103 | HomeListItem
10480 | ThreadListV2ListItem
10581 | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number };
10682
......@@ -178,7 +154,6 @@ function ThreadNavigationSidebarPane(
178154 renameThread,
179155 regenerateThreadTitle,
180156 } = useThreadListActions();
181 const threadListV2Enabled = useThreadListV2Enabled();
182157 const pendingTasks = usePendingNewTasks();
183158 const queuedThreadKeys = useQueuedThreadKeys();
184159 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
......@@ -196,8 +171,7 @@ function ThreadNavigationSidebarPane(
196171 () => new Set(environments.map((environment) => environment.environmentId)),
197172 [environments],
198173 );
199 const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } =
200 useHomeListOptions(availableEnvironmentIds);
174 const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds);
201175 const searchEnvironmentIds = useMemo(
202176 () =>
203177 options.selectedEnvironmentId === null
......@@ -286,93 +260,6 @@ function ThreadNavigationSidebarPane(
286260 ),
287261 [selectedProjectScope],
288262 );
289 const scopedProjects = useMemo(
290 () =>
291 threadListV2Enabled
292 ? []
293 : selectedProjectRefs === null
294 ? projects
295 : projects.filter((project) =>
296 selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)),
297 ),
298 [threadListV2Enabled, projects, selectedProjectRefs],
299 );
300 const scopedThreads = useMemo(
301 () =>
302 threadListV2Enabled
303 ? []
304 : selectedProjectRefs === null
305 ? threads
306 : threads.filter((thread) =>
307 selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)),
308 ),
309 [threadListV2Enabled, selectedProjectRefs, threads],
310 );
311 const scopedPendingTasks = useMemo(
312 () =>
313 threadListV2Enabled
314 ? []
315 : selectedProjectRefs === null
316 ? pendingTasks
317 : pendingTasks.filter((pendingTask) =>
318 selectedProjectRefs.has(
319 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
320 ),
321 ),
322 [threadListV2Enabled, pendingTasks, selectedProjectRefs],
323 );
324 const groups = useMemo(
325 () =>
326 threadListV2Enabled
327 ? []
328 : buildHomeThreadGroups({
329 projects: scopedProjects,
330 threads: scopedThreads,
331 pendingTasks: scopedPendingTasks,
332 queuedThreadKeys,
333 environmentId: options.selectedEnvironmentId,
334 searchQuery: props.searchQuery,
335 matchedThreadKeys,
336 projectSortOrder: options.projectSortOrder,
337 threadSortOrder: options.threadSortOrder,
338 projectGroupingMode: options.projectGroupingMode,
339 }),
340 [
341 threadListV2Enabled,
342 queuedThreadKeys,
343 matchedThreadKeys,
344 options,
345 props.searchQuery,
346 scopedPendingTasks,
347 scopedProjects,
348 scopedThreads,
349 ],
350 );
351 const [groupDisplayStates, setGroupDisplayStates] = useState<
352 ReadonlyMap<string, HomeGroupDisplayState>
353 >(() => new Map());
354 const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => {
355 setGroupDisplayStates((previous) => {
356 const next = new Map(previous);
357 next.set(
358 key,
359 nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action),
360 );
361 return next;
362 });
363 }, []);
364 const hasSearchQuery = props.searchQuery.trim().length > 0;
365 const listLayout = useMemo(
366 () =>
367 threadListV2Enabled
368 ? EMPTY_HOME_LIST_LAYOUT
369 : buildHomeListLayout({
370 groups,
371 displayStates: groupDisplayStates,
372 showAllThreads: hasSearchQuery,
373 }),
374 [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery],
375 );
376263 const projectByKey = useMemo(() => {
377264 const map = new Map<string, EnvironmentProject>();
378265 for (const project of projects) {
......@@ -412,12 +299,11 @@ function ThreadNavigationSidebarPane(
412299 // thread reappears immediately instead of on the next minute tick.
413300 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
414301 useEffect(() => {
415 if (!threadListV2Enabled) return;
416302 // Refresh immediately because the mount-time value can be hours old.
417303 setNowMinute(new Date().toISOString().slice(0, 16));
418304 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
419305 return () => clearInterval(id);
420 }, [threadListV2Enabled]);
306 }, []);
421307 // Threads on servers without the settlement capability never classify as
422308 // settled (the user could neither un-settle nor pin them).
423309 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
......@@ -529,16 +415,6 @@ function ThreadNavigationSidebarPane(
529415 snoozeWakeTick,
530416 ]);
531417 const threadListV2Layout = useMemo(() => {
532 if (!threadListV2Enabled)
533 return {
534 items: [],
535 hiddenSettledCount: 0,
536 snoozedCount: 0,
537 snoozedShelfHeaderIndex: null,
538 settledCount: 0,
539 settledShelfHeaderIndex: null,
540 nextSnoozeWakeAt: null,
541 };
542418 return buildThreadListV2Items({
543419 pendingOrder,
544420 threads: threads.filter((thread) => thread.archivedAt === null),
......@@ -569,7 +445,6 @@ function ThreadNavigationSidebarPane(
569445 settledVisibleCount,
570446 settlementEnvironmentIds,
571447 snoozeEnvironmentIds,
572 threadListV2Enabled,
573448 threads,
574449 selectedProjectScope,
575450 ]);
......@@ -588,7 +463,6 @@ function ThreadNavigationSidebarPane(
588463 // range) the boundary string is identical and the chain would die.
589464 }, [nextSnoozeWakeAt, snoozeWakeTick]);
590465 const listItems = useMemo<readonly SidebarListItem[]>(() => {
591 if (!threadListV2Enabled) return listLayout.items;
592466 // Queued offline tasks are not thread shells, so the v2 item builder
593467 // never sees them; the shared splice puts them below the active block
594468 // (mirrors the compact Home v2 list) where they stay visible and
......@@ -630,7 +504,6 @@ function ThreadNavigationSidebarPane(
630504 }
631505 return items;
632506 }, [
633 listLayout.items,
634507 nowMinute,
635508 options.selectedEnvironmentId,
636509 pendingTasks,
......@@ -642,7 +515,6 @@ function ThreadNavigationSidebarPane(
642515 shelfPreferencesLoaded,
643516 snoozedShelfExpanded,
644517 snoozeEnvironmentIds,
645 threadListV2Enabled,
646518 threadListV2Layout,
647519 ]);
648520 const listMenuActions = useMemo<MenuAction[]>(
......@@ -688,33 +560,8 @@ function ThreadNavigationSidebarPane(
688560 ],
689561 },
690562 ] satisfies MenuAction[])),
691 // v2 lays the list out in fixed creation order — offering sort/group
692 // controls it silently ignores would be a lie. Environment still
693 // scopes the v2 partition, so it stays.
694 ...(threadListV2Enabled
695 ? []
696 : ([
697 {
698 id: "project-sort",
699 title: "Sort projects",
700 subactions: PROJECT_SORT_OPTIONS.map((option) => ({
701 id: `project-sort:${option.value}`,
702 title: option.label,
703 state: options.projectSortOrder === option.value ? "on" : "off",
704 })),
705 },
706 {
707 id: "thread-sort",
708 title: "Sort threads",
709 subactions: THREAD_SORT_OPTIONS.map((option) => ({
710 id: `thread-sort:${option.value}`,
711 title: option.label,
712 state: options.threadSortOrder === option.value ? "on" : "off",
713 })),
714 },
715 ] satisfies MenuAction[])),
716563 ],
717 [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled],
564 [environments, options, projectFilterOptions, selectedProjectKey],
718565 );
719566 const handleListMenuAction = useCallback(
720567 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
......@@ -741,28 +588,8 @@ function ThreadNavigationSidebarPane(
741588 }
742589 return;
743590 }
744 const projectSort = PROJECT_SORT_OPTIONS.find(
745 (option) => `project-sort:${option.value}` === event,
746 );
747 if (projectSort) {
748 setProjectSortOrder(projectSort.value);
749 return;
750 }
751 const threadSort = THREAD_SORT_OPTIONS.find(
752 (option) => `thread-sort:${option.value}` === event,
753 );
754 if (threadSort) {
755 setThreadSortOrder(threadSort.value);
756 return;
757 }
758591 },
759 [
760 environments,
761 projectFilterOptions,
762 setProjectSortOrder,
763 setSelectedEnvironmentId,
764 setThreadSortOrder,
765 ],
592 [environments, projectFilterOptions, setSelectedEnvironmentId],
766593 );
767594
768595 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
......@@ -838,15 +665,7 @@ function ThreadNavigationSidebarPane(
838665 if (previous.type === "v2-show-more" && item.type === "v2-show-more") {
839666 return previous.hiddenCount === item.hiddenCount;
840667 }
841 if (
842 isThreadListV2ListItem(previous) ||
843 previous.type === "v2-show-more" ||
844 isThreadListV2ListItem(item) ||
845 item.type === "v2-show-more"
846 ) {
847 return false;
848 }
849 return homeListItemsAreEqual(previous, item);
668 return false;
850669 },
851670 [],
852671 );
......@@ -994,90 +813,11 @@ function ThreadNavigationSidebarPane(
994813 onPress={showMoreSettled}
995814 />
996815 );
997 case "header":
998 return (
999 <ThreadListGroupHeader
1000 variant="sidebar"
1001 collapsed={item.collapsed}
1002 isFirst={item.isFirst}
1003 groupKey={item.group.key}
1004 onGroupAction={updateGroupDisplay}
1005 // Same gating as the compact Home list: aggregated groups have no
1006 // single target project, and pending-project groups hold a
1007 // placeholder shell rather than a real project.
1008 newThreadTarget={item.group.newThreadTarget}
1009 onNewThread={props.onNewThreadInProject}
1010 project={item.group.representative}
1011 threadCount={item.group.threads.length + item.group.pendingTasks.length}
1012 title={item.group.title}
1013 />
1014 );
1015 case "pending-task":
1016 return (
1017 <PendingTaskListRow
1018 variant="sidebar"
1019 pendingTask={item.pendingTask}
1020 environmentLabel={
1021 savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null
1022 }
1023 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
1024 isLast={item.isLast}
1025 onSelectPendingTask={openPendingTask}
1026 onDeletePendingTask={confirmDeletePendingTask}
1027 />
1028 );
1029 case "thread": {
1030 const thread = item.thread;
1031 return (
1032 <ThreadListRow
1033 onNewThreadOnBranch={props.onNewThreadOnBranch}
1034 variant="sidebar"
1035 thread={thread}
1036 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
1037 environmentLabel={
1038 savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
1039 }
1040 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
1041 isLast={item.isLast}
1042 searchMatch={threadSearchMatchByKey.get(
1043 threadSearchMatchKey({
1044 environmentId: thread.environmentId,
1045 threadId: thread.id,
1046 }),
1047 )}
1048 searchQuery={props.searchQuery}
1049 selected={
1050 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
1051 }
1052 fullSwipeWidth={props.width - 20}
1053 onArchiveThread={archiveThread}
1054 onDeleteThread={confirmDeleteThread}
1055 onRenameThread={renameThread}
1056 onRegenerateThreadTitle={regenerateThreadTitle}
1057 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
1058 onSelectThread={handleSelectThread}
1059 onSwipeableClose={handleSwipeableClose}
1060 onSwipeableWillOpen={handleSwipeableWillOpen}
1061 simultaneousSwipeGesture={sidebarScrollGesture}
1062 />
1063 );
1064 }
1065 case "show-more":
1066 return (
1067 <ThreadListShowMoreRow
1068 variant="sidebar"
1069 hiddenCount={item.hiddenCount}
1070 canShowLess={item.canShowLess}
1071 groupKey={item.groupKey}
1072 onGroupAction={updateGroupDisplay}
1073 />
1074 );
1075816 }
1076817 },
1077818 [
1078819 archiveThread,
1079820 activeReorderEnvironmentIds,
1080 queuedThreadKeys,
1081821 confirmDeletePendingTask,
1082822 confirmDeleteThread,
1083823 handleSelectThread,
......@@ -1093,13 +833,13 @@ function ThreadNavigationSidebarPane(
1093833 projectTitleByProjectKey,
1094834 regenerateThreadTitle,
1095835 renameThread,
836 threadSearchMatchByKey,
1096837 props.onNewThreadInProject,
1097838 props.onNewThreadOnBranch,
1098839 props.searchQuery,
1099840 props.selectedThreadKey,
1100841 props.width,
1101842 savedConnectionsById,
1102 threadSearchMatchByKey,
1103843 titleRegenerationEnvironmentIds,
1104844 settleThread,
1105845 settlementEnvironmentIds,
......@@ -1113,14 +853,11 @@ function ThreadNavigationSidebarPane(
1113853 unpinThread,
1114854 unsettleThread,
1115855 unsnoozeThread,
1116 updateGroupDisplay,
1117856 ],
1118857 );
1119 // v2 ignores the sort/group options, so only the environment filter can
1120 // light the "customized" state while the beta is on.
1121 const filterCustomized = threadListV2Enabled
1122 ? options.selectedEnvironmentId !== null || selectedProjectKey !== null
1123 : hasCustomHomeListOptions({ ...options, selectedProjectKey });
858 // The list ignores sort/group options, so only the environment and project
859 // filters can light the "customized" state.
860 const filterCustomized = options.selectedEnvironmentId !== null || selectedProjectKey !== null;
1124861 const filterIcon = filterCustomized
1125862 ? "line.3.horizontal.decrease.circle.fill"
1126863 : "line.3.horizontal.decrease.circle";
......@@ -1131,24 +868,10 @@ function ThreadNavigationSidebarPane(
1131868 projects: projectFilterOptions,
1132869 selectedEnvironmentId: options.selectedEnvironmentId,
1133870 selectedProjectKey,
1134 projectSortOrder: options.projectSortOrder,
1135 threadSortOrder: options.threadSortOrder,
1136871 onEnvironmentChange: setSelectedEnvironmentId,
1137872 onProjectChange: setSelectedProjectKey,
1138 onProjectSortOrderChange: setProjectSortOrder,
1139 onThreadSortOrderChange: setThreadSortOrder,
1140 listOrganization: !threadListV2Enabled,
1141873 }),
1142 [
1143 environments,
1144 options,
1145 projectFilterOptions,
1146 selectedProjectKey,
1147 setProjectSortOrder,
1148 setSelectedEnvironmentId,
1149 setThreadSortOrder,
1150 threadListV2Enabled,
1151 ],
874 [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId],
1152875 );
1153876 const nativeHeaderItems = useMemo(
1154877 () =>
......@@ -1358,10 +1081,7 @@ function ThreadNavigationSidebarPane(
13581081 />
13591082 <View className="flex-row items-center gap-2.5">
13601083 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
1361 <SidebarFilterButton
1362 accessibilityLabel="Filter and sort threads"
1363 icon={filterIcon}
1364 />
1084 <SidebarFilterButton accessibilityLabel="Filter threads" icon={filterIcon} />
13651085 </ControlPillMenu>
13661086 <SidebarHeaderActions onOpenSettings={props.onOpenSettings} />
13671087 </View>
apps/mobile/src/features/threads/sidebar-native-header-items.ts+1-1
......@@ -45,7 +45,7 @@ export function createSidebarHeaderItems(input: {
4545 withNativeGlassHeaderItem({
4646 type: "menu",
4747 label: "",
48 accessibilityLabel: "Filter and sort threads",
48 accessibilityLabel: "Filter threads",
4949 icon: sfSymbolIcon(input.filterIcon),
5050 menu: {
5151 title: input.filterMenu.title,
apps/mobile/src/features/threads/thread-list-items.tsx deleted-849
......@@ -1,849 +0,0 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
6import type { EnvironmentMachineKind } from "@t3tools/contracts";
7import type { MenuAction } from "@react-native-menu/menu";
8import { SymbolView } from "../../components/AppSymbol";
9import { memo, useCallback, useMemo, type ComponentProps } from "react";
10import { Platform, Pressable, useWindowDimensions, View } from "react-native";
11import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
12import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
13import Svg, { Circle, Path } from "react-native-svg";
14
15import { RowPressable } from "../../components/RowPressable";
16import { AppText as Text } from "../../components/AppText";
17import { ControlPillMenu } from "../../components/ControlPill";
18import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
19import { ProjectFavicon } from "../../components/ProjectFavicon";
20import { cn } from "../../lib/cn";
21import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
22import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
23import { relativeTime } from "../../lib/time";
24import { useUniwindTheme } from "../../lib/useUniwindTheme";
25import type { PendingNewTask } from "../../state/use-pending-new-tasks";
26import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr";
27import type { HomeGroupDisplayAction } from "../home/homeListItems";
28import { ThreadSwipeable } from "../home/thread-swipe-actions";
29import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
30import { QueuedMessageIcon } from "./queued-message-icon";
31import { resolveThreadStatus } from "./threadPresentation";
32import { ThreadSearchMatchExcerpt } from "./thread-search-match";
33
34/**
35 * Shared presentation for the thread lists: the compact (phone) Home list and
36 * the iPad sidebar render the SAME items — group headers with collapse,
37 * thread rows with status/PR/subtitle, and show-more rows — differing only in
38 * metrics and chrome via `variant`.
39 */
40export type ThreadListVariant = "compact" | "sidebar";
41
42/** Left inset that aligns compact secondary rows with the title column. */
43export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;
44const SIDEBAR_ROW_RADIUS = 12;
45
46function pullRequestTintColor(
47 pr: Pick<ThreadPrPresentation, "state" | "isDraft" | "others" | "kind">,
48 colorScheme: "light" | "dark",
49) {
50 const dark = colorScheme === "dark";
51 if (pr.state === "open" && pr.isDraft === true) {
52 return dark ? "#a1a1aa" : "#71717a";
53 }
54 switch (pr.state) {
55 case "open":
56 return dark ? "#34d399" : "#059669";
57 case "merged":
58 return dark ? "#a78bfa" : "#7c3aed";
59 case "closed":
60 if (pr.kind === "stack" || pr.others > 0) {
61 return dark ? "#fb7185" : "#e11d48";
62 }
63 return dark ? "#a1a1aa" : "#71717a";
64 case null:
65 return dark ? "#a1a1aa" : "#71717a";
66 }
67}
68
69function PullRequestIcon(props: { readonly size: number; readonly color: string }) {
70 return (
71 <Svg
72 width={props.size}
73 height={props.size}
74 viewBox="0 0 24 24"
75 fill="none"
76 stroke={props.color}
77 strokeWidth={2}
78 strokeLinecap="round"
79 strokeLinejoin="round"
80 >
81 <Circle cx={18} cy={18} r={3} />
82 <Circle cx={6} cy={6} r={3} />
83 <Path d="M13 6h3a2 2 0 0 1 2 2v7" />
84 <Path d="M6 9v12" />
85 </Svg>
86 );
87}
88
89/* ─── Project group header ───────────────────────────────────────────── */
90
91export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: {
92 readonly variant: ThreadListVariant;
93 readonly project: EnvironmentProject;
94 readonly title: string;
95 readonly threadCount: number;
96 readonly collapsed: boolean;
97 readonly isFirst: boolean;
98 readonly groupKey: string;
99 readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void;
100 /** Project a quick new thread should target; null hides the button. */
101 readonly newThreadTarget?: EnvironmentProject | null;
102 readonly onNewThread?: (project: EnvironmentProject) => void;
103}) {
104 const { groupKey, onGroupAction, onNewThread } = props;
105 const newThreadTarget = props.newThreadTarget ?? null;
106 const compact = props.variant === "compact";
107 const handleToggle = useCallback(
108 () => onGroupAction(groupKey, "toggle-collapsed"),
109 [groupKey, onGroupAction],
110 );
111 const handleNewThread = useCallback(() => {
112 if (newThreadTarget) {
113 onNewThread?.(newThreadTarget);
114 }
115 }, [newThreadTarget, onNewThread]);
116 const showNewThreadButton = onNewThread !== undefined && newThreadTarget !== null;
117
118 // The new-thread button is a SIBLING of the collapse toggle, not a child:
119 // nested touchables are unreachable to VoiceOver/TalkBack (the parent
120 // swallows focus). Row padding lives on the container (explicit styles —
121 // dynamic padding classes on Pressable did not apply reliably) so both
122 // children share one centerline; hitSlop restores the padded tap area.
123 const verticalHitSlop = { top: props.isFirst ? 8 : 24, bottom: 12 };
124 return (
125 <View
126 className={compact ? "flex-row items-center bg-screen" : "flex-row items-center"}
127 style={{
128 minHeight: compact ? 44 : 36,
129 paddingLeft: compact ? 20 : 12,
130 // Compact right padding centers the 20pt plus glyph on the thread
131 // rows' trailing chevron column (18 + 13/2 ≈ 24.5 from the edge).
132 paddingRight: compact ? 14 : 12,
133 paddingBottom: compact ? 12 : 8,
134 paddingTop: props.isFirst ? (compact ? 8 : 4) : compact ? 24 : 20,
135 }}
136 >
137 <Pressable
138 accessibilityRole="button"
139 accessibilityState={{ expanded: !props.collapsed }}
140 accessibilityLabel={`${props.title}, ${props.threadCount} threads`}
141 accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"}
142 className={
143 compact ? "flex-1 flex-row items-center gap-2.5" : "flex-1 flex-row items-center gap-2"
144 }
145 hitSlop={{ ...verticalHitSlop, left: compact ? 20 : 12 }}
146 onPress={handleToggle}
147 >
148 <ProjectFavicon
149 environmentId={props.project.environmentId}
150 faviconPath={props.project.faviconPath}
151 open={!props.collapsed}
152 size={compact ? 22 : 18}
153 projectTitle={props.project.title}
154 workspaceRoot={props.project.workspaceRoot}
155 />
156 <Text
157 className={
158 compact
159 ? "flex-shrink text-base font-t3-bold tracking-[0.2px] text-foreground-muted"
160 : "flex-shrink text-sm font-t3-bold tracking-[0.2px] text-drawer-foreground-muted"
161 }
162 numberOfLines={1}
163 >
164 {props.title}
165 </Text>
166 <Text
167 className={
168 compact
169 ? "flex-1 text-sm font-t3-medium text-foreground-tertiary"
170 : "flex-1 text-xs font-t3-medium text-drawer-foreground-muted"
171 }
172 >
173 {props.threadCount}
174 </Text>
175 </Pressable>
176 {showNewThreadButton ? (
177 <Pressable
178 accessibilityLabel={`Create new thread in ${props.title}`}
179 accessibilityRole="button"
180 hitSlop={{ ...verticalHitSlop, left: 10, right: 14 }}
181 onPress={handleNewThread}
182 style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })}
183 >
184 <SymbolView
185 name="plus"
186 size={compact ? 20 : 16}
187 tintColorClassName="accent-icon-muted"
188 type="monochrome"
189 weight="medium"
190 />
191 </Pressable>
192 ) : null}
193 </View>
194 );
195});
196
197/* ─── Show more / show less row ──────────────────────────────────────── */
198
199export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: {
200 readonly variant: ThreadListVariant;
201 readonly hiddenCount: number;
202 readonly canShowLess: boolean;
203 readonly groupKey: string;
204 readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void;
205}) {
206 const showsMore = props.hiddenCount > 0;
207 const compact = props.variant === "compact";
208 const { groupKey, onGroupAction } = props;
209 const handleShowMore = useCallback(
210 () => onGroupAction(groupKey, "show-more"),
211 [groupKey, onGroupAction],
212 );
213 const handleShowLess = useCallback(
214 () => onGroupAction(groupKey, "show-less"),
215 [groupKey, onGroupAction],
216 );
217
218 const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => (
219 <Pressable
220 accessibilityRole="button"
221 accessibilityLabel={label === "Show more" ? "Show more threads" : "Show fewer threads"}
222 className="rounded-full bg-subtle"
223 hitSlop={6}
224 onPress={onPress}
225 style={({ pressed }) => ({
226 opacity: pressed ? 0.6 : 1,
227 paddingHorizontal: compact ? 14 : 12,
228 paddingVertical: compact ? 7 : 6,
229 borderCurve: "continuous",
230 })}
231 >
232 <View className="flex-row items-center gap-1.5">
233 <SymbolView
234 name={icon}
235 size={10}
236 tintColorClassName="accent-icon-subtle"
237 type="monochrome"
238 weight="semibold"
239 />
240 <Text
241 className={
242 compact
243 ? "text-sm font-t3-medium text-foreground-muted"
244 : "text-xs font-t3-medium text-drawer-foreground-muted"
245 }
246 >
247 {label}
248 </Text>
249 </View>
250 </Pressable>
251 );
252
253 return (
254 <View
255 className={
256 compact ? "flex-row items-center gap-2.5 bg-screen" : "flex-row items-center gap-2"
257 }
258 style={{
259 paddingLeft: compact ? THREAD_LIST_COMPACT_INSET : 12,
260 paddingRight: compact ? 18 : 12,
261 paddingVertical: compact ? 12 : 8,
262 }}
263 >
264 {showsMore ? button("Show more", "chevron.down", handleShowMore) : null}
265 {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null}
266 </View>
267 );
268});
269
270/* ─── Pending task row ───────────────────────────────────────────────── */
271
272const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [
273 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
274];
275
276const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [
277 { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } },
278];
279
280/**
281 * Unsent work: a task queued in the outbox for its environment to reconnect,
282 * or a draft still sitting in the project's new-task composer. Tapping
283 * reopens the composer with everything prefilled; the row disappears once
284 * the work is sent and the real thread arrives. The two kinds differ in what
285 * happens next, so the pill and icon say which one this is: a queued task
286 * sends itself, a draft waits for the user.
287 */
288export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
289 readonly variant: ThreadListVariant;
290 readonly pendingTask: PendingNewTask;
291 readonly environmentLabel: string | null;
292 readonly environmentMachine?: EnvironmentMachineKind;
293 readonly isLast: boolean;
294 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
295 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
296}) {
297 const compact = props.variant === "compact";
298
299 const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
300 const isDraft = pendingTask.kind === "draft";
301 const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt);
302 // The pill only has room for one word, so what happens next goes in the
303 // subtitle: a queued task sends itself, a draft waits for the user.
304 const subtitleParts = [
305 isDraft ? null : "Sends on reconnect",
306 props.environmentLabel,
307 pendingTask.branch,
308 ].filter((part): part is string => Boolean(part));
309
310 const handleMenuAction = useCallback(
311 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
312 if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask);
313 },
314 [onDeletePendingTask, pendingTask],
315 );
316
317 const statusPill = isDraft ? (
318 <View className="rounded-full bg-adaptive-amber-500-a12-a16 px-1.5 py-0.5">
319 <Text className="text-3xs font-t3-bold text-adaptive-amber-700-300">Draft</Text>
320 </View>
321 ) : (
322 <View className="rounded-full bg-subtle px-1.5 py-0.5">
323 <Text
324 className={cn(
325 "text-3xs font-t3-bold text-foreground-muted",
326 !compact && "text-drawer-foreground-muted",
327 )}
328 >
329 Pending
330 </Text>
331 </View>
332 );
333
334 const subtitleRow =
335 subtitleParts.length > 0 ? (
336 <View className="mt-px flex-row items-center gap-1.5">
337 <SymbolView
338 name={isDraft ? "square.and.pencil" : "tray.and.arrow.up"}
339 size={10}
340 tintColorClassName={compact ? "accent-icon-subtle" : "accent-drawer-foreground-muted"}
341 type="monochrome"
342 />
343 {props.environmentLabel && props.environmentMachine ? (
344 <EnvironmentMachineSymbol
345 kind={props.environmentMachine}
346 size={compact ? 12 : 10}
347 tintColorClassName={compact ? "accent-icon-subtle" : "accent-drawer-foreground-muted"}
348 />
349 ) : null}
350 <Text
351 className={
352 compact
353 ? "shrink text-sm text-foreground-muted"
354 : "shrink text-xs text-drawer-foreground-muted"
355 }
356 numberOfLines={1}
357 >
358 {subtitleParts.join(" · ")}
359 </Text>
360 </View>
361 ) : null;
362
363 const accessibilityHint = isDraft
364 ? "Opens the draft in the new task composer"
365 : "Sends when the environment reconnects. Opens the task for editing";
366
367 const rowContent = compact ? (
368 <RowPressable
369 key={pendingTask.key}
370 accessibilityHint={accessibilityHint}
371 accessibilityLabel={pendingTask.title}
372 accessibilityRole="button"
373 className="bg-screen"
374 onPress={() => onSelectPendingTask(pendingTask)}
375 >
376 <View className="pr-[18px] pt-[10px]" style={{ paddingLeft: THREAD_LIST_COMPACT_INSET }}>
377 <View className={cn("gap-[3px] pb-[10px]", !props.isLast && "border-b border-separator")}>
378 <View className="flex-row items-center justify-between gap-2">
379 <Text
380 className={cn(
381 "flex-1 text-lg font-t3-bold text-foreground",
382 !compact && "text-drawer-foreground",
383 )}
384 numberOfLines={1}
385 >
386 {pendingTask.title}
387 </Text>
388 <View className="flex-row items-center gap-2">
389 {statusPill}
390 {timestamp !== null ? (
391 <Text
392 className={cn(
393 "text-base tabular-nums text-foreground-tertiary",
394 !compact && "text-drawer-foreground-muted",
395 )}
396 >
397 {timestamp}
398 </Text>
399 ) : null}
400 <SymbolView
401 name="chevron.right"
402 size={13}
403 tintColorClassName="accent-icon-subtle"
404 type="monochrome"
405 />
406 </View>
407 </View>
408 {subtitleRow}
409 </View>
410 </View>
411 </RowPressable>
412 ) : (
413 <RowPressable
414 key={pendingTask.key}
415 accessibilityHint={accessibilityHint}
416 accessibilityLabel={pendingTask.title}
417 accessibilityRole="button"
418 interactionClassName="bg-thread-hover"
419 onPress={() => onSelectPendingTask(pendingTask)}
420 style={{
421 borderRadius: SIDEBAR_ROW_RADIUS,
422 minHeight: 64,
423 justifyContent: "center",
424 paddingHorizontal: 12,
425 paddingVertical: 10,
426 }}
427 >
428 <View className="gap-[3px]">
429 <View className="flex-row items-center justify-between gap-2">
430 <Text
431 className="flex-1 text-base font-t3-medium text-drawer-foreground"
432 numberOfLines={1}
433 >
434 {pendingTask.title}
435 </Text>
436 <View className="flex-row items-center gap-2">
437 {statusPill}
438 {timestamp !== null ? (
439 <Text
440 className={cn(
441 "text-xs tabular-nums text-foreground-muted",
442 !compact && "text-drawer-foreground-muted",
443 )}
444 numberOfLines={1}
445 >
446 {timestamp}
447 </Text>
448 ) : null}
449 </View>
450 </View>
451 {subtitleRow}
452 </View>
453 </RowPressable>
454 );
455
456 return (
457 <ControlPillMenu
458 actions={isDraft ? DRAFT_TASK_MENU_ACTIONS : PENDING_TASK_MENU_ACTIONS}
459 onPressAction={handleMenuAction}
460 shouldOpenOnLongPress
461 >
462 {rowContent}
463 </ControlPillMenu>
464 );
465});
466
467/* ─── Thread row ─────────────────────────────────────────────────────── */
468
469const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [
470 { id: "archive", title: "Archive", image: "archivebox" },
471 { id: "rename", title: "Rename", image: "square.and.pencil" },
472 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
473];
474
475export const ThreadListRow = memo(function ThreadListRow(props: {
476 readonly variant: ThreadListVariant;
477 readonly thread: EnvironmentThreadShell;
478 readonly environmentLabel: string | null;
479 readonly environmentMachine?: EnvironmentMachineKind;
480 /** A message for this thread is waiting in the outbox. */
481 readonly hasQueuedMessages?: boolean;
482 readonly searchMatch?: EnvironmentThreadSearchMatch;
483 readonly searchQuery?: string;
484 readonly isLast: boolean;
485 /** Sidebar only: the thread currently open in the detail pane. */
486 readonly selected?: boolean;
487 /** Defaults to window width minus compact margins. */
488 readonly fullSwipeWidth?: number;
489 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
490 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
491 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
492 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
493 readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
494 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
495 readonly titleRegenerationSupported: boolean;
496 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
497 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
498 readonly simultaneousSwipeGesture?: ComponentProps<
499 typeof ThreadSwipeable
500 >["simultaneousWithExternalGesture"];
501}) {
502 const { width: windowWidth } = useWindowDimensions();
503 const { themeAppearance: colorScheme } = useAppearancePreferences();
504 const compact = props.variant === "compact";
505 const selected = props.selected === true;
506 const visuallySelected = selected && (!compact || Platform.OS === "android");
507 const theme = useUniwindTheme();
508 const screenColor = theme["--color-screen"];
509 const drawerColor = theme["--color-drawer"];
510 const selectedBackgroundColor = theme["--color-thread-selected"];
511
512 const {
513 thread,
514 onSelectThread,
515 onArchiveThread,
516 onDeleteThread,
517 onRenameThread,
518 onRegenerateThreadTitle,
519 onNewThreadOnBranch,
520 } = props;
521 const status = resolveThreadStatus(thread);
522 const pr = useThreadPr(thread);
523 const timestamp = relativeTime(
524 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
525 );
526 const threadAccessibilityLabel = [
527 thread.title,
528 pr?.accessibilityLabel,
529 props.hasQueuedMessages ? "messages queued to send" : null,
530 ]
531 .filter(Boolean)
532 .join(", ");
533 const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string =>
534 Boolean(part),
535 );
536
537 const backgroundColor = compact ? screenColor : drawerColor;
538
539 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
540 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
541 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
542 const handleRegenerateTitle = useCallback(
543 () => onRegenerateThreadTitle(thread),
544 [onRegenerateThreadTitle, thread],
545 );
546 const menuActions = useMemo<MenuAction[]>(
547 () => [
548 ...(thread.branch
549 ? [
550 {
551 id: "new-thread-on-branch",
552 title:
553 Platform.OS === "ios" ? "New thread on branch" : `New thread on ${thread.branch}`,
554 image: "square.and.pencil",
555 },
556 ]
557 : []),
558 THREAD_ROW_MENU_ACTIONS[0]!,
559 THREAD_ROW_MENU_ACTIONS[1]!,
560 { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
561 ...buildThreadTitleRegenerationMenuItems({
562 supported: props.titleRegenerationSupported,
563 isRegenerating: thread.titleRegeneration != null,
564 }),
565 THREAD_ROW_MENU_ACTIONS[2]!,
566 ],
567 [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration],
568 );
569 const primaryAction = useMemo(
570 () => ({
571 accessibilityLabel: `Archive ${thread.title}`,
572 icon: "archivebox" as const,
573 label: "Archive",
574 onPress: handleArchive,
575 }),
576 [handleArchive, thread.title],
577 );
578 const handleMenuAction = useCallback(
579 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
580 if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
581 if (nativeEvent.event === "archive") handleArchive();
582 if (nativeEvent.event === "rename") handleRename();
583 if (nativeEvent.event === "copy-thread-id") {
584 copyTextWithHaptic(thread.id, { target: "thread-id" });
585 }
586 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
587 if (nativeEvent.event === "delete") handleDelete();
588 },
589 [handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread],
590 );
591
592 const statusPill = status ? (
593 <View className={`${status.pillClassName} rounded-full px-1.5 py-0.5`}>
594 <Text className={`text-3xs font-t3-bold ${status.textClassName}`}>{status.label}</Text>
595 </View>
596 ) : null;
597
598 const subtitleRow =
599 subtitleParts.length > 0 || pr !== null ? (
600 <View className="mt-px flex-row items-center gap-1.5">
601 {subtitleParts.length > 0 ? (
602 <>
603 {props.environmentLabel && props.environmentMachine ? (
604 <EnvironmentMachineSymbol
605 kind={props.environmentMachine}
606 size={compact ? 12 : 10}
607 tintColorClassName={
608 compact
609 ? "accent-icon-subtle"
610 : selected
611 ? "accent-thread-selected-foreground-muted"
612 : "accent-drawer-foreground-muted"
613 }
614 />
615 ) : null}
616 <Text
617 className={cn(
618 "shrink",
619 compact ? "text-sm" : "text-xs",
620 visuallySelected
621 ? "text-thread-selected-foreground-muted"
622 : compact
623 ? "text-foreground-muted"
624 : "text-drawer-foreground-muted",
625 )}
626 numberOfLines={1}
627 >
628 {subtitleParts.join(" · ")}
629 </Text>
630 </>
631 ) : null}
632 {pr !== null ? (
633 <View
634 className="flex-row items-center gap-0.5"
635 accessibilityLabel={pr.accessibilityLabel}
636 >
637 {pr.kind === "stack" ? (
638 <SymbolView
639 name="square.3.layers.3d"
640 size={compact ? 13 : 11}
641 tintColor={pullRequestTintColor(pr, colorScheme)}
642 />
643 ) : (
644 <PullRequestIcon
645 size={compact ? 13 : 11}
646 color={pullRequestTintColor(pr, colorScheme)}
647 />
648 )}
649 <Text
650 className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${pr.textClassName}`}
651 >
652 {pr.label}
653 </Text>
654 </View>
655 ) : null}
656 </View>
657 ) : null;
658
659 const rowContent = (close: () => void) =>
660 compact ? (
661 <RowPressable
662 key={`${thread.environmentId}:${thread.id}`}
663 interactionClassName="bg-row-hover"
664 interactionOpacity={visuallySelected ? 0 : 1}
665 accessibilityHint="Swipe left for archive and delete actions"
666 accessibilityLabel={threadAccessibilityLabel}
667 accessibilityRole="button"
668 className="bg-screen"
669 style={
670 Platform.OS === "android"
671 ? {
672 backgroundColor: visuallySelected ? selectedBackgroundColor : backgroundColor,
673 borderRadius: 20,
674 }
675 : undefined
676 }
677 onPress={() => {
678 close();
679 onSelectThread(thread);
680 }}
681 >
682 <View className="pr-[18px] pt-[10px]" style={{ paddingLeft: THREAD_LIST_COMPACT_INSET }}>
683 <View
684 className={cn(
685 "gap-[3px] pb-[10px]",
686 Platform.OS !== "android" && !props.isLast && "border-b border-separator",
687 )}
688 >
689 <View className="flex-row items-center justify-between gap-2">
690 <Text
691 className={cn(
692 "flex-1 text-lg font-t3-bold",
693 visuallySelected
694 ? "text-thread-selected-foreground"
695 : compact
696 ? "text-foreground"
697 : "text-drawer-foreground",
698 )}
699 numberOfLines={1}
700 >
701 {thread.title}
702 </Text>
703 <View className="flex-row items-center gap-2">
704 {props.hasQueuedMessages ? (
705 <QueuedMessageIcon selected={visuallySelected && Platform.OS !== "android"} />
706 ) : null}
707 {statusPill}
708 <Text
709 className={cn(
710 "text-base tabular-nums",
711 visuallySelected
712 ? "text-thread-selected-foreground-muted"
713 : "text-drawer-foreground-muted",
714 )}
715 >
716 {timestamp}
717 </Text>
718 <SymbolView
719 name="chevron.right"
720 size={13}
721 tintColorClassName="accent-icon-subtle"
722 type="monochrome"
723 />
724 </View>
725 </View>
726 {props.searchMatch ? (
727 <ThreadSearchMatchExcerpt
728 compact
729 sidebar={!compact}
730 match={props.searchMatch}
731 query={props.searchQuery ?? ""}
732 selected={visuallySelected}
733 />
734 ) : null}
735 {subtitleRow}
736 </View>
737 </View>
738 </RowPressable>
739 ) : (
740 <RowPressable
741 key={`${thread.environmentId}:${thread.id}`}
742 interactionClassName="bg-thread-hover"
743 interactionOpacity={visuallySelected ? 0 : 1}
744 accessibilityHint="Opens the thread"
745 accessibilityLabel={threadAccessibilityLabel}
746 accessibilityRole="button"
747 accessibilityState={{ selected }}
748 onPress={() => {
749 close();
750 onSelectThread(thread);
751 }}
752 style={{
753 backgroundColor: visuallySelected ? selectedBackgroundColor : backgroundColor,
754 borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_ROW_RADIUS,
755 minHeight: 64,
756 justifyContent: "center",
757 paddingHorizontal: 12,
758 paddingVertical: 10,
759 }}
760 >
761 <View className="gap-[3px]">
762 <View className="flex-row items-center justify-between gap-2">
763 <Text
764 className={cn(
765 "flex-1 text-base font-t3-medium",
766 visuallySelected
767 ? "text-thread-selected-foreground"
768 : compact
769 ? "text-foreground"
770 : "text-drawer-foreground",
771 )}
772 numberOfLines={1}
773 >
774 {thread.title}
775 </Text>
776 <View className="flex-row items-center gap-2">
777 {props.hasQueuedMessages ? (
778 <QueuedMessageIcon selected={visuallySelected && Platform.OS !== "android"} />
779 ) : null}
780 {statusPill}
781 <Text
782 className={cn(
783 "text-xs tabular-nums",
784 visuallySelected
785 ? "text-thread-selected-foreground-muted"
786 : compact
787 ? "text-foreground-muted"
788 : "text-drawer-foreground-muted",
789 )}
790 numberOfLines={1}
791 >
792 {timestamp}
793 </Text>
794 </View>
795 </View>
796 {props.searchMatch ? (
797 <ThreadSearchMatchExcerpt
798 sidebar={!compact}
799 match={props.searchMatch}
800 query={props.searchQuery ?? ""}
801 selected={visuallySelected}
802 />
803 ) : null}
804 {subtitleRow}
805 </View>
806 </RowPressable>
807 );
808
809 return (
810 <ThreadSwipeable
811 threadKey={`${thread.environmentId}:${thread.id}`}
812 backgroundColor={backgroundColor}
813 containerStyle={
814 Platform.OS === "android"
815 ? { borderRadius: 20, overflow: "hidden", marginHorizontal: 8, marginVertical: 2 }
816 : compact
817 ? undefined
818 : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" }
819 }
820 enableTrackpadSwipe
821 fullSwipeWidth={props.fullSwipeWidth ?? windowWidth - 32}
822 onDelete={handleDelete}
823 onSwipeableClose={props.onSwipeableClose}
824 onSwipeableWillOpen={props.onSwipeableWillOpen}
825 primaryAction={primaryAction}
826 resetKey={`${thread.environmentId}:${thread.id}`}
827 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
828 threadTitle={thread.title}
829 >
830 {(close) => (
831 // Messages-style row actions on long-press. iOS: a real
832 // UIContextMenuInteraction with the row as the zoom preview (needs the
833 // patched @react-native-menu, see
834 // patches/@react-native-menu__menu@2.0.0.patch — in long-press mode the
835 // interaction is hosted by the component view and the underlying
836 // UIButton passes touches through, so row taps keep working). Android:
837 // ControlPillMenu injects onLongPress into the row and anchors the
838 // token-styled dropdown to it; taps and swipes are untouched.
839 <ControlPillMenu
840 actions={menuActions}
841 onPressAction={handleMenuAction}
842 shouldOpenOnLongPress
843 >
844 {rowContent(close)}
845 </ControlPillMenu>
846 )}
847 </ThreadSwipeable>
848 );
849});
apps/mobile/src/features/threads/threadListV2.test.ts-24
......@@ -30,7 +30,6 @@ import {
3030 buildThreadListV2ListItems,
3131 getThreadListV2OrderedSection,
3232 isThreadListV2ListItem,
33 resolveThreadListV2Enabled,
3433 resolveThreadListV2SnoozeMenuSelection,
3534 resolveThreadListV2SnoozeGateExpiryMs,
3635 resolveThreadListV2Status,
......@@ -125,29 +124,6 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
125124 });
126125});
127126
128describe("resolveThreadListV2Enabled", () => {
129 it("defaults on when the device has never chosen", () => {
130 expect(
131 resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }),
132 ).toBe(true);
133 });
134
135 it("honors an explicit legacy opt-in", () => {
136 expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe(
137 false,
138 );
139 expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe(
140 true,
141 );
142 });
143
144 it("holds the default while preferences are still loading so the list does not remount", () => {
145 expect(
146 resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }),
147 ).toBe(true);
148 });
149});
150
151127describe("resolveThreadListV2Status", () => {
152128 it("prioritizes approval over a running session", () => {
153129 const thread = makeThread({
apps/mobile/src/features/threads/threadListV2.ts-20
......@@ -116,26 +116,6 @@ export function resolveThreadListV2SnoozeGateExpiryMs(
116116export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
117117export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
118118
119/**
120 * The flat Thread List v2 is the default on every app variant; the Settings →
121 * Legacy toggle opts a device back into the grouped legacy list. Preferences
122 * persist as sparse patches, so `undefined` genuinely means "never chosen".
123 *
124 * `preferencesLoaded` guards the startup window: preferences load
125 * asynchronously, and rendering one list before the stored choice arrives would
126 * remount the whole thing a tick later. While loading, hold the default — that
127 * is where every device without an explicit legacy opt-in lands anyway.
128 */
129export function resolveThreadListV2Enabled(input: {
130 readonly legacyPreference: boolean | undefined;
131 readonly preferencesLoaded: boolean;
132}): boolean {
133 if (!input.preferencesLoaded) {
134 return true;
135 }
136 return input.legacyPreference !== true;
137}
138
139119export function resolveThreadListV2Status(
140120 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
141121): ThreadListV2Status {
apps/mobile/src/features/threads/threadPresentation.ts deleted-102
......@@ -1,102 +0,0 @@
1import type { StatusTone } from "../../components/StatusPill";
2import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts";
3import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4
5export type ThreadStatusKind =
6 | "pending-approval"
7 | "awaiting-input"
8 | "working"
9 | "connecting"
10 | "error"
11 | "plan-ready";
12
13export interface ThreadStatusPresentation extends StatusTone {
14 readonly kind: ThreadStatusKind;
15 /** Whether the indicator represents in-flight activity. */
16 readonly pulse: boolean;
17}
18
19function isLatestTurnSettled(
20 latestTurn: OrchestrationLatestTurn | null,
21 session: OrchestrationSession | null,
22): boolean {
23 if (!latestTurn?.startedAt) return false;
24 if (!latestTurn.completedAt) return false;
25 if (!session) return true;
26 return session.status !== "running";
27}
28
29/**
30 * Resolves the user-facing status of a thread, in priority order. Returns
31 * `null` for quiescent threads so rows stay free of "Idle"-style noise.
32 * Mirrors `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts.
33 */
34export function resolveThreadStatus(
35 thread: EnvironmentThreadShell,
36): ThreadStatusPresentation | null {
37 if (thread.hasPendingApprovals) {
38 return {
39 kind: "pending-approval",
40 label: "Needs Approval",
41 pillClassName: "bg-warning",
42 textClassName: "text-warning-foreground",
43 pulse: false,
44 };
45 }
46
47 if (thread.hasPendingUserInput) {
48 return {
49 kind: "awaiting-input",
50 label: "Awaiting Input",
51 pillClassName: "bg-adaptive-indigo-500-a12-a16",
52 textClassName: "text-adaptive-indigo-600-300",
53 pulse: false,
54 };
55 }
56
57 if (thread.session?.status === "running") {
58 return {
59 kind: "working",
60 label: "Working",
61 pillClassName: "bg-adaptive-sky-500-a12-a16",
62 textClassName: "text-adaptive-sky-600-400",
63 pulse: true,
64 };
65 }
66
67 if (thread.session?.status === "starting") {
68 return {
69 kind: "connecting",
70 label: "Connecting",
71 pillClassName: "bg-adaptive-sky-500-a12-a16",
72 textClassName: "text-adaptive-sky-600-400",
73 pulse: true,
74 };
75 }
76
77 if (thread.session?.status === "error" || thread.latestTurn?.state === "error") {
78 return {
79 kind: "error",
80 label: "Error",
81 pillClassName: "bg-danger",
82 textClassName: "text-danger-foreground",
83 pulse: false,
84 };
85 }
86
87 const hasPlanReadyPrompt =
88 thread.interactionMode === "plan" &&
89 isLatestTurnSettled(thread.latestTurn, thread.session) &&
90 thread.hasActionableProposedPlan;
91 if (hasPlanReadyPrompt) {
92 return {
93 kind: "plan-ready",
94 label: "Plan Ready",
95 pillClassName: "bg-adaptive-violet-500-a12-a16",
96 textClassName: "text-adaptive-violet-600-400",
97 pulse: false,
98 };
99 }
100
101 return null;
102}
apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts deleted-19
......@@ -1,19 +0,0 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";
3
4import { mobilePreferencesAtom } from "../../state/preferences";
5import { resolveThreadListV2Enabled } from "./threadListV2";
6
7/**
8 * Resolved Thread List v2 state: on unless the device opted into the legacy
9 * grouped list (Settings → Legacy). Every consumer must read through this
10 * rather than the raw preference, which is undefined until explicitly chosen.
11 */
12export function useThreadListV2Enabled(): boolean {
13 const preferencesResult = useAtomValue(mobilePreferencesAtom);
14 const loaded = AsyncResult.isSuccess(preferencesResult);
15 return resolveThreadListV2Enabled({
16 legacyPreference: loaded ? preferencesResult.value.legacyThreadListEnabled : undefined,
17 preferencesLoaded: loaded,
18 });
19}
apps/mobile/src/persistence/mobile-preferences.ts-12
......@@ -33,14 +33,6 @@ export interface Preferences {
3333 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
3434 readonly projectGroupingEnabled?: boolean;
3535 readonly projectGroupingMode?: SidebarProjectGroupingMode;
36 /**
37 * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
38 * no client-settings sync, so the legacy grouped thread list is opted into
39 * per device. Deliberately a fresh key (was `threadListV2Enabled`, an
40 * opt-out): sanitizing drops the old key, so every device resets to the
41 * default flat list — see `resolveThreadListV2Enabled`.
42 */
43 readonly legacyThreadListEnabled?: boolean;
4436 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
4537 readonly planModeEnabled?: boolean;
4638 /** Model favorites belong to this device, like the web client setting. */
......@@ -107,7 +99,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
10799 composerEnterBehavior?: ComposerEnterBehavior;
108100 projectGroupingEnabled?: boolean;
109101 projectGroupingMode?: SidebarProjectGroupingMode;
110 legacyThreadListEnabled?: boolean;
111102 planModeEnabled?: boolean;
112103 modelFavorites?: Preferences["modelFavorites"];
113104 threadListSettledShelfExpanded?: boolean;
......@@ -176,9 +167,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
176167 ) {
177168 preferences.projectGroupingMode = parsed.projectGroupingMode;
178169 }
179 if (typeof parsed.legacyThreadListEnabled === "boolean") {
180 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
181 }
182170 if (typeof parsed.planModeEnabled === "boolean") {
183171 preferences.planModeEnabled = parsed.planModeEnabled;
184172 }