| 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,13 +1,7 @@ |
| 1 | import type { MenuAction } from "@react-native-menu/menu"; | 1 | import type { MenuAction } from "@react-native-menu/menu"; |
| 2 | import { useCallback, useMemo } from "react"; | 2 | import { useCallback, useMemo } from "react"; |
| 3 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 3 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 4 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | ||
| 5 | import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; | 4 | import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; |
| 6 | import { | ||
| 7 | hasCustomHomeListOptions, | ||
| 8 | PROJECT_SORT_OPTIONS, | ||
| 9 | THREAD_SORT_OPTIONS, | ||
| 10 | } from "./home-list-options"; | ||
| 11 | import type { HomeHeaderProps } from "./HomeHeader.types"; | 5 | import type { HomeHeaderProps } from "./HomeHeader.types"; |
| 12 | 6 | ||
| 13 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; | 7 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| ... | @@ -17,13 +11,11 @@ function checkedMenuState(checked: boolean) { | ... | @@ -17,13 +11,11 @@ function checkedMenuState(checked: boolean) { |
| 17 | } | 11 | } |
| 18 | 12 | ||
| 19 | export function HomeHeader(props: HomeHeaderProps) { | 13 | export function HomeHeader(props: HomeHeaderProps) { |
| 20 | // Thread List v2 lays the list out in fixed creation order, so the | 14 | // The list uses a fixed creation order and ignores sort/group options, so |
| 21 | // sort/group filter controls would be silently ignored — hide them and | 15 | // the filter menu only carries the filters and the "customized" icon state |
| 22 | // key the "customized" icon state off the environment filter alone. | 16 | // keys off those alone. |
| 23 | const threadListV2Enabled = useThreadListV2Enabled(); | 17 | const hasCustomListOptions = |
| 24 | const hasCustomListOptions = threadListV2Enabled | 18 | props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; |
| 25 | ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null | ||
| 26 | : hasCustomHomeListOptions(props); | ||
| 27 | const menuActions = useMemo<MenuAction[]>( | 19 | const menuActions = useMemo<MenuAction[]>( |
| 28 | () => [ | 20 | () => [ |
| 29 | { | 21 | { |
| ... | @@ -62,38 +54,8 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -62,38 +54,8 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 62 | ], | 54 | ], |
| 63 | }, | 55 | }, |
| 64 | ] satisfies MenuAction[])), | 56 | ] satisfies MenuAction[])), |
| 65 | ...(threadListV2Enabled | ||
| 66 | ? [] | ||
| 67 | : ([ | ||
| 68 | { | ||
| 69 | id: "project-sort", | ||
| 70 | title: "Sort projects", | ||
| 71 | subactions: PROJECT_SORT_OPTIONS.map((option) => ({ | ||
| 72 | id: `project-sort:${option.value}`, | ||
| 73 | title: option.label, | ||
| 74 | state: checkedMenuState(props.projectSortOrder === option.value), | ||
| 75 | })), | ||
| 76 | }, | ||
| 77 | { | ||
| 78 | id: "thread-sort", | ||
| 79 | title: "Sort threads", | ||
| 80 | subactions: THREAD_SORT_OPTIONS.map((option) => ({ | ||
| 81 | id: `thread-sort:${option.value}`, | ||
| 82 | title: option.label, | ||
| 83 | state: checkedMenuState(props.threadSortOrder === option.value), | ||
| 84 | })), | ||
| 85 | }, | ||
| 86 | ] satisfies MenuAction[])), | ||
| 87 | ], | ||
| 88 | [ | ||
| 89 | props.environments, | ||
| 90 | props.projectSortOrder, | ||
| 91 | props.projects, | ||
| 92 | props.selectedEnvironmentId, | ||
| 93 | props.selectedProjectKey, | ||
| 94 | props.threadSortOrder, | ||
| 95 | threadListV2Enabled, | ||
| 96 | ], | 57 | ], |
| 58 | [props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey], | ||
| 97 | ); | 59 | ); |
| 98 | const handleMenuAction = useCallback( | 60 | const handleMenuAction = useCallback( |
| 99 | (event: { nativeEvent: { event: string } }) => { | 61 | (event: { nativeEvent: { event: string } }) => { |
| ... | @@ -126,20 +88,6 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -126,20 +88,6 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 126 | } | 88 | } |
| 127 | return; | 89 | return; |
| 128 | } | 90 | } |
| 129 | |||
| 130 | const projectSort = PROJECT_SORT_OPTIONS.find( | ||
| 131 | (option) => id === `project-sort:${option.value}`, | ||
| 132 | ); | ||
| 133 | if (projectSort) { | ||
| 134 | props.onProjectSortOrderChange(projectSort.value); | ||
| 135 | return; | ||
| 136 | } | ||
| 137 | |||
| 138 | const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`); | ||
| 139 | if (threadSort) { | ||
| 140 | props.onThreadSortOrderChange(threadSort.value); | ||
| 141 | return; | ||
| 142 | } | ||
| 143 | }, | 91 | }, |
| 144 | [props], | 92 | [props], |
| 145 | ); | 93 | ); |
apps/mobile/src/features/home/HomeHeader.tsx+7-48| ... | @@ -2,7 +2,6 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac | ... | @@ -2,7 +2,6 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac |
| 2 | import { useCallback, useRef } from "react"; | 2 | import { useCallback, useRef } from "react"; |
| 3 | import type { SearchBarCommands } from "react-native-screens"; | 3 | import type { SearchBarCommands } from "react-native-screens"; |
| 4 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 4 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 5 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | ||
| 6 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 5 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 7 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | 6 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 8 | import { | 7 | import { |
| ... | @@ -10,11 +9,6 @@ import { | ... | @@ -10,11 +9,6 @@ import { |
| 10 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | 9 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 11 | } from "../layout/native-mail-search-toolbar"; | 10 | } from "../layout/native-mail-search-toolbar"; |
| 12 | import { buildHomeListFilterMenu } from "./home-list-filter-menu"; | 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 | import type { HomeHeaderProps } from "./HomeHeader.types"; | 12 | import type { HomeHeaderProps } from "./HomeHeader.types"; |
| 19 | 13 | ||
| 20 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; | 14 | export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| ... | @@ -22,22 +16,17 @@ export type { HomeHeaderEnvironment } from "./HomeHeader.types"; | ... | @@ -22,22 +16,17 @@ export type { HomeHeaderEnvironment } from "./HomeHeader.types"; |
| 22 | export function HomeHeader(props: HomeHeaderProps) { | 16 | export function HomeHeader(props: HomeHeaderProps) { |
| 23 | const searchBarRef = useRef<SearchBarCommands>(null); | 17 | const searchBarRef = useRef<SearchBarCommands>(null); |
| 24 | const iconColor = useUniwindTheme()["--color-icon"]; | 18 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 25 | // Thread List v2 lays the list out in fixed creation order, so the | 19 | // The list uses a fixed creation order and ignores sort/group options, so |
| 26 | // sort/group filter controls would be silently ignored — hide them and | 20 | // the filter menu only carries the filters and the "customized" icon state |
| 27 | // key the "customized" icon state off the environment filter alone. | 21 | // keys off those alone. |
| 28 | const threadListV2Enabled = useThreadListV2Enabled(); | 22 | const hasCustomListOptions = |
| 29 | const hasCustomListOptions = threadListV2Enabled | 23 | props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; |
| 30 | ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null | ||
| 31 | : hasCustomHomeListOptions(props); | ||
| 32 | const focusSearch = useCallback(() => { | 24 | const focusSearch = useCallback(() => { |
| 33 | searchBarRef.current?.focus(); | 25 | searchBarRef.current?.focus(); |
| 34 | return searchBarRef.current !== null; | 26 | return searchBarRef.current !== null; |
| 35 | }, []); | 27 | }, []); |
| 36 | useHardwareKeyboardCommand("focusSearch", focusSearch); | 28 | useHardwareKeyboardCommand("focusSearch", focusSearch); |
| 37 | const filterMenu = buildHomeListFilterMenu({ | 29 | const filterMenu = buildHomeListFilterMenu(props); |
| 38 | ...props, | ||
| 39 | listOrganization: !threadListV2Enabled, | ||
| 40 | }); | ||
| 41 | 30 | ||
| 42 | return ( | 31 | return ( |
| 43 | <> | 32 | <> |
| ... | @@ -100,7 +89,7 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -100,7 +89,7 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 100 | {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( | 89 | {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( |
| 101 | <NativeHeaderToolbar placement="bottom"> | 90 | <NativeHeaderToolbar placement="bottom"> |
| 102 | <NativeHeaderToolbar.Menu | 91 | <NativeHeaderToolbar.Menu |
| 103 | accessibilityLabel="Filter and sort threads" | 92 | accessibilityLabel="Filter threads" |
| 104 | icon={ | 93 | icon={ |
| 105 | hasCustomListOptions | 94 | hasCustomListOptions |
| 106 | ? "line.3.horizontal.decrease.circle.fill" | 95 | ? "line.3.horizontal.decrease.circle.fill" |
| ... | @@ -150,36 +139,6 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -150,36 +139,6 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 150 | ))} | 139 | ))} |
| 151 | </NativeHeaderToolbar.Menu> | 140 | </NativeHeaderToolbar.Menu> |
| 152 | ) : null} | 141 | ) : null} |
| 153 | |||
| 154 | {threadListV2Enabled ? null : ( | ||
| 155 | <NativeHeaderToolbar.Menu title="Sort projects"> | ||
| 156 | <NativeHeaderToolbar.Label>Sort projects</NativeHeaderToolbar.Label> | ||
| 157 | {PROJECT_SORT_OPTIONS.map((option) => ( | ||
| 158 | <NativeHeaderToolbar.MenuAction | ||
| 159 | key={option.value} | ||
| 160 | isOn={props.projectSortOrder === option.value} | ||
| 161 | onPress={() => props.onProjectSortOrderChange(option.value)} | ||
| 162 | > | ||
| 163 | <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label> | ||
| 164 | </NativeHeaderToolbar.MenuAction> | ||
| 165 | ))} | ||
| 166 | </NativeHeaderToolbar.Menu> | ||
| 167 | )} | ||
| 168 | |||
| 169 | {threadListV2Enabled ? null : ( | ||
| 170 | <NativeHeaderToolbar.Menu title="Sort threads"> | ||
| 171 | <NativeHeaderToolbar.Label>Sort threads</NativeHeaderToolbar.Label> | ||
| 172 | {THREAD_SORT_OPTIONS.map((option) => ( | ||
| 173 | <NativeHeaderToolbar.MenuAction | ||
| 174 | key={option.value} | ||
| 175 | isOn={props.threadSortOrder === option.value} | ||
| 176 | onPress={() => props.onThreadSortOrderChange(option.value)} | ||
| 177 | > | ||
| 178 | <NativeHeaderToolbar.Label>{option.label}</NativeHeaderToolbar.Label> | ||
| 179 | </NativeHeaderToolbar.MenuAction> | ||
| 180 | ))} | ||
| 181 | </NativeHeaderToolbar.Menu> | ||
| 182 | )} | ||
| 183 | </NativeHeaderToolbar.Menu> | 142 | </NativeHeaderToolbar.Menu> |
| 184 | <NativeHeaderToolbar.Spacer flexible /> | 143 | <NativeHeaderToolbar.Spacer flexible /> |
| 185 | <NativeHeaderToolbar.Button | 144 | <NativeHeaderToolbar.Button |
apps/mobile/src/features/home/HomeHeader.types.ts+1-6| ... | @@ -1,5 +1,4 @@ | ... | @@ -1,5 +1,4 @@ |
| 1 | import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import type { HomeProjectSortOrder } from "./homeThreadList"; | ||
| 3 | import type { | 2 | import type { |
| 4 | HomeListFilterMenuEnvironment, | 3 | HomeListFilterMenuEnvironment, |
| 5 | HomeListFilterMenuProject, | 4 | HomeListFilterMenuProject, |
| ... | @@ -13,13 +12,9 @@ export interface HomeHeaderProps { | ... | @@ -13,13 +12,9 @@ export interface HomeHeaderProps { |
| 13 | readonly searchQuery: string; | 12 | readonly searchQuery: string; |
| 14 | readonly selectedEnvironmentId: EnvironmentId | null; | 13 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 15 | readonly selectedProjectKey: string | null; | 14 | readonly selectedProjectKey: string | null; |
| 16 | readonly projectSortOrder: HomeProjectSortOrder; | ||
| 17 | readonly threadSortOrder: SidebarThreadSortOrder; | ||
| 18 | readonly onSearchQueryChange: (query: string) => void; | 15 | readonly onSearchQueryChange: (query: string) => void; |
| 19 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; | 16 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; |
| 20 | readonly onProjectChange: (projectKey: string | null) => void; | 17 | readonly onProjectChange: (projectKey: string | null) => void; |
| 21 | readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; | ||
| 22 | readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | ||
| 23 | readonly onOpenEnvironments: () => void; | 18 | readonly onOpenEnvironments: () => void; |
| 24 | readonly onOpenSettings: () => void; | 19 | readonly onOpenSettings: () => void; |
| 25 | readonly onStartNewTask: () => void; | 20 | readonly onStartNewTask: () => void; |
apps/mobile/src/features/home/HomeRouteScreen.tsx+2-13| ... | @@ -76,12 +76,8 @@ export function HomeRouteScreen() { | ... | @@ -76,12 +76,8 @@ export function HomeRouteScreen() { |
| 76 | () => new Set(environments.map((environment) => environment.environmentId)), | 76 | () => new Set(environments.map((environment) => environment.environmentId)), |
| 77 | [environments], | 77 | [environments], |
| 78 | ); | 78 | ); |
| 79 | const { | 79 | const { options: listOptions, setSelectedEnvironmentId } = |
| 80 | options: listOptions, | 80 | useHomeListOptions(availableEnvironmentIds); |
| 81 | setSelectedEnvironmentId, | ||
| 82 | setProjectSortOrder, | ||
| 83 | setThreadSortOrder, | ||
| 84 | } = useHomeListOptions(availableEnvironmentIds); | ||
| 85 | const selectedEnvironmentId = listOptions.selectedEnvironmentId; | 81 | const selectedEnvironmentId = listOptions.selectedEnvironmentId; |
| 86 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); | 82 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); |
| 87 | const projectFilterOptions = useMemo( | 83 | const projectFilterOptions = useMemo( |
| ... | @@ -175,8 +171,6 @@ export function HomeRouteScreen() { | ... | @@ -175,8 +171,6 @@ export function HomeRouteScreen() { |
| 175 | searchQuery={searchQuery} | 171 | searchQuery={searchQuery} |
| 176 | selectedEnvironmentId={selectedEnvironmentId} | 172 | selectedEnvironmentId={selectedEnvironmentId} |
| 177 | selectedProjectKey={selectedProjectKey} | 173 | selectedProjectKey={selectedProjectKey} |
| 178 | projectSortOrder={listOptions.projectSortOrder} | ||
| 179 | threadSortOrder={listOptions.threadSortOrder} | ||
| 180 | onEnvironmentChange={setSelectedEnvironmentId} | 174 | onEnvironmentChange={setSelectedEnvironmentId} |
| 181 | onProjectChange={setSelectedProjectKey} | 175 | onProjectChange={setSelectedProjectKey} |
| 182 | onOpenEnvironments={() => | 176 | onOpenEnvironments={() => |
| ... | @@ -191,10 +185,8 @@ export function HomeRouteScreen() { | ... | @@ -191,10 +185,8 @@ export function HomeRouteScreen() { |
| 191 | params: { screen: "Settings" }, | 185 | params: { screen: "Settings" }, |
| 192 | }) | 186 | }) |
| 193 | } | 187 | } |
| 194 | onProjectSortOrderChange={setProjectSortOrder} | ||
| 195 | onSearchQueryChange={setSearchQuery} | 188 | onSearchQueryChange={setSearchQuery} |
| 196 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | 189 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 197 | onThreadSortOrderChange={setThreadSortOrder} | ||
| 198 | /> | 190 | /> |
| 199 | 191 | ||
| 200 | <HomeScreen | 192 | <HomeScreen |
| ... | @@ -225,7 +217,6 @@ export function HomeRouteScreen() { | ... | @@ -225,7 +217,6 @@ export function HomeRouteScreen() { |
| 225 | params: { screen: "Settings" }, | 217 | params: { screen: "Settings" }, |
| 226 | }) | 218 | }) |
| 227 | } | 219 | } |
| 228 | onProjectSortOrderChange={setProjectSortOrder} | ||
| 229 | onSearchQueryChange={setSearchQuery} | 220 | onSearchQueryChange={setSearchQuery} |
| 230 | onSelectThread={handleSelectThread} | 221 | onSelectThread={handleSelectThread} |
| 231 | onSelectPendingTask={openPendingTask} | 222 | onSelectPendingTask={openPendingTask} |
| ... | @@ -252,7 +243,6 @@ export function HomeRouteScreen() { | ... | @@ -252,7 +243,6 @@ export function HomeRouteScreen() { |
| 252 | }); | 243 | }); |
| 253 | }} | 244 | }} |
| 254 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | 245 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 255 | onThreadSortOrderChange={setThreadSortOrder} | ||
| 256 | pendingTasks={pendingTasks} | 246 | pendingTasks={pendingTasks} |
| 257 | projectGroupingMode={listOptions.projectGroupingMode} | 247 | projectGroupingMode={listOptions.projectGroupingMode} |
| 258 | projects={projects} | 248 | projects={projects} |
| ... | @@ -262,7 +252,6 @@ export function HomeRouteScreen() { | ... | @@ -262,7 +252,6 @@ export function HomeRouteScreen() { |
| 262 | selectedEnvironmentId={selectedEnvironmentId} | 252 | selectedEnvironmentId={selectedEnvironmentId} |
| 263 | selectedProjectKey={selectedProjectKey} | 253 | selectedProjectKey={selectedProjectKey} |
| 264 | threads={threads} | 254 | threads={threads} |
| 265 | threadSortOrder={listOptions.threadSortOrder} | ||
| 266 | /> | 255 | /> |
| 267 | </> | 256 | </> |
| 268 | </AndroidHomeFabLayout> | 257 | </AndroidHomeFabLayout> |
apps/mobile/src/features/home/HomeScreen.tsx+36-424| ... | @@ -1,10 +1,6 @@ | ... | @@ -1,10 +1,6 @@ |
| 1 | import type { ThreadMoveDestination } from "../threads/threadOrder"; | 1 | import type { ThreadMoveDestination } from "../threads/threadOrder"; |
| 2 | import { computeThreadMoveAvailability } from "../threads/threadOrder"; | 2 | import { computeThreadMoveAvailability } from "../threads/threadOrder"; |
| 3 | import { | 3 | import { LegendList } from "@legendapp/list/react-native"; |
| 4 | LegendList, | ||
| 5 | type LegendListRef, | ||
| 6 | type LegendListRenderItemProps, | ||
| 7 | } from "@legendapp/list/react-native"; | ||
| 8 | import { | 4 | import { |
| 9 | type EnvironmentProject, | 5 | type EnvironmentProject, |
| 10 | type EnvironmentThreadShell, | 6 | type EnvironmentThreadShell, |
| ... | @@ -17,10 +13,8 @@ import { | ... | @@ -17,10 +13,8 @@ import { |
| 17 | type EnvironmentId, | 13 | type EnvironmentId, |
| 18 | resolveEnvironmentMachineKind, | 14 | resolveEnvironmentMachineKind, |
| 19 | type SidebarProjectGroupingMode, | 15 | type SidebarProjectGroupingMode, |
| 20 | type SidebarThreadSortOrder, | ||
| 21 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 22 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 17 | import { useAtomValue } from "@effect/atom-react"; |
| 23 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 24 | import { useFocusEffect } from "@react-navigation/native"; | 18 | import { useFocusEffect } from "@react-navigation/native"; |
| 25 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 19 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 26 | import { ActivityIndicator, Platform, View } from "react-native"; | 20 | import { ActivityIndicator, Platform, View } from "react-native"; |
| ... | @@ -34,20 +28,12 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace | ... | @@ -34,20 +28,12 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace |
| 34 | import type { SavedRemoteConnection } from "../../lib/connection"; | 28 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 35 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 29 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 36 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 30 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 37 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | ||
| 38 | import { useThreadSearch } from "../../state/queries"; | 31 | import { useThreadSearch } from "../../state/queries"; |
| 39 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; | 32 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; |
| 40 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | ||
| 41 | import { usePendingThreadOrder } from "../../state/thread-order"; | 33 | import { usePendingThreadOrder } from "../../state/thread-order"; |
| 42 | import { environmentServerConfigsAtom } from "../../state/server"; | 34 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 43 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 35 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 44 | import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; | 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 | import { | 37 | import { |
| 52 | ThreadListV2PendingRow, | 38 | ThreadListV2PendingRow, |
| 53 | ThreadListV2Row, | 39 | ThreadListV2Row, |
| ... | @@ -67,19 +53,8 @@ import { | ... | @@ -67,19 +53,8 @@ import { |
| 67 | } from "../threads/threadListV2"; | 53 | } from "../threads/threadListV2"; |
| 68 | import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; | 54 | import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; |
| 69 | import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; | 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 | import { | 56 | import { |
| 81 | buildHomeProjectScopes, | 57 | buildHomeProjectScopes, |
| 82 | buildHomeThreadGroups, | ||
| 83 | sortHomeProjectScopes, | 58 | sortHomeProjectScopes, |
| 84 | type HomeProjectSortOrder, | 59 | type HomeProjectSortOrder, |
| 85 | } from "./homeThreadList"; | 60 | } from "./homeThreadList"; |
| ... | @@ -101,13 +76,10 @@ interface HomeScreenProps { | ... | @@ -101,13 +76,10 @@ interface HomeScreenProps { |
| 101 | readonly selectedEnvironmentId: EnvironmentId | null; | 76 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 102 | readonly selectedProjectKey: string | null; | 77 | readonly selectedProjectKey: string | null; |
| 103 | readonly projectSortOrder: HomeProjectSortOrder; | 78 | readonly projectSortOrder: HomeProjectSortOrder; |
| 104 | readonly threadSortOrder: SidebarThreadSortOrder; | ||
| 105 | readonly projectGroupingMode: SidebarProjectGroupingMode; | 79 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 106 | readonly onSearchQueryChange: (query: string) => void; | 80 | readonly onSearchQueryChange: (query: string) => void; |
| 107 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; | 81 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; |
| 108 | readonly onProjectChange: (projectKey: string | null) => void; | 82 | readonly onProjectChange: (projectKey: string | null) => void; |
| 109 | readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; | ||
| 110 | readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | ||
| 111 | readonly onAddConnection: () => void; | 83 | readonly onAddConnection: () => void; |
| 112 | readonly onOpenSettings: () => void; | 84 | readonly onOpenSettings: () => void; |
| 113 | readonly onStartNewTask: () => void; | 85 | readonly onStartNewTask: () => void; |
| ... | @@ -138,7 +110,6 @@ interface HomeScreenProps { | ... | @@ -138,7 +110,6 @@ interface HomeScreenProps { |
| 138 | 110 | ||
| 139 | /* ─── Layout constants ───────────────────────────────────────────────── */ | 111 | /* ─── Layout constants ───────────────────────────────────────────────── */ |
| 140 | 112 | ||
| 141 | const ESTIMATED_THREAD_ROW_HEIGHT = 72; | ||
| 142 | // v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards | 113 | // v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards |
| 143 | // measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line | 114 | // measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line |
| 144 | // cards ~94dp. The estimate seeds the recycler's initial container count, | 115 | // cards ~94dp. The estimate seeds the recycler's initial container count, |
| ... | @@ -236,15 +207,8 @@ function HomeTopContentSpacer() { | ... | @@ -236,15 +207,8 @@ function HomeTopContentSpacer() { |
| 236 | /* ─── Main screen ────────────────────────────────────────────────────── */ | 207 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 237 | 208 | ||
| 238 | export function HomeScreen(props: HomeScreenProps) { | 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 | const queuedThreadKeys = useQueuedThreadKeys(); | 210 | const queuedThreadKeys = useQueuedThreadKeys(); |
| 245 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | ||
| 246 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | 211 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 247 | const listRef = useRef<LegendListRef | null>(null); | ||
| 248 | const insets = useSafeAreaInsets(); | 212 | const insets = useSafeAreaInsets(); |
| 249 | const iosBottomToolbarClearance = | 213 | const iosBottomToolbarClearance = |
| 250 | Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED | 214 | Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED |
| ... | @@ -279,42 +243,6 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -279,42 +243,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 279 | () => new Set(threadSearch.matches.map(threadSearchMatchKey)), | 243 | () => new Set(threadSearch.matches.map(threadSearchMatchKey)), |
| 280 | [threadSearch.matches], | 244 | [threadSearch.matches], |
| 281 | ); | 245 | ); |
| 282 | const effectiveGroupDisplayStates = useMemo(() => { | ||
| 283 | const next = new Map(groupDisplayStates); | ||
| 284 | if (!AsyncResult.isSuccess(preferencesResult)) { | ||
| 285 | return next; | ||
| 286 | } | ||
| 287 | for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { | ||
| 288 | const existing = next.get(key); | ||
| 289 | next.set(key, { | ||
| 290 | ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE), | ||
| 291 | collapsed: true, | ||
| 292 | }); | ||
| 293 | } | ||
| 294 | return next; | ||
| 295 | }, [groupDisplayStates, preferencesResult]); | ||
| 296 | const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); | ||
| 297 | effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; | ||
| 298 | |||
| 299 | const updateGroupDisplay = useCallback( | ||
| 300 | (key: string, action: HomeGroupDisplayAction) => { | ||
| 301 | const next = new Map(effectiveGroupDisplayStatesRef.current); | ||
| 302 | next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); | ||
| 303 | effectiveGroupDisplayStatesRef.current = next; | ||
| 304 | setGroupDisplayStates(next); | ||
| 305 | if (action === "toggle-collapsed") { | ||
| 306 | const collapsedProjectGroups: string[] = []; | ||
| 307 | for (const [groupKey, state] of next) { | ||
| 308 | if (state.collapsed) { | ||
| 309 | collapsedProjectGroups.push(groupKey); | ||
| 310 | } | ||
| 311 | } | ||
| 312 | savePreferences({ collapsedProjectGroups }); | ||
| 313 | } | ||
| 314 | }, | ||
| 315 | [savePreferences], | ||
| 316 | ); | ||
| 317 | |||
| 318 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { | 246 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { |
| 319 | if (openSwipeableRef.current !== methods) { | 247 | if (openSwipeableRef.current !== methods) { |
| 320 | openSwipeableRef.current?.close(); | 248 | openSwipeableRef.current?.close(); |
| ... | @@ -346,112 +274,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -346,112 +274,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 346 | }), | 274 | }), |
| 347 | [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], | 275 | [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], |
| 348 | ); | 276 | ); |
| 349 | const selectedProjectScope = useMemo( | ||
| 350 | () => | ||
| 351 | props.selectedProjectKey === null | ||
| 352 | ? null | ||
| 353 | : (projectScopes.find( | ||
| 354 | (scope) => | ||
| 355 | scope.key === props.selectedProjectKey || | ||
| 356 | scope.projectRefs.some( | ||
| 357 | (projectRef) => | ||
| 358 | scopedProjectKey(projectRef.environmentId, projectRef.projectId) === | ||
| 359 | props.selectedProjectKey, | ||
| 360 | ), | ||
| 361 | ) ?? null), | ||
| 362 | [projectScopes, props.selectedProjectKey], | ||
| 363 | ); | ||
| 364 | const selectedProjectRefKeys = useMemo( | ||
| 365 | () => | ||
| 366 | selectedProjectScope === null | ||
| 367 | ? null | ||
| 368 | : new Set( | ||
| 369 | selectedProjectScope.projectRefs.map((projectRef) => | ||
| 370 | scopedProjectKey(projectRef.environmentId, projectRef.projectId), | ||
| 371 | ), | ||
| 372 | ), | ||
| 373 | [selectedProjectScope], | ||
| 374 | ); | ||
| 375 | const scopedProjects = useMemo( | ||
| 376 | () => | ||
| 377 | threadListV2Enabled | ||
| 378 | ? [] | ||
| 379 | : selectedProjectRefKeys === null | ||
| 380 | ? props.projects | ||
| 381 | : props.projects.filter((project) => | ||
| 382 | selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)), | ||
| 383 | ), | ||
| 384 | [threadListV2Enabled, props.projects, selectedProjectRefKeys], | ||
| 385 | ); | ||
| 386 | const scopedThreads = useMemo( | ||
| 387 | () => | ||
| 388 | threadListV2Enabled | ||
| 389 | ? [] | ||
| 390 | : selectedProjectRefKeys === null | ||
| 391 | ? props.threads | ||
| 392 | : props.threads.filter((thread) => | ||
| 393 | selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), | ||
| 394 | ), | ||
| 395 | [threadListV2Enabled, props.threads, selectedProjectRefKeys], | ||
| 396 | ); | ||
| 397 | const scopedPendingTasks = useMemo( | ||
| 398 | () => | ||
| 399 | threadListV2Enabled | ||
| 400 | ? [] | ||
| 401 | : selectedProjectRefKeys === null | ||
| 402 | ? props.pendingTasks | ||
| 403 | : props.pendingTasks.filter((pendingTask) => | ||
| 404 | selectedProjectRefKeys.has( | ||
| 405 | scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), | ||
| 406 | ), | ||
| 407 | ), | ||
| 408 | [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys], | ||
| 409 | ); | ||
| 410 | |||
| 411 | const projectGroups = useMemo( | ||
| 412 | () => | ||
| 413 | threadListV2Enabled | ||
| 414 | ? [] | ||
| 415 | : buildHomeThreadGroups({ | ||
| 416 | projects: scopedProjects, | ||
| 417 | threads: scopedThreads, | ||
| 418 | pendingTasks: scopedPendingTasks, | ||
| 419 | queuedThreadKeys, | ||
| 420 | environmentId: props.selectedEnvironmentId, | ||
| 421 | searchQuery: props.searchQuery, | ||
| 422 | matchedThreadKeys, | ||
| 423 | projectSortOrder: props.projectSortOrder, | ||
| 424 | threadSortOrder: props.threadSortOrder, | ||
| 425 | projectGroupingMode: props.projectGroupingMode, | ||
| 426 | }), | ||
| 427 | [ | ||
| 428 | threadListV2Enabled, | ||
| 429 | queuedThreadKeys, | ||
| 430 | props.projectGroupingMode, | ||
| 431 | props.projectSortOrder, | ||
| 432 | props.searchQuery, | ||
| 433 | props.selectedEnvironmentId, | ||
| 434 | props.threadSortOrder, | ||
| 435 | matchedThreadKeys, | ||
| 436 | scopedPendingTasks, | ||
| 437 | scopedProjects, | ||
| 438 | scopedThreads, | ||
| 439 | ], | ||
| 440 | ); | ||
| 441 | |||
| 442 | const hasSearchQuery = props.searchQuery.trim().length > 0; | 277 | const hasSearchQuery = props.searchQuery.trim().length > 0; |
| 443 | const listLayout = useMemo( | ||
| 444 | () => | ||
| 445 | threadListV2Enabled | ||
| 446 | ? EMPTY_HOME_LIST_LAYOUT | ||
| 447 | : buildHomeListLayout({ | ||
| 448 | groups: projectGroups, | ||
| 449 | displayStates: effectiveGroupDisplayStates, | ||
| 450 | showAllThreads: hasSearchQuery, | ||
| 451 | }), | ||
| 452 | [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery], | ||
| 453 | ); | ||
| 454 | |||
| 455 | const projectByKey = useMemo(() => { | 278 | const projectByKey = useMemo(() => { |
| 456 | const map = new Map<string, EnvironmentProject>(); | 279 | const map = new Map<string, EnvironmentProject>(); |
| 457 | for (const project of props.projects) { | 280 | for (const project of props.projects) { |
| ... | @@ -597,12 +420,11 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -597,12 +420,11 @@ export function HomeScreen(props: HomeScreenProps) { |
| 597 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); | 420 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); |
| 598 | useFocusEffect( | 421 | useFocusEffect( |
| 599 | useCallback(() => { | 422 | useCallback(() => { |
| 600 | if (!threadListV2Enabled) return; | ||
| 601 | // Refresh immediately on enable or focus because the previous value can be hours old. | 423 | // Refresh immediately on enable or focus because the previous value can be hours old. |
| 602 | setNowMinute(new Date().toISOString().slice(0, 16)); | 424 | setNowMinute(new Date().toISOString().slice(0, 16)); |
| 603 | const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); | 425 | const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); |
| 604 | return () => clearInterval(id); | 426 | return () => clearInterval(id); |
| 605 | }, [threadListV2Enabled]), | 427 | }, []), |
| 606 | ); | 428 | ); |
| 607 | // Threads on servers without the settlement capability never classify as | 429 | // Threads on servers without the settlement capability never classify as |
| 608 | // settled (the user could neither un-settle nor pin them). | 430 | // settled (the user could neither un-settle nor pin them). |
| ... | @@ -715,16 +537,6 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -715,16 +537,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 715 | snoozeWakeTick, | 537 | snoozeWakeTick, |
| 716 | ]); | 538 | ]); |
| 717 | const threadListV2Layout = useMemo(() => { | 539 | const threadListV2Layout = useMemo(() => { |
| 718 | if (!threadListV2Enabled) | ||
| 719 | return { | ||
| 720 | items: [], | ||
| 721 | hiddenSettledCount: 0, | ||
| 722 | snoozedCount: 0, | ||
| 723 | snoozedShelfHeaderIndex: null, | ||
| 724 | settledCount: 0, | ||
| 725 | settledShelfHeaderIndex: null, | ||
| 726 | nextSnoozeWakeAt: null, | ||
| 727 | }; | ||
| 728 | // Settled threads are live shells; archived threads keep their original | 540 | // Settled threads are live shells; archived threads keep their original |
| 729 | // "hidden from lists" meaning. | 541 | // "hidden from lists" meaning. |
| 730 | return buildThreadListV2Items({ | 542 | return buildThreadListV2Items({ |
| ... | @@ -757,7 +569,6 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -757,7 +569,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 757 | props.selectedEnvironmentId, | 569 | props.selectedEnvironmentId, |
| 758 | props.threads, | 570 | props.threads, |
| 759 | matchedThreadKeys, | 571 | matchedThreadKeys, |
| 760 | threadListV2Enabled, | ||
| 761 | v2ScopedProjectGroup, | 572 | v2ScopedProjectGroup, |
| 762 | ]); | 573 | ]); |
| 763 | // Re-partition the moment the earliest snooze expires (clamped to the | 574 | // Re-partition the moment the earliest snooze expires (clamped to the |
| ... | @@ -824,10 +635,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -824,10 +635,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 824 | ], | 635 | ], |
| 825 | ); | 636 | ); |
| 826 | 637 | ||
| 827 | useThreadJumpShortcuts( | 638 | useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); |
| 828 | threadListV2Enabled ? threadListV2Items : listLayout.items, | ||
| 829 | props.onSelectThread, | ||
| 830 | ); | ||
| 831 | 639 | ||
| 832 | const renderV2Item = useCallback( | 640 | const renderV2Item = useCallback( |
| 833 | ({ item }: { readonly item: ThreadListV2ListItem }) => { | 641 | ({ item }: { readonly item: ThreadListV2ListItem }) => { |
| ... | @@ -996,126 +804,13 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -996,126 +804,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 996 | ], | 804 | ], |
| 997 | ); | 805 | ); |
| 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 | /* Empty states */ | 807 | /* Empty states */ |
| 1112 | // The signal must ignore the search/environment filters: an active query | 808 | // The signal must ignore the search/environment filters: an active query |
| 1113 | // that matches nothing needs the in-list "No results" state, not the | 809 | // that matches nothing needs the in-list "No results" state, not the |
| 1114 | // full-page "No threads yet". Settled threads are unarchived live shells, | 810 | // full-page "No threads yet". Settled threads are unarchived live shells, |
| 1115 | // so the v1 check already covers v2. | 811 | // so the archived-at check already covers the settled shelf. |
| 1116 | const hasAnyThreads = | 812 | const hasAnyThreads = |
| 1117 | props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; | 813 | props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; |
| 1118 | const hasResults = threadListV2Enabled ? threadListV2Items.length > 0 : projectGroups.length > 0; | ||
| 1119 | const selectedEnvironmentLabel = | 814 | const selectedEnvironmentLabel = |
| 1120 | props.selectedEnvironmentId === null | 815 | props.selectedEnvironmentId === null |
| 1121 | ? null | 816 | ? null |
| ... | @@ -1178,16 +873,18 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1178,16 +873,18 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1178 | // mobile — the menu is the one filter surface). | 873 | // mobile — the menu is the one filter surface). |
| 1179 | const v2ListHeader = listHeader; | 874 | const v2ListHeader = listHeader; |
| 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 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( | 879 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( |
| 1183 | <EmptyState | 880 | <EmptyState |
| 1184 | title="No results" | 881 | title="No results" |
| 1185 | detail={`No threads matching "${props.searchQuery}".`} | 882 | detail={`No threads matching "${props.searchQuery}".`} |
| 1186 | variant={Platform.OS === "android" ? "plain" : undefined} | 883 | variant={Platform.OS === "android" ? "plain" : undefined} |
| 1187 | /> | 884 | /> |
| 1188 | ) : selectedProjectScope !== null ? ( | 885 | ) : v2ScopedProjectGroup !== null ? ( |
| 1189 | <EmptyState | 886 | <EmptyState |
| 1190 | title={`No threads in ${selectedProjectScope.title}`} | 887 | title={`No threads in ${v2ScopedProjectGroup.title}`} |
| 1191 | detail="Choose another project or create a new task." | 888 | detail="Choose another project or create a new task." |
| 1192 | variant={Platform.OS === "android" ? "plain" : undefined} | 889 | variant={Platform.OS === "android" ? "plain" : undefined} |
| 1193 | /> | 890 | /> |
| ... | @@ -1203,93 +900,16 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1203,93 +900,16 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1203 | detail="Create a task to start a new coding session." | 900 | detail="Create a task to start a new coding session." |
| 1204 | variant={Platform.OS === "android" ? "plain" : undefined} | 901 | variant={Platform.OS === "android" ? "plain" : undefined} |
| 1205 | /> | 902 | /> |
| 1206 | ) | ||
| 1207 | ) : null; | ||
| 1208 | // Use the v2 project scope for its empty state. Snoozed threads need no | ||
| 1209 | // special empty state: their shelf header is a list row even while collapsed. | ||
| 1210 | const v2ListEmpty = | ||
| 1211 | hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( | ||
| 1212 | <EmptyState | ||
| 1213 | title="No results" | ||
| 1214 | detail={`No threads matching "${props.searchQuery}".`} | ||
| 1215 | variant={Platform.OS === "android" ? "plain" : undefined} | ||
| 1216 | /> | ||
| 1217 | ) : v2ScopedProjectGroup !== null ? ( | ||
| 1218 | <EmptyState | ||
| 1219 | title={`No threads in ${v2ScopedProjectGroup.title}`} | ||
| 1220 | detail="Choose another project or create a new task." | ||
| 1221 | variant={Platform.OS === "android" ? "plain" : undefined} | ||
| 1222 | /> | ||
| 1223 | ) : ( | ||
| 1224 | listEmpty | ||
| 1225 | ); | 903 | ); |
| 1226 | 904 | ||
| 1227 | if ( | 905 | if (Platform.OS === "android" && threadListV2Items.length === 0) { |
| 1228 | Platform.OS === "android" && | ||
| 1229 | (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0) | ||
| 1230 | ) { | ||
| 1231 | return ( | 906 | return ( |
| 1232 | <View className="flex-1 bg-header"> | 907 | <View className="flex-1 bg-header"> |
| 1233 | <View | 908 | <View |
| 1234 | className="flex-1 items-center justify-center overflow-hidden rounded-t-[28px] bg-screen px-4" | 909 | className="flex-1 items-center justify-center overflow-hidden rounded-t-[28px] bg-screen px-4" |
| 1235 | style={{ paddingBottom: insets.bottom }} | 910 | style={{ paddingBottom: insets.bottom }} |
| 1236 | > | 911 | > |
| 1237 | {threadListV2Enabled ? v2ListEmpty : listEmpty} | 912 | {v2ListEmpty} |
| 1238 | </View> | ||
| 1239 | </View> | ||
| 1240 | ); | ||
| 1241 | } | ||
| 1242 | |||
| 1243 | if (threadListV2Enabled) { | ||
| 1244 | return ( | ||
| 1245 | <View className="flex-1 bg-screen android:bg-header"> | ||
| 1246 | <View | ||
| 1247 | className={ | ||
| 1248 | Platform.OS === "android" | ||
| 1249 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-screen" | ||
| 1250 | : "flex-1 bg-screen" | ||
| 1251 | } | ||
| 1252 | > | ||
| 1253 | {/* Same recycler the iPad sidebar and the legacy list use: cells are | ||
| 1254 | reused across data rebuilds and `itemsAreEqual` keeps a minute tick | ||
| 1255 | (or an unrelated shell update) from re-rendering untouched rows. */} | ||
| 1256 | <SwipeableScrollGateProvider enabled={swipeEnabled}> | ||
| 1257 | <LegendList | ||
| 1258 | data={threadListV2Items} | ||
| 1259 | renderItem={renderV2Item} | ||
| 1260 | keyExtractor={v2KeyExtractor} | ||
| 1261 | getItemType={(item) => item.type} | ||
| 1262 | itemsAreEqual={threadListV2ListItemsAreEqual} | ||
| 1263 | estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} | ||
| 1264 | drawDistance={500} | ||
| 1265 | recycleItems | ||
| 1266 | extraData={v2ExtraData} | ||
| 1267 | ListHeaderComponent={v2ListHeader} | ||
| 1268 | ListFooterComponent={ | ||
| 1269 | settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( | ||
| 1270 | <ThreadListV2ShowMoreRow | ||
| 1271 | hiddenCount={threadListV2Layout.hiddenSettledCount} | ||
| 1272 | onPress={showMoreSettled} | ||
| 1273 | /> | ||
| 1274 | ) : null | ||
| 1275 | } | ||
| 1276 | ListEmptyComponent={v2ListEmpty} | ||
| 1277 | style={{ flex: 1 }} | ||
| 1278 | automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} | ||
| 1279 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} | ||
| 1280 | showsVerticalScrollIndicator={false} | ||
| 1281 | keyboardDismissMode="on-drag" | ||
| 1282 | keyboardShouldPersistTaps="handled" | ||
| 1283 | {...scrollGateHandlers} | ||
| 1284 | scrollEventThrottle={16} | ||
| 1285 | contentContainerStyle={{ | ||
| 1286 | paddingBottom: | ||
| 1287 | Platform.OS === "ios" | ||
| 1288 | ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance | ||
| 1289 | : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), | ||
| 1290 | }} | ||
| 1291 | /> | ||
| 1292 | </SwipeableScrollGateProvider> | ||
| 1293 | </View> | 913 | </View> |
| 1294 | </View> | 914 | </View> |
| 1295 | ); | 915 | ); |
| ... | @@ -1304,52 +924,44 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1304,52 +924,44 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1304 | : "flex-1 bg-screen" | 924 | : "flex-1 bg-screen" |
| 1305 | } | 925 | } |
| 1306 | > | 926 | > |
| 1307 | {/* Sticky headers are deliberately not wired up: LegendList's JS sticky | 927 | {/* Same recycler the iPad sidebar and the legacy list use: cells are |
| 1308 | implementation mispositions pinned headers at mount under iOS | 928 | reused across data rebuilds and `itemsAreEqual` keeps a minute tick |
| 1309 | automatic content insets (headers render one nav-inset too low until | 929 | (or an unrelated shell update) from re-rendering untouched rows. */} |
| 1310 | the first scroll event) and blanks non-pinned headers after | ||
| 1311 | collapse/expand data changes. The flattened layout still exposes | ||
| 1312 | `stickyHeaderIndices` if this gets revisited. */} | ||
| 1313 | <SwipeableScrollGateProvider enabled={swipeEnabled}> | 930 | <SwipeableScrollGateProvider enabled={swipeEnabled}> |
| 1314 | <LegendList | 931 | <LegendList |
| 1315 | ref={listRef} | 932 | data={threadListV2Items} |
| 1316 | data={listLayout.items} | 933 | renderItem={renderV2Item} |
| 1317 | renderItem={renderItem} | 934 | keyExtractor={v2KeyExtractor} |
| 1318 | keyExtractor={keyExtractor} | 935 | getItemType={(item) => item.type} |
| 1319 | itemsAreEqual={homeListItemsAreEqual} | 936 | itemsAreEqual={threadListV2ListItemsAreEqual} |
| 937 | estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} | ||
| 1320 | drawDistance={500} | 938 | drawDistance={500} |
| 1321 | estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} | 939 | recycleItems |
| 1322 | extraData={extraData} | 940 | extraData={v2ExtraData} |
| 1323 | ListHeaderComponent={listHeader} | 941 | ListHeaderComponent={v2ListHeader} |
| 1324 | ListEmptyComponent={listEmpty} | 942 | ListFooterComponent={ |
| 943 | settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( | ||
| 944 | <ThreadListV2ShowMoreRow | ||
| 945 | hiddenCount={threadListV2Layout.hiddenSettledCount} | ||
| 946 | onPress={showMoreSettled} | ||
| 947 | /> | ||
| 948 | ) : null | ||
| 949 | } | ||
| 950 | ListEmptyComponent={v2ListEmpty} | ||
| 1325 | style={{ flex: 1 }} | 951 | style={{ flex: 1 }} |
| 1326 | automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} | 952 | automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} |
| 1327 | contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} | 953 | contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} |
| 1328 | showsVerticalScrollIndicator={false} | 954 | showsVerticalScrollIndicator={false} |
| 1329 | keyboardDismissMode="on-drag" | 955 | keyboardDismissMode="on-drag" |
| 1330 | keyboardShouldPersistTaps="handled" | 956 | keyboardShouldPersistTaps="handled" |
| 1331 | {...scrollGateHandlers} | 957 | {...scrollGateHandlers} |
| 1332 | recycleItems | ||
| 1333 | scrollEventThrottle={16} | 958 | scrollEventThrottle={16} |
| 1334 | contentContainerStyle={{ | 959 | contentContainerStyle={{ |
| 1335 | // Android reserves room for the floating new-task FAB | ||
| 1336 | // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a | ||
| 1337 | // standard 44pt bottom toolbar that overlays the list and is not | ||
| 1338 | // reflected in insets while contentInsetAdjustmentBehavior is | ||
| 1339 | // "never". | ||
| 1340 | paddingBottom: | 960 | paddingBottom: |
| 1341 | Platform.OS === "ios" | 961 | Platform.OS === "ios" |
| 1342 | ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance | 962 | ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance |
| 1343 | : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), | 963 | : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), |
| 1344 | }} | 964 | }} |
| 1345 | scrollIndicatorInsets={ | ||
| 1346 | Platform.OS === "ios" | ||
| 1347 | ? { | ||
| 1348 | bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance, | ||
| 1349 | top: 0, | ||
| 1350 | } | ||
| 1351 | : undefined | ||
| 1352 | } | ||
| 1353 | /> | 965 | /> |
| 1354 | </SwipeableScrollGateProvider> | 966 | </SwipeableScrollGateProvider> |
| 1355 | </View> | 967 | </View> |
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx+1-1| ... | @@ -130,7 +130,7 @@ export function MaterialThreadListToolbar(props: { | ... | @@ -130,7 +130,7 @@ export function MaterialThreadListToolbar(props: { |
| 130 | <AndroidAnchoredMenu actions={props.filterActions} onPressAction={props.onFilterAction}> | 130 | <AndroidAnchoredMenu actions={props.filterActions} onPressAction={props.onFilterAction}> |
| 131 | {(open) => ( | 131 | {(open) => ( |
| 132 | <MaterialFloatingActionButton | 132 | <MaterialFloatingActionButton |
| 133 | label="Filter and sort threads" | 133 | label="Filter threads" |
| 134 | icon={filterIcon} | 134 | icon={filterIcon} |
| 135 | onPress={open} | 135 | onPress={open} |
| 136 | /> | 136 | /> |
apps/mobile/src/features/home/home-list-filter-menu.test.ts-4| ... | @@ -13,12 +13,8 @@ describe("buildHomeListFilterMenu", () => { | ... | @@ -13,12 +13,8 @@ describe("buildHomeListFilterMenu", () => { |
| 13 | ], | 13 | ], |
| 14 | selectedEnvironmentId: null, | 14 | selectedEnvironmentId: null, |
| 15 | selectedProjectKey: "environment-1:project-1", | 15 | selectedProjectKey: "environment-1:project-1", |
| 16 | projectSortOrder: "updated_at", | ||
| 17 | threadSortOrder: "updated_at", | ||
| 18 | onEnvironmentChange: vi.fn(), | 16 | onEnvironmentChange: vi.fn(), |
| 19 | onProjectChange, | 17 | onProjectChange, |
| 20 | onProjectSortOrderChange: vi.fn(), | ||
| 21 | onThreadSortOrderChange: vi.fn(), | ||
| 22 | }); | 18 | }); |
| 23 | 19 | ||
| 24 | const projectMenu = menu.items.find( | 20 | const projectMenu = menu.items.find( |
apps/mobile/src/features/home/home-list-filter-menu.ts+1-37| ... | @@ -1,7 +1,4 @@ | ... | @@ -1,7 +1,4 @@ |
| 1 | import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | |||
| 3 | import type { HomeProjectSortOrder } from "./homeThreadList"; | ||
| 4 | import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; | ||
| 5 | 2 | ||
| 6 | export interface HomeListFilterMenuEnvironment { | 3 | export interface HomeListFilterMenuEnvironment { |
| 7 | readonly environmentId: EnvironmentId; | 4 | readonly environmentId: EnvironmentId; |
| ... | @@ -37,16 +34,8 @@ export function buildHomeListFilterMenu(props: { | ... | @@ -37,16 +34,8 @@ export function buildHomeListFilterMenu(props: { |
| 37 | readonly projects: ReadonlyArray<HomeListFilterMenuProject>; | 34 | readonly projects: ReadonlyArray<HomeListFilterMenuProject>; |
| 38 | readonly selectedEnvironmentId: EnvironmentId | null; | 35 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 39 | readonly selectedProjectKey: string | null; | 36 | readonly selectedProjectKey: string | null; |
| 40 | readonly projectSortOrder: HomeProjectSortOrder; | ||
| 41 | readonly threadSortOrder: SidebarThreadSortOrder; | ||
| 42 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; | 37 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; |
| 43 | readonly onProjectChange: (projectKey: string | null) => void; | 38 | readonly onProjectChange: (projectKey: string | null) => void; |
| 44 | readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; | ||
| 45 | readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | ||
| 46 | /** False hides the sort/group submenus. Thread List v2 uses a fixed | ||
| 47 | creation-order layout, so offering those controls while it silently | ||
| 48 | ignores them would be a lie; the environment filter still applies. */ | ||
| 49 | readonly listOrganization?: boolean; | ||
| 50 | }): HomeListFilterMenu { | 39 | }): HomeListFilterMenu { |
| 51 | const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = []; | 40 | const items: Array<HomeListFilterMenuAction | HomeListFilterMenuSubmenu> = []; |
| 52 | 41 | ||
| ... | @@ -95,31 +84,6 @@ export function buildHomeListFilterMenu(props: { | ... | @@ -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 | return { | 87 | return { |
| 124 | title: "Thread list options", | 88 | title: "Thread list options", |
| 125 | items, | 89 | items, |
apps/mobile/src/features/home/home-list-options.test.ts deleted-31| ... | @@ -1,31 +0,0 @@ | ||
| 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,22 +31,6 @@ export interface ResolvedHomeListOptions extends HomeListOptions { |
| 31 | readonly projectGroupingMode: SidebarProjectGroupingMode; | 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 | function defaultHomeListOptions(): HomeListOptions { | 34 | function defaultHomeListOptions(): HomeListOptions { |
| 51 | return { | 35 | return { |
| 52 | selectedEnvironmentId: null, | 36 | selectedEnvironmentId: null, |
| ... | @@ -81,23 +65,6 @@ export function HomeListOptionsProvider({ | ... | @@ -81,23 +65,6 @@ export function HomeListOptionsProvider({ |
| 81 | return createElement(HomeListOptionsContext, { value }, children); | 65 | return createElement(HomeListOptionsContext, { value }, children); |
| 82 | } | 66 | } |
| 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 | export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) { | 68 | export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<EnvironmentId>) { |
| 102 | const shared = useContext(HomeListOptionsContext); | 69 | const shared = useContext(HomeListOptionsContext); |
| 103 | const [localOptions, setLocalOptions] = useState<HomeListOptions>(defaultHomeListOptions); | 70 | const [localOptions, setLocalOptions] = useState<HomeListOptions>(defaultHomeListOptions); |
apps/mobile/src/features/home/homeListItems.test.ts deleted-266| ... | @@ -1,266 +0,0 @@ | ||
| 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,11 +6,7 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-searc |
| 6 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 6 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 7 | import { describe, expect, it } from "vite-plus/test"; | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 8 | 8 | ||
| 9 | import { | 9 | import { buildHomeProjectScopes, sortHomeProjectScopes } from "./homeThreadList"; |
| 10 | buildHomeProjectScopes, | ||
| 11 | buildHomeThreadGroups, | ||
| 12 | sortHomeProjectScopes, | ||
| 13 | } from "./homeThreadList"; | ||
| 14 | 10 | ||
| 15 | function makeProject( | 11 | function makeProject( |
| 16 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, | 12 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, |
| ... | @@ -54,25 +50,7 @@ function makeThread( | ... | @@ -54,25 +50,7 @@ function makeThread( |
| 54 | 50 | ||
| 55 | const NOW = Date.parse("2026-06-29T00:00:00.000Z"); | 51 | const NOW = Date.parse("2026-06-29T00:00:00.000Z"); |
| 56 | 52 | ||
| 57 | function buildGroups( | 53 | describe("home project scopes", () => { |
| 58 | projects: ReadonlyArray<EnvironmentProject>, | ||
| 59 | threads: ReadonlyArray<EnvironmentThreadShell>, | ||
| 60 | overrides: Partial<Parameters<typeof buildHomeThreadGroups>[0]> = {}, | ||
| 61 | ) { | ||
| 62 | return buildHomeThreadGroups({ | ||
| 63 | projects, | ||
| 64 | threads, | ||
| 65 | environmentId: null, | ||
| 66 | searchQuery: "", | ||
| 67 | projectSortOrder: "updated_at", | ||
| 68 | threadSortOrder: "updated_at", | ||
| 69 | projectGroupingMode: "repository", | ||
| 70 | now: NOW, | ||
| 71 | ...overrides, | ||
| 72 | }); | ||
| 73 | } | ||
| 74 | |||
| 75 | describe("buildHomeThreadGroups", () => { | ||
| 76 | it("builds one v2 scope for the same repository across environments", () => { | 54 | it("builds one v2 scope for the same repository across environments", () => { |
| 77 | const localEnvironmentId = EnvironmentId.make("environment-local"); | 55 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 78 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); | 56 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); |
| ... | @@ -116,70 +94,6 @@ describe("buildHomeThreadGroups", () => { | ... | @@ -116,70 +94,6 @@ describe("buildHomeThreadGroups", () => { |
| 116 | ); | 94 | ); |
| 117 | }); | 95 | }); |
| 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 | it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => { | 97 | it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => { |
| 184 | const localEnvironmentId = EnvironmentId.make("environment-local"); | 98 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 185 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); | 99 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); |
| ... | @@ -375,410 +289,4 @@ describe("buildHomeThreadGroups", () => { | ... | @@ -375,410 +289,4 @@ describe("buildHomeThreadGroups", () => { |
| 375 | }), | 289 | }), |
| 376 | ).toHaveLength(2); | 290 | ).toHaveLength(2); |
| 377 | }); | 291 | }); |
| 378 | |||
| 379 | it("uses the physical project title for a singleton scope", () => { | ||
| 380 | const project = makeProject({ | ||
| 381 | environmentId: EnvironmentId.make("environment-1"), | ||
| 382 | id: ProjectId.make("project-1"), | ||
| 383 | title: "local-worktree-name", | ||
| 384 | repositoryIdentity: { | ||
| 385 | canonicalKey: "github.com/pingdotgg/t3code", | ||
| 386 | displayName: "codething-mvp", | ||
| 387 | locator: { | ||
| 388 | source: "git-remote" as const, | ||
| 389 | remoteName: "origin", | ||
| 390 | remoteUrl: "git@github.com:pingdotgg/t3code.git", | ||
| 391 | }, | ||
| 392 | }, | ||
| 393 | }); | ||
| 394 | |||
| 395 | const scopes = buildHomeProjectScopes({ | ||
| 396 | projects: [project], | ||
| 397 | environmentId: null, | ||
| 398 | projectGroupingMode: "repository", | ||
| 399 | }); | ||
| 400 | const groups = buildGroups( | ||
| 401 | [project], | ||
| 402 | [ | ||
| 403 | makeThread({ | ||
| 404 | environmentId: project.environmentId, | ||
| 405 | id: ThreadId.make("thread-1"), | ||
| 406 | projectId: project.id, | ||
| 407 | title: "Thread", | ||
| 408 | }), | ||
| 409 | ], | ||
| 410 | ); | ||
| 411 | |||
| 412 | expect(scopes[0]?.title).toBe("local-worktree-name"); | ||
| 413 | expect(groups[0]?.title).toBe("local-worktree-name"); | ||
| 414 | }); | ||
| 415 | |||
| 416 | it("sorts the newest thread first regardless of snapshot order", () => { | ||
| 417 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 418 | const project = makeProject({ | ||
| 419 | environmentId, | ||
| 420 | id: ProjectId.make("project-1"), | ||
| 421 | title: "T3 Code", | ||
| 422 | }); | ||
| 423 | const threads = [ | ||
| 424 | makeThread({ | ||
| 425 | environmentId, | ||
| 426 | id: ThreadId.make("thread-old"), | ||
| 427 | projectId: project.id, | ||
| 428 | title: "Older thread", | ||
| 429 | updatedAt: "2026-06-02T00:00:00.000Z", | ||
| 430 | }), | ||
| 431 | makeThread({ | ||
| 432 | environmentId, | ||
| 433 | id: ThreadId.make("thread-new"), | ||
| 434 | projectId: project.id, | ||
| 435 | title: "Newer thread", | ||
| 436 | updatedAt: "2026-06-03T00:00:00.000Z", | ||
| 437 | }), | ||
| 438 | ]; | ||
| 439 | |||
| 440 | expect(buildGroups([project], threads)[0]?.threads.map((thread) => thread.id)).toEqual([ | ||
| 441 | "thread-new", | ||
| 442 | "thread-old", | ||
| 443 | ]); | ||
| 444 | }); | ||
| 445 | |||
| 446 | it("supports independent project and thread creation-time sorting", () => { | ||
| 447 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 448 | const olderProject = makeProject({ | ||
| 449 | environmentId, | ||
| 450 | id: ProjectId.make("project-older"), | ||
| 451 | title: "Older project", | ||
| 452 | }); | ||
| 453 | const newerProject = makeProject({ | ||
| 454 | environmentId, | ||
| 455 | id: ProjectId.make("project-newer"), | ||
| 456 | title: "Newer project", | ||
| 457 | }); | ||
| 458 | const threads = [ | ||
| 459 | makeThread({ | ||
| 460 | environmentId, | ||
| 461 | id: ThreadId.make("old-created"), | ||
| 462 | projectId: olderProject.id, | ||
| 463 | title: "Updated recently", | ||
| 464 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 465 | updatedAt: "2026-06-05T00:00:00.000Z", | ||
| 466 | }), | ||
| 467 | makeThread({ | ||
| 468 | environmentId, | ||
| 469 | id: ThreadId.make("new-created"), | ||
| 470 | projectId: olderProject.id, | ||
| 471 | title: "Created recently", | ||
| 472 | createdAt: "2026-06-04T00:00:00.000Z", | ||
| 473 | updatedAt: "2026-06-04T00:00:00.000Z", | ||
| 474 | }), | ||
| 475 | makeThread({ | ||
| 476 | environmentId, | ||
| 477 | id: ThreadId.make("newest-project-thread"), | ||
| 478 | projectId: newerProject.id, | ||
| 479 | title: "Newest project", | ||
| 480 | createdAt: "2026-06-06T00:00:00.000Z", | ||
| 481 | }), | ||
| 482 | ]; | ||
| 483 | |||
| 484 | const groups = buildGroups([olderProject, newerProject], threads, { | ||
| 485 | projectSortOrder: "created_at", | ||
| 486 | threadSortOrder: "created_at", | ||
| 487 | projectGroupingMode: "separate", | ||
| 488 | }); | ||
| 489 | |||
| 490 | expect(groups.map((group) => group.representative.id)).toEqual([ | ||
| 491 | "project-newer", | ||
| 492 | "project-older", | ||
| 493 | ]); | ||
| 494 | expect(groups[1]?.threads.map((thread) => thread.id)).toEqual(["new-created", "old-created"]); | ||
| 495 | }); | ||
| 496 | |||
| 497 | it("filters both projects and threads to one environment", () => { | ||
| 498 | const localEnvironmentId = EnvironmentId.make("environment-local"); | ||
| 499 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); | ||
| 500 | const projects = [ | ||
| 501 | makeProject({ | ||
| 502 | environmentId: localEnvironmentId, | ||
| 503 | id: ProjectId.make("project-local"), | ||
| 504 | title: "Local", | ||
| 505 | }), | ||
| 506 | makeProject({ | ||
| 507 | environmentId: remoteEnvironmentId, | ||
| 508 | id: ProjectId.make("project-remote"), | ||
| 509 | title: "Remote", | ||
| 510 | }), | ||
| 511 | ]; | ||
| 512 | const threads = projects.map((project) => | ||
| 513 | makeThread({ | ||
| 514 | environmentId: project.environmentId, | ||
| 515 | id: ThreadId.make(`thread-${project.id}`), | ||
| 516 | projectId: project.id, | ||
| 517 | title: project.title, | ||
| 518 | }), | ||
| 519 | ); | ||
| 520 | |||
| 521 | const groups = buildGroups(projects, threads, { environmentId: remoteEnvironmentId }); | ||
| 522 | |||
| 523 | expect(groups).toHaveLength(1); | ||
| 524 | expect(groups[0]?.representative.environmentId).toBe(remoteEnvironmentId); | ||
| 525 | expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); | ||
| 526 | }); | ||
| 527 | |||
| 528 | it("matches web repository, repository-path, and separate grouping modes", () => { | ||
| 529 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 530 | const repositoryIdentity = { | ||
| 531 | canonicalKey: "github.com/t3tools/t3code", | ||
| 532 | locator: { | ||
| 533 | source: "git-remote" as const, | ||
| 534 | remoteName: "origin", | ||
| 535 | remoteUrl: "git@github.com:t3tools/t3code.git", | ||
| 536 | }, | ||
| 537 | provider: "github", | ||
| 538 | owner: "t3tools", | ||
| 539 | name: "t3code", | ||
| 540 | displayName: "T3 Code", | ||
| 541 | rootPath: "/workspaces/t3code", | ||
| 542 | }; | ||
| 543 | const projects = [ | ||
| 544 | makeProject({ | ||
| 545 | environmentId, | ||
| 546 | id: ProjectId.make("project-web"), | ||
| 547 | title: "Web", | ||
| 548 | workspaceRoot: "/workspaces/t3code/apps/web", | ||
| 549 | repositoryIdentity, | ||
| 550 | }), | ||
| 551 | makeProject({ | ||
| 552 | environmentId, | ||
| 553 | id: ProjectId.make("project-mobile"), | ||
| 554 | title: "Mobile", | ||
| 555 | workspaceRoot: "/workspaces/t3code/apps/mobile", | ||
| 556 | repositoryIdentity, | ||
| 557 | }), | ||
| 558 | ]; | ||
| 559 | const threads = projects.map((project) => | ||
| 560 | makeThread({ | ||
| 561 | environmentId, | ||
| 562 | id: ThreadId.make(`thread-${project.id}`), | ||
| 563 | projectId: project.id, | ||
| 564 | title: project.title, | ||
| 565 | }), | ||
| 566 | ); | ||
| 567 | |||
| 568 | expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); | ||
| 569 | expect( | ||
| 570 | buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map( | ||
| 571 | (group) => group.title, | ||
| 572 | ), | ||
| 573 | ).toEqual(["Mobile", "Web"]); | ||
| 574 | expect( | ||
| 575 | buildGroups(projects, threads, { projectGroupingMode: "separate" }).map( | ||
| 576 | (group) => group.title, | ||
| 577 | ), | ||
| 578 | ).toEqual(["Mobile", "Web"]); | ||
| 579 | }); | ||
| 580 | |||
| 581 | it("default view shows only threads from the last 5 days", () => { | ||
| 582 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 583 | const project = makeProject({ | ||
| 584 | environmentId, | ||
| 585 | id: ProjectId.make("project-1"), | ||
| 586 | title: "T3 Code", | ||
| 587 | }); | ||
| 588 | const threads = [ | ||
| 589 | makeThread({ | ||
| 590 | environmentId, | ||
| 591 | id: ThreadId.make("recent-1"), | ||
| 592 | projectId: project.id, | ||
| 593 | title: "Today", | ||
| 594 | updatedAt: "2026-06-28T00:00:00.000Z", | ||
| 595 | }), | ||
| 596 | makeThread({ | ||
| 597 | environmentId, | ||
| 598 | id: ThreadId.make("recent-2"), | ||
| 599 | projectId: project.id, | ||
| 600 | title: "Within window", | ||
| 601 | updatedAt: "2026-06-25T00:00:00.000Z", | ||
| 602 | }), | ||
| 603 | makeThread({ | ||
| 604 | environmentId, | ||
| 605 | id: ThreadId.make("old"), | ||
| 606 | projectId: project.id, | ||
| 607 | title: "Two weeks ago", | ||
| 608 | updatedAt: "2026-06-14T00:00:00.000Z", | ||
| 609 | }), | ||
| 610 | ]; | ||
| 611 | |||
| 612 | const group = buildGroups([project], threads)[0]; | ||
| 613 | // Default view trims to recent threads... | ||
| 614 | expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2"]); | ||
| 615 | // ...while full history stays available for the expanded view. | ||
| 616 | expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]); | ||
| 617 | }); | ||
| 618 | |||
| 619 | it("keeps an old thread in the default view while a message waits in its outbox", () => { | ||
| 620 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 621 | const project = makeProject({ | ||
| 622 | environmentId, | ||
| 623 | id: ProjectId.make("project-1"), | ||
| 624 | title: "T3 Code", | ||
| 625 | }); | ||
| 626 | const threads = [ | ||
| 627 | makeThread({ | ||
| 628 | environmentId, | ||
| 629 | id: ThreadId.make("recent"), | ||
| 630 | projectId: project.id, | ||
| 631 | title: "Today", | ||
| 632 | updatedAt: "2026-06-28T00:00:00.000Z", | ||
| 633 | }), | ||
| 634 | makeThread({ | ||
| 635 | environmentId, | ||
| 636 | id: ThreadId.make("old-queued"), | ||
| 637 | projectId: project.id, | ||
| 638 | title: "Two weeks ago, follow-up queued offline", | ||
| 639 | updatedAt: "2026-06-14T00:00:00.000Z", | ||
| 640 | }), | ||
| 641 | makeThread({ | ||
| 642 | environmentId, | ||
| 643 | id: ThreadId.make("old"), | ||
| 644 | projectId: project.id, | ||
| 645 | title: "Two weeks ago", | ||
| 646 | updatedAt: "2026-06-13T00:00:00.000Z", | ||
| 647 | }), | ||
| 648 | ]; | ||
| 649 | |||
| 650 | const group = buildGroups([project], threads, { | ||
| 651 | queuedThreadKeys: new Set([`${environmentId}:old-queued`]), | ||
| 652 | })[0]; | ||
| 653 | expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent", "old-queued"]); | ||
| 654 | }); | ||
| 655 | |||
| 656 | it("falls back to the most recent 3 threads when none are within 5 days", () => { | ||
| 657 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 658 | const project = makeProject({ | ||
| 659 | environmentId, | ||
| 660 | id: ProjectId.make("project-1"), | ||
| 661 | title: "T3 Code", | ||
| 662 | }); | ||
| 663 | const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( | ||
| 664 | (day, index) => | ||
| 665 | makeThread({ | ||
| 666 | environmentId, | ||
| 667 | id: ThreadId.make(`thread-${index}`), | ||
| 668 | projectId: project.id, | ||
| 669 | title: `Thread ${index}`, | ||
| 670 | updatedAt: `${day}T00:00:00.000Z`, | ||
| 671 | }), | ||
| 672 | ); | ||
| 673 | |||
| 674 | const group = buildGroups([project], threads)[0]; | ||
| 675 | expect(group?.recentThreads.map((thread) => thread.id)).toEqual([ | ||
| 676 | "thread-4", | ||
| 677 | "thread-3", | ||
| 678 | "thread-2", | ||
| 679 | ]); | ||
| 680 | expect(group?.threads).toHaveLength(5); | ||
| 681 | }); | ||
| 682 | |||
| 683 | it("does not apply the recency window while searching", () => { | ||
| 684 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 685 | const project = makeProject({ | ||
| 686 | environmentId, | ||
| 687 | id: ProjectId.make("project-1"), | ||
| 688 | title: "T3 Code", | ||
| 689 | }); | ||
| 690 | const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( | ||
| 691 | (day, index) => | ||
| 692 | makeThread({ | ||
| 693 | environmentId, | ||
| 694 | id: ThreadId.make(`thread-${index}`), | ||
| 695 | projectId: project.id, | ||
| 696 | title: `Thread ${index}`, | ||
| 697 | updatedAt: `${day}T00:00:00.000Z`, | ||
| 698 | }), | ||
| 699 | ); | ||
| 700 | |||
| 701 | const group = buildGroups([project], threads, { searchQuery: "T3 Code" })[0]; | ||
| 702 | // Search reaches the full history rather than the 3-thread fallback. | ||
| 703 | expect(group?.recentThreads).toHaveLength(5); | ||
| 704 | expect(group?.recentThreads.map((thread) => thread.id)).toEqual( | ||
| 705 | group?.threads.map((thread) => thread.id), | ||
| 706 | ); | ||
| 707 | }); | ||
| 708 | |||
| 709 | it("includes a thread matched by message content", () => { | ||
| 710 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 711 | const project = makeProject({ | ||
| 712 | environmentId, | ||
| 713 | id: ProjectId.make("project-1"), | ||
| 714 | title: "T3 Code", | ||
| 715 | }); | ||
| 716 | const thread = makeThread({ | ||
| 717 | environmentId, | ||
| 718 | id: ThreadId.make("thread-content"), | ||
| 719 | projectId: project.id, | ||
| 720 | title: "Unrelated title", | ||
| 721 | }); | ||
| 722 | |||
| 723 | const groups = buildGroups([project], [thread], { | ||
| 724 | searchQuery: "relay reconnect", | ||
| 725 | matchedThreadKeys: new Set([ | ||
| 726 | threadSearchMatchKey({ | ||
| 727 | environmentId, | ||
| 728 | threadId: thread.id, | ||
| 729 | }), | ||
| 730 | ]), | ||
| 731 | }); | ||
| 732 | |||
| 733 | expect(groups[0]?.threads.map((candidate) => candidate.id)).toEqual(["thread-content"]); | ||
| 734 | }); | ||
| 735 | |||
| 736 | it("targets quick new threads at the group member with the newest thread", () => { | ||
| 737 | const laptopEnv = EnvironmentId.make("environment-laptop"); | ||
| 738 | const desktopEnv = EnvironmentId.make("environment-desktop"); | ||
| 739 | const repositoryIdentity = { | ||
| 740 | canonicalKey: "github.com/pingdotgg/t3code", | ||
| 741 | locator: { | ||
| 742 | source: "git-remote" as const, | ||
| 743 | remoteName: "origin", | ||
| 744 | remoteUrl: "git@github.com:pingdotgg/t3code.git", | ||
| 745 | }, | ||
| 746 | }; | ||
| 747 | const laptopProject = makeProject({ | ||
| 748 | environmentId: laptopEnv, | ||
| 749 | id: ProjectId.make("project-laptop"), | ||
| 750 | title: "t3code", | ||
| 751 | repositoryIdentity, | ||
| 752 | }); | ||
| 753 | const desktopProject = makeProject({ | ||
| 754 | environmentId: desktopEnv, | ||
| 755 | id: ProjectId.make("project-desktop"), | ||
| 756 | title: "t3code", | ||
| 757 | repositoryIdentity, | ||
| 758 | }); | ||
| 759 | const threads = [ | ||
| 760 | makeThread({ | ||
| 761 | environmentId: laptopEnv, | ||
| 762 | id: ThreadId.make("thread-laptop"), | ||
| 763 | projectId: laptopProject.id, | ||
| 764 | title: "Older laptop thread", | ||
| 765 | updatedAt: "2026-06-27T00:00:00.000Z", | ||
| 766 | }), | ||
| 767 | makeThread({ | ||
| 768 | environmentId: desktopEnv, | ||
| 769 | id: ThreadId.make("thread-desktop"), | ||
| 770 | projectId: desktopProject.id, | ||
| 771 | title: "Newest desktop thread", | ||
| 772 | updatedAt: "2026-06-28T00:00:00.000Z", | ||
| 773 | }), | ||
| 774 | ]; | ||
| 775 | |||
| 776 | // Aggregated into one group by repository; the quick new-thread target | ||
| 777 | // must follow the newest thread (desktop), not the arbitrary first member. | ||
| 778 | const groups = buildGroups([laptopProject, desktopProject], threads); | ||
| 779 | expect(groups).toHaveLength(1); | ||
| 780 | expect(groups[0]?.projects).toHaveLength(2); | ||
| 781 | expect(groups[0]?.newThreadTarget?.environmentId).toBe(desktopEnv); | ||
| 782 | expect(groups[0]?.newThreadTarget?.id).toBe(desktopProject.id); | ||
| 783 | }); | ||
| 784 | }); | 292 | }); |
apps/mobile/src/features/home/homeThreadList.ts-249| ... | @@ -141,252 +141,3 @@ export function sortHomeProjectScopes(input: { | ... | @@ -141,252 +141,3 @@ export function sortHomeProjectScopes(input: { |
| 141 | const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000; | 141 | const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000; |
| 142 | /** Fallback when a project has no threads inside the recency window. */ | 142 | /** Fallback when a project has no threads inside the recency window. */ |
| 143 | const RECENT_THREAD_FALLBACK_COUNT = 3; | 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,23 +2,19 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell |
| 2 | import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; | 2 | import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; |
| 3 | import { useCallback } from "react"; | 3 | import { useCallback } from "react"; |
| 4 | 4 | ||
| 5 | import type { HomeListItem } from "../home/homeListItems"; | ||
| 6 | import type { ThreadListV2ListItem } from "../threads/threadListV2"; | 5 | import type { ThreadListV2ListItem } from "../threads/threadListV2"; |
| 7 | import { | 6 | import { |
| 8 | useHardwareKeyboardCommand, | 7 | useHardwareKeyboardCommand, |
| 9 | type HardwareKeyboardCommand, | 8 | type HardwareKeyboardCommand, |
| 10 | } from "./hardwareKeyboardCommands"; | 9 | } from "./hardwareKeyboardCommands"; |
| 11 | 10 | ||
| 12 | type ThreadShortcutListItem = | 11 | type ThreadShortcutListItem = ThreadListV2ListItem | { readonly type: "v2-show-more" }; |
| 13 | | HomeListItem | ||
| 14 | | ThreadListV2ListItem | ||
| 15 | | { readonly type: "v2-show-more" }; | ||
| 16 | 12 | ||
| 17 | export function threadJumpIndex(command: HardwareKeyboardCommand) { | 13 | export function threadJumpIndex(command: HardwareKeyboardCommand) { |
| 18 | return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command); | 14 | return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command); |
| 19 | } | 15 | } |
| 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 | export function threadJumpTarget( | 18 | export function threadJumpTarget( |
| 23 | items: ReadonlyArray<ThreadShortcutListItem>, | 19 | items: ReadonlyArray<ThreadShortcutListItem>, |
| 24 | command: HardwareKeyboardCommand, | 20 | command: HardwareKeyboardCommand, |
| ... | @@ -26,8 +22,7 @@ export function threadJumpTarget( | ... | @@ -26,8 +22,7 @@ export function threadJumpTarget( |
| 26 | let index = threadJumpIndex(command); | 22 | let index = threadJumpIndex(command); |
| 27 | if (index < 0) return null; | 23 | if (index < 0) return null; |
| 28 | for (const item of items) { | 24 | for (const item of items) { |
| 29 | const thread = | 25 | const thread = item.type === "v2-thread" ? item.item.thread : null; |
| 30 | item.type === "thread" ? item.thread : item.type === "v2-thread" ? item.item.thread : null; | ||
| 31 | if (thread !== null && index-- === 0) return thread; | 26 | if (thread !== null && index-- === 0) return thread; |
| 32 | } | 27 | } |
| 33 | return null; | 28 | return null; |
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx-8| ... | @@ -12,7 +12,6 @@ import { AppText as Text } from "../../components/AppText"; | ... | @@ -12,7 +12,6 @@ import { AppText as Text } from "../../components/AppText"; |
| 12 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 12 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 13 | import { serverEnvironment } from "../../state/server"; | 13 | import { serverEnvironment } from "../../state/server"; |
| 14 | import { useAtomCommand } from "../../state/use-atom-command"; | 14 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 15 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | ||
| 16 | import { SettingsSection } from "./components/SettingsSection"; | 15 | import { SettingsSection } from "./components/SettingsSection"; |
| 17 | import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; | 16 | import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; |
| 18 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; | 17 | import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; |
| ... | @@ -224,19 +223,12 @@ function AutoSettleSettingsRows() { | ... | @@ -224,19 +223,12 @@ function AutoSettleSettingsRows() { |
| 224 | function LegacySettingsSection() { | 223 | function LegacySettingsSection() { |
| 225 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | 224 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 226 | const preferences = useAtomValue(mobilePreferencesAtom); | 225 | const preferences = useAtomValue(mobilePreferencesAtom); |
| 227 | const threadListV2Enabled = useThreadListV2Enabled(); | ||
| 228 | const planModeEnabled = | 226 | const planModeEnabled = |
| 229 | AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; | 227 | AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true; |
| 230 | 228 | ||
| 231 | return ( | 229 | return ( |
| 232 | <View className="gap-3"> | 230 | <View className="gap-3"> |
| 233 | <SettingsSection title="Legacy"> | 231 | <SettingsSection title="Legacy"> |
| 234 | <SettingsSwitchRow | ||
| 235 | icon="sidebar.left" | ||
| 236 | label="Legacy Thread List" | ||
| 237 | value={!threadListV2Enabled} | ||
| 238 | onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })} | ||
| 239 | /> | ||
| 240 | <SettingsSwitchRow | 232 | <SettingsSwitchRow |
| 241 | icon="hammer" | 233 | icon="hammer" |
| 242 | label="Plan Mode" | 234 | label="Plan Mode" |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+15-295| ... | @@ -29,7 +29,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; | ... | @@ -29,7 +29,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 29 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; | 29 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 30 | import { useProjects, useThreadShells } from "../../state/entities"; | 30 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 31 | import { useThreadSearch } from "../../state/queries"; | 31 | import { useThreadSearch } from "../../state/queries"; |
| 32 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; | ||
| 33 | import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; | 32 | import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; |
| 34 | import { usePendingThreadOrder } from "../../state/thread-order"; | 33 | import { usePendingThreadOrder } from "../../state/thread-order"; |
| 35 | import { environmentServerConfigsAtom } from "../../state/server"; | 34 | import { environmentServerConfigsAtom } from "../../state/server"; |
| ... | @@ -39,24 +38,9 @@ import { useWorkspaceState } from "../../state/workspace"; | ... | @@ -39,24 +38,9 @@ import { useWorkspaceState } from "../../state/workspace"; |
| 39 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 38 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 40 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 39 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| 41 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; | 40 | import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; |
| 42 | import { | 41 | import { useHomeListOptions } from "../home/home-list-options"; |
| 43 | hasCustomHomeListOptions, | ||
| 44 | PROJECT_SORT_OPTIONS, | ||
| 45 | THREAD_SORT_OPTIONS, | ||
| 46 | useHomeListOptions, | ||
| 47 | } from "../home/home-list-options"; | ||
| 48 | import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; | 42 | import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; |
| 49 | import { | 43 | import { buildHomeProjectScopes } from "../home/homeThreadList"; |
| 50 | buildHomeListLayout, | ||
| 51 | DEFAULT_GROUP_DISPLAY_STATE, | ||
| 52 | EMPTY_HOME_LIST_LAYOUT, | ||
| 53 | homeListItemsAreEqual, | ||
| 54 | nextGroupDisplayState, | ||
| 55 | type HomeGroupDisplayAction, | ||
| 56 | type HomeGroupDisplayState, | ||
| 57 | type HomeListItem, | ||
| 58 | } from "../home/homeListItems"; | ||
| 59 | import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; | ||
| 60 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; | 44 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; |
| 61 | import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; | 45 | import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; |
| 62 | import { useThreadListActions } from "../home/useThreadListActions"; | 46 | import { useThreadListActions } from "../home/useThreadListActions"; |
| ... | @@ -71,12 +55,6 @@ import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; | ... | @@ -71,12 +55,6 @@ import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; |
| 71 | import { SidebarFilterButton } from "./sidebar-filter-button"; | 55 | import { SidebarFilterButton } from "./sidebar-filter-button"; |
| 72 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; | 56 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; |
| 73 | import { SidebarNavigationShell } from "./sidebar-navigation-shell"; | 57 | import { SidebarNavigationShell } from "./sidebar-navigation-shell"; |
| 74 | import { | ||
| 75 | PendingTaskListRow, | ||
| 76 | ThreadListGroupHeader, | ||
| 77 | ThreadListRow, | ||
| 78 | ThreadListShowMoreRow, | ||
| 79 | } from "./thread-list-items"; | ||
| 80 | import { | 58 | import { |
| 81 | ThreadListV2PendingRow, | 59 | ThreadListV2PendingRow, |
| 82 | ThreadListV2Row, | 60 | ThreadListV2Row, |
| ... | @@ -96,11 +74,9 @@ import { | ... | @@ -96,11 +74,9 @@ import { |
| 96 | type ThreadListV2ListItem, | 74 | type ThreadListV2ListItem, |
| 97 | } from "./threadListV2"; | 75 | } from "./threadListV2"; |
| 98 | 76 | ||
| 99 | /** The sidebar list serves both lists: v1 grouped items or, when the Thread | 77 | /** The sidebar list: flat v2 rows with queued tasks spliced in, plus a |
| 100 | List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled | 78 | settled "Show more" pager row. */ |
| 101 | "Show more" pager. */ | ||
| 102 | type SidebarListItem = | 79 | type SidebarListItem = |
| 103 | | HomeListItem | ||
| 104 | | ThreadListV2ListItem | 80 | | ThreadListV2ListItem |
| 105 | | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; | 81 | | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; |
| 106 | 82 | ||
| ... | @@ -178,7 +154,6 @@ function ThreadNavigationSidebarPane( | ... | @@ -178,7 +154,6 @@ function ThreadNavigationSidebarPane( |
| 178 | renameThread, | 154 | renameThread, |
| 179 | regenerateThreadTitle, | 155 | regenerateThreadTitle, |
| 180 | } = useThreadListActions(); | 156 | } = useThreadListActions(); |
| 181 | const threadListV2Enabled = useThreadListV2Enabled(); | ||
| 182 | const pendingTasks = usePendingNewTasks(); | 157 | const pendingTasks = usePendingNewTasks(); |
| 183 | const queuedThreadKeys = useQueuedThreadKeys(); | 158 | const queuedThreadKeys = useQueuedThreadKeys(); |
| 184 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); | 159 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); |
| ... | @@ -196,8 +171,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -196,8 +171,7 @@ function ThreadNavigationSidebarPane( |
| 196 | () => new Set(environments.map((environment) => environment.environmentId)), | 171 | () => new Set(environments.map((environment) => environment.environmentId)), |
| 197 | [environments], | 172 | [environments], |
| 198 | ); | 173 | ); |
| 199 | const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } = | 174 | const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds); |
| 200 | useHomeListOptions(availableEnvironmentIds); | ||
| 201 | const searchEnvironmentIds = useMemo( | 175 | const searchEnvironmentIds = useMemo( |
| 202 | () => | 176 | () => |
| 203 | options.selectedEnvironmentId === null | 177 | options.selectedEnvironmentId === null |
| ... | @@ -286,93 +260,6 @@ function ThreadNavigationSidebarPane( | ... | @@ -286,93 +260,6 @@ function ThreadNavigationSidebarPane( |
| 286 | ), | 260 | ), |
| 287 | [selectedProjectScope], | 261 | [selectedProjectScope], |
| 288 | ); | 262 | ); |
| 289 | const scopedProjects = useMemo( | ||
| 290 | () => | ||
| 291 | threadListV2Enabled | ||
| 292 | ? [] | ||
| 293 | : selectedProjectRefs === null | ||
| 294 | ? projects | ||
| 295 | : projects.filter((project) => | ||
| 296 | selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), | ||
| 297 | ), | ||
| 298 | [threadListV2Enabled, projects, selectedProjectRefs], | ||
| 299 | ); | ||
| 300 | const scopedThreads = useMemo( | ||
| 301 | () => | ||
| 302 | threadListV2Enabled | ||
| 303 | ? [] | ||
| 304 | : selectedProjectRefs === null | ||
| 305 | ? threads | ||
| 306 | : threads.filter((thread) => | ||
| 307 | selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), | ||
| 308 | ), | ||
| 309 | [threadListV2Enabled, selectedProjectRefs, threads], | ||
| 310 | ); | ||
| 311 | const scopedPendingTasks = useMemo( | ||
| 312 | () => | ||
| 313 | threadListV2Enabled | ||
| 314 | ? [] | ||
| 315 | : selectedProjectRefs === null | ||
| 316 | ? pendingTasks | ||
| 317 | : pendingTasks.filter((pendingTask) => | ||
| 318 | selectedProjectRefs.has( | ||
| 319 | scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), | ||
| 320 | ), | ||
| 321 | ), | ||
| 322 | [threadListV2Enabled, pendingTasks, selectedProjectRefs], | ||
| 323 | ); | ||
| 324 | const groups = useMemo( | ||
| 325 | () => | ||
| 326 | threadListV2Enabled | ||
| 327 | ? [] | ||
| 328 | : buildHomeThreadGroups({ | ||
| 329 | projects: scopedProjects, | ||
| 330 | threads: scopedThreads, | ||
| 331 | pendingTasks: scopedPendingTasks, | ||
| 332 | queuedThreadKeys, | ||
| 333 | environmentId: options.selectedEnvironmentId, | ||
| 334 | searchQuery: props.searchQuery, | ||
| 335 | matchedThreadKeys, | ||
| 336 | projectSortOrder: options.projectSortOrder, | ||
| 337 | threadSortOrder: options.threadSortOrder, | ||
| 338 | projectGroupingMode: options.projectGroupingMode, | ||
| 339 | }), | ||
| 340 | [ | ||
| 341 | threadListV2Enabled, | ||
| 342 | queuedThreadKeys, | ||
| 343 | matchedThreadKeys, | ||
| 344 | options, | ||
| 345 | props.searchQuery, | ||
| 346 | scopedPendingTasks, | ||
| 347 | scopedProjects, | ||
| 348 | scopedThreads, | ||
| 349 | ], | ||
| 350 | ); | ||
| 351 | const [groupDisplayStates, setGroupDisplayStates] = useState< | ||
| 352 | ReadonlyMap<string, HomeGroupDisplayState> | ||
| 353 | >(() => new Map()); | ||
| 354 | const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { | ||
| 355 | setGroupDisplayStates((previous) => { | ||
| 356 | const next = new Map(previous); | ||
| 357 | next.set( | ||
| 358 | key, | ||
| 359 | nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), | ||
| 360 | ); | ||
| 361 | return next; | ||
| 362 | }); | ||
| 363 | }, []); | ||
| 364 | const hasSearchQuery = props.searchQuery.trim().length > 0; | ||
| 365 | const listLayout = useMemo( | ||
| 366 | () => | ||
| 367 | threadListV2Enabled | ||
| 368 | ? EMPTY_HOME_LIST_LAYOUT | ||
| 369 | : buildHomeListLayout({ | ||
| 370 | groups, | ||
| 371 | displayStates: groupDisplayStates, | ||
| 372 | showAllThreads: hasSearchQuery, | ||
| 373 | }), | ||
| 374 | [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery], | ||
| 375 | ); | ||
| 376 | const projectByKey = useMemo(() => { | 263 | const projectByKey = useMemo(() => { |
| 377 | const map = new Map<string, EnvironmentProject>(); | 264 | const map = new Map<string, EnvironmentProject>(); |
| 378 | for (const project of projects) { | 265 | for (const project of projects) { |
| ... | @@ -412,12 +299,11 @@ function ThreadNavigationSidebarPane( | ... | @@ -412,12 +299,11 @@ function ThreadNavigationSidebarPane( |
| 412 | // thread reappears immediately instead of on the next minute tick. | 299 | // thread reappears immediately instead of on the next minute tick. |
| 413 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); | 300 | const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); |
| 414 | useEffect(() => { | 301 | useEffect(() => { |
| 415 | if (!threadListV2Enabled) return; | ||
| 416 | // Refresh immediately because the mount-time value can be hours old. | 302 | // Refresh immediately because the mount-time value can be hours old. |
| 417 | setNowMinute(new Date().toISOString().slice(0, 16)); | 303 | setNowMinute(new Date().toISOString().slice(0, 16)); |
| 418 | const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); | 304 | const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); |
| 419 | return () => clearInterval(id); | 305 | return () => clearInterval(id); |
| 420 | }, [threadListV2Enabled]); | 306 | }, []); |
| 421 | // Threads on servers without the settlement capability never classify as | 307 | // Threads on servers without the settlement capability never classify as |
| 422 | // settled (the user could neither un-settle nor pin them). | 308 | // settled (the user could neither un-settle nor pin them). |
| 423 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); | 309 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); |
| ... | @@ -529,16 +415,6 @@ function ThreadNavigationSidebarPane( | ... | @@ -529,16 +415,6 @@ function ThreadNavigationSidebarPane( |
| 529 | snoozeWakeTick, | 415 | snoozeWakeTick, |
| 530 | ]); | 416 | ]); |
| 531 | const threadListV2Layout = useMemo(() => { | 417 | const threadListV2Layout = useMemo(() => { |
| 532 | if (!threadListV2Enabled) | ||
| 533 | return { | ||
| 534 | items: [], | ||
| 535 | hiddenSettledCount: 0, | ||
| 536 | snoozedCount: 0, | ||
| 537 | snoozedShelfHeaderIndex: null, | ||
| 538 | settledCount: 0, | ||
| 539 | settledShelfHeaderIndex: null, | ||
| 540 | nextSnoozeWakeAt: null, | ||
| 541 | }; | ||
| 542 | return buildThreadListV2Items({ | 418 | return buildThreadListV2Items({ |
| 543 | pendingOrder, | 419 | pendingOrder, |
| 544 | threads: threads.filter((thread) => thread.archivedAt === null), | 420 | threads: threads.filter((thread) => thread.archivedAt === null), |
| ... | @@ -569,7 +445,6 @@ function ThreadNavigationSidebarPane( | ... | @@ -569,7 +445,6 @@ function ThreadNavigationSidebarPane( |
| 569 | settledVisibleCount, | 445 | settledVisibleCount, |
| 570 | settlementEnvironmentIds, | 446 | settlementEnvironmentIds, |
| 571 | snoozeEnvironmentIds, | 447 | snoozeEnvironmentIds, |
| 572 | threadListV2Enabled, | ||
| 573 | threads, | 448 | threads, |
| 574 | selectedProjectScope, | 449 | selectedProjectScope, |
| 575 | ]); | 450 | ]); |
| ... | @@ -588,7 +463,6 @@ function ThreadNavigationSidebarPane( | ... | @@ -588,7 +463,6 @@ function ThreadNavigationSidebarPane( |
| 588 | // range) the boundary string is identical and the chain would die. | 463 | // range) the boundary string is identical and the chain would die. |
| 589 | }, [nextSnoozeWakeAt, snoozeWakeTick]); | 464 | }, [nextSnoozeWakeAt, snoozeWakeTick]); |
| 590 | const listItems = useMemo<readonly SidebarListItem[]>(() => { | 465 | const listItems = useMemo<readonly SidebarListItem[]>(() => { |
| 591 | if (!threadListV2Enabled) return listLayout.items; | ||
| 592 | // Queued offline tasks are not thread shells, so the v2 item builder | 466 | // Queued offline tasks are not thread shells, so the v2 item builder |
| 593 | // never sees them; the shared splice puts them below the active block | 467 | // never sees them; the shared splice puts them below the active block |
| 594 | // (mirrors the compact Home v2 list) where they stay visible and | 468 | // (mirrors the compact Home v2 list) where they stay visible and |
| ... | @@ -630,7 +504,6 @@ function ThreadNavigationSidebarPane( | ... | @@ -630,7 +504,6 @@ function ThreadNavigationSidebarPane( |
| 630 | } | 504 | } |
| 631 | return items; | 505 | return items; |
| 632 | }, [ | 506 | }, [ |
| 633 | listLayout.items, | ||
| 634 | nowMinute, | 507 | nowMinute, |
| 635 | options.selectedEnvironmentId, | 508 | options.selectedEnvironmentId, |
| 636 | pendingTasks, | 509 | pendingTasks, |
| ... | @@ -642,7 +515,6 @@ function ThreadNavigationSidebarPane( | ... | @@ -642,7 +515,6 @@ function ThreadNavigationSidebarPane( |
| 642 | shelfPreferencesLoaded, | 515 | shelfPreferencesLoaded, |
| 643 | snoozedShelfExpanded, | 516 | snoozedShelfExpanded, |
| 644 | snoozeEnvironmentIds, | 517 | snoozeEnvironmentIds, |
| 645 | threadListV2Enabled, | ||
| 646 | threadListV2Layout, | 518 | threadListV2Layout, |
| 647 | ]); | 519 | ]); |
| 648 | const listMenuActions = useMemo<MenuAction[]>( | 520 | const listMenuActions = useMemo<MenuAction[]>( |
| ... | @@ -688,33 +560,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -688,33 +560,8 @@ function ThreadNavigationSidebarPane( |
| 688 | ], | 560 | ], |
| 689 | }, | 561 | }, |
| 690 | ] satisfies MenuAction[])), | 562 | ] satisfies MenuAction[])), |
| 691 | // v2 lays the list out in fixed creation order — offering sort/group | ||
| 692 | // controls it silently ignores would be a lie. Environment still | ||
| 693 | // scopes the v2 partition, so it stays. | ||
| 694 | ...(threadListV2Enabled | ||
| 695 | ? [] | ||
| 696 | : ([ | ||
| 697 | { | ||
| 698 | id: "project-sort", | ||
| 699 | title: "Sort projects", | ||
| 700 | subactions: PROJECT_SORT_OPTIONS.map((option) => ({ | ||
| 701 | id: `project-sort:${option.value}`, | ||
| 702 | title: option.label, | ||
| 703 | state: options.projectSortOrder === option.value ? "on" : "off", | ||
| 704 | })), | ||
| 705 | }, | ||
| 706 | { | ||
| 707 | id: "thread-sort", | ||
| 708 | title: "Sort threads", | ||
| 709 | subactions: THREAD_SORT_OPTIONS.map((option) => ({ | ||
| 710 | id: `thread-sort:${option.value}`, | ||
| 711 | title: option.label, | ||
| 712 | state: options.threadSortOrder === option.value ? "on" : "off", | ||
| 713 | })), | ||
| 714 | }, | ||
| 715 | ] satisfies MenuAction[])), | ||
| 716 | ], | 563 | ], |
| 717 | [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], | 564 | [environments, options, projectFilterOptions, selectedProjectKey], |
| 718 | ); | 565 | ); |
| 719 | const handleListMenuAction = useCallback( | 566 | const handleListMenuAction = useCallback( |
| 720 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { | 567 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { |
| ... | @@ -741,28 +588,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -741,28 +588,8 @@ function ThreadNavigationSidebarPane( |
| 741 | } | 588 | } |
| 742 | return; | 589 | return; |
| 743 | } | 590 | } |
| 744 | const projectSort = PROJECT_SORT_OPTIONS.find( | ||
| 745 | (option) => `project-sort:${option.value}` === event, | ||
| 746 | ); | ||
| 747 | if (projectSort) { | ||
| 748 | setProjectSortOrder(projectSort.value); | ||
| 749 | return; | ||
| 750 | } | ||
| 751 | const threadSort = THREAD_SORT_OPTIONS.find( | ||
| 752 | (option) => `thread-sort:${option.value}` === event, | ||
| 753 | ); | ||
| 754 | if (threadSort) { | ||
| 755 | setThreadSortOrder(threadSort.value); | ||
| 756 | return; | ||
| 757 | } | ||
| 758 | }, | 591 | }, |
| 759 | [ | 592 | [environments, projectFilterOptions, setSelectedEnvironmentId], |
| 760 | environments, | ||
| 761 | projectFilterOptions, | ||
| 762 | setProjectSortOrder, | ||
| 763 | setSelectedEnvironmentId, | ||
| 764 | setThreadSortOrder, | ||
| 765 | ], | ||
| 766 | ); | 593 | ); |
| 767 | 594 | ||
| 768 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); | 595 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); |
| ... | @@ -838,15 +665,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -838,15 +665,7 @@ function ThreadNavigationSidebarPane( |
| 838 | if (previous.type === "v2-show-more" && item.type === "v2-show-more") { | 665 | if (previous.type === "v2-show-more" && item.type === "v2-show-more") { |
| 839 | return previous.hiddenCount === item.hiddenCount; | 666 | return previous.hiddenCount === item.hiddenCount; |
| 840 | } | 667 | } |
| 841 | if ( | 668 | return false; |
| 842 | isThreadListV2ListItem(previous) || | ||
| 843 | previous.type === "v2-show-more" || | ||
| 844 | isThreadListV2ListItem(item) || | ||
| 845 | item.type === "v2-show-more" | ||
| 846 | ) { | ||
| 847 | return false; | ||
| 848 | } | ||
| 849 | return homeListItemsAreEqual(previous, item); | ||
| 850 | }, | 669 | }, |
| 851 | [], | 670 | [], |
| 852 | ); | 671 | ); |
| ... | @@ -994,90 +813,11 @@ function ThreadNavigationSidebarPane( | ... | @@ -994,90 +813,11 @@ function ThreadNavigationSidebarPane( |
| 994 | onPress={showMoreSettled} | 813 | onPress={showMoreSettled} |
| 995 | /> | 814 | /> |
| 996 | ); | 815 | ); |
| 997 | case "header": | ||
| 998 | return ( | ||
| 999 | <ThreadListGroupHeader | ||
| 1000 | variant="sidebar" | ||
| 1001 | collapsed={item.collapsed} | ||
| 1002 | isFirst={item.isFirst} | ||
| 1003 | groupKey={item.group.key} | ||
| 1004 | onGroupAction={updateGroupDisplay} | ||
| 1005 | // Same gating as the compact Home list: aggregated groups have no | ||
| 1006 | // single target project, and pending-project groups hold a | ||
| 1007 | // placeholder shell rather than a real project. | ||
| 1008 | newThreadTarget={item.group.newThreadTarget} | ||
| 1009 | onNewThread={props.onNewThreadInProject} | ||
| 1010 | project={item.group.representative} | ||
| 1011 | threadCount={item.group.threads.length + item.group.pendingTasks.length} | ||
| 1012 | title={item.group.title} | ||
| 1013 | /> | ||
| 1014 | ); | ||
| 1015 | case "pending-task": | ||
| 1016 | return ( | ||
| 1017 | <PendingTaskListRow | ||
| 1018 | variant="sidebar" | ||
| 1019 | pendingTask={item.pendingTask} | ||
| 1020 | environmentLabel={ | ||
| 1021 | savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null | ||
| 1022 | } | ||
| 1023 | environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} | ||
| 1024 | isLast={item.isLast} | ||
| 1025 | onSelectPendingTask={openPendingTask} | ||
| 1026 | onDeletePendingTask={confirmDeletePendingTask} | ||
| 1027 | /> | ||
| 1028 | ); | ||
| 1029 | case "thread": { | ||
| 1030 | const thread = item.thread; | ||
| 1031 | return ( | ||
| 1032 | <ThreadListRow | ||
| 1033 | onNewThreadOnBranch={props.onNewThreadOnBranch} | ||
| 1034 | variant="sidebar" | ||
| 1035 | thread={thread} | ||
| 1036 | hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)} | ||
| 1037 | environmentLabel={ | ||
| 1038 | savedConnectionsById[thread.environmentId]?.environmentLabel ?? null | ||
| 1039 | } | ||
| 1040 | environmentMachine={machineByEnvironmentId.get(thread.environmentId)} | ||
| 1041 | isLast={item.isLast} | ||
| 1042 | searchMatch={threadSearchMatchByKey.get( | ||
| 1043 | threadSearchMatchKey({ | ||
| 1044 | environmentId: thread.environmentId, | ||
| 1045 | threadId: thread.id, | ||
| 1046 | }), | ||
| 1047 | )} | ||
| 1048 | searchQuery={props.searchQuery} | ||
| 1049 | selected={ | ||
| 1050 | scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey | ||
| 1051 | } | ||
| 1052 | fullSwipeWidth={props.width - 20} | ||
| 1053 | onArchiveThread={archiveThread} | ||
| 1054 | onDeleteThread={confirmDeleteThread} | ||
| 1055 | onRenameThread={renameThread} | ||
| 1056 | onRegenerateThreadTitle={regenerateThreadTitle} | ||
| 1057 | titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} | ||
| 1058 | onSelectThread={handleSelectThread} | ||
| 1059 | onSwipeableClose={handleSwipeableClose} | ||
| 1060 | onSwipeableWillOpen={handleSwipeableWillOpen} | ||
| 1061 | simultaneousSwipeGesture={sidebarScrollGesture} | ||
| 1062 | /> | ||
| 1063 | ); | ||
| 1064 | } | ||
| 1065 | case "show-more": | ||
| 1066 | return ( | ||
| 1067 | <ThreadListShowMoreRow | ||
| 1068 | variant="sidebar" | ||
| 1069 | hiddenCount={item.hiddenCount} | ||
| 1070 | canShowLess={item.canShowLess} | ||
| 1071 | groupKey={item.groupKey} | ||
| 1072 | onGroupAction={updateGroupDisplay} | ||
| 1073 | /> | ||
| 1074 | ); | ||
| 1075 | } | 816 | } |
| 1076 | }, | 817 | }, |
| 1077 | [ | 818 | [ |
| 1078 | archiveThread, | 819 | archiveThread, |
| 1079 | activeReorderEnvironmentIds, | 820 | activeReorderEnvironmentIds, |
| 1080 | queuedThreadKeys, | ||
| 1081 | confirmDeletePendingTask, | 821 | confirmDeletePendingTask, |
| 1082 | confirmDeleteThread, | 822 | confirmDeleteThread, |
| 1083 | handleSelectThread, | 823 | handleSelectThread, |
| ... | @@ -1093,13 +833,13 @@ function ThreadNavigationSidebarPane( | ... | @@ -1093,13 +833,13 @@ function ThreadNavigationSidebarPane( |
| 1093 | projectTitleByProjectKey, | 833 | projectTitleByProjectKey, |
| 1094 | regenerateThreadTitle, | 834 | regenerateThreadTitle, |
| 1095 | renameThread, | 835 | renameThread, |
| 836 | threadSearchMatchByKey, | ||
| 1096 | props.onNewThreadInProject, | 837 | props.onNewThreadInProject, |
| 1097 | props.onNewThreadOnBranch, | 838 | props.onNewThreadOnBranch, |
| 1098 | props.searchQuery, | 839 | props.searchQuery, |
| 1099 | props.selectedThreadKey, | 840 | props.selectedThreadKey, |
| 1100 | props.width, | 841 | props.width, |
| 1101 | savedConnectionsById, | 842 | savedConnectionsById, |
| 1102 | threadSearchMatchByKey, | ||
| 1103 | titleRegenerationEnvironmentIds, | 843 | titleRegenerationEnvironmentIds, |
| 1104 | settleThread, | 844 | settleThread, |
| 1105 | settlementEnvironmentIds, | 845 | settlementEnvironmentIds, |
| ... | @@ -1113,14 +853,11 @@ function ThreadNavigationSidebarPane( | ... | @@ -1113,14 +853,11 @@ function ThreadNavigationSidebarPane( |
| 1113 | unpinThread, | 853 | unpinThread, |
| 1114 | unsettleThread, | 854 | unsettleThread, |
| 1115 | unsnoozeThread, | 855 | unsnoozeThread, |
| 1116 | updateGroupDisplay, | ||
| 1117 | ], | 856 | ], |
| 1118 | ); | 857 | ); |
| 1119 | // v2 ignores the sort/group options, so only the environment filter can | 858 | // The list ignores sort/group options, so only the environment and project |
| 1120 | // light the "customized" state while the beta is on. | 859 | // filters can light the "customized" state. |
| 1121 | const filterCustomized = threadListV2Enabled | 860 | const filterCustomized = options.selectedEnvironmentId !== null || selectedProjectKey !== null; |
| 1122 | ? options.selectedEnvironmentId !== null || selectedProjectKey !== null | ||
| 1123 | : hasCustomHomeListOptions({ ...options, selectedProjectKey }); | ||
| 1124 | const filterIcon = filterCustomized | 861 | const filterIcon = filterCustomized |
| 1125 | ? "line.3.horizontal.decrease.circle.fill" | 862 | ? "line.3.horizontal.decrease.circle.fill" |
| 1126 | : "line.3.horizontal.decrease.circle"; | 863 | : "line.3.horizontal.decrease.circle"; |
| ... | @@ -1131,24 +868,10 @@ function ThreadNavigationSidebarPane( | ... | @@ -1131,24 +868,10 @@ function ThreadNavigationSidebarPane( |
| 1131 | projects: projectFilterOptions, | 868 | projects: projectFilterOptions, |
| 1132 | selectedEnvironmentId: options.selectedEnvironmentId, | 869 | selectedEnvironmentId: options.selectedEnvironmentId, |
| 1133 | selectedProjectKey, | 870 | selectedProjectKey, |
| 1134 | projectSortOrder: options.projectSortOrder, | ||
| 1135 | threadSortOrder: options.threadSortOrder, | ||
| 1136 | onEnvironmentChange: setSelectedEnvironmentId, | 871 | onEnvironmentChange: setSelectedEnvironmentId, |
| 1137 | onProjectChange: setSelectedProjectKey, | 872 | onProjectChange: setSelectedProjectKey, |
| 1138 | onProjectSortOrderChange: setProjectSortOrder, | ||
| 1139 | onThreadSortOrderChange: setThreadSortOrder, | ||
| 1140 | listOrganization: !threadListV2Enabled, | ||
| 1141 | }), | 873 | }), |
| 1142 | [ | 874 | [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId], |
| 1143 | environments, | ||
| 1144 | options, | ||
| 1145 | projectFilterOptions, | ||
| 1146 | selectedProjectKey, | ||
| 1147 | setProjectSortOrder, | ||
| 1148 | setSelectedEnvironmentId, | ||
| 1149 | setThreadSortOrder, | ||
| 1150 | threadListV2Enabled, | ||
| 1151 | ], | ||
| 1152 | ); | 875 | ); |
| 1153 | const nativeHeaderItems = useMemo( | 876 | const nativeHeaderItems = useMemo( |
| 1154 | () => | 877 | () => |
| ... | @@ -1358,10 +1081,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -1358,10 +1081,7 @@ function ThreadNavigationSidebarPane( |
| 1358 | /> | 1081 | /> |
| 1359 | <View className="flex-row items-center gap-2.5"> | 1082 | <View className="flex-row items-center gap-2.5"> |
| 1360 | <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}> | 1083 | <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}> |
| 1361 | <SidebarFilterButton | 1084 | <SidebarFilterButton accessibilityLabel="Filter threads" icon={filterIcon} /> |
| 1362 | accessibilityLabel="Filter and sort threads" | ||
| 1363 | icon={filterIcon} | ||
| 1364 | /> | ||
| 1365 | </ControlPillMenu> | 1085 | </ControlPillMenu> |
| 1366 | <SidebarHeaderActions onOpenSettings={props.onOpenSettings} /> | 1086 | <SidebarHeaderActions onOpenSettings={props.onOpenSettings} /> |
| 1367 | </View> | 1087 | </View> |
apps/mobile/src/features/threads/sidebar-native-header-items.ts+1-1| ... | @@ -45,7 +45,7 @@ export function createSidebarHeaderItems(input: { | ... | @@ -45,7 +45,7 @@ export function createSidebarHeaderItems(input: { |
| 45 | withNativeGlassHeaderItem({ | 45 | withNativeGlassHeaderItem({ |
| 46 | type: "menu", | 46 | type: "menu", |
| 47 | label: "", | 47 | label: "", |
| 48 | accessibilityLabel: "Filter and sort threads", | 48 | accessibilityLabel: "Filter threads", |
| 49 | icon: sfSymbolIcon(input.filterIcon), | 49 | icon: sfSymbolIcon(input.filterIcon), |
| 50 | menu: { | 50 | menu: { |
| 51 | title: input.filterMenu.title, | 51 | title: input.filterMenu.title, |
apps/mobile/src/features/threads/thread-list-items.tsx deleted-849| ... | @@ -1,849 +0,0 @@ | ||
| 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,7 +30,6 @@ import { |
| 30 | buildThreadListV2ListItems, | 30 | buildThreadListV2ListItems, |
| 31 | getThreadListV2OrderedSection, | 31 | getThreadListV2OrderedSection, |
| 32 | isThreadListV2ListItem, | 32 | isThreadListV2ListItem, |
| 33 | resolveThreadListV2Enabled, | ||
| 34 | resolveThreadListV2SnoozeMenuSelection, | 33 | resolveThreadListV2SnoozeMenuSelection, |
| 35 | resolveThreadListV2SnoozeGateExpiryMs, | 34 | resolveThreadListV2SnoozeGateExpiryMs, |
| 36 | resolveThreadListV2Status, | 35 | resolveThreadListV2Status, |
| ... | @@ -125,29 +124,6 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { | ... | @@ -125,29 +124,6 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { |
| 125 | }); | 124 | }); |
| 126 | }); | 125 | }); |
| 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 | describe("resolveThreadListV2Status", () => { | 127 | describe("resolveThreadListV2Status", () => { |
| 152 | it("prioritizes approval over a running session", () => { | 128 | it("prioritizes approval over a running session", () => { |
| 153 | const thread = makeThread({ | 129 | const thread = makeThread({ |
apps/mobile/src/features/threads/threadListV2.ts-20| ... | @@ -116,26 +116,6 @@ export function resolveThreadListV2SnoozeGateExpiryMs( | ... | @@ -116,26 +116,6 @@ export function resolveThreadListV2SnoozeGateExpiryMs( |
| 116 | export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; | 116 | export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; |
| 117 | export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; | 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 | export function resolveThreadListV2Status( | 119 | export function resolveThreadListV2Status( |
| 140 | thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">, | 120 | thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">, |
| 141 | ): ThreadListV2Status { | 121 | ): ThreadListV2Status { |
apps/mobile/src/features/threads/threadPresentation.ts deleted-102| ... | @@ -1,102 +0,0 @@ | ||
| 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,14 +33,6 @@ export interface Preferences { |
| 33 | /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ | 33 | /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ |
| 34 | readonly projectGroupingEnabled?: boolean; | 34 | readonly projectGroupingEnabled?: boolean; |
| 35 | readonly projectGroupingMode?: SidebarProjectGroupingMode; | 35 | readonly projectGroupingMode?: SidebarProjectGroupingMode; |
| 36 | /** | ||
| 37 | * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has | ||
| 38 | * no client-settings sync, so the legacy grouped thread list is opted into | ||
| 39 | * per device. Deliberately a fresh key (was `threadListV2Enabled`, an | ||
| 40 | * opt-out): sanitizing drops the old key, so every device resets to the | ||
| 41 | * default flat list — see `resolveThreadListV2Enabled`. | ||
| 42 | */ | ||
| 43 | readonly legacyThreadListEnabled?: boolean; | ||
| 44 | /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ | 36 | /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ |
| 45 | readonly planModeEnabled?: boolean; | 37 | readonly planModeEnabled?: boolean; |
| 46 | /** Model favorites belong to this device, like the web client setting. */ | 38 | /** Model favorites belong to this device, like the web client setting. */ |
| ... | @@ -107,7 +99,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -107,7 +99,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 107 | composerEnterBehavior?: ComposerEnterBehavior; | 99 | composerEnterBehavior?: ComposerEnterBehavior; |
| 108 | projectGroupingEnabled?: boolean; | 100 | projectGroupingEnabled?: boolean; |
| 109 | projectGroupingMode?: SidebarProjectGroupingMode; | 101 | projectGroupingMode?: SidebarProjectGroupingMode; |
| 110 | legacyThreadListEnabled?: boolean; | ||
| 111 | planModeEnabled?: boolean; | 102 | planModeEnabled?: boolean; |
| 112 | modelFavorites?: Preferences["modelFavorites"]; | 103 | modelFavorites?: Preferences["modelFavorites"]; |
| 113 | threadListSettledShelfExpanded?: boolean; | 104 | threadListSettledShelfExpanded?: boolean; |
| ... | @@ -176,9 +167,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { | ... | @@ -176,9 +167,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 176 | ) { | 167 | ) { |
| 177 | preferences.projectGroupingMode = parsed.projectGroupingMode; | 168 | preferences.projectGroupingMode = parsed.projectGroupingMode; |
| 178 | } | 169 | } |
| 179 | if (typeof parsed.legacyThreadListEnabled === "boolean") { | ||
| 180 | preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; | ||
| 181 | } | ||
| 182 | if (typeof parsed.planModeEnabled === "boolean") { | 170 | if (typeof parsed.planModeEnabled === "boolean") { |
| 183 | preferences.planModeEnabled = parsed.planModeEnabled; | 171 | preferences.planModeEnabled = parsed.planModeEnabled; |
| 184 | } | 172 | } |