| author | |
| committer | |
| log | 0c91f687de606f62e157209f34e6aa74744b555a |
| tree | 5094490b84e743b84efd8fa2a371193ec4fc075d |
| parent | 151324b2c0bf0ace789820b8ea4ec8ceb55753d9 |
| signature | Signed by PGP key B5690EEEBB952194 |
24 files changed, 75 insertions(+), 3211 deletions(-)
apps/mobile/src/features/home/HomeHeader.android.tsx+6-58| ... | ... | @@ -1,13 +1,7 @@ |
| 1 | 1 | import type { MenuAction } from "@react-native-menu/menu"; |
| 2 | 2 | import { useCallback, useMemo } from "react"; |
| 3 | 3 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 4 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | |
| 5 | 4 | import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; |
| 6 | import { | |
| 7 | hasCustomHomeListOptions, | |
| 8 | PROJECT_SORT_OPTIONS, | |
| 9 | THREAD_SORT_OPTIONS, | |
| 10 | } from "./home-list-options"; | |
| 11 | 5 | import type { HomeHeaderProps } from "./HomeHeader.types"; |
| 12 | 6 | |
| 13 | 7 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| ... | ... | @@ -17,13 +11,11 @@ function checkedMenuState(checked: boolean) { |
| 17 | 11 | } |
| 18 | 12 | |
| 19 | 13 | export function HomeHeader(props: HomeHeaderProps) { |
| 20 | // Thread List v2 lays the list out in fixed creation order, so the | |
| 21 | // sort/group filter controls would be silently ignored — hide them and | |
| 22 | // key the "customized" icon state off the environment filter alone. | |
| 23 | const threadListV2Enabled = useThreadListV2Enabled(); | |
| 24 | const hasCustomListOptions = threadListV2Enabled | |
| 25 | ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null | |
| 26 | : hasCustomHomeListOptions(props); | |
| 14 | // The list uses a fixed creation order and ignores sort/group options, so | |
| 15 | // the filter menu only carries the filters and the "customized" icon state | |
| 16 | // keys off those alone. | |
| 17 | const hasCustomListOptions = | |
| 18 | props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; | |
| 27 | 19 | const menuActions = useMemo<MenuAction[]>( |
| 28 | 20 | () => [ |
| 29 | 21 | { |
| ... | ... | @@ -62,38 +54,8 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 62 | 54 | ], |
| 63 | 55 | }, |
| 64 | 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 | 60 | const handleMenuAction = useCallback( |
| 99 | 61 | (event: { nativeEvent: { event: string } }) => { |
| ... | ... | @@ -126,20 +88,6 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 126 | 88 | } |
| 127 | 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 | 92 | [props], |
| 145 | 93 | ); |
apps/mobile/src/features/home/HomeHeader.tsx+7-48| ... | ... | @@ -2,7 +2,6 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac |
| 2 | 2 | import { useCallback, useRef } from "react"; |
| 3 | 3 | import type { SearchBarCommands } from "react-native-screens"; |
| 4 | 4 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 5 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | |
| 6 | 5 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 7 | 6 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 8 | 7 | import { |
| ... | ... | @@ -10,11 +9,6 @@ import { |
| 10 | 9 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 11 | 10 | } from "../layout/native-mail-search-toolbar"; |
| 12 | 11 | import { buildHomeListFilterMenu } from "./home-list-filter-menu"; |
| 13 | import { | |
| 14 | hasCustomHomeListOptions, | |
| 15 | PROJECT_SORT_OPTIONS, | |
| 16 | THREAD_SORT_OPTIONS, | |
| 17 | } from "./home-list-options"; | |
| 18 | 12 | import type { HomeHeaderProps } from "./HomeHeader.types"; |
| 19 | 13 | |
| 20 | 14 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| ... | ... | @@ -22,22 +16,17 @@ export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| 22 | 16 | export function HomeHeader(props: HomeHeaderProps) { |
| 23 | 17 | const searchBarRef = useRef<SearchBarCommands>(null); |
| 24 | 18 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 25 | // Thread List v2 lays the list out in fixed creation order, so the | |
| 26 | // sort/group filter controls would be silently ignored — hide them and | |
| 27 | // key the "customized" icon state off the environment filter alone. | |
| 28 | const threadListV2Enabled = useThreadListV2Enabled(); | |
| 29 | const hasCustomListOptions = threadListV2Enabled | |
| 30 | ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null | |
| 31 | : hasCustomHomeListOptions(props); | |
| 19 | // The list uses a fixed creation order and ignores sort/group options, so | |
| 20 | // the filter menu only carries the filters and the "customized" icon state | |
| 21 | // keys off those alone. | |
| 22 | const hasCustomListOptions = | |
| 23 | props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; | |
| 32 | 24 | const focusSearch = useCallback(() => { |
| 33 | 25 | searchBarRef.current?.focus(); |
| 34 | 26 | return searchBarRef.current !== null; |
| 35 | 27 | }, []); |
| 36 | 28 | useHardwareKeyboardCommand("focusSearch", focusSearch); |
| 37 | const filterMenu = buildHomeListFilterMenu({ | |
| 38 | ...props, | |
| 39 | listOrganization: !threadListV2Enabled, | |
| 40 | }); | |
| 29 | const filterMenu = buildHomeListFilterMenu(props); | |
| 41 | 30 | |
| 42 | 31 | return ( |
| 43 | 32 | <> |
| ... | ... | @@ -100,7 +89,7 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 100 | 89 | {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( |
| 101 | 90 | <NativeHeaderToolbar placement="bottom"> |
| 102 | 91 | <NativeHeaderToolbar.Menu |
| 103 | accessibilityLabel="Filter and sort threads" | |
| 92 | accessibilityLabel="Filter threads" | |
| 104 | 93 | icon={ |
| 105 | 94 | hasCustomListOptions |
| 106 | 95 | ? "line.3.horizontal.decrease.circle.fill" |
| ... | ... | @@ -150,36 +139,6 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 150 | 139 | ))} |
| 151 | 140 | </NativeHeaderToolbar.Menu> |
| 152 | 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 | 142 | </NativeHeaderToolbar.Menu> |
| 184 | 143 | <NativeHeaderToolbar.Spacer flexible /> |
| 185 | 144 | <NativeHeaderToolbar.Button |
apps/mobile/src/features/home/HomeHeader.types.ts+1-6| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; | |
| 2 | import type { HomeProjectSortOrder } from "./homeThreadList"; | |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | 2 | import type { |
| 4 | 3 | HomeListFilterMenuEnvironment, |
| 5 | 4 | HomeListFilterMenuProject, |
| ... | ... | @@ -13,13 +12,9 @@ export interface HomeHeaderProps { |
| 13 | 12 | readonly searchQuery: string; |
| 14 | 13 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 15 | 14 | readonly selectedProjectKey: string | null; |
| 16 | readonly projectSortOrder: HomeProjectSortOrder; | |
| 17 | readonly threadSortOrder: SidebarThreadSortOrder; | |
| 18 | 15 | readonly onSearchQueryChange: (query: string) => void; |
| 19 | 16 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; |
| 20 | 17 | readonly onProjectChange: (projectKey: string | null) => void; |
| 21 | readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; | |
| 22 | readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | |
| 23 | 18 | readonly onOpenEnvironments: () => void; |
| 24 | 19 | readonly onOpenSettings: () => void; |
| 25 | 20 | readonly onStartNewTask: () => void; |
apps/mobile/src/features/home/HomeRouteScreen.tsx+2-13| ... | ... | @@ -76,12 +76,8 @@ export function HomeRouteScreen() { |
| 76 | 76 | () => new Set(environments.map((environment) => environment.environmentId)), |
| 77 | 77 | [environments], |
| 78 | 78 | ); |
| 79 | const { | |
| 80 | options: listOptions, | |
| 81 | setSelectedEnvironmentId, | |
| 82 | setProjectSortOrder, | |
| 83 | setThreadSortOrder, | |
| 84 | } = useHomeListOptions(availableEnvironmentIds); | |
| 79 | const { options: listOptions, setSelectedEnvironmentId } = | |
| 80 | useHomeListOptions(availableEnvironmentIds); | |
| 85 | 81 | const selectedEnvironmentId = listOptions.selectedEnvironmentId; |
| 86 | 82 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); |
| 87 | 83 | const projectFilterOptions = useMemo( |
| ... | ... | @@ -175,8 +171,6 @@ export function HomeRouteScreen() { |
| 175 | 171 | searchQuery={searchQuery} |
| 176 | 172 | selectedEnvironmentId={selectedEnvironmentId} |
| 177 | 173 | selectedProjectKey={selectedProjectKey} |
| 178 | projectSortOrder={listOptions.projectSortOrder} | |
| 179 | threadSortOrder={listOptions.threadSortOrder} | |
| 180 | 174 | onEnvironmentChange={setSelectedEnvironmentId} |
| 181 | 175 | onProjectChange={setSelectedProjectKey} |
| 182 | 176 | onOpenEnvironments={() => |
| ... | ... | @@ -191,10 +185,8 @@ export function HomeRouteScreen() { |
| 191 | 185 | params: { screen: "Settings" }, |
| 192 | 186 | }) |
| 193 | 187 | } |
| 194 | onProjectSortOrderChange={setProjectSortOrder} | |
| 195 | 188 | onSearchQueryChange={setSearchQuery} |
| 196 | 189 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 197 | onThreadSortOrderChange={setThreadSortOrder} | |
| 198 | 190 | /> |
| 199 | 191 | |
| 200 | 192 | <HomeScreen |
| ... | ... | @@ -225,7 +217,6 @@ export function HomeRouteScreen() { |
| 225 | 217 | params: { screen: "Settings" }, |
| 226 | 218 | }) |
| 227 | 219 | } |
| 228 | onProjectSortOrderChange={setProjectSortOrder} | |
| 229 | 220 | onSearchQueryChange={setSearchQuery} |
| 230 | 221 | onSelectThread={handleSelectThread} |
| 231 | 222 | onSelectPendingTask={openPendingTask} |
| ... | ... | @@ -252,7 +243,6 @@ export function HomeRouteScreen() { |
| 252 | 243 | }); |
| 253 | 244 | }} |
| 254 | 245 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 255 | onThreadSortOrderChange={setThreadSortOrder} | |
| 256 | 246 | pendingTasks={pendingTasks} |
| 257 | 247 | projectGroupingMode={listOptions.projectGroupingMode} |
| 258 | 248 | projects={projects} |
| ... | ... | @@ -262,7 +252,6 @@ export function HomeRouteScreen() { |
| 262 | 252 | selectedEnvironmentId={selectedEnvironmentId} |
| 263 | 253 | selectedProjectKey={selectedProjectKey} |
| 264 | 254 | threads={threads} |
| 265 | threadSortOrder={listOptions.threadSortOrder} | |
| 266 | 255 | /> |
| 267 | 256 | </> |
| 268 | 257 | </AndroidHomeFabLayout> |
apps/mobile/src/features/home/HomeScreen.tsx+36-424| ... | ... | @@ -1,10 +1,6 @@ |
| 1 | 1 | import type { ThreadMoveDestination } from "../threads/threadOrder"; |
| 2 | 2 | import { computeThreadMoveAvailability } from "../threads/threadOrder"; |
| 3 | import { | |
| 4 | LegendList, | |
| 5 | type LegendListRef, | |
| 6 | type LegendListRenderItemProps, | |
| 7 | } from "@legendapp/list/react-native"; | |
| 3 | import { LegendList } from "@legendapp/list/react-native"; | |
| 8 | 4 | import { |
| 9 | 5 | type EnvironmentProject, |
| 10 | 6 | type EnvironmentThreadShell, |
| ... | ... | @@ -17,10 +13,8 @@ import { |
| 17 | 13 | type EnvironmentId, |
| 18 | 14 | resolveEnvironmentMachineKind, |
| 19 | 15 | type SidebarProjectGroupingMode, |
| 20 | type SidebarThreadSortOrder, | |
| 21 | 16 | } from "@t3tools/contracts"; |
| 22 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 23 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 17 | import { useAtomValue } from "@effect/atom-react"; | |
| 24 | 18 | import { useFocusEffect } from "@react-navigation/native"; |
| 25 | 19 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 26 | 20 | import { ActivityIndicator, Platform, View } from "react-native"; |
| ... | ... | @@ -34,20 +28,12 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace |
| 34 | 28 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 35 | 29 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 36 | 30 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 37 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | |
| 38 | 31 | import { useThreadSearch } from "../../state/queries"; |
| 39 | 32 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; |
| 40 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | |
| 41 | 33 | import { usePendingThreadOrder } from "../../state/thread-order"; |
| 42 | 34 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 43 | 35 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 44 | 36 | import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; |
| 45 | import { | |
| 46 | PendingTaskListRow, | |
| 47 | ThreadListGroupHeader, | |
| 48 | ThreadListRow, | |
| 49 | ThreadListShowMoreRow, | |
| 50 | } from "../threads/thread-list-items"; | |
| 51 | 37 | import { |
| 52 | 38 | ThreadListV2PendingRow, |
| 53 | 39 | ThreadListV2Row, |
| ... | ... | @@ -67,19 +53,8 @@ import { |
| 67 | 53 | } from "../threads/threadListV2"; |
| 68 | 54 | import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; |
| 69 | 55 | import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; |
| 70 | import { | |
| 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"; | |
| 80 | 56 | import { |
| 81 | 57 | buildHomeProjectScopes, |
| 82 | buildHomeThreadGroups, | |
| 83 | 58 | sortHomeProjectScopes, |
| 84 | 59 | type HomeProjectSortOrder, |
| 85 | 60 | } from "./homeThreadList"; |
| ... | ... | @@ -101,13 +76,10 @@ interface HomeScreenProps { |
| 101 | 76 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 102 | 77 | readonly selectedProjectKey: string | null; |
| 103 | 78 | readonly projectSortOrder: HomeProjectSortOrder; |
| 104 | readonly threadSortOrder: SidebarThreadSortOrder; | |
| 105 | 79 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 106 | 80 | readonly onSearchQueryChange: (query: string) => void; |
| 107 | 81 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; |
| 108 | 82 | readonly onProjectChange: (projectKey: string | null) => void; |
| 109 | readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; | |
| 110 | readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | |
| 111 | 83 | readonly onAddConnection: () => void; |
| 112 | 84 | readonly onOpenSettings: () => void; |
| 113 | 85 | readonly onStartNewTask: () => void; |
| ... | ... | @@ -138,7 +110,6 @@ interface HomeScreenProps { |
| 138 | 110 | |
| 139 | 111 | /* ─── Layout constants ───────────────────────────────────────────────── */ |
| 140 | 112 | |
| 141 | const ESTIMATED_THREAD_ROW_HEIGHT = 72; | |
| 142 | 113 | // v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards |
| 143 | 114 | // measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line |
| 144 | 115 | // cards ~94dp. The estimate seeds the recycler's initial container count, |
| ... | ... | @@ -236,15 +207,8 @@ function HomeTopContentSpacer() { |
| 236 | 207 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 237 | 208 | |
| 238 | 209 | export 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 | 210 | const queuedThreadKeys = useQueuedThreadKeys(); |
| 245 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | |
| 246 | 211 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 247 | const listRef = useRef<LegendListRef | null>(null); | |
| 248 | 212 | const insets = useSafeAreaInsets(); |
| 249 | 213 | const iosBottomToolbarClearance = |
| 250 | 214 | Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED |
| ... | ... | @@ -279,42 +243,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 279 | 243 | () => new Set(threadSearch.matches.map(threadSearchMatchKey)), |
| 280 | 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 | 246 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { |
| 319 | 247 | if (openSwipeableRef.current !== methods) { |
| 320 | 248 | openSwipeableRef.current?.close(); |
| ... | ... | @@ -346,112 +274,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 346 | 274 | }), |
| 347 | 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 | 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 | 278 | const projectByKey = useMemo(() => { |
| 456 | 279 | const map = new Map<string, EnvironmentProject>(); |
| 457 | 280 | for (const project of props.projects) { |
| ... | ... | @@ -597,12 +420,11 @@ export function HomeScreen(props: HomeScreenProps) { |
| 597 | 420 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); |
| 598 | 421 | useFocusEffect( |
| 599 | 422 | useCallback(() => { |
| 600 | if (!threadListV2Enabled) return; | |
| 601 | 423 | // Refresh immediately on enable or focus because the previous value can be hours old. |
| 602 | 424 | setNowMinute(new Date().toISOString().slice(0, 16)); |
| 603 | 425 | const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); |
| 604 | 426 | return () => clearInterval(id); |
| 605 | }, [threadListV2Enabled]), | |
| 427 | }, []), | |
| 606 | 428 | ); |
| 607 | 429 | // Threads on servers without the settlement capability never classify as |
| 608 | 430 | // settled (the user could neither un-settle nor pin them). |
| ... | ... | @@ -715,16 +537,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 715 | 537 | snoozeWakeTick, |
| 716 | 538 | ]); |
| 717 | 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 | 540 | // Settled threads are live shells; archived threads keep their original |
| 729 | 541 | // "hidden from lists" meaning. |
| 730 | 542 | return buildThreadListV2Items({ |
| ... | ... | @@ -757,7 +569,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 757 | 569 | props.selectedEnvironmentId, |
| 758 | 570 | props.threads, |
| 759 | 571 | matchedThreadKeys, |
| 760 | threadListV2Enabled, | |
| 761 | 572 | v2ScopedProjectGroup, |
| 762 | 573 | ]); |
| 763 | 574 | // Re-partition the moment the earliest snooze expires (clamped to the |
| ... | ... | @@ -824,10 +635,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 824 | 635 | ], |
| 825 | 636 | ); |
| 826 | 637 | |
| 827 | useThreadJumpShortcuts( | |
| 828 | threadListV2Enabled ? threadListV2Items : listLayout.items, | |
| 829 | props.onSelectThread, | |
| 830 | ); | |
| 638 | useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); | |
| 831 | 639 | |
| 832 | 640 | const renderV2Item = useCallback( |
| 833 | 641 | ({ item }: { readonly item: ThreadListV2ListItem }) => { |
| ... | ... | @@ -996,126 +804,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 996 | 804 | ], |
| 997 | 805 | ); |
| 998 | 806 | |
| 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 | 807 | /* Empty states */ |
| 1112 | 808 | // The signal must ignore the search/environment filters: an active query |
| 1113 | 809 | // that matches nothing needs the in-list "No results" state, not the |
| 1114 | 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 | 812 | const hasAnyThreads = |
| 1117 | 813 | props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; |
| 1118 | const hasResults = threadListV2Enabled ? threadListV2Items.length > 0 : projectGroups.length > 0; | |
| 1119 | 814 | const selectedEnvironmentLabel = |
| 1120 | 815 | props.selectedEnvironmentId === null |
| 1121 | 816 | ? null |
| ... | ... | @@ -1178,16 +873,18 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1178 | 873 | // mobile — the menu is the one filter surface). |
| 1179 | 874 | const v2ListHeader = listHeader; |
| 1180 | 875 | |
| 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 | 879 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( |
| 1183 | 880 | <EmptyState |
| 1184 | 881 | title="No results" |
| 1185 | 882 | detail={`No threads matching "${props.searchQuery}".`} |
| 1186 | 883 | variant={Platform.OS === "android" ? "plain" : undefined} |
| 1187 | 884 | /> |
| 1188 | ) : selectedProjectScope !== null ? ( | |
| 885 | ) : v2ScopedProjectGroup !== null ? ( | |
| 1189 | 886 | <EmptyState |
| 1190 | title={`No threads in ${selectedProjectScope.title}`} | |
| 887 | title={`No threads in ${v2ScopedProjectGroup.title}`} | |
| 1191 | 888 | detail="Choose another project or create a new task." |
| 1192 | 889 | variant={Platform.OS === "android" ? "plain" : undefined} |
| 1193 | 890 | /> |
| ... | ... | @@ -1203,93 +900,16 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1203 | 900 | detail="Create a task to start a new coding session." |
| 1204 | 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 | ); |
| 1226 | 904 | |
| 1227 | if ( | |
| 1228 | Platform.OS === "android" && | |
| 1229 | (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0) | |
| 1230 | ) { | |
| 905 | if (Platform.OS === "android" && threadListV2Items.length === 0) { | |
| 1231 | 906 | return ( |
| 1232 | 907 | <View className="flex-1 bg-header"> |
| 1233 | 908 | <View |
| 1234 | 909 | className="flex-1 items-center justify-center overflow-hidden rounded-t-[28px] bg-screen px-4" |
| 1235 | 910 | style={{ paddingBottom: insets.bottom }} |
| 1236 | 911 | > |
| 1237 | {threadListV2Enabled ? v2ListEmpty : listEmpty} | |
| 1238 | </View> | |
| 1239 | </View> | |
| 1240 | ); | |
| 1241 | } | |
| 1242 | ||
| 1243 | if (threadListV2Enabled) { | |
| 1244 | return ( | |
| 1245 | <View className="flex-1 bg-screen android:bg-header"> | |
| 1246 | <View | |
| 1247 | className={ | |
| 1248 | Platform.OS === "android" | |
| 1249 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen" | |
| 1250 | : "flex-1 bg-screen" | |
| 1251 | } | |
| 1252 | > | |
| 1253 | {/* Same recycler the iPad sidebar and the legacy list use: cells are | |
| 1254 | reused across data rebuilds and `itemsAreEqual` keeps a minute tick | |
| 1255 | (or an unrelated shell update) from re-rendering untouched rows. */} | |
| 1256 | <SwipeableScrollGateProvider enabled={swipeEnabled}> | |
| 1257 | <LegendList | |
| 1258 | data={threadListV2Items} | |
| 1259 | renderItem={renderV2Item} | |
| 1260 | keyExtractor={v2KeyExtractor} | |
| 1261 | getItemType={(item) => item.type} | |
| 1262 | itemsAreEqual={threadListV2ListItemsAreEqual} | |
| 1263 | estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} | |
| 1264 | drawDistance={500} | |
| 1265 | recycleItems | |
| 1266 | extraData={v2ExtraData} | |
| 1267 | ListHeaderComponent={v2ListHeader} | |
| 1268 | ListFooterComponent={ | |
| 1269 | settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( | |
| 1270 | <ThreadListV2ShowMoreRow | |
| 1271 | hiddenCount={threadListV2Layout.hiddenSettledCount} | |
| 1272 | onPress={showMoreSettled} | |
| 1273 | /> | |
| 1274 | ) : null | |
| 1275 | } | |
| 1276 | ListEmptyComponent={v2ListEmpty} | |
| 1277 | style={{ flex: 1 }} | |
| 1278 | automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} | |
| 1279 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | |
| 1280 | showsVerticalScrollIndicator={false} | |
| 1281 | keyboardDismissMode="on-drag" | |
| 1282 | keyboardShouldPersistTaps="handled" | |
| 1283 | {...scrollGateHandlers} | |
| 1284 | scrollEventThrottle={16} | |
| 1285 | contentContainerStyle={{ | |
| 1286 | paddingBottom: | |
| 1287 | Platform.OS === "ios" | |
| 1288 | ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance | |
| 1289 | : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), | |
| 1290 | }} | |
| 1291 | /> | |
| 1292 | </SwipeableScrollGateProvider> | |
| 912 | {v2ListEmpty} | |
| 1293 | 913 | </View> |
| 1294 | 914 | </View> |
| 1295 | 915 | ); |
| ... | ... | @@ -1304,52 +924,44 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1304 | 924 | : "flex-1 bg-screen" |
| 1305 | 925 | } |
| 1306 | 926 | > |
| 1307 | {/* Sticky headers are deliberately not wired up: LegendList's JS sticky | |
| 1308 | implementation mispositions pinned headers at mount under iOS | |
| 1309 | automatic content insets (headers render one nav-inset too low until | |
| 1310 | the first scroll event) and blanks non-pinned headers after | |
| 1311 | collapse/expand data changes. The flattened layout still exposes | |
| 1312 | `stickyHeaderIndices` if this gets revisited. */} | |
| 927 | {/* Same recycler the iPad sidebar and the legacy list use: cells are | |
| 928 | reused across data rebuilds and `itemsAreEqual` keeps a minute tick | |
| 929 | (or an unrelated shell update) from re-rendering untouched rows. */} | |
| 1313 | 930 | <SwipeableScrollGateProvider enabled={swipeEnabled}> |
| 1314 | 931 | <LegendList |
| 1315 | ref={listRef} | |
| 1316 | data={listLayout.items} | |
| 1317 | renderItem={renderItem} | |
| 1318 | keyExtractor={keyExtractor} | |
| 1319 | itemsAreEqual={homeListItemsAreEqual} | |
| 932 | data={threadListV2Items} | |
| 933 | renderItem={renderV2Item} | |
| 934 | keyExtractor={v2KeyExtractor} | |
| 935 | getItemType={(item) => item.type} | |
| 936 | itemsAreEqual={threadListV2ListItemsAreEqual} | |
| 937 | estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} | |
| 1320 | 938 | drawDistance={500} |
| 1321 | estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} | |
| 1322 | extraData={extraData} | |
| 1323 | ListHeaderComponent={listHeader} | |
| 1324 | ListEmptyComponent={listEmpty} | |
| 939 | recycleItems | |
| 940 | extraData={v2ExtraData} | |
| 941 | ListHeaderComponent={v2ListHeader} | |
| 942 | ListFooterComponent={ | |
| 943 | settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( | |
| 944 | <ThreadListV2ShowMoreRow | |
| 945 | hiddenCount={threadListV2Layout.hiddenSettledCount} | |
| 946 | onPress={showMoreSettled} | |
| 947 | /> | |
| 948 | ) : null | |
| 949 | } | |
| 950 | ListEmptyComponent={v2ListEmpty} | |
| 1325 | 951 | style={{ flex: 1 }} |
| 1326 | automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} | |
| 1327 | contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} | |
| 952 | automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} | |
| 953 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | |
| 1328 | 954 | showsVerticalScrollIndicator={false} |
| 1329 | 955 | keyboardDismissMode="on-drag" |
| 1330 | 956 | keyboardShouldPersistTaps="handled" |
| 1331 | 957 | {...scrollGateHandlers} |
| 1332 | recycleItems | |
| 1333 | 958 | scrollEventThrottle={16} |
| 1334 | 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 | 960 | paddingBottom: |
| 1341 | 961 | Platform.OS === "ios" |
| 1342 | ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance | |
| 962 | ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance | |
| 1343 | 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 | 966 | </SwipeableScrollGateProvider> |
| 1355 | 967 | </View> |
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx+1-1| ... | ... | @@ -130,7 +130,7 @@ export function MaterialThreadListToolbar(props: { |
| 130 | 130 | <AndroidAnchoredMenu actions={props.filterActions} onPressAction={props.onFilterAction}> |
| 131 | 131 | {(open) => ( |
| 132 | 132 | <MaterialFloatingActionButton |
| 133 | label="Filter and sort threads" | |
| 133 | label="Filter threads" | |
| 134 | 134 | icon={filterIcon} |
| 135 | 135 | onPress={open} |
| 136 | 136 | /> |
apps/mobile/src/features/home/home-list-filter-menu.test.ts-4| ... | ... | @@ -13,12 +13,8 @@ describe("buildHomeListFilterMenu", () => { |
| 13 | 13 | ], |
| 14 | 14 | selectedEnvironmentId: null, |
| 15 | 15 | selectedProjectKey: "environment-1:project-1", |
| 16 | projectSortOrder: "updated_at", | |
| 17 | threadSortOrder: "updated_at", | |
| 18 | 16 | onEnvironmentChange: vi.fn(), |
| 19 | 17 | onProjectChange, |
| 20 | onProjectSortOrderChange: vi.fn(), | |
| 21 | onThreadSortOrderChange: vi.fn(), | |
| 22 | 18 | }); |
| 23 | 19 | |
| 24 | 20 | const projectMenu = menu.items.find( |
apps/mobile/src/features/home/home-list-filter-menu.ts+1-37| ... | ... | @@ -1,7 +1,4 @@ |
| 1 | import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import type { HomeProjectSortOrder } from "./homeThreadList"; | |
| 4 | import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; | |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 5 | 2 | |
| 6 | 3 | export interface HomeListFilterMenuEnvironment { |
| 7 | 4 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -37,16 +34,8 @@ export function buildHomeListFilterMenu(props: { |
| 37 | 34 | readonly projects: ReadonlyArray<HomeListFilterMenuProject>; |
| 38 | 35 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 39 | 36 | readonly selectedProjectKey: string | null; |
| 40 | readonly projectSortOrder: HomeProjectSortOrder; | |
| 41 | readonly threadSortOrder: SidebarThreadSortOrder; | |
| 42 | 37 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; |
| 43 | 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 | 39 | }): HomeListFilterMenu { |
| 51 | 40 | const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = []; |
| 52 | 41 | |
| ... | ... | @@ -95,31 +84,6 @@ export function buildHomeListFilterMenu(props: { |
| 95 | 84 | }); |
| 96 | 85 | } |
| 97 | 86 | |
| 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 | 87 | return { |
| 124 | 88 | title: "Thread list options", |
| 125 | 89 | items, |
apps/mobile/src/features/home/home-list-options.test.ts deleted-31| ... | ... | @@ -1,31 +0,0 @@ |
| 1 | import { | |
| 2 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | |
| 3 | DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | |
| 4 | } from "@t3tools/contracts"; | |
| 5 | import { describe, expect, it } from "vite-plus/test"; | |
| 6 | ||
| 7 | import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options"; | |
| 8 | ||
| 9 | const 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 | ||
| 18 | describe("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 | 31 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | export 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 | ||
| 42 | export 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 | ||
| 50 | 34 | function defaultHomeListOptions(): HomeListOptions { |
| 51 | 35 | return { |
| 52 | 36 | selectedEnvironmentId: null, |
| ... | ... | @@ -81,23 +65,6 @@ export function HomeListOptionsProvider({ |
| 81 | 65 | return createElement(HomeListOptionsContext, { value }, children); |
| 82 | 66 | } |
| 83 | 67 | |
| 84 | export 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 | ||
| 101 | 68 | export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) { |
| 102 | 69 | const shared = useContext(HomeListOptionsContext); |
| 103 | 70 | const [localOptions, setLocalOptions] = useState<HomeListOptions>(defaultHomeListOptions); |
apps/mobile/src/features/home/homeListItems.test.ts deleted-266| ... | ... | @@ -1,266 +0,0 @@ |
| 1 | import type { | |
| 2 | EnvironmentProject, | |
| 3 | EnvironmentThreadShell, | |
| 4 | } from "@t3tools/client-runtime/state/shell"; | |
| 5 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 6 | import { describe, expect, it } from "vite-plus/test"; | |
| 7 | ||
| 8 | import { | |
| 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"; | |
| 17 | import type { HomeThreadGroup } from "./homeThreadList"; | |
| 18 | import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; | |
| 19 | ||
| 20 | const environmentId = EnvironmentId.make("environment-1"); | |
| 21 | ||
| 22 | function 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 | ||
| 36 | function 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 | ||
| 62 | function 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 | ||
| 81 | function itemTypes(items: ReadonlyArray<HomeListItem>): string[] { | |
| 82 | return items.map((item) => item.type); | |
| 83 | } | |
| 84 | ||
| 85 | function displayStates( | |
| 86 | entries: Record<string, HomeGroupDisplayState>, | |
| 87 | ): ReadonlyMap<string, HomeGroupDisplayState> { | |
| 88 | return new Map(Object.entries(entries)); | |
| 89 | } | |
| 90 | ||
| 91 | describe("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 | ||
| 113 | describe("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 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 2 | ||
| 3 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | |
| 4 | import type { HomeThreadGroup } from "./homeThreadList"; | |
| 5 | ||
| 6 | /** Threads shown per project before the "Show more" affordance appears. */ | |
| 7 | export const HOME_INITIAL_VISIBLE_THREADS = 6; | |
| 8 | /** Additional threads revealed per "Show more" tap. */ | |
| 9 | export const HOME_SHOW_MORE_STEP = 10; | |
| 10 | ||
| 11 | export interface HomeGroupDisplayState { | |
| 12 | readonly collapsed: boolean; | |
| 13 | /** How many threads are currently revealed (clamped to the group size). */ | |
| 14 | readonly visibleCount: number; | |
| 15 | } | |
| 16 | ||
| 17 | export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = { | |
| 18 | collapsed: false, | |
| 19 | visibleCount: HOME_INITIAL_VISIBLE_THREADS, | |
| 20 | }; | |
| 21 | ||
| 22 | export interface HomeHeaderListItem { | |
| 23 | readonly type: "header"; | |
| 24 | readonly key: string; | |
| 25 | readonly group: HomeThreadGroup; | |
| 26 | readonly collapsed: boolean; | |
| 27 | readonly isFirst: boolean; | |
| 28 | } | |
| 29 | ||
| 30 | export interface HomeThreadListItem { | |
| 31 | readonly type: "thread"; | |
| 32 | readonly key: string; | |
| 33 | readonly thread: EnvironmentThreadShell; | |
| 34 | readonly isLast: boolean; | |
| 35 | } | |
| 36 | ||
| 37 | export interface HomePendingTaskListItem { | |
| 38 | readonly type: "pending-task"; | |
| 39 | readonly key: string; | |
| 40 | readonly pendingTask: PendingNewTask; | |
| 41 | readonly isLast: boolean; | |
| 42 | } | |
| 43 | ||
| 44 | export 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 | ||
| 54 | export type HomeListItem = | |
| 55 | | HomeHeaderListItem | |
| 56 | | HomePendingTaskListItem | |
| 57 | | HomeThreadListItem | |
| 58 | | HomeShowMoreListItem; | |
| 59 | ||
| 60 | export interface HomeListLayout { | |
| 61 | readonly items: ReadonlyArray<HomeListItem>; | |
| 62 | readonly stickyHeaderIndices: ReadonlyArray<number>; | |
| 63 | } | |
| 64 | ||
| 65 | export const EMPTY_HOME_LIST_LAYOUT: HomeListLayout = { items: [], stickyHeaderIndices: [] }; | |
| 66 | ||
| 67 | export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less"; | |
| 68 | ||
| 69 | export 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 | */ | |
| 90 | export 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 | ||
| 121 | export 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 | 6 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 7 | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 8 | 8 | |
| 9 | import { | |
| 10 | buildHomeProjectScopes, | |
| 11 | buildHomeThreadGroups, | |
| 12 | sortHomeProjectScopes, | |
| 13 | } from "./homeThreadList"; | |
| 9 | import { buildHomeProjectScopes, sortHomeProjectScopes } from "./homeThreadList"; | |
| 14 | 10 | |
| 15 | 11 | function makeProject( |
| 16 | 12 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, |
| ... | ... | @@ -54,25 +50,7 @@ function makeThread( |
| 54 | 50 | |
| 55 | 51 | const NOW = Date.parse("2026-06-29T00:00:00.000Z"); |
| 56 | 52 | |
| 57 | function buildGroups( | |
| 58 | projects: ReadonlyArray<EnvironmentProject>, | |
| 59 | threads: ReadonlyArray<EnvironmentThreadShell>, | |
| 60 | overrides: Partial<Parameters<typeof buildHomeThreadGroups>[0]> = {}, | |
| 61 | ) { | |
| 62 | return buildHomeThreadGroups({ | |
| 63 | projects, | |
| 64 | threads, | |
| 65 | environmentId: null, | |
| 66 | searchQuery: "", | |
| 67 | projectSortOrder: "updated_at", | |
| 68 | threadSortOrder: "updated_at", | |
| 69 | projectGroupingMode: "repository", | |
| 70 | now: NOW, | |
| 71 | ...overrides, | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | describe("buildHomeThreadGroups", () => { | |
| 53 | describe("home project scopes", () => { | |
| 76 | 54 | it("builds one v2 scope for the same repository across environments", () => { |
| 77 | 55 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 78 | 56 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); |
| ... | ... | @@ -116,70 +94,6 @@ describe("buildHomeThreadGroups", () => { |
| 116 | 94 | ); |
| 117 | 95 | }); |
| 118 | 96 | |
| 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 | 97 | it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => { |
| 184 | 98 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 185 | 99 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); |
| ... | ... | @@ -375,410 +289,4 @@ describe("buildHomeThreadGroups", () => { |
| 375 | 289 | }), |
| 376 | 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 | 141 | const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000; |
| 142 | 142 | /** Fallback when a project has no threads inside the recency window. */ |
| 143 | 143 | const RECENT_THREAD_FALLBACK_COUNT = 3; |
| 144 | ||
| 145 | export 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 | ||
| 166 | interface MutableHomeThreadGroup { | |
| 167 | readonly key: string; | |
| 168 | readonly projects: EnvironmentProject[]; | |
| 169 | readonly pendingTasks: PendingNewTask[]; | |
| 170 | readonly threads: EnvironmentThreadShell[]; | |
| 171 | } | |
| 172 | ||
| 173 | function 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 | */ | |
| 190 | function 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 | ||
| 207 | export 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 | 2 | import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; |
| 3 | 3 | import { useCallback } from "react"; |
| 4 | 4 | |
| 5 | import type { HomeListItem } from "../home/homeListItems"; | |
| 6 | 5 | import type { ThreadListV2ListItem } from "../threads/threadListV2"; |
| 7 | 6 | import { |
| 8 | 7 | useHardwareKeyboardCommand, |
| 9 | 8 | type HardwareKeyboardCommand, |
| 10 | 9 | } from "./hardwareKeyboardCommands"; |
| 11 | 10 | |
| 12 | type ThreadShortcutListItem = | |
| 13 | | HomeListItem | |
| 14 | | ThreadListV2ListItem | |
| 15 | | { readonly type: "v2-show-more" }; | |
| 11 | type ThreadShortcutListItem = ThreadListV2ListItem | { readonly type: "v2-show-more" }; | |
| 16 | 12 | |
| 17 | 13 | export function threadJumpIndex(command: HardwareKeyboardCommand) { |
| 18 | 14 | return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command); |
| 19 | 15 | } |
| 20 | 16 | |
| 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. */ | |
| 22 | 18 | export function threadJumpTarget( |
| 23 | 19 | items: ReadonlyArray<ThreadShortcutListItem>, |
| 24 | 20 | command: HardwareKeyboardCommand, |
| ... | ... | @@ -26,8 +22,7 @@ export function threadJumpTarget( |
| 26 | 22 | let index = threadJumpIndex(command); |
| 27 | 23 | if (index < 0) return null; |
| 28 | 24 | for (const item of items) { |
| 29 | const thread = | |
| 30 | item.type === "thread" ? item.thread : item.type === "v2-thread" ? item.item.thread : null; | |
| 25 | const thread = item.type === "v2-thread" ? item.item.thread : null; | |
| 31 | 26 | if (thread !== null && index-- === 0) return thread; |
| 32 | 27 | } |
| 33 | 28 | return null; |
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx-8| ... | ... | @@ -12,7 +12,6 @@ import { AppText as Text } from "../../components/AppText"; |
| 12 | 12 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 13 | 13 | import { serverEnvironment } from "../../state/server"; |
| 14 | 14 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 15 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | |
| 16 | 15 | import { SettingsSection } from "./components/SettingsSection"; |
| 17 | 16 | import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; |
| 18 | 17 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; |
| ... | ... | @@ -224,19 +223,12 @@ function AutoSettleSettingsRows() { |
| 224 | 223 | function LegacySettingsSection() { |
| 225 | 224 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 226 | 225 | const preferences = useAtomValue(mobilePreferencesAtom); |
| 227 | const threadListV2Enabled = useThreadListV2Enabled(); | |
| 228 | 226 | const planModeEnabled = |
| 229 | 227 | AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; |
| 230 | 228 | |
| 231 | 229 | return ( |
| 232 | 230 | <View className="gap-3"> |
| 233 | 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 | 232 | <SettingsSwitchRow |
| 241 | 233 | icon="hammer" |
| 242 | 234 | label="Plan Mode" |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+15-295| ... | ... | @@ -29,7 +29,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 29 | 29 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 30 | 30 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 31 | 31 | import { useThreadSearch } from "../../state/queries"; |
| 32 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; | |
| 33 | 32 | import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; |
| 34 | 33 | import { usePendingThreadOrder } from "../../state/thread-order"; |
| 35 | 34 | import { environmentServerConfigsAtom } from "../../state/server"; |
| ... | ... | @@ -39,24 +38,9 @@ import { useWorkspaceState } from "../../state/workspace"; |
| 39 | 38 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 40 | 39 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 41 | 40 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; |
| 42 | import { | |
| 43 | hasCustomHomeListOptions, | |
| 44 | PROJECT_SORT_OPTIONS, | |
| 45 | THREAD_SORT_OPTIONS, | |
| 46 | useHomeListOptions, | |
| 47 | } from "../home/home-list-options"; | |
| 41 | import { useHomeListOptions } from "../home/home-list-options"; | |
| 48 | 42 | import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; |
| 49 | import { | |
| 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"; | |
| 59 | import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; | |
| 43 | import { buildHomeProjectScopes } from "../home/homeThreadList"; | |
| 60 | 44 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; |
| 61 | 45 | import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; |
| 62 | 46 | import { useThreadListActions } from "../home/useThreadListActions"; |
| ... | ... | @@ -71,12 +55,6 @@ import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; |
| 71 | 55 | import { SidebarFilterButton } from "./sidebar-filter-button"; |
| 72 | 56 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; |
| 73 | 57 | import { SidebarNavigationShell } from "./sidebar-navigation-shell"; |
| 74 | import { | |
| 75 | PendingTaskListRow, | |
| 76 | ThreadListGroupHeader, | |
| 77 | ThreadListRow, | |
| 78 | ThreadListShowMoreRow, | |
| 79 | } from "./thread-list-items"; | |
| 80 | 58 | import { |
| 81 | 59 | ThreadListV2PendingRow, |
| 82 | 60 | ThreadListV2Row, |
| ... | ... | @@ -96,11 +74,9 @@ import { |
| 96 | 74 | type ThreadListV2ListItem, |
| 97 | 75 | } from "./threadListV2"; |
| 98 | 76 | |
| 99 | /** The sidebar list serves both lists: v1 grouped items or, when the Thread | |
| 100 | List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled | |
| 101 | "Show more" pager. */ | |
| 77 | /** The sidebar list: flat v2 rows with queued tasks spliced in, plus a | |
| 78 | settled "Show more" pager row. */ | |
| 102 | 79 | type SidebarListItem = |
| 103 | | HomeListItem | |
| 104 | 80 | | ThreadListV2ListItem |
| 105 | 81 | | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; |
| 106 | 82 | |
| ... | ... | @@ -178,7 +154,6 @@ function ThreadNavigationSidebarPane( |
| 178 | 154 | renameThread, |
| 179 | 155 | regenerateThreadTitle, |
| 180 | 156 | } = useThreadListActions(); |
| 181 | const threadListV2Enabled = useThreadListV2Enabled(); | |
| 182 | 157 | const pendingTasks = usePendingNewTasks(); |
| 183 | 158 | const queuedThreadKeys = useQueuedThreadKeys(); |
| 184 | 159 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); |
| ... | ... | @@ -196,8 +171,7 @@ function ThreadNavigationSidebarPane( |
| 196 | 171 | () => new Set(environments.map((environment) => environment.environmentId)), |
| 197 | 172 | [environments], |
| 198 | 173 | ); |
| 199 | const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } = | |
| 200 | useHomeListOptions(availableEnvironmentIds); | |
| 174 | const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds); | |
| 201 | 175 | const searchEnvironmentIds = useMemo( |
| 202 | 176 | () => |
| 203 | 177 | options.selectedEnvironmentId === null |
| ... | ... | @@ -286,93 +260,6 @@ function ThreadNavigationSidebarPane( |
| 286 | 260 | ), |
| 287 | 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 | 263 | const projectByKey = useMemo(() => { |
| 377 | 264 | const map = new Map<string, EnvironmentProject>(); |
| 378 | 265 | for (const project of projects) { |
| ... | ... | @@ -412,12 +299,11 @@ function ThreadNavigationSidebarPane( |
| 412 | 299 | // thread reappears immediately instead of on the next minute tick. |
| 413 | 300 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); |
| 414 | 301 | useEffect(() => { |
| 415 | if (!threadListV2Enabled) return; | |
| 416 | 302 | // Refresh immediately because the mount-time value can be hours old. |
| 417 | 303 | setNowMinute(new Date().toISOString().slice(0, 16)); |
| 418 | 304 | const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); |
| 419 | 305 | return () => clearInterval(id); |
| 420 | }, [threadListV2Enabled]); | |
| 306 | }, []); | |
| 421 | 307 | // Threads on servers without the settlement capability never classify as |
| 422 | 308 | // settled (the user could neither un-settle nor pin them). |
| 423 | 309 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); |
| ... | ... | @@ -529,16 +415,6 @@ function ThreadNavigationSidebarPane( |
| 529 | 415 | snoozeWakeTick, |
| 530 | 416 | ]); |
| 531 | 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 | 418 | return buildThreadListV2Items({ |
| 543 | 419 | pendingOrder, |
| 544 | 420 | threads: threads.filter((thread) => thread.archivedAt === null), |
| ... | ... | @@ -569,7 +445,6 @@ function ThreadNavigationSidebarPane( |
| 569 | 445 | settledVisibleCount, |
| 570 | 446 | settlementEnvironmentIds, |
| 571 | 447 | snoozeEnvironmentIds, |
| 572 | threadListV2Enabled, | |
| 573 | 448 | threads, |
| 574 | 449 | selectedProjectScope, |
| 575 | 450 | ]); |
| ... | ... | @@ -588,7 +463,6 @@ function ThreadNavigationSidebarPane( |
| 588 | 463 | // range) the boundary string is identical and the chain would die. |
| 589 | 464 | }, [nextSnoozeWakeAt, snoozeWakeTick]); |
| 590 | 465 | const listItems = useMemo<readonly SidebarListItem[]>(() => { |
| 591 | if (!threadListV2Enabled) return listLayout.items; | |
| 592 | 466 | // Queued offline tasks are not thread shells, so the v2 item builder |
| 593 | 467 | // never sees them; the shared splice puts them below the active block |
| 594 | 468 | // (mirrors the compact Home v2 list) where they stay visible and |
| ... | ... | @@ -630,7 +504,6 @@ function ThreadNavigationSidebarPane( |
| 630 | 504 | } |
| 631 | 505 | return items; |
| 632 | 506 | }, [ |
| 633 | listLayout.items, | |
| 634 | 507 | nowMinute, |
| 635 | 508 | options.selectedEnvironmentId, |
| 636 | 509 | pendingTasks, |
| ... | ... | @@ -642,7 +515,6 @@ function ThreadNavigationSidebarPane( |
| 642 | 515 | shelfPreferencesLoaded, |
| 643 | 516 | snoozedShelfExpanded, |
| 644 | 517 | snoozeEnvironmentIds, |
| 645 | threadListV2Enabled, | |
| 646 | 518 | threadListV2Layout, |
| 647 | 519 | ]); |
| 648 | 520 | const listMenuActions = useMemo<MenuAction[]>( |
| ... | ... | @@ -688,33 +560,8 @@ function ThreadNavigationSidebarPane( |
| 688 | 560 | ], |
| 689 | 561 | }, |
| 690 | 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 | 566 | const handleListMenuAction = useCallback( |
| 720 | 567 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { |
| ... | ... | @@ -741,28 +588,8 @@ function ThreadNavigationSidebarPane( |
| 741 | 588 | } |
| 742 | 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 | [ | |
| 760 | environments, | |
| 761 | projectFilterOptions, | |
| 762 | setProjectSortOrder, | |
| 763 | setSelectedEnvironmentId, | |
| 764 | setThreadSortOrder, | |
| 765 | ], | |
| 592 | [environments, projectFilterOptions, setSelectedEnvironmentId], | |
| 766 | 593 | ); |
| 767 | 594 | |
| 768 | 595 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); |
| ... | ... | @@ -838,15 +665,7 @@ function ThreadNavigationSidebarPane( |
| 838 | 665 | if (previous.type === "v2-show-more" && item.type === "v2-show-more") { |
| 839 | 666 | return previous.hiddenCount === item.hiddenCount; |
| 840 | 667 | } |
| 841 | if ( | |
| 842 | isThreadListV2ListItem(previous) || | |
| 843 | previous.type === "v2-show-more" || | |
| 844 | isThreadListV2ListItem(item) || | |
| 845 | item.type === "v2-show-more" | |
| 846 | ) { | |
| 847 | return false; | |
| 848 | } | |
| 849 | return homeListItemsAreEqual(previous, item); | |
| 668 | return false; | |
| 850 | 669 | }, |
| 851 | 670 | [], |
| 852 | 671 | ); |
| ... | ... | @@ -994,90 +813,11 @@ function ThreadNavigationSidebarPane( |
| 994 | 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 | 819 | archiveThread, |
| 1079 | 820 | activeReorderEnvironmentIds, |
| 1080 | queuedThreadKeys, | |
| 1081 | 821 | confirmDeletePendingTask, |
| 1082 | 822 | confirmDeleteThread, |
| 1083 | 823 | handleSelectThread, |
| ... | ... | @@ -1093,13 +833,13 @@ function ThreadNavigationSidebarPane( |
| 1093 | 833 | projectTitleByProjectKey, |
| 1094 | 834 | regenerateThreadTitle, |
| 1095 | 835 | renameThread, |
| 836 | threadSearchMatchByKey, | |
| 1096 | 837 | props.onNewThreadInProject, |
| 1097 | 838 | props.onNewThreadOnBranch, |
| 1098 | 839 | props.searchQuery, |
| 1099 | 840 | props.selectedThreadKey, |
| 1100 | 841 | props.width, |
| 1101 | 842 | savedConnectionsById, |
| 1102 | threadSearchMatchByKey, | |
| 1103 | 843 | titleRegenerationEnvironmentIds, |
| 1104 | 844 | settleThread, |
| 1105 | 845 | settlementEnvironmentIds, |
| ... | ... | @@ -1113,14 +853,11 @@ function ThreadNavigationSidebarPane( |
| 1113 | 853 | unpinThread, |
| 1114 | 854 | unsettleThread, |
| 1115 | 855 | unsnoozeThread, |
| 1116 | updateGroupDisplay, | |
| 1117 | 856 | ], |
| 1118 | 857 | ); |
| 1119 | // v2 ignores the sort/group options, so only the environment filter can | |
| 1120 | // light the "customized" state while the beta is on. | |
| 1121 | const filterCustomized = threadListV2Enabled | |
| 1122 | ? options.selectedEnvironmentId !== null || selectedProjectKey !== null | |
| 1123 | : hasCustomHomeListOptions({ ...options, selectedProjectKey }); | |
| 858 | // The list ignores sort/group options, so only the environment and project | |
| 859 | // filters can light the "customized" state. | |
| 860 | const filterCustomized = options.selectedEnvironmentId !== null || selectedProjectKey !== null; | |
| 1124 | 861 | const filterIcon = filterCustomized |
| 1125 | 862 | ? "line.3.horizontal.decrease.circle.fill" |
| 1126 | 863 | : "line.3.horizontal.decrease.circle"; |
| ... | ... | @@ -1131,24 +868,10 @@ function ThreadNavigationSidebarPane( |
| 1131 | 868 | projects: projectFilterOptions, |
| 1132 | 869 | selectedEnvironmentId: options.selectedEnvironmentId, |
| 1133 | 870 | selectedProjectKey, |
| 1134 | projectSortOrder: options.projectSortOrder, | |
| 1135 | threadSortOrder: options.threadSortOrder, | |
| 1136 | 871 | onEnvironmentChange: setSelectedEnvironmentId, |
| 1137 | 872 | onProjectChange: setSelectedProjectKey, |
| 1138 | onProjectSortOrderChange: setProjectSortOrder, | |
| 1139 | onThreadSortOrderChange: setThreadSortOrder, | |
| 1140 | listOrganization: !threadListV2Enabled, | |
| 1141 | 873 | }), |
| 1142 | [ | |
| 1143 | environments, | |
| 1144 | options, | |
| 1145 | projectFilterOptions, | |
| 1146 | selectedProjectKey, | |
| 1147 | setProjectSortOrder, | |
| 1148 | setSelectedEnvironmentId, | |
| 1149 | setThreadSortOrder, | |
| 1150 | threadListV2Enabled, | |
| 1151 | ], | |
| 874 | [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId], | |
| 1152 | 875 | ); |
| 1153 | 876 | const nativeHeaderItems = useMemo( |
| 1154 | 877 | () => |
| ... | ... | @@ -1358,10 +1081,7 @@ function ThreadNavigationSidebarPane( |
| 1358 | 1081 | /> |
| 1359 | 1082 | <View className="flex-row items-center gap-2.5"> |
| 1360 | 1083 | <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}> |
| 1361 | <SidebarFilterButton | |
| 1362 | accessibilityLabel="Filter and sort threads" | |
| 1363 | icon={filterIcon} | |
| 1364 | /> | |
| 1084 | <SidebarFilterButton accessibilityLabel="Filter threads" icon={filterIcon} /> | |
| 1365 | 1085 | </ControlPillMenu> |
| 1366 | 1086 | <SidebarHeaderActions onOpenSettings={props.onOpenSettings} /> |
| 1367 | 1087 | </View> |
apps/mobile/src/features/threads/sidebar-native-header-items.ts+1-1| ... | ... | @@ -45,7 +45,7 @@ export function createSidebarHeaderItems(input: { |
| 45 | 45 | withNativeGlassHeaderItem({ |
| 46 | 46 | type: "menu", |
| 47 | 47 | label: "", |
| 48 | accessibilityLabel: "Filter and sort threads", | |
| 48 | accessibilityLabel: "Filter threads", | |
| 49 | 49 | icon: sfSymbolIcon(input.filterIcon), |
| 50 | 50 | menu: { |
| 51 | 51 | title: input.filterMenu.title, |
apps/mobile/src/features/threads/thread-list-items.tsx deleted-849| ... | ... | @@ -1,849 +0,0 @@ |
| 1 | import type { | |
| 2 | EnvironmentProject, | |
| 3 | EnvironmentThreadShell, | |
| 4 | } from "@t3tools/client-runtime/state/shell"; | |
| 5 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; | |
| 6 | import type { EnvironmentMachineKind } from "@t3tools/contracts"; | |
| 7 | import type { MenuAction } from "@react-native-menu/menu"; | |
| 8 | import { SymbolView } from "../../components/AppSymbol"; | |
| 9 | import { memo, useCallback, useMemo, type ComponentProps } from "react"; | |
| 10 | import { Platform, Pressable, useWindowDimensions, View } from "react-native"; | |
| 11 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | |
| 12 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 13 | import Svg, { Circle, Path } from "react-native-svg"; | |
| 14 | ||
| 15 | import { RowPressable } from "../../components/RowPressable"; | |
| 16 | import { AppText as Text } from "../../components/AppText"; | |
| 17 | import { ControlPillMenu } from "../../components/ControlPill"; | |
| 18 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | |
| 19 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | |
| 20 | import { cn } from "../../lib/cn"; | |
| 21 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | |
| 22 | import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; | |
| 23 | import { relativeTime } from "../../lib/time"; | |
| 24 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | |
| 25 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | |
| 26 | import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr"; | |
| 27 | import type { HomeGroupDisplayAction } from "../home/homeListItems"; | |
| 28 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | |
| 29 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | |
| 30 | import { QueuedMessageIcon } from "./queued-message-icon"; | |
| 31 | import { resolveThreadStatus } from "./threadPresentation"; | |
| 32 | import { 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 | */ | |
| 40 | export type ThreadListVariant = "compact" | "sidebar"; | |
| 41 | ||
| 42 | /** Left inset that aligns compact secondary rows with the title column. */ | |
| 43 | export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; | |
| 44 | const SIDEBAR_ROW_RADIUS = 12; | |
| 45 | ||
| 46 | function 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 | ||
| 69 | function 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 | ||
| 91 | export 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 | ||
| 199 | export 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 | ||
| 272 | const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ | |
| 273 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | |
| 274 | ]; | |
| 275 | ||
| 276 | const 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 | */ | |
| 288 | export 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 | ||
| 469 | const 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 | ||
| 475 | export 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 | 30 | buildThreadListV2ListItems, |
| 31 | 31 | getThreadListV2OrderedSection, |
| 32 | 32 | isThreadListV2ListItem, |
| 33 | resolveThreadListV2Enabled, | |
| 34 | 33 | resolveThreadListV2SnoozeMenuSelection, |
| 35 | 34 | resolveThreadListV2SnoozeGateExpiryMs, |
| 36 | 35 | resolveThreadListV2Status, |
| ... | ... | @@ -125,29 +124,6 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { |
| 125 | 124 | }); |
| 126 | 125 | }); |
| 127 | 126 | |
| 128 | describe("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 | ||
| 151 | 127 | describe("resolveThreadListV2Status", () => { |
| 152 | 128 | it("prioritizes approval over a running session", () => { |
| 153 | 129 | const thread = makeThread({ |
apps/mobile/src/features/threads/threadListV2.ts-20| ... | ... | @@ -116,26 +116,6 @@ export function resolveThreadListV2SnoozeGateExpiryMs( |
| 116 | 116 | export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; |
| 117 | 117 | export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; |
| 118 | 118 | |
| 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 | */ | |
| 129 | export 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 | ||
| 139 | 119 | export function resolveThreadListV2Status( |
| 140 | 120 | thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">, |
| 141 | 121 | ): ThreadListV2Status { |
apps/mobile/src/features/threads/threadPresentation.ts deleted-102| ... | ... | @@ -1,102 +0,0 @@ |
| 1 | import type { StatusTone } from "../../components/StatusPill"; | |
| 2 | import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts"; | |
| 3 | import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 4 | ||
| 5 | export type ThreadStatusKind = | |
| 6 | | "pending-approval" | |
| 7 | | "awaiting-input" | |
| 8 | | "working" | |
| 9 | | "connecting" | |
| 10 | | "error" | |
| 11 | | "plan-ready"; | |
| 12 | ||
| 13 | export interface ThreadStatusPresentation extends StatusTone { | |
| 14 | readonly kind: ThreadStatusKind; | |
| 15 | /** Whether the indicator represents in-flight activity. */ | |
| 16 | readonly pulse: boolean; | |
| 17 | } | |
| 18 | ||
| 19 | function 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 | */ | |
| 34 | export 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 3 | ||
| 4 | import { mobilePreferencesAtom } from "../../state/preferences"; | |
| 5 | import { 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 | */ | |
| 12 | export 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 | 33 | /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ |
| 34 | 34 | readonly projectGroupingEnabled?: boolean; |
| 35 | 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 | 36 | /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ |
| 45 | 37 | readonly planModeEnabled?: boolean; |
| 46 | 38 | /** Model favorites belong to this device, like the web client setting. */ |
| ... | ... | @@ -107,7 +99,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 107 | 99 | composerEnterBehavior?: ComposerEnterBehavior; |
| 108 | 100 | projectGroupingEnabled?: boolean; |
| 109 | 101 | projectGroupingMode?: SidebarProjectGroupingMode; |
| 110 | legacyThreadListEnabled?: boolean; | |
| 111 | 102 | planModeEnabled?: boolean; |
| 112 | 103 | modelFavorites?: Preferences["modelFavorites"]; |
| 113 | 104 | threadListSettledShelfExpanded?: boolean; |
| ... | ... | @@ -176,9 +167,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 176 | 167 | ) { |
| 177 | 168 | preferences.projectGroupingMode = parsed.projectGroupingMode; |
| 178 | 169 | } |
| 179 | if (typeof parsed.legacyThreadListEnabled === "boolean") { | |
| 180 | preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; | |
| 181 | } | |
| 182 | 170 | if (typeof parsed.planModeEnabled === "boolean") { |
| 183 | 171 | preferences.planModeEnabled = parsed.planModeEnabled; |
| 184 | 172 | } |