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 @@...@@ -1,13 +1,7 @@
1import type { MenuAction } from "@react-native-menu/menu";1import type { MenuAction } from "@react-native-menu/menu";
2import { useCallback, useMemo } from "react";2import { useCallback, useMemo } from "react";
3import { NativeStackScreenOptions } from "../../native/StackHeader";3import { NativeStackScreenOptions } from "../../native/StackHeader";
4import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
5import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar";4import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar";
6import {
7 hasCustomHomeListOptions,
8 PROJECT_SORT_OPTIONS,
9 THREAD_SORT_OPTIONS,
10} from "./home-list-options";
11import type { HomeHeaderProps } from "./HomeHeader.types";5import type { HomeHeaderProps } from "./HomeHeader.types";
126
13export type { HomeHeaderEnvironment } from "./HomeHeader.types";7export type { HomeHeaderEnvironment } from "./HomeHeader.types";
...@@ -17,13 +11,11 @@ function checkedMenuState(checked: boolean) {...@@ -17,13 +11,11 @@ function checkedMenuState(checked: boolean) {
17}11}
1812
19export function HomeHeader(props: HomeHeaderProps) {13export function HomeHeader(props: HomeHeaderProps) {
20 // Thread List v2 lays the list out in fixed creation order, so the14 // The list uses a fixed creation order and ignores sort/group options, so
21 // sort/group filter controls would be silently ignored — hide them and15 // the filter menu only carries the filters and the "customized" icon state
22 // key the "customized" icon state off the environment filter alone.16 // keys off those alone.
23 const threadListV2Enabled = useThreadListV2Enabled();17 const hasCustomListOptions =
24 const hasCustomListOptions = threadListV2Enabled18 props.selectedEnvironmentId !== null || props.selectedProjectKey !== null;
25 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
26 : hasCustomHomeListOptions(props);
27 const menuActions = useMemo<MenuAction[]>(19 const menuActions = useMemo<MenuAction[]>(
28 () => [20 () => [
29 {21 {
...@@ -62,38 +54,8 @@ export function HomeHeader(props: HomeHeaderProps) {...@@ -62,38 +54,8 @@ export function HomeHeader(props: HomeHeaderProps) {
62 ],54 ],
63 },55 },
64 ] satisfies MenuAction[])),56 ] 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,
96 ],57 ],
58 [props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey],
97 );59 );
98 const handleMenuAction = useCallback(60 const handleMenuAction = useCallback(
99 (event: { nativeEvent: { event: string } }) => {61 (event: { nativeEvent: { event: string } }) => {
...@@ -126,20 +88,6 @@ export function HomeHeader(props: HomeHeaderProps) {...@@ -126,20 +88,6 @@ export function HomeHeader(props: HomeHeaderProps) {
126 }88 }
127 return;89 return;
128 }90 }
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 }
143 },91 },
144 [props],92 [props],
145 );93 );
apps/mobile/src/features/home/HomeHeader.tsx+7-48
...@@ -2,7 +2,6 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac...@@ -2,7 +2,6 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
2import { useCallback, useRef } from "react";2import { useCallback, useRef } from "react";
3import type { SearchBarCommands } from "react-native-screens";3import type { SearchBarCommands } from "react-native-screens";
4import { useUniwindTheme } from "../../lib/useUniwindTheme";4import { useUniwindTheme } from "../../lib/useUniwindTheme";
5import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
6import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";5import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
7import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";6import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
8import {7import {
...@@ -10,11 +9,6 @@ import {...@@ -10,11 +9,6 @@ import {
10 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,9 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
11} from "../layout/native-mail-search-toolbar";10} from "../layout/native-mail-search-toolbar";
12import { buildHomeListFilterMenu } from "./home-list-filter-menu";11import { buildHomeListFilterMenu } from "./home-list-filter-menu";
13import {
14 hasCustomHomeListOptions,
15 PROJECT_SORT_OPTIONS,
16 THREAD_SORT_OPTIONS,
17} from "./home-list-options";
18import type { HomeHeaderProps } from "./HomeHeader.types";12import type { HomeHeaderProps } from "./HomeHeader.types";
1913
20export type { HomeHeaderEnvironment } from "./HomeHeader.types";14export type { HomeHeaderEnvironment } from "./HomeHeader.types";
...@@ -22,22 +16,17 @@ export type { HomeHeaderEnvironment } from "./HomeHeader.types";...@@ -22,22 +16,17 @@ export type { HomeHeaderEnvironment } from "./HomeHeader.types";
22export function HomeHeader(props: HomeHeaderProps) {16export function HomeHeader(props: HomeHeaderProps) {
23 const searchBarRef = useRef<SearchBarCommands>(null);17 const searchBarRef = useRef<SearchBarCommands>(null);
24 const iconColor = useUniwindTheme()["--color-icon"];18 const iconColor = useUniwindTheme()["--color-icon"];
25 // Thread List v2 lays the list out in fixed creation order, so the19 // The list uses a fixed creation order and ignores sort/group options, so
26 // sort/group filter controls would be silently ignored — hide them and20 // the filter menu only carries the filters and the "customized" icon state
27 // key the "customized" icon state off the environment filter alone.21 // keys off those alone.
28 const threadListV2Enabled = useThreadListV2Enabled();22 const hasCustomListOptions =
29 const hasCustomListOptions = threadListV2Enabled23 props.selectedEnvironmentId !== null || props.selectedProjectKey !== null;
30 ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
31 : hasCustomHomeListOptions(props);
32 const focusSearch = useCallback(() => {24 const focusSearch = useCallback(() => {
33 searchBarRef.current?.focus();25 searchBarRef.current?.focus();
34 return searchBarRef.current !== null;26 return searchBarRef.current !== null;
35 }, []);27 }, []);
36 useHardwareKeyboardCommand("focusSearch", focusSearch);28 useHardwareKeyboardCommand("focusSearch", focusSearch);
37 const filterMenu = buildHomeListFilterMenu({29 const filterMenu = buildHomeListFilterMenu(props);
38 ...props,
39 listOrganization: !threadListV2Enabled,
40 });
4130
42 return (31 return (
43 <>32 <>
...@@ -100,7 +89,7 @@ export function HomeHeader(props: HomeHeaderProps) {...@@ -100,7 +89,7 @@ export function HomeHeader(props: HomeHeaderProps) {
100 {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (89 {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (
101 <NativeHeaderToolbar placement="bottom">90 <NativeHeaderToolbar placement="bottom">
102 <NativeHeaderToolbar.Menu91 <NativeHeaderToolbar.Menu
103 accessibilityLabel="Filter and sort threads"92 accessibilityLabel="Filter threads"
104 icon={93 icon={
105 hasCustomListOptions94 hasCustomListOptions
106 ? "line.3.horizontal.decrease.circle.fill"95 ? "line.3.horizontal.decrease.circle.fill"
...@@ -150,36 +139,6 @@ export function HomeHeader(props: HomeHeaderProps) {...@@ -150,36 +139,6 @@ export function HomeHeader(props: HomeHeaderProps) {
150 ))}139 ))}
151 </NativeHeaderToolbar.Menu>140 </NativeHeaderToolbar.Menu>
152 ) : null}141 ) : 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 )}
183 </NativeHeaderToolbar.Menu>142 </NativeHeaderToolbar.Menu>
184 <NativeHeaderToolbar.Spacer flexible />143 <NativeHeaderToolbar.Spacer flexible />
185 <NativeHeaderToolbar.Button144 <NativeHeaderToolbar.Button
apps/mobile/src/features/home/HomeHeader.types.ts+1-6
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import type { HomeProjectSortOrder } from "./homeThreadList";
3import type {2import type {
4 HomeListFilterMenuEnvironment,3 HomeListFilterMenuEnvironment,
5 HomeListFilterMenuProject,4 HomeListFilterMenuProject,
...@@ -13,13 +12,9 @@ export interface HomeHeaderProps {...@@ -13,13 +12,9 @@ export interface HomeHeaderProps {
13 readonly searchQuery: string;12 readonly searchQuery: string;
14 readonly selectedEnvironmentId: EnvironmentId | null;13 readonly selectedEnvironmentId: EnvironmentId | null;
15 readonly selectedProjectKey: string | null;14 readonly selectedProjectKey: string | null;
16 readonly projectSortOrder: HomeProjectSortOrder;
17 readonly threadSortOrder: SidebarThreadSortOrder;
18 readonly onSearchQueryChange: (query: string) => void;15 readonly onSearchQueryChange: (query: string) => void;
19 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;16 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
20 readonly onProjectChange: (projectKey: string | null) => void;17 readonly onProjectChange: (projectKey: string | null) => void;
21 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
22 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
23 readonly onOpenEnvironments: () => void;18 readonly onOpenEnvironments: () => void;
24 readonly onOpenSettings: () => void;19 readonly onOpenSettings: () => void;
25 readonly onStartNewTask: () => void;20 readonly onStartNewTask: () => void;
apps/mobile/src/features/home/HomeRouteScreen.tsx+2-13
...@@ -76,12 +76,8 @@ export function HomeRouteScreen() {...@@ -76,12 +76,8 @@ export function HomeRouteScreen() {
76 () => new Set(environments.map((environment) => environment.environmentId)),76 () => new Set(environments.map((environment) => environment.environmentId)),
77 [environments],77 [environments],
78 );78 );
79 const {79 const { options: listOptions, setSelectedEnvironmentId } =
80 options: listOptions,80 useHomeListOptions(availableEnvironmentIds);
81 setSelectedEnvironmentId,
82 setProjectSortOrder,
83 setThreadSortOrder,
84 } = useHomeListOptions(availableEnvironmentIds);
85 const selectedEnvironmentId = listOptions.selectedEnvironmentId;81 const selectedEnvironmentId = listOptions.selectedEnvironmentId;
86 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);82 const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null);
87 const projectFilterOptions = useMemo(83 const projectFilterOptions = useMemo(
...@@ -175,8 +171,6 @@ export function HomeRouteScreen() {...@@ -175,8 +171,6 @@ export function HomeRouteScreen() {
175 searchQuery={searchQuery}171 searchQuery={searchQuery}
176 selectedEnvironmentId={selectedEnvironmentId}172 selectedEnvironmentId={selectedEnvironmentId}
177 selectedProjectKey={selectedProjectKey}173 selectedProjectKey={selectedProjectKey}
178 projectSortOrder={listOptions.projectSortOrder}
179 threadSortOrder={listOptions.threadSortOrder}
180 onEnvironmentChange={setSelectedEnvironmentId}174 onEnvironmentChange={setSelectedEnvironmentId}
181 onProjectChange={setSelectedProjectKey}175 onProjectChange={setSelectedProjectKey}
182 onOpenEnvironments={() =>176 onOpenEnvironments={() =>
...@@ -191,10 +185,8 @@ export function HomeRouteScreen() {...@@ -191,10 +185,8 @@ export function HomeRouteScreen() {
191 params: { screen: "Settings" },185 params: { screen: "Settings" },
192 })186 })
193 }187 }
194 onProjectSortOrderChange={setProjectSortOrder}
195 onSearchQueryChange={setSearchQuery}188 onSearchQueryChange={setSearchQuery}
196 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}189 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
197 onThreadSortOrderChange={setThreadSortOrder}
198 />190 />
199191
200 <HomeScreen192 <HomeScreen
...@@ -225,7 +217,6 @@ export function HomeRouteScreen() {...@@ -225,7 +217,6 @@ export function HomeRouteScreen() {
225 params: { screen: "Settings" },217 params: { screen: "Settings" },
226 })218 })
227 }219 }
228 onProjectSortOrderChange={setProjectSortOrder}
229 onSearchQueryChange={setSearchQuery}220 onSearchQueryChange={setSearchQuery}
230 onSelectThread={handleSelectThread}221 onSelectThread={handleSelectThread}
231 onSelectPendingTask={openPendingTask}222 onSelectPendingTask={openPendingTask}
...@@ -252,7 +243,6 @@ export function HomeRouteScreen() {...@@ -252,7 +243,6 @@ export function HomeRouteScreen() {
252 });243 });
253 }}244 }}
254 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}245 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
255 onThreadSortOrderChange={setThreadSortOrder}
256 pendingTasks={pendingTasks}246 pendingTasks={pendingTasks}
257 projectGroupingMode={listOptions.projectGroupingMode}247 projectGroupingMode={listOptions.projectGroupingMode}
258 projects={projects}248 projects={projects}
...@@ -262,7 +252,6 @@ export function HomeRouteScreen() {...@@ -262,7 +252,6 @@ export function HomeRouteScreen() {
262 selectedEnvironmentId={selectedEnvironmentId}252 selectedEnvironmentId={selectedEnvironmentId}
263 selectedProjectKey={selectedProjectKey}253 selectedProjectKey={selectedProjectKey}
264 threads={threads}254 threads={threads}
265 threadSortOrder={listOptions.threadSortOrder}
266 />255 />
267 </>256 </>
268 </AndroidHomeFabLayout>257 </AndroidHomeFabLayout>
apps/mobile/src/features/home/HomeScreen.tsx+36-424
...@@ -1,10 +1,6 @@...@@ -1,10 +1,6 @@
1import type { ThreadMoveDestination } from "../threads/threadOrder";1import type { ThreadMoveDestination } from "../threads/threadOrder";
2import { computeThreadMoveAvailability } from "../threads/threadOrder";2import { computeThreadMoveAvailability } from "../threads/threadOrder";
3import {3import { LegendList } from "@legendapp/list/react-native";
4 LegendList,
5 type LegendListRef,
6 type LegendListRenderItemProps,
7} from "@legendapp/list/react-native";
8import {4import {
9 type EnvironmentProject,5 type EnvironmentProject,
10 type EnvironmentThreadShell,6 type EnvironmentThreadShell,
...@@ -17,10 +13,8 @@ import {...@@ -17,10 +13,8 @@ import {
17 type EnvironmentId,13 type EnvironmentId,
18 resolveEnvironmentMachineKind,14 resolveEnvironmentMachineKind,
19 type SidebarProjectGroupingMode,15 type SidebarProjectGroupingMode,
20 type SidebarThreadSortOrder,
21} from "@t3tools/contracts";16} from "@t3tools/contracts";
22import { useAtomSet, useAtomValue } from "@effect/atom-react";17import { useAtomValue } from "@effect/atom-react";
23import { AsyncResult } from "effect/unstable/reactivity";
24import { useFocusEffect } from "@react-navigation/native";18import { useFocusEffect } from "@react-navigation/native";
25import { useCallback, useEffect, useMemo, useRef, useState } from "react";19import { useCallback, useEffect, useMemo, useRef, useState } from "react";
26import { ActivityIndicator, Platform, View } from "react-native";20import { ActivityIndicator, Platform, View } from "react-native";
...@@ -34,20 +28,12 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace...@@ -34,20 +28,12 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace
34import type { SavedRemoteConnection } from "../../lib/connection";28import type { SavedRemoteConnection } from "../../lib/connection";
35import { scopedProjectKey } from "../../lib/scopedEntities";29import { scopedProjectKey } from "../../lib/scopedEntities";
36import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";30import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
37import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
38import { useThreadSearch } from "../../state/queries";31import { useThreadSearch } from "../../state/queries";
39import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";32import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
40import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
41import { usePendingThreadOrder } from "../../state/thread-order";33import { usePendingThreadOrder } from "../../state/thread-order";
42import { environmentServerConfigsAtom } from "../../state/server";34import { environmentServerConfigsAtom } from "../../state/server";
43import type { PendingNewTask } from "../../state/use-pending-new-tasks";35import type { PendingNewTask } from "../../state/use-pending-new-tasks";
44import { useQueuedThreadKeys } from "../../state/use-thread-outbox";36import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
45import {
46 PendingTaskListRow,
47 ThreadListGroupHeader,
48 ThreadListRow,
49 ThreadListShowMoreRow,
50} from "../threads/thread-list-items";
51import {37import {
52 ThreadListV2PendingRow,38 ThreadListV2PendingRow,
53 ThreadListV2Row,39 ThreadListV2Row,
...@@ -67,19 +53,8 @@ import {...@@ -67,19 +53,8 @@ import {
67} from "../threads/threadListV2";53} from "../threads/threadListV2";
68import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";54import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
69import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";55import 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";
80import {56import {
81 buildHomeProjectScopes,57 buildHomeProjectScopes,
82 buildHomeThreadGroups,
83 sortHomeProjectScopes,58 sortHomeProjectScopes,
84 type HomeProjectSortOrder,59 type HomeProjectSortOrder,
85} from "./homeThreadList";60} from "./homeThreadList";
...@@ -101,13 +76,10 @@ interface HomeScreenProps {...@@ -101,13 +76,10 @@ interface HomeScreenProps {
101 readonly selectedEnvironmentId: EnvironmentId | null;76 readonly selectedEnvironmentId: EnvironmentId | null;
102 readonly selectedProjectKey: string | null;77 readonly selectedProjectKey: string | null;
103 readonly projectSortOrder: HomeProjectSortOrder;78 readonly projectSortOrder: HomeProjectSortOrder;
104 readonly threadSortOrder: SidebarThreadSortOrder;
105 readonly projectGroupingMode: SidebarProjectGroupingMode;79 readonly projectGroupingMode: SidebarProjectGroupingMode;
106 readonly onSearchQueryChange: (query: string) => void;80 readonly onSearchQueryChange: (query: string) => void;
107 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;81 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
108 readonly onProjectChange: (projectKey: string | null) => void;82 readonly onProjectChange: (projectKey: string | null) => void;
109 readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
110 readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
111 readonly onAddConnection: () => void;83 readonly onAddConnection: () => void;
112 readonly onOpenSettings: () => void;84 readonly onOpenSettings: () => void;
113 readonly onStartNewTask: () => void;85 readonly onStartNewTask: () => void;
...@@ -138,7 +110,6 @@ interface HomeScreenProps {...@@ -138,7 +110,6 @@ interface HomeScreenProps {
138110
139/* ─── Layout constants ───────────────────────────────────────────────── */111/* ─── Layout constants ───────────────────────────────────────────────── */
140112
141const ESTIMATED_THREAD_ROW_HEIGHT = 72;
142// v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards113// v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards
143// measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line114// measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line
144// cards ~94dp. The estimate seeds the recycler's initial container count,115// cards ~94dp. The estimate seeds the recycler's initial container count,
...@@ -236,15 +207,8 @@ function HomeTopContentSpacer() {...@@ -236,15 +207,8 @@ function HomeTopContentSpacer() {
236/* ─── Main screen ────────────────────────────────────────────────────── */207/* ─── Main screen ────────────────────────────────────────────────────── */
237208
238export function HomeScreen(props: HomeScreenProps) {209export 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();
244 const queuedThreadKeys = useQueuedThreadKeys();210 const queuedThreadKeys = useQueuedThreadKeys();
245 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
246 const openSwipeableRef = useRef<SwipeableMethods | null>(null);211 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
247 const listRef = useRef<LegendListRef | null>(null);
248 const insets = useSafeAreaInsets();212 const insets = useSafeAreaInsets();
249 const iosBottomToolbarClearance =213 const iosBottomToolbarClearance =
250 Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED214 Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED
...@@ -279,42 +243,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -279,42 +243,6 @@ export function HomeScreen(props: HomeScreenProps) {
279 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),243 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),
280 [threadSearch.matches],244 [threadSearch.matches],
281 );245 );
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
318 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {246 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
319 if (openSwipeableRef.current !== methods) {247 if (openSwipeableRef.current !== methods) {
320 openSwipeableRef.current?.close();248 openSwipeableRef.current?.close();
...@@ -346,112 +274,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -346,112 +274,7 @@ export function HomeScreen(props: HomeScreenProps) {
346 }),274 }),
347 [props.projectGroupingMode, props.projects, props.selectedEnvironmentId],275 [props.projectGroupingMode, props.projects, props.selectedEnvironmentId],
348 );276 );
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
442 const hasSearchQuery = props.searchQuery.trim().length > 0;277 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
455 const projectByKey = useMemo(() => {278 const projectByKey = useMemo(() => {
456 const map = new Map<string, EnvironmentProject>();279 const map = new Map<string, EnvironmentProject>();
457 for (const project of props.projects) {280 for (const project of props.projects) {
...@@ -597,12 +420,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -597,12 +420,11 @@ export function HomeScreen(props: HomeScreenProps) {
597 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);420 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
598 useFocusEffect(421 useFocusEffect(
599 useCallback(() => {422 useCallback(() => {
600 if (!threadListV2Enabled) return;
601 // Refresh immediately on enable or focus because the previous value can be hours old.423 // Refresh immediately on enable or focus because the previous value can be hours old.
602 setNowMinute(new Date().toISOString().slice(0, 16));424 setNowMinute(new Date().toISOString().slice(0, 16));
603 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);425 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
604 return () => clearInterval(id);426 return () => clearInterval(id);
605 }, [threadListV2Enabled]),427 }, []),
606 );428 );
607 // Threads on servers without the settlement capability never classify as429 // Threads on servers without the settlement capability never classify as
608 // settled (the user could neither un-settle nor pin them).430 // settled (the user could neither un-settle nor pin them).
...@@ -715,16 +537,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -715,16 +537,6 @@ export function HomeScreen(props: HomeScreenProps) {
715 snoozeWakeTick,537 snoozeWakeTick,
716 ]);538 ]);
717 const threadListV2Layout = useMemo(() => {539 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 };
728 // Settled threads are live shells; archived threads keep their original540 // Settled threads are live shells; archived threads keep their original
729 // "hidden from lists" meaning.541 // "hidden from lists" meaning.
730 return buildThreadListV2Items({542 return buildThreadListV2Items({
...@@ -757,7 +569,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -757,7 +569,6 @@ export function HomeScreen(props: HomeScreenProps) {
757 props.selectedEnvironmentId,569 props.selectedEnvironmentId,
758 props.threads,570 props.threads,
759 matchedThreadKeys,571 matchedThreadKeys,
760 threadListV2Enabled,
761 v2ScopedProjectGroup,572 v2ScopedProjectGroup,
762 ]);573 ]);
763 // Re-partition the moment the earliest snooze expires (clamped to the574 // Re-partition the moment the earliest snooze expires (clamped to the
...@@ -824,10 +635,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -824,10 +635,7 @@ export function HomeScreen(props: HomeScreenProps) {
824 ],635 ],
825 );636 );
826637
827 useThreadJumpShortcuts(638 useThreadJumpShortcuts(threadListV2Items, props.onSelectThread);
828 threadListV2Enabled ? threadListV2Items : listLayout.items,
829 props.onSelectThread,
830 );
831639
832 const renderV2Item = useCallback(640 const renderV2Item = useCallback(
833 ({ item }: { readonly item: ThreadListV2ListItem }) => {641 ({ item }: { readonly item: ThreadListV2ListItem }) => {
...@@ -996,126 +804,13 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -996,126 +804,13 @@ export function HomeScreen(props: HomeScreenProps) {
996 ],804 ],
997 );805 );
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
1111 /* Empty states */807 /* Empty states */
1112 // The signal must ignore the search/environment filters: an active query808 // The signal must ignore the search/environment filters: an active query
1113 // that matches nothing needs the in-list "No results" state, not the809 // that matches nothing needs the in-list "No results" state, not the
1114 // full-page "No threads yet". Settled threads are unarchived live shells,810 // 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.
1116 const hasAnyThreads =812 const hasAnyThreads =
1117 props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0;813 props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0;
1118 const hasResults = threadListV2Enabled ? threadListV2Items.length > 0 : projectGroups.length > 0;
1119 const selectedEnvironmentLabel =814 const selectedEnvironmentLabel =
1120 props.selectedEnvironmentId === null815 props.selectedEnvironmentId === null
1121 ? null816 ? null
...@@ -1178,16 +873,18 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1178,16 +873,18 @@ export function HomeScreen(props: HomeScreenProps) {
1178 // mobile — the menu is the one filter surface).873 // mobile — the menu is the one filter surface).
1179 const v2ListHeader = listHeader;874 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 =
1182 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (879 hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? (
1183 <EmptyState880 <EmptyState
1184 title="No results"881 title="No results"
1185 detail={`No threads matching "${props.searchQuery}".`}882 detail={`No threads matching "${props.searchQuery}".`}
1186 variant={Platform.OS === "android" ? "plain" : undefined}883 variant={Platform.OS === "android" ? "plain" : undefined}
1187 />884 />
1188 ) : selectedProjectScope !== null ? (885 ) : v2ScopedProjectGroup !== null ? (
1189 <EmptyState886 <EmptyState
1190 title={`No threads in ${selectedProjectScope.title}`}887 title={`No threads in ${v2ScopedProjectGroup.title}`}
1191 detail="Choose another project or create a new task."888 detail="Choose another project or create a new task."
1192 variant={Platform.OS === "android" ? "plain" : undefined}889 variant={Platform.OS === "android" ? "plain" : undefined}
1193 />890 />
...@@ -1203,93 +900,16 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1203,93 +900,16 @@ export function HomeScreen(props: HomeScreenProps) {
1203 detail="Create a task to start a new coding session."900 detail="Create a task to start a new coding session."
1204 variant={Platform.OS === "android" ? "plain" : undefined}901 variant={Platform.OS === "android" ? "plain" : undefined}
1205 />902 />
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
1225 );903 );
1226904
1227 if (905 if (Platform.OS === "android" && threadListV2Items.length === 0) {
1228 Platform.OS === "android" &&
1229 (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0)
1230 ) {
1231 return (906 return (
1232 <View className="flex-1 bg-header">907 <View className="flex-1 bg-header">
1233 <View908 <View
1234 className="flex-1 items-center justify-center overflow-hidden rounded-t-[28px] bg-screen px-4"909 className="flex-1 items-center justify-center overflow-hidden rounded-t-[28px] bg-screen px-4"
1235 style={{ paddingBottom: insets.bottom }}910 style={{ paddingBottom: insets.bottom }}
1236 >911 >
1237 {threadListV2Enabled ? v2ListEmpty : listEmpty}912 {v2ListEmpty}
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>
1293 </View>913 </View>
1294 </View>914 </View>
1295 );915 );
...@@ -1304,52 +924,44 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1304,52 +924,44 @@ export function HomeScreen(props: HomeScreenProps) {
1304 : "flex-1 bg-screen"924 : "flex-1 bg-screen"
1305 }925 }
1306 >926 >
1307 {/* Sticky headers are deliberately not wired up: LegendList's JS sticky927 {/* Same recycler the iPad sidebar and the legacy list use: cells are
1308 implementation mispositions pinned headers at mount under iOS928 reused across data rebuilds and `itemsAreEqual` keeps a minute tick
1309 automatic content insets (headers render one nav-inset too low until929 (or an unrelated shell update) from re-rendering untouched rows. */}
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. */}
1313 <SwipeableScrollGateProvider enabled={swipeEnabled}>930 <SwipeableScrollGateProvider enabled={swipeEnabled}>
1314 <LegendList931 <LegendList
1315 ref={listRef}932 data={threadListV2Items}
1316 data={listLayout.items}933 renderItem={renderV2Item}
1317 renderItem={renderItem}934 keyExtractor={v2KeyExtractor}
1318 keyExtractor={keyExtractor}935 getItemType={(item) => item.type}
1319 itemsAreEqual={homeListItemsAreEqual}936 itemsAreEqual={threadListV2ListItemsAreEqual}
937 estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT}
1320 drawDistance={500}938 drawDistance={500}
1321 estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT}939 recycleItems
1322 extraData={extraData}940 extraData={v2ExtraData}
1323 ListHeaderComponent={listHeader}941 ListHeaderComponent={v2ListHeader}
1324 ListEmptyComponent={listEmpty}942 ListFooterComponent={
943 settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? (
944 <ThreadListV2ShowMoreRow
945 hiddenCount={threadListV2Layout.hiddenSettledCount}
946 onPress={showMoreSettled}
947 />
948 ) : null
949 }
950 ListEmptyComponent={v2ListEmpty}
1325 style={{ flex: 1 }}951 style={{ flex: 1 }}
1326 automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED}952 automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
1327 contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"}953 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
1328 showsVerticalScrollIndicator={false}954 showsVerticalScrollIndicator={false}
1329 keyboardDismissMode="on-drag"955 keyboardDismissMode="on-drag"
1330 keyboardShouldPersistTaps="handled"956 keyboardShouldPersistTaps="handled"
1331 {...scrollGateHandlers}957 {...scrollGateHandlers}
1332 recycleItems
1333 scrollEventThrottle={16}958 scrollEventThrottle={16}
1334 contentContainerStyle={{959 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".
1340 paddingBottom:960 paddingBottom:
1341 Platform.OS === "ios"961 Platform.OS === "ios"
1342 ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance962 ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance
1343 : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88),963 : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88),
1344 }}964 }}
1345 scrollIndicatorInsets={
1346 Platform.OS === "ios"
1347 ? {
1348 bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance,
1349 top: 0,
1350 }
1351 : undefined
1352 }
1353 />965 />
1354 </SwipeableScrollGateProvider>966 </SwipeableScrollGateProvider>
1355 </View>967 </View>
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx+1-1
...@@ -130,7 +130,7 @@ export function MaterialThreadListToolbar(props: {...@@ -130,7 +130,7 @@ export function MaterialThreadListToolbar(props: {
130 <AndroidAnchoredMenu actions={props.filterActions} onPressAction={props.onFilterAction}>130 <AndroidAnchoredMenu actions={props.filterActions} onPressAction={props.onFilterAction}>
131 {(open) => (131 {(open) => (
132 <MaterialFloatingActionButton132 <MaterialFloatingActionButton
133 label="Filter and sort threads"133 label="Filter threads"
134 icon={filterIcon}134 icon={filterIcon}
135 onPress={open}135 onPress={open}
136 />136 />
apps/mobile/src/features/home/home-list-filter-menu.test.ts-4
...@@ -13,12 +13,8 @@ describe("buildHomeListFilterMenu", () => {...@@ -13,12 +13,8 @@ describe("buildHomeListFilterMenu", () => {
13 ],13 ],
14 selectedEnvironmentId: null,14 selectedEnvironmentId: null,
15 selectedProjectKey: "environment-1:project-1",15 selectedProjectKey: "environment-1:project-1",
16 projectSortOrder: "updated_at",
17 threadSortOrder: "updated_at",
18 onEnvironmentChange: vi.fn(),16 onEnvironmentChange: vi.fn(),
19 onProjectChange,17 onProjectChange,
20 onProjectSortOrderChange: vi.fn(),
21 onThreadSortOrderChange: vi.fn(),
22 });18 });
2319
24 const projectMenu = menu.items.find(20 const projectMenu = menu.items.find(
apps/mobile/src/features/home/home-list-filter-menu.ts+1-37
...@@ -1,7 +1,4 @@...@@ -1,7 +1,4 @@
1import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2
3import type { HomeProjectSortOrder } from "./homeThreadList";
4import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options";
52
6export interface HomeListFilterMenuEnvironment {3export interface HomeListFilterMenuEnvironment {
7 readonly environmentId: EnvironmentId;4 readonly environmentId: EnvironmentId;
...@@ -37,16 +34,8 @@ export function buildHomeListFilterMenu(props: {...@@ -37,16 +34,8 @@ export function buildHomeListFilterMenu(props: {
37 readonly projects: ReadonlyArray<HomeListFilterMenuProject>;34 readonly projects: ReadonlyArray<HomeListFilterMenuProject>;
38 readonly selectedEnvironmentId: EnvironmentId | null;35 readonly selectedEnvironmentId: EnvironmentId | null;
39 readonly selectedProjectKey: string | null;36 readonly selectedProjectKey: string | null;
40 readonly projectSortOrder: HomeProjectSortOrder;
41 readonly threadSortOrder: SidebarThreadSortOrder;
42 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;37 readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
43 readonly onProjectChange: (projectKey: string | null) => void;38 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;
50}): HomeListFilterMenu {39}): HomeListFilterMenu {
51 const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];40 const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = [];
5241
...@@ -95,31 +84,6 @@ export function buildHomeListFilterMenu(props: {...@@ -95,31 +84,6 @@ export function buildHomeListFilterMenu(props: {
95 });84 });
96 }85 }
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
123 return {87 return {
124 title: "Thread list options",88 title: "Thread list options",
125 items,89 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 {...@@ -31,22 +31,6 @@ export interface ResolvedHomeListOptions extends HomeListOptions {
31 readonly projectGroupingMode: SidebarProjectGroupingMode;31 readonly projectGroupingMode: SidebarProjectGroupingMode;
32}32}
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
50function defaultHomeListOptions(): HomeListOptions {34function defaultHomeListOptions(): HomeListOptions {
51 return {35 return {
52 selectedEnvironmentId: null,36 selectedEnvironmentId: null,
...@@ -81,23 +65,6 @@ export function HomeListOptionsProvider({...@@ -81,23 +65,6 @@ export function HomeListOptionsProvider({
81 return createElement(HomeListOptionsContext, { value }, children);65 return createElement(HomeListOptionsContext, { value }, children);
82}66}
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
101export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) {68export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) {
102 const shared = useContext(HomeListOptionsContext);69 const shared = useContext(HomeListOptionsContext);
103 const [localOptions, setLocalOptions] = useState<HomeListOptions>(defaultHomeListOptions);70 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...@@ -6,11 +6,7 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-searc
6import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";6import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
7import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
88
9import {9import { buildHomeProjectScopes, sortHomeProjectScopes } from "./homeThreadList";
10 buildHomeProjectScopes,
11 buildHomeThreadGroups,
12 sortHomeProjectScopes,
13} from "./homeThreadList";
1410
15function makeProject(11function makeProject(
16 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,12 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
...@@ -54,25 +50,7 @@ function makeThread(...@@ -54,25 +50,7 @@ function makeThread(
5450
55const NOW = Date.parse("2026-06-29T00:00:00.000Z");51const NOW = Date.parse("2026-06-29T00:00:00.000Z");
5652
57function buildGroups(53describe("home project scopes", () => {
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", () => {
76 it("builds one v2 scope for the same repository across environments", () => {54 it("builds one v2 scope for the same repository across environments", () => {
77 const localEnvironmentId = EnvironmentId.make("environment-local");55 const localEnvironmentId = EnvironmentId.make("environment-local");
78 const remoteEnvironmentId = EnvironmentId.make("environment-remote");56 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
...@@ -116,70 +94,6 @@ describe("buildHomeThreadGroups", () => {...@@ -116,70 +94,6 @@ describe("buildHomeThreadGroups", () => {
116 );94 );
117 });95 });
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
183 it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => {97 it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => {
184 const localEnvironmentId = EnvironmentId.make("environment-local");98 const localEnvironmentId = EnvironmentId.make("environment-local");
185 const remoteEnvironmentId = EnvironmentId.make("environment-remote");99 const remoteEnvironmentId = EnvironmentId.make("environment-remote");
...@@ -375,410 +289,4 @@ describe("buildHomeThreadGroups", () => {...@@ -375,410 +289,4 @@ describe("buildHomeThreadGroups", () => {
375 }),289 }),
376 ).toHaveLength(2);290 ).toHaveLength(2);
377 });291 });
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 });
784});292});
apps/mobile/src/features/home/homeThreadList.ts-249
...@@ -141,252 +141,3 @@ export function sortHomeProjectScopes(input: {...@@ -141,252 +141,3 @@ export function sortHomeProjectScopes(input: {
141const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000;141const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000;
142/** Fallback when a project has no threads inside the recency window. */142/** Fallback when a project has no threads inside the recency window. */
143const RECENT_THREAD_FALLBACK_COUNT = 3;143const 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...@@ -2,23 +2,19 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell
2import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";2import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts";
3import { useCallback } from "react";3import { useCallback } from "react";
44
5import type { HomeListItem } from "../home/homeListItems";
6import type { ThreadListV2ListItem } from "../threads/threadListV2";5import type { ThreadListV2ListItem } from "../threads/threadListV2";
7import {6import {
8 useHardwareKeyboardCommand,7 useHardwareKeyboardCommand,
9 type HardwareKeyboardCommand,8 type HardwareKeyboardCommand,
10} from "./hardwareKeyboardCommands";9} from "./hardwareKeyboardCommands";
1110
12type ThreadShortcutListItem =11type ThreadShortcutListItem = ThreadListV2ListItem | { readonly type: "v2-show-more" };
13 | HomeListItem
14 | ThreadListV2ListItem
15 | { readonly type: "v2-show-more" };
1612
17export function threadJumpIndex(command: HardwareKeyboardCommand) {13export function threadJumpIndex(command: HardwareKeyboardCommand) {
18 return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command);14 return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command);
19}15}
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. */
22export function threadJumpTarget(18export function threadJumpTarget(
23 items: ReadonlyArray<ThreadShortcutListItem>,19 items: ReadonlyArray<ThreadShortcutListItem>,
24 command: HardwareKeyboardCommand,20 command: HardwareKeyboardCommand,
...@@ -26,8 +22,7 @@ export function threadJumpTarget(...@@ -26,8 +22,7 @@ export function threadJumpTarget(
26 let index = threadJumpIndex(command);22 let index = threadJumpIndex(command);
27 if (index < 0) return null;23 if (index < 0) return null;
28 for (const item of items) {24 for (const item of items) {
29 const thread =25 const thread = item.type === "v2-thread" ? item.item.thread : null;
30 item.type === "thread" ? item.thread : item.type === "v2-thread" ? item.item.thread : null;
31 if (thread !== null && index-- === 0) return thread;26 if (thread !== null && index-- === 0) return thread;
32 }27 }
33 return null;28 return null;
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx-8
...@@ -12,7 +12,6 @@ import { AppText as Text } from "../../components/AppText";...@@ -12,7 +12,6 @@ import { AppText as Text } from "../../components/AppText";
12import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";12import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
13import { serverEnvironment } from "../../state/server";13import { serverEnvironment } from "../../state/server";
14import { useAtomCommand } from "../../state/use-atom-command";14import { useAtomCommand } from "../../state/use-atom-command";
15import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
16import { SettingsSection } from "./components/SettingsSection";15import { SettingsSection } from "./components/SettingsSection";
17import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";16import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";
18import { SettingsSwitchRow } from "./components/SettingsSwitchRow";17import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
...@@ -224,19 +223,12 @@ function AutoSettleSettingsRows() {...@@ -224,19 +223,12 @@ function AutoSettleSettingsRows() {
224function LegacySettingsSection() {223function LegacySettingsSection() {
225 const savePreferences = useAtomSet(updateMobilePreferencesAtom);224 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
226 const preferences = useAtomValue(mobilePreferencesAtom);225 const preferences = useAtomValue(mobilePreferencesAtom);
227 const threadListV2Enabled = useThreadListV2Enabled();
228 const planModeEnabled =226 const planModeEnabled =
229 AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true;227 AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true;
230228
231 return (229 return (
232 <View className="gap-3">230 <View className="gap-3">
233 <SettingsSection title="Legacy">231 <SettingsSection title="Legacy">
234 <SettingsSwitchRow
235 icon="sidebar.left"
236 label="Legacy Thread List"
237 value={!threadListV2Enabled}
238 onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })}
239 />
240 <SettingsSwitchRow232 <SettingsSwitchRow
241 icon="hammer"233 icon="hammer"
242 label="Plan Mode"234 label="Plan Mode"
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+15-295
...@@ -29,7 +29,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";...@@ -29,7 +29,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
29import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";29import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
30import { useProjects, useThreadShells } from "../../state/entities";30import { useProjects, useThreadShells } from "../../state/entities";
31import { useThreadSearch } from "../../state/queries";31import { useThreadSearch } from "../../state/queries";
32import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
33import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";32import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
34import { usePendingThreadOrder } from "../../state/thread-order";33import { usePendingThreadOrder } from "../../state/thread-order";
35import { environmentServerConfigsAtom } from "../../state/server";34import { environmentServerConfigsAtom } from "../../state/server";
...@@ -39,24 +38,9 @@ import { useWorkspaceState } from "../../state/workspace";...@@ -39,24 +38,9 @@ import { useWorkspaceState } from "../../state/workspace";
39import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";38import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
40import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";39import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
41import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";40import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
42import {41import { useHomeListOptions } from "../home/home-list-options";
43 hasCustomHomeListOptions,
44 PROJECT_SORT_OPTIONS,
45 THREAD_SORT_OPTIONS,
46 useHomeListOptions,
47} from "../home/home-list-options";
48import { buildHomeListFilterMenu } from "../home/home-list-filter-menu";42import { buildHomeListFilterMenu } from "../home/home-list-filter-menu";
49import {43import { buildHomeProjectScopes } from "../home/homeThreadList";
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";
60import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";44import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
61import { usePendingTaskListActions } from "../home/usePendingTaskListActions";45import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
62import { useThreadListActions } from "../home/useThreadListActions";46import { useThreadListActions } from "../home/useThreadListActions";
...@@ -71,12 +55,6 @@ import { useMaterialFabScroll } from "../home/MaterialFabScrollContext";...@@ -71,12 +55,6 @@ import { useMaterialFabScroll } from "../home/MaterialFabScrollContext";
71import { SidebarFilterButton } from "./sidebar-filter-button";55import { SidebarFilterButton } from "./sidebar-filter-button";
72import { createSidebarHeaderItems } from "./sidebar-native-header-items";56import { createSidebarHeaderItems } from "./sidebar-native-header-items";
73import { SidebarNavigationShell } from "./sidebar-navigation-shell";57import { SidebarNavigationShell } from "./sidebar-navigation-shell";
74import {
75 PendingTaskListRow,
76 ThreadListGroupHeader,
77 ThreadListRow,
78 ThreadListShowMoreRow,
79} from "./thread-list-items";
80import {58import {
81 ThreadListV2PendingRow,59 ThreadListV2PendingRow,
82 ThreadListV2Row,60 ThreadListV2Row,
...@@ -96,11 +74,9 @@ import {...@@ -96,11 +74,9 @@ import {
96 type ThreadListV2ListItem,74 type ThreadListV2ListItem,
97} from "./threadListV2";75} from "./threadListV2";
9876
99/** The sidebar list serves both lists: v1 grouped items or, when the Thread77/** The sidebar list: flat v2 rows with queued tasks spliced in, plus a
100 List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled78 settled "Show more" pager row. */
101 "Show more" pager. */
102type SidebarListItem =79type SidebarListItem =
103 | HomeListItem
104 | ThreadListV2ListItem80 | ThreadListV2ListItem
105 | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number };81 | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number };
10682
...@@ -178,7 +154,6 @@ function ThreadNavigationSidebarPane(...@@ -178,7 +154,6 @@ function ThreadNavigationSidebarPane(
178 renameThread,154 renameThread,
179 regenerateThreadTitle,155 regenerateThreadTitle,
180 } = useThreadListActions();156 } = useThreadListActions();
181 const threadListV2Enabled = useThreadListV2Enabled();
182 const pendingTasks = usePendingNewTasks();157 const pendingTasks = usePendingNewTasks();
183 const queuedThreadKeys = useQueuedThreadKeys();158 const queuedThreadKeys = useQueuedThreadKeys();
184 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();159 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
...@@ -196,8 +171,7 @@ function ThreadNavigationSidebarPane(...@@ -196,8 +171,7 @@ function ThreadNavigationSidebarPane(
196 () => new Set(environments.map((environment) => environment.environmentId)),171 () => new Set(environments.map((environment) => environment.environmentId)),
197 [environments],172 [environments],
198 );173 );
199 const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } =174 const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds);
200 useHomeListOptions(availableEnvironmentIds);
201 const searchEnvironmentIds = useMemo(175 const searchEnvironmentIds = useMemo(
202 () =>176 () =>
203 options.selectedEnvironmentId === null177 options.selectedEnvironmentId === null
...@@ -286,93 +260,6 @@ function ThreadNavigationSidebarPane(...@@ -286,93 +260,6 @@ function ThreadNavigationSidebarPane(
286 ),260 ),
287 [selectedProjectScope],261 [selectedProjectScope],
288 );262 );
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 );
376 const projectByKey = useMemo(() => {263 const projectByKey = useMemo(() => {
377 const map = new Map<string, EnvironmentProject>();264 const map = new Map<string, EnvironmentProject>();
378 for (const project of projects) {265 for (const project of projects) {
...@@ -412,12 +299,11 @@ function ThreadNavigationSidebarPane(...@@ -412,12 +299,11 @@ function ThreadNavigationSidebarPane(
412 // thread reappears immediately instead of on the next minute tick.299 // thread reappears immediately instead of on the next minute tick.
413 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);300 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
414 useEffect(() => {301 useEffect(() => {
415 if (!threadListV2Enabled) return;
416 // Refresh immediately because the mount-time value can be hours old.302 // Refresh immediately because the mount-time value can be hours old.
417 setNowMinute(new Date().toISOString().slice(0, 16));303 setNowMinute(new Date().toISOString().slice(0, 16));
418 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);304 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
419 return () => clearInterval(id);305 return () => clearInterval(id);
420 }, [threadListV2Enabled]);306 }, []);
421 // Threads on servers without the settlement capability never classify as307 // Threads on servers without the settlement capability never classify as
422 // settled (the user could neither un-settle nor pin them).308 // settled (the user could neither un-settle nor pin them).
423 const serverConfigs = useAtomValue(environmentServerConfigsAtom);309 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
...@@ -529,16 +415,6 @@ function ThreadNavigationSidebarPane(...@@ -529,16 +415,6 @@ function ThreadNavigationSidebarPane(
529 snoozeWakeTick,415 snoozeWakeTick,
530 ]);416 ]);
531 const threadListV2Layout = useMemo(() => {417 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 };
542 return buildThreadListV2Items({418 return buildThreadListV2Items({
543 pendingOrder,419 pendingOrder,
544 threads: threads.filter((thread) => thread.archivedAt === null),420 threads: threads.filter((thread) => thread.archivedAt === null),
...@@ -569,7 +445,6 @@ function ThreadNavigationSidebarPane(...@@ -569,7 +445,6 @@ function ThreadNavigationSidebarPane(
569 settledVisibleCount,445 settledVisibleCount,
570 settlementEnvironmentIds,446 settlementEnvironmentIds,
571 snoozeEnvironmentIds,447 snoozeEnvironmentIds,
572 threadListV2Enabled,
573 threads,448 threads,
574 selectedProjectScope,449 selectedProjectScope,
575 ]);450 ]);
...@@ -588,7 +463,6 @@ function ThreadNavigationSidebarPane(...@@ -588,7 +463,6 @@ function ThreadNavigationSidebarPane(
588 // range) the boundary string is identical and the chain would die.463 // range) the boundary string is identical and the chain would die.
589 }, [nextSnoozeWakeAt, snoozeWakeTick]);464 }, [nextSnoozeWakeAt, snoozeWakeTick]);
590 const listItems = useMemo<readonly SidebarListItem[]>(() => {465 const listItems = useMemo<readonly SidebarListItem[]>(() => {
591 if (!threadListV2Enabled) return listLayout.items;
592 // Queued offline tasks are not thread shells, so the v2 item builder466 // Queued offline tasks are not thread shells, so the v2 item builder
593 // never sees them; the shared splice puts them below the active block467 // never sees them; the shared splice puts them below the active block
594 // (mirrors the compact Home v2 list) where they stay visible and468 // (mirrors the compact Home v2 list) where they stay visible and
...@@ -630,7 +504,6 @@ function ThreadNavigationSidebarPane(...@@ -630,7 +504,6 @@ function ThreadNavigationSidebarPane(
630 }504 }
631 return items;505 return items;
632 }, [506 }, [
633 listLayout.items,
634 nowMinute,507 nowMinute,
635 options.selectedEnvironmentId,508 options.selectedEnvironmentId,
636 pendingTasks,509 pendingTasks,
...@@ -642,7 +515,6 @@ function ThreadNavigationSidebarPane(...@@ -642,7 +515,6 @@ function ThreadNavigationSidebarPane(
642 shelfPreferencesLoaded,515 shelfPreferencesLoaded,
643 snoozedShelfExpanded,516 snoozedShelfExpanded,
644 snoozeEnvironmentIds,517 snoozeEnvironmentIds,
645 threadListV2Enabled,
646 threadListV2Layout,518 threadListV2Layout,
647 ]);519 ]);
648 const listMenuActions = useMemo<MenuAction[]>(520 const listMenuActions = useMemo<MenuAction[]>(
...@@ -688,33 +560,8 @@ function ThreadNavigationSidebarPane(...@@ -688,33 +560,8 @@ function ThreadNavigationSidebarPane(
688 ],560 ],
689 },561 },
690 ] satisfies MenuAction[])),562 ] 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[])),
716 ],563 ],
717 [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled],564 [environments, options, projectFilterOptions, selectedProjectKey],
718 );565 );
719 const handleListMenuAction = useCallback(566 const handleListMenuAction = useCallback(
720 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {567 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
...@@ -741,28 +588,8 @@ function ThreadNavigationSidebarPane(...@@ -741,28 +588,8 @@ function ThreadNavigationSidebarPane(
741 }588 }
742 return;589 return;
743 }590 }
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 }
758 },591 },
759 [592 [environments, projectFilterOptions, setSelectedEnvironmentId],
760 environments,
761 projectFilterOptions,
762 setProjectSortOrder,
763 setSelectedEnvironmentId,
764 setThreadSortOrder,
765 ],
766 );593 );
767594
768 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);595 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
...@@ -838,15 +665,7 @@ function ThreadNavigationSidebarPane(...@@ -838,15 +665,7 @@ function ThreadNavigationSidebarPane(
838 if (previous.type === "v2-show-more" && item.type === "v2-show-more") {665 if (previous.type === "v2-show-more" && item.type === "v2-show-more") {
839 return previous.hiddenCount === item.hiddenCount;666 return previous.hiddenCount === item.hiddenCount;
840 }667 }
841 if (668 return false;
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);
850 },669 },
851 [],670 [],
852 );671 );
...@@ -994,90 +813,11 @@ function ThreadNavigationSidebarPane(...@@ -994,90 +813,11 @@ function ThreadNavigationSidebarPane(
994 onPress={showMoreSettled}813 onPress={showMoreSettled}
995 />814 />
996 );815 );
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 );
1075 }816 }
1076 },817 },
1077 [818 [
1078 archiveThread,819 archiveThread,
1079 activeReorderEnvironmentIds,820 activeReorderEnvironmentIds,
1080 queuedThreadKeys,
1081 confirmDeletePendingTask,821 confirmDeletePendingTask,
1082 confirmDeleteThread,822 confirmDeleteThread,
1083 handleSelectThread,823 handleSelectThread,
...@@ -1093,13 +833,13 @@ function ThreadNavigationSidebarPane(...@@ -1093,13 +833,13 @@ function ThreadNavigationSidebarPane(
1093 projectTitleByProjectKey,833 projectTitleByProjectKey,
1094 regenerateThreadTitle,834 regenerateThreadTitle,
1095 renameThread,835 renameThread,
836 threadSearchMatchByKey,
1096 props.onNewThreadInProject,837 props.onNewThreadInProject,
1097 props.onNewThreadOnBranch,838 props.onNewThreadOnBranch,
1098 props.searchQuery,839 props.searchQuery,
1099 props.selectedThreadKey,840 props.selectedThreadKey,
1100 props.width,841 props.width,
1101 savedConnectionsById,842 savedConnectionsById,
1102 threadSearchMatchByKey,
1103 titleRegenerationEnvironmentIds,843 titleRegenerationEnvironmentIds,
1104 settleThread,844 settleThread,
1105 settlementEnvironmentIds,845 settlementEnvironmentIds,
...@@ -1113,14 +853,11 @@ function ThreadNavigationSidebarPane(...@@ -1113,14 +853,11 @@ function ThreadNavigationSidebarPane(
1113 unpinThread,853 unpinThread,
1114 unsettleThread,854 unsettleThread,
1115 unsnoozeThread,855 unsnoozeThread,
1116 updateGroupDisplay,
1117 ],856 ],
1118 );857 );
1119 // v2 ignores the sort/group options, so only the environment filter can858 // The list ignores sort/group options, so only the environment and project
1120 // light the "customized" state while the beta is on.859 // filters can light the "customized" state.
1121 const filterCustomized = threadListV2Enabled860 const filterCustomized = options.selectedEnvironmentId !== null || selectedProjectKey !== null;
1122 ? options.selectedEnvironmentId !== null || selectedProjectKey !== null
1123 : hasCustomHomeListOptions({ ...options, selectedProjectKey });
1124 const filterIcon = filterCustomized861 const filterIcon = filterCustomized
1125 ? "line.3.horizontal.decrease.circle.fill"862 ? "line.3.horizontal.decrease.circle.fill"
1126 : "line.3.horizontal.decrease.circle";863 : "line.3.horizontal.decrease.circle";
...@@ -1131,24 +868,10 @@ function ThreadNavigationSidebarPane(...@@ -1131,24 +868,10 @@ function ThreadNavigationSidebarPane(
1131 projects: projectFilterOptions,868 projects: projectFilterOptions,
1132 selectedEnvironmentId: options.selectedEnvironmentId,869 selectedEnvironmentId: options.selectedEnvironmentId,
1133 selectedProjectKey,870 selectedProjectKey,
1134 projectSortOrder: options.projectSortOrder,
1135 threadSortOrder: options.threadSortOrder,
1136 onEnvironmentChange: setSelectedEnvironmentId,871 onEnvironmentChange: setSelectedEnvironmentId,
1137 onProjectChange: setSelectedProjectKey,872 onProjectChange: setSelectedProjectKey,
1138 onProjectSortOrderChange: setProjectSortOrder,
1139 onThreadSortOrderChange: setThreadSortOrder,
1140 listOrganization: !threadListV2Enabled,
1141 }),873 }),
1142 [874 [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId],
1143 environments,
1144 options,
1145 projectFilterOptions,
1146 selectedProjectKey,
1147 setProjectSortOrder,
1148 setSelectedEnvironmentId,
1149 setThreadSortOrder,
1150 threadListV2Enabled,
1151 ],
1152 );875 );
1153 const nativeHeaderItems = useMemo(876 const nativeHeaderItems = useMemo(
1154 () =>877 () =>
...@@ -1358,10 +1081,7 @@ function ThreadNavigationSidebarPane(...@@ -1358,10 +1081,7 @@ function ThreadNavigationSidebarPane(
1358 />1081 />
1359 <View className="flex-row items-center gap-2.5">1082 <View className="flex-row items-center gap-2.5">
1360 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>1083 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
1361 <SidebarFilterButton1084 <SidebarFilterButton accessibilityLabel="Filter threads" icon={filterIcon} />
1362 accessibilityLabel="Filter and sort threads"
1363 icon={filterIcon}
1364 />
1365 </ControlPillMenu>1085 </ControlPillMenu>
1366 <SidebarHeaderActions onOpenSettings={props.onOpenSettings} />1086 <SidebarHeaderActions onOpenSettings={props.onOpenSettings} />
1367 </View>1087 </View>
apps/mobile/src/features/threads/sidebar-native-header-items.ts+1-1
...@@ -45,7 +45,7 @@ export function createSidebarHeaderItems(input: {...@@ -45,7 +45,7 @@ export function createSidebarHeaderItems(input: {
45 withNativeGlassHeaderItem({45 withNativeGlassHeaderItem({
46 type: "menu",46 type: "menu",
47 label: "",47 label: "",
48 accessibilityLabel: "Filter and sort threads",48 accessibilityLabel: "Filter threads",
49 icon: sfSymbolIcon(input.filterIcon),49 icon: sfSymbolIcon(input.filterIcon),
50 menu: {50 menu: {
51 title: input.filterMenu.title,51 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 {...@@ -30,7 +30,6 @@ import {
30 buildThreadListV2ListItems,30 buildThreadListV2ListItems,
31 getThreadListV2OrderedSection,31 getThreadListV2OrderedSection,
32 isThreadListV2ListItem,32 isThreadListV2ListItem,
33 resolveThreadListV2Enabled,
34 resolveThreadListV2SnoozeMenuSelection,33 resolveThreadListV2SnoozeMenuSelection,
35 resolveThreadListV2SnoozeGateExpiryMs,34 resolveThreadListV2SnoozeGateExpiryMs,
36 resolveThreadListV2Status,35 resolveThreadListV2Status,
...@@ -125,29 +124,6 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {...@@ -125,29 +124,6 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {
125 });124 });
126});125});
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
151describe("resolveThreadListV2Status", () => {127describe("resolveThreadListV2Status", () => {
152 it("prioritizes approval over a running session", () => {128 it("prioritizes approval over a running session", () => {
153 const thread = makeThread({129 const thread = makeThread({
apps/mobile/src/features/threads/threadListV2.ts-20
...@@ -116,26 +116,6 @@ export function resolveThreadListV2SnoozeGateExpiryMs(...@@ -116,26 +116,6 @@ export function resolveThreadListV2SnoozeGateExpiryMs(
116export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;116export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
117export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;117export 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
139export function resolveThreadListV2Status(119export function resolveThreadListV2Status(
140 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,120 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
141): ThreadListV2Status {121): 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 {...@@ -33,14 +33,6 @@ export interface Preferences {
33 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */33 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
34 readonly projectGroupingEnabled?: boolean;34 readonly projectGroupingEnabled?: boolean;
35 readonly projectGroupingMode?: SidebarProjectGroupingMode;35 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;
44 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */36 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
45 readonly planModeEnabled?: boolean;37 readonly planModeEnabled?: boolean;
46 /** Model favorites belong to this device, like the web client setting. */38 /** Model favorites belong to this device, like the web client setting. */
...@@ -107,7 +99,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -107,7 +99,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
107 composerEnterBehavior?: ComposerEnterBehavior;99 composerEnterBehavior?: ComposerEnterBehavior;
108 projectGroupingEnabled?: boolean;100 projectGroupingEnabled?: boolean;
109 projectGroupingMode?: SidebarProjectGroupingMode;101 projectGroupingMode?: SidebarProjectGroupingMode;
110 legacyThreadListEnabled?: boolean;
111 planModeEnabled?: boolean;102 planModeEnabled?: boolean;
112 modelFavorites?: Preferences["modelFavorites"];103 modelFavorites?: Preferences["modelFavorites"];
113 threadListSettledShelfExpanded?: boolean;104 threadListSettledShelfExpanded?: boolean;
...@@ -176,9 +167,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -176,9 +167,6 @@ function sanitizePreferences(parsed: Preferences): Preferences {
176 ) {167 ) {
177 preferences.projectGroupingMode = parsed.projectGroupingMode;168 preferences.projectGroupingMode = parsed.projectGroupingMode;
178 }169 }
179 if (typeof parsed.legacyThreadListEnabled === "boolean") {
180 preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
181 }
182 if (typeof parsed.planModeEnabled === "boolean") {170 if (typeof parsed.planModeEnabled === "boolean") {
183 preferences.planModeEnabled = parsed.planModeEnabled;171 preferences.planModeEnabled = parsed.planModeEnabled;
184 }172 }