diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index dae7c4ed3d9..2078369c399 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -34,8 +34,12 @@ import { HOME_LIST_MODE_ICONS, HOME_LIST_MODE_LABELS, HOME_LIST_MODE_TITLES, + HOME_THREAD_GROUPING_LABELS, + HOME_THREAD_GROUPINGS, otherHomeListModes, + usesProjectThreadGrouping, type HomeListMode, + type HomeThreadGrouping, } from "./homeListMode"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -47,17 +51,19 @@ export function HomeHeader(props: { readonly projects: ReadonlyArray; readonly searchQuery: string; readonly listMode: HomeListMode; + readonly threadGrouping: HomeThreadGrouping; readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; /** - * Hide settled threads for the active list mode. Recent defaults on; - * Projects defaults off. Toggle is offered in Recent and Projects filters. + * Hide settled threads for the Threads surface. Recency/none default on; + * project grouping defaults off at the call site. */ readonly hideSettledThreads: boolean; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly onSearchQueryChange: (query: string) => void; readonly onListModeChange: (mode: HomeListMode) => void; + readonly onThreadGroupingChange: (grouping: HomeThreadGrouping) => void; readonly onClearEnvironments: () => void; readonly onToggleEnvironment: (environmentId: EnvironmentId) => void; readonly onProjectChange: (projectKey: string | null) => void; @@ -80,26 +86,32 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } -/** Sort controls only apply in Projects mode. */ -function usesListOrganization(listMode: HomeListMode) { - return listMode === "projects"; +/** Sort projects/threads only apply when Threads are grouped by project. */ +function usesListOrganization(listMode: HomeListMode, threadGrouping: HomeThreadGrouping) { + return listMode === "threads" && usesProjectThreadGrouping(threadGrouping); +} + +function defaultHideSettledForGrouping(threadGrouping: HomeThreadGrouping): boolean { + return !usesProjectThreadGrouping(threadGrouping); } function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); - const listOrganization = usesListOrganization(props.listMode); + const listOrganization = usesListOrganization(props.listMode, props.threadGrouping); const alternateModes = otherHomeListModes(props.listMode); const hasCustomListOptions = props.selectedEnvironmentIds.length > 0 || props.selectedProjectKey !== null || - ((props.listMode === "recent" || props.listMode === "projects") && - props.hideSettledThreads !== (props.listMode === "recent")) || + (props.listMode === "threads" && + props.hideSettledThreads !== defaultHideSettledForGrouping(props.threadGrouping)) || + props.threadGrouping !== "project" || (listOrganization && hasCustomHomeListOptions({ selectedEnvironmentIds: props.selectedEnvironmentIds, listMode: props.listMode, + threadGrouping: props.threadGrouping, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, selectedProjectKey: props.selectedProjectKey, @@ -144,8 +156,17 @@ function AndroidHomeHeader(props: HomeHeaderProps) { ], }, ] satisfies MenuAction[])), - ...(props.listMode === "recent" || props.listMode === "projects" + ...(props.listMode === "threads" ? ([ + { + id: "grouping", + title: "Group threads", + subactions: HOME_THREAD_GROUPINGS.map((grouping) => ({ + id: `grouping:${grouping}`, + title: HOME_THREAD_GROUPING_LABELS[grouping], + state: checkedMenuState(props.threadGrouping === grouping), + })), + }, { id: "hide-settled", title: "Hide settled", @@ -185,6 +206,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { props.projects, props.selectedEnvironmentIds, props.selectedProjectKey, + props.threadGrouping, props.threadSortOrder, ], ); @@ -215,6 +237,14 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return; } + if (id.startsWith("grouping:")) { + const grouping = id.slice("grouping:".length); + if (grouping === "recency" || grouping === "project" || grouping === "none") { + props.onThreadGroupingChange(grouping); + } + return; + } + if (id === "hide-settled") { props.onHideSettledThreadsChange(!props.hideSettledThreads); return; @@ -352,7 +382,7 @@ function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); const sheetBackground = useThemeColor("--color-sheet"); - const listOrganization = usesListOrganization(props.listMode); + const listOrganization = usesListOrganization(props.listMode, props.threadGrouping); const alternateModes = otherHomeListModes(props.listMode); const isBoardMode = props.listMode === "board"; // Board columns are nested horizontal/vertical lists — not one UIKit scroll @@ -362,12 +392,14 @@ function IosHomeHeader(props: HomeHeaderProps) { const hasCustomListOptions = props.selectedEnvironmentIds.length > 0 || props.selectedProjectKey !== null || - ((props.listMode === "recent" || props.listMode === "projects") && - props.hideSettledThreads !== (props.listMode === "recent")) || + (props.listMode === "threads" && + props.hideSettledThreads !== defaultHideSettledForGrouping(props.threadGrouping)) || + props.threadGrouping !== "project" || (listOrganization && hasCustomHomeListOptions({ selectedEnvironmentIds: props.selectedEnvironmentIds, listMode: props.listMode, + threadGrouping: props.threadGrouping, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, selectedProjectKey: props.selectedProjectKey, @@ -391,7 +423,9 @@ function IosHomeHeader(props: HomeHeaderProps) { onThreadSortOrderChange: props.onThreadSortOrderChange, listOrganization, showProjectFilter: props.listMode !== "board", - ...(props.listMode === "recent" || props.listMode === "projects" + threadGrouping: props.listMode === "threads" ? props.threadGrouping : undefined, + onThreadGroupingChange: props.listMode === "threads" ? props.onThreadGroupingChange : undefined, + ...(props.listMode === "threads" ? { hideSettledThreads: props.hideSettledThreads, onHideSettledThreadsChange: props.onHideSettledThreadsChange, @@ -564,6 +598,32 @@ function IosHomeHeader(props: HomeHeaderProps) { ) : null} + {props.listMode === "threads" ? ( + <> + + Group threads + {HOME_THREAD_GROUPINGS.map((grouping) => ( + props.onThreadGroupingChange(grouping)} + > + + {HOME_THREAD_GROUPING_LABELS[grouping]} + + + ))} + + props.onHideSettledThreadsChange(!props.hideSettledThreads)} + subtitle="Omit settled threads from this list" + > + Hide settled + + + ) : null} + {listOrganization ? ( <> diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index e03553d35e0..0416c647c87 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -64,13 +64,14 @@ export function HomeRouteScreen() { toggleSelectedEnvironmentId, clearSelectedEnvironments, setListMode, + setThreadGrouping, setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentIds = listOptions.selectedEnvironmentIds; const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); - // Recent defaults to hide settled; Projects defaults to show settled. + // Recency/none default to hide settled; project grouping defaults to show. const hideSettledOnRecent = AsyncResult.isSuccess(preferencesResult) ? resolveHideSettledOnRecent(preferencesResult.value) : true; @@ -78,16 +79,16 @@ export function HomeRouteScreen() { ? resolveHideSettledOnProjects(preferencesResult.value) : false; const hideSettledThreads = - listOptions.listMode === "projects" ? hideSettledOnProjects : hideSettledOnRecent; + listOptions.threadGrouping === "project" ? hideSettledOnProjects : hideSettledOnRecent; const setHideSettledThreads = useCallback( (hide: boolean) => { - if (listOptions.listMode === "projects") { + if (listOptions.threadGrouping === "project") { savePreferences({ hideSettledOnProjects: hide }); return; } savePreferences({ hideSettledOnRecent: hide }); }, - [listOptions.listMode, savePreferences], + [listOptions.threadGrouping, savePreferences], ); const [selectedProjectKey, setSelectedProjectKey] = useState(null); const projectFilterOptions = useMemo( @@ -144,12 +145,14 @@ export function HomeRouteScreen() { projects={projectFilterOptions} searchQuery={searchQuery} listMode={listOptions.listMode} + threadGrouping={listOptions.threadGrouping} selectedEnvironmentIds={selectedEnvironmentIds} selectedProjectKey={selectedProjectKey} hideSettledThreads={hideSettledThreads} projectSortOrder={listOptions.projectSortOrder} threadSortOrder={listOptions.threadSortOrder} onListModeChange={setListMode} + onThreadGroupingChange={setThreadGrouping} onClearEnvironments={clearSelectedEnvironments} onToggleEnvironment={toggleSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} @@ -165,6 +168,7 @@ export function HomeRouteScreen() { catalogState={catalogState} environments={environments} listMode={listOptions.listMode} + threadGrouping={listOptions.threadGrouping} hideSettledThreads={hideSettledThreads} onAddConnection={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 156520f4f16..c7835f61db7 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -35,6 +35,7 @@ import { PendingTaskListRow, ThreadListGroupHeader, ThreadListRow, + ThreadListSectionHeader, ThreadListShowMoreRow, } from "../threads/thread-list-items"; import { ThreadListV2Row } from "../threads/thread-list-v2-items"; @@ -56,7 +57,12 @@ import { type HomeGroupDisplayState, type HomeListItem, } from "./homeListItems"; -import type { HomeListMode } from "./homeListMode"; +import { + usesFlatThreadGrouping, + usesProjectThreadGrouping, + type HomeListMode, + type HomeThreadGrouping, +} from "./homeListMode"; import { buildHomeRecentListEntries, buildHomeRecentPendingEntries } from "./homeRecentList"; import { buildHomeProjectScopes, @@ -79,11 +85,12 @@ interface HomeScreenProps { readonly environments: ReadonlyArray; readonly searchQuery: string; readonly listMode: HomeListMode; + readonly threadGrouping: HomeThreadGrouping; readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; /** - * When true, omit settled threads from the active list mode. Recent defaults - * on; Projects defaults off (call site). Toggle in the filter menu. + * When true, omit settled threads from the Threads list. Recency/none default + * on; project grouping defaults off (call site). Toggle in the filter menu. */ readonly hideSettledThreads: boolean; readonly projectSortOrder: HomeProjectSortOrder; @@ -194,9 +201,10 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); - // Thread List v2 only applies in Projects mode; Recent/Board use fixed layouts. + // Thread List v2 only applies when Threads are grouped by project. const threadListV2Enabled = - props.listMode === "projects" && + props.listMode === "threads" && + usesProjectThreadGrouping(props.threadGrouping) && AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true; const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -325,9 +333,14 @@ export function HomeScreen(props: HomeScreenProps) { [props.pendingTasks, selectedProjectRefKeys], ); + const showFlatThreadList = + props.listMode === "threads" && usesFlatThreadGrouping(props.threadGrouping); + const showProjectThreadList = + props.listMode === "threads" && usesProjectThreadGrouping(props.threadGrouping); + const recentEntries = useMemo( () => - props.listMode === "recent" + showFlatThreadList ? buildHomeRecentListEntries({ projects: scopedProjects, threads: scopedThreads, @@ -337,17 +350,17 @@ export function HomeScreen(props: HomeScreenProps) { }) : [], [ - props.listMode, props.searchQuery, props.selectedEnvironmentIds, scopedProjects, scopedThreads, selectedProjectRefKeys, + showFlatThreadList, ], ); const recentPendingEntries = useMemo( () => - props.listMode === "recent" + showFlatThreadList ? buildHomeRecentPendingEntries({ pendingTasks: scopedPendingTasks, selectedEnvironmentIds: props.selectedEnvironmentIds, @@ -356,11 +369,11 @@ export function HomeScreen(props: HomeScreenProps) { }) : [], [ - props.listMode, props.searchQuery, props.selectedEnvironmentIds, scopedPendingTasks, selectedProjectRefKeys, + showFlatThreadList, ], ); @@ -498,8 +511,7 @@ export function HomeScreen(props: HomeScreenProps) { // next wake boundary re-runs the partition with a fresh clock so a woken // thread reappears immediately instead of on the next minute tick. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - const needsSettlementClock = - threadListV2Enabled || props.listMode === "recent" || props.listMode === "projects"; + const needsSettlementClock = threadListV2Enabled || props.listMode === "threads"; useEffect(() => { if (!needsSettlementClock) return; // Refresh immediately on enable: the mount-time value can be hours old @@ -563,7 +575,7 @@ export function HomeScreen(props: HomeScreenProps) { const projectGroups = useMemo( () => - props.listMode === "projects" && !threadListV2Enabled + showProjectThreadList && !threadListV2Enabled ? buildHomeThreadGroups({ projects: scopedProjects, threads: threadsForProjectList, @@ -576,7 +588,6 @@ export function HomeScreen(props: HomeScreenProps) { }) : [], [ - props.listMode, props.projectGroupingMode, props.projectSortOrder, props.searchQuery, @@ -584,13 +595,14 @@ export function HomeScreen(props: HomeScreenProps) { props.threadSortOrder, scopedPendingTasks, scopedProjects, + showProjectThreadList, threadListV2Enabled, threadsForProjectList, ], ); const visibleRecentEntries = useMemo(() => { - if (props.listMode !== "recent") return []; + if (!showFlatThreadList) return []; return recentEntries.flatMap((entry) => { const key = scopedThreadKey(entry.thread.environmentId, entry.thread.id); if (props.hideSettledThreads && settledThreadKeys.has(key)) { @@ -598,16 +610,17 @@ export function HomeScreen(props: HomeScreenProps) { } return [{ thread: entry.thread, projectTitle: entry.project.title }]; }); - }, [props.hideSettledThreads, props.listMode, recentEntries, settledThreadKeys]); + }, [props.hideSettledThreads, recentEntries, settledThreadKeys, showFlatThreadList]); const listLayout = useMemo(() => { - if (props.listMode === "recent") { + if (showFlatThreadList) { return buildHomeRecentListLayout({ pendingTasks: recentPendingEntries.map((entry) => entry.pendingTask), entries: visibleRecentEntries, + groupByRecency: props.threadGrouping === "recency", }); } - if (props.listMode !== "projects") { + if (!showProjectThreadList) { return { items: [] as HomeListItem[], stickyHeaderIndices: [] as number[] }; } return buildHomeListLayout({ @@ -619,8 +632,10 @@ export function HomeScreen(props: HomeScreenProps) { effectiveGroupDisplayStates, hasSearchQuery, projectGroups, - props.listMode, + props.threadGrouping, recentPendingEntries, + showFlatThreadList, + showProjectThreadList, visibleRecentEntries, ]); const threadListV2Layout = useMemo(() => { @@ -769,6 +784,8 @@ export function HomeScreen(props: HomeScreenProps) { title={item.group.title} /> ); + case "section-header": + return ; case "pending-task": return ( thread.archivedAt === null) || props.pendingTasks.length > 0; - const hasResults = - props.listMode === "recent" - ? visibleRecentEntries.length > 0 || recentPendingEntries.length > 0 - : projectGroups.length > 0; + const hasResults = showFlatThreadList + ? visibleRecentEntries.length > 0 || recentPendingEntries.length > 0 + : projectGroups.length > 0; const selectedEnvironmentLabel = props.selectedEnvironmentIds.length === 0 ? null diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index dbbf905dc33..f494b9a2dc3 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,6 +1,11 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import { isAllEnvironmentsSelected, isEnvironmentSelected } from "./homeEnvironmentFilter"; +import { + HOME_THREAD_GROUPING_LABELS, + HOME_THREAD_GROUPINGS, + type HomeThreadGrouping, +} from "./homeListMode"; import type { HomeProjectSortOrder } from "./homeThreadList"; import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; @@ -46,18 +51,21 @@ export function buildHomeListFilterMenu(props: { readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; /** - * False hides the sort/group submenus. Recent/Board and Thread List v2 use - * fixed layouts; the environment multi-filter still applies. + * False hides project/thread sort submenus. Flat recency, Board, and Thread + * List v2 use fixed layouts; the environment multi-filter still applies. */ readonly listOrganization?: boolean; /** When false, hide the project scope submenu (Board uses its own control). */ readonly showProjectFilter?: boolean; /** - * Recent/Projects: hide settled threads. When provided, the menu offers a - * toggle (Recent defaults on; Projects defaults off at the call site). + * Threads surface: hide settled threads. When provided, the menu offers a + * toggle (recency/none default on; project defaults off at the call site). */ readonly hideSettledThreads?: boolean; readonly onHideSettledThreadsChange?: (hide: boolean) => void; + /** When set, offers Group by recency / project / nothing. */ + readonly threadGrouping?: HomeThreadGrouping; + readonly onThreadGroupingChange?: (grouping: HomeThreadGrouping) => void; }): HomeListFilterMenu { const items: Array = []; @@ -107,6 +115,19 @@ export function buildHomeListFilterMenu(props: { }); } + if (props.threadGrouping !== undefined && props.onThreadGroupingChange !== undefined) { + items.push({ + type: "submenu", + title: "Group threads", + items: HOME_THREAD_GROUPINGS.map((grouping) => ({ + type: "action" as const, + title: HOME_THREAD_GROUPING_LABELS[grouping], + state: props.threadGrouping === grouping ? ("on" as const) : ("off" as const), + onPress: () => props.onThreadGroupingChange?.(grouping), + })), + }); + } + if (props.onHideSettledThreadsChange !== undefined && props.hideSettledThreads !== undefined) { items.push({ type: "action", diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index 0bb4f4395f8..edb75427674 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -8,7 +8,8 @@ import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-opti const defaults: HomeListOptions = { selectedEnvironmentIds: [], - listMode: "projects", + listMode: "threads", + threadGrouping: "project", projectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 2221c8b85d2..ceeace193f2 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -22,17 +22,24 @@ import { } from "react"; import { resolveSelectedEnvironmentIds, toggleEnvironmentId } from "./homeEnvironmentFilter"; -import { DEFAULT_HOME_LIST_MODE, type HomeListMode } from "./homeListMode"; +import { + DEFAULT_HOME_LIST_MODE, + DEFAULT_HOME_THREAD_GROUPING, + type HomeListMode, + type HomeThreadGrouping, +} from "./homeListMode"; import type { HomeProjectSortOrder } from "./homeThreadList"; export interface HomeListOptions { /** * Multi-select environment filter. Empty means all environments. - * Applies to Recent, Projects, and Board modes. Persisted on device when + * Applies to Threads and Board modes. Persisted on device when * the provider is given a storage callback. */ readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly listMode: HomeListMode; + /** Organization of the Threads list (ignored on Board). */ + readonly threadGrouping: HomeThreadGrouping; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; } @@ -67,6 +74,7 @@ function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentIds: [], listMode: DEFAULT_HOME_LIST_MODE, + threadGrouping: DEFAULT_HOME_THREAD_GROUPING, projectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" @@ -200,6 +208,12 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { + setOptions((current) => ({ ...current, threadGrouping: value })); + }, + [setOptions], + ); const setProjectSortOrder = useCallback( (value: HomeProjectSortOrder) => { setOptions((current) => ({ ...current, projectSortOrder: value })); @@ -218,6 +232,7 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { expect(layout.items[0]).toMatchObject({ type: "thread", projectTitle: "Alpha" }); expect(layout.stickyHeaderIndices).toEqual([]); }); + + it("builds recency sections with Today / Older headers", () => { + const now = new Date(2026, 2, 15, 12, 0, 0); + const startToday = new Date(2026, 2, 15).getTime(); + const todayIso = new Date(startToday + 3_600_000).toISOString(); + const olderIso = new Date(startToday - 40 * 24 * 60 * 60 * 1000).toISOString(); + const todayThread = { + ...makeThread("t-today", ProjectId.make("alpha")), + updatedAt: todayIso, + latestUserMessageAt: todayIso, + }; + const olderThread = { + ...makeThread("t-older", ProjectId.make("beta")), + updatedAt: olderIso, + latestUserMessageAt: olderIso, + }; + const layout = buildHomeRecentListLayout({ + pendingTasks: [], + entries: [ + { thread: todayThread, projectTitle: "Alpha" }, + { thread: olderThread, projectTitle: "Beta" }, + ], + groupByRecency: true, + now, + }); + expect(itemTypes(layout.items)).toEqual([ + "section-header", + "thread", + "section-header", + "thread", + ]); + expect(layout.items[0]).toMatchObject({ type: "section-header", title: "Today" }); + expect(layout.items[2]).toMatchObject({ type: "section-header", title: "Older" }); + expect(layout.stickyHeaderIndices).toEqual([0, 2]); + }); }); diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts index dfdc73cd225..68264a9bc5d 100644 --- a/apps/mobile/src/features/home/homeListItems.ts +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -1,4 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { groupSortedThreadsByRecency } from "@t3tools/client-runtime/state/thread-recency-groups"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import type { HomeThreadGroup } from "./homeThreadList"; @@ -27,12 +28,20 @@ export interface HomeHeaderListItem { readonly isFirst: boolean; } +/** Non-collapsible calendar section (Today / Yesterday / …). */ +export interface HomeSectionHeaderListItem { + readonly type: "section-header"; + readonly key: string; + readonly title: string; + readonly isFirst: boolean; +} + export interface HomeThreadListItem { readonly type: "thread"; readonly key: string; readonly thread: EnvironmentThreadShell; readonly isLast: boolean; - /** Optional project title for cross-project contexts (Recent mode). */ + /** Optional project title for cross-project contexts (recency / flat). */ readonly projectTitle?: string; } @@ -55,6 +64,7 @@ export interface HomeShowMoreListItem { export type HomeListItem = | HomeHeaderListItem + | HomeSectionHeaderListItem | HomePendingTaskListItem | HomeThreadListItem | HomeShowMoreListItem; @@ -96,6 +106,12 @@ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem previous.collapsed === item.collapsed && previous.isFirst === item.isFirst ); + case "section-header": + return ( + previous.type === "section-header" && + previous.title === item.title && + previous.isFirst === item.isFirst + ); case "pending-task": return ( previous.type === "pending-task" && @@ -210,9 +226,11 @@ export function buildHomeListLayout(input: { } /** - * Flat Recent mode layout: pending tasks first, then threads by recency. + * Flat / recency Threads layouts: pending tasks first, then threads by activity. * Each thread row can carry a project title for multi-project context. * Callers apply hide-settled / project filters before building entries. + * + * When `groupByRecency` is true, inserts Today / Yesterday / … section headers. */ export function buildHomeRecentListLayout(input: { readonly pendingTasks: ReadonlyArray; @@ -220,29 +238,69 @@ export function buildHomeRecentListLayout(input: { readonly thread: EnvironmentThreadShell; readonly projectTitle: string; }>; + readonly groupByRecency?: boolean; + readonly now?: Date; }): HomeListLayout { const items: HomeListItem[] = []; - const total = input.pendingTasks.length + input.entries.length; + const stickyHeaderIndices: number[] = []; for (const [index, pendingTask] of input.pendingTasks.entries()) { items.push({ type: "pending-task", key: `pending-task:${pendingTask.message.messageId}`, pendingTask, - isLast: index === total - 1, + isLast: + index === input.pendingTasks.length - 1 && + input.entries.length === 0 && + input.groupByRecency !== true, }); } - for (const [index, entry] of input.entries.entries()) { - const absoluteIndex = input.pendingTasks.length + index; + if (input.groupByRecency !== true) { + const total = input.pendingTasks.length + input.entries.length; + for (const [index, entry] of input.entries.entries()) { + const absoluteIndex = input.pendingTasks.length + index; + items.push({ + type: "thread", + key: `thread:${entry.thread.environmentId}:${entry.thread.id}`, + thread: entry.thread, + projectTitle: entry.projectTitle, + isLast: absoluteIndex === total - 1, + }); + } + return { items, stickyHeaderIndices: [] }; + } + + const projectTitleByThreadKey = new Map( + input.entries.map((entry) => [ + `${entry.thread.environmentId}:${entry.thread.id}`, + entry.projectTitle, + ]), + ); + const recencyGroups = groupSortedThreadsByRecency( + input.entries.map((entry) => entry.thread), + input.now, + ); + + for (const [groupIndex, group] of recencyGroups.entries()) { + stickyHeaderIndices.push(items.length); items.push({ - type: "thread", - key: `thread:${entry.thread.environmentId}:${entry.thread.id}`, - thread: entry.thread, - projectTitle: entry.projectTitle, - isLast: absoluteIndex === total - 1, + type: "section-header", + key: `section:${group.id}`, + title: group.label, + isFirst: groupIndex === 0 && input.pendingTasks.length === 0, }); + + for (const [threadIndex, thread] of group.threads.entries()) { + items.push({ + type: "thread", + key: `thread:${thread.environmentId}:${thread.id}`, + thread, + projectTitle: projectTitleByThreadKey.get(`${thread.environmentId}:${thread.id}`), + isLast: groupIndex === recencyGroups.length - 1 && threadIndex === group.threads.length - 1, + }); + } } - return { items, stickyHeaderIndices: [] }; + return { items, stickyHeaderIndices }; } diff --git a/apps/mobile/src/features/home/homeListMode.test.ts b/apps/mobile/src/features/home/homeListMode.test.ts index 73964d50643..5b650dcef66 100644 --- a/apps/mobile/src/features/home/homeListMode.test.ts +++ b/apps/mobile/src/features/home/homeListMode.test.ts @@ -3,9 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { otherHomeListModes } from "./homeListMode"; describe("otherHomeListModes", () => { - it("returns the two modes that are not current", () => { - expect(otherHomeListModes("projects")).toEqual(["recent", "board"]); - expect(otherHomeListModes("recent")).toEqual(["projects", "board"]); - expect(otherHomeListModes("board")).toEqual(["recent", "projects"]); + it("returns the mode that is not current", () => { + expect(otherHomeListModes("threads")).toEqual(["board"]); + expect(otherHomeListModes("board")).toEqual(["threads"]); }); }); diff --git a/apps/mobile/src/features/home/homeListMode.ts b/apps/mobile/src/features/home/homeListMode.ts index d3ae19de01d..08293a678bf 100644 --- a/apps/mobile/src/features/home/homeListMode.ts +++ b/apps/mobile/src/features/home/homeListMode.ts @@ -1,41 +1,68 @@ /** * Home list presentation modes. Shared by compact home and split sidebar. * Environment multi-filter applies to every mode. + * + * Threads replaces the former Recent + Projects modes with a single list + * controlled by {@link HomeThreadGrouping}. */ -export type HomeListMode = "recent" | "projects" | "board"; +export type HomeListMode = "threads" | "board"; -export const HOME_LIST_MODES = [ - "recent", - "projects", - "board", -] as const satisfies readonly HomeListMode[]; +export const HOME_LIST_MODES = ["threads", "board"] as const satisfies readonly HomeListMode[]; export const HOME_LIST_MODE_LABELS: Record = { - recent: "Recent", - projects: "Projects", + threads: "Threads", board: "Board", }; /** SF Symbol names for header mode buttons (AppSymbol / native bar items). */ export const HOME_LIST_MODE_ICONS: Record = { - recent: "clock", - projects: "folder", + threads: "list.bullet", board: "square.split.2x1", }; export const HOME_LIST_MODE_TITLES: Record = { - recent: "Recent", - projects: "Threads", + threads: "Threads", board: "Board", }; export function isHomeListMode(value: unknown): value is HomeListMode { - return value === "recent" || value === "projects" || value === "board"; + return value === "threads" || value === "board"; } -/** Modes the user can switch to from the current one (always two). */ +/** Modes the user can switch to from the current one. */ export function otherHomeListModes(mode: HomeListMode): readonly HomeListMode[] { return HOME_LIST_MODES.filter((candidate) => candidate !== mode); } -export const DEFAULT_HOME_LIST_MODE: HomeListMode = "projects"; +export const DEFAULT_HOME_LIST_MODE: HomeListMode = "threads"; + +/** + * How the Threads list is organized. Custom user groups are a follow-up. + */ +export type HomeThreadGrouping = "recency" | "project" | "none"; + +export const HOME_THREAD_GROUPINGS = [ + "recency", + "project", + "none", +] as const satisfies readonly HomeThreadGrouping[]; + +export const HOME_THREAD_GROUPING_LABELS: Record = { + recency: "Group by recency", + project: "Group by project", + none: "Group by nothing", +}; + +export function isHomeThreadGrouping(value: unknown): value is HomeThreadGrouping { + return value === "recency" || value === "project" || value === "none"; +} + +export const DEFAULT_HOME_THREAD_GROUPING: HomeThreadGrouping = "project"; + +export function usesProjectThreadGrouping(grouping: HomeThreadGrouping): boolean { + return grouping === "project"; +} + +export function usesFlatThreadGrouping(grouping: HomeThreadGrouping): boolean { + return grouping === "recency" || grouping === "none"; +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 868f8cc3a15..5e1bcd05690 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -56,7 +56,13 @@ import { type HomeListItem, } from "../home/homeListItems"; import { buildHomeRecentListEntries, buildHomeRecentPendingEntries } from "../home/homeRecentList"; -import { HOME_LIST_MODE_TITLES } from "../home/homeListMode"; +import { + HOME_LIST_MODE_TITLES, + HOME_THREAD_GROUPING_LABELS, + HOME_THREAD_GROUPINGS, + usesFlatThreadGrouping, + usesProjectThreadGrouping, +} from "../home/homeListMode"; import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; @@ -71,6 +77,7 @@ import { PendingTaskListRow, ThreadListGroupHeader, ThreadListRow, + ThreadListSectionHeader, ThreadListShowMoreRow, } from "./thread-list-items"; import { ThreadListV2Row } from "./thread-list-v2-items"; @@ -229,12 +236,14 @@ function ThreadNavigationSidebarPane( toggleSelectedEnvironmentId, clearSelectedEnvironments, setListMode, + setThreadGrouping, setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); - // Thread List v2 only applies in Projects mode; Recent/Board use fixed layouts. + // Thread List v2 only applies when Threads are grouped by project. const threadListV2Enabled = - options.listMode === "projects" && + options.listMode === "threads" && + usesProjectThreadGrouping(options.threadGrouping) && AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true; const hideSettledOnRecent = AsyncResult.isSuccess(preferencesResult) @@ -244,17 +253,21 @@ function ThreadNavigationSidebarPane( ? resolveHideSettledOnProjects(preferencesResult.value) : false; const hideSettledThreads = - options.listMode === "projects" ? hideSettledOnProjects : hideSettledOnRecent; + options.threadGrouping === "project" ? hideSettledOnProjects : hideSettledOnRecent; const setHideSettledThreads = useCallback( (hide: boolean) => { - if (options.listMode === "projects") { + if (options.threadGrouping === "project") { savePreferences({ hideSettledOnProjects: hide }); return; } savePreferences({ hideSettledOnRecent: hide }); }, - [options.listMode, savePreferences], + [options.threadGrouping, savePreferences], ); + const showFlatThreadList = + options.listMode === "threads" && usesFlatThreadGrouping(options.threadGrouping); + const showProjectThreadList = + options.listMode === "threads" && usesProjectThreadGrouping(options.threadGrouping); const [selectedProjectKey, setSelectedProjectKey] = useState(null); const projectScopes = useMemo( () => @@ -345,7 +358,7 @@ function ThreadNavigationSidebarPane( ); const recentEntries = useMemo( () => - options.listMode === "recent" + showFlatThreadList ? buildHomeRecentListEntries({ projects: scopedProjects, threads: scopedThreads, @@ -355,17 +368,17 @@ function ThreadNavigationSidebarPane( }) : [], [ - options.listMode, options.selectedEnvironmentIds, props.searchQuery, scopedProjects, scopedThreads, selectedProjectRefs, + showFlatThreadList, ], ); const recentPendingEntries = useMemo( () => - options.listMode === "recent" + showFlatThreadList ? buildHomeRecentPendingEntries({ pendingTasks: scopedPendingTasks, selectedEnvironmentIds: options.selectedEnvironmentIds, @@ -374,11 +387,11 @@ function ThreadNavigationSidebarPane( }) : [], [ - options.listMode, options.selectedEnvironmentIds, props.searchQuery, scopedPendingTasks, selectedProjectRefs, + showFlatThreadList, ], ); const environmentLabelById = useMemo(() => { @@ -462,8 +475,7 @@ function ThreadNavigationSidebarPane( // next wake boundary re-runs the partition with a fresh clock so a woken // thread reappears immediately instead of on the next minute tick. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - const needsSettlementClock = - threadListV2Enabled || options.listMode === "recent" || options.listMode === "projects"; + const needsSettlementClock = threadListV2Enabled || options.listMode === "threads"; useEffect(() => { if (!needsSettlementClock) return; // Refresh immediately on enable: the mount-time value can be hours old @@ -525,7 +537,7 @@ function ThreadNavigationSidebarPane( }, [hideSettledThreads, scopedThreads, settledThreadKeys]); const visibleRecentEntries = useMemo(() => { - if (options.listMode !== "recent") return []; + if (!showFlatThreadList) return []; return recentEntries.flatMap((entry) => { const key = scopedThreadKey(entry.thread.environmentId, entry.thread.id); if (hideSettledThreads && settledThreadKeys.has(key)) { @@ -533,11 +545,11 @@ function ThreadNavigationSidebarPane( } return [{ thread: entry.thread, projectTitle: entry.project.title }]; }); - }, [hideSettledThreads, options.listMode, recentEntries, settledThreadKeys]); + }, [hideSettledThreads, recentEntries, settledThreadKeys, showFlatThreadList]); const groups = useMemo( () => - options.listMode === "projects" && !threadListV2Enabled + showProjectThreadList && !threadListV2Enabled ? buildHomeThreadGroups({ projects: scopedProjects, threads: threadsForProjectList, @@ -554,19 +566,21 @@ function ThreadNavigationSidebarPane( props.searchQuery, scopedPendingTasks, scopedProjects, + showProjectThreadList, threadListV2Enabled, threadsForProjectList, ], ); const listLayout = useMemo(() => { - if (options.listMode === "recent") { + if (showFlatThreadList) { return buildHomeRecentListLayout({ pendingTasks: recentPendingEntries.map((entry) => entry.pendingTask), entries: visibleRecentEntries, + groupByRecency: options.threadGrouping === "recency", }); } - if (options.listMode !== "projects") { + if (!showProjectThreadList) { return { items: [] as HomeListItem[], stickyHeaderIndices: [] as number[] }; } return buildHomeListLayout({ @@ -578,8 +592,10 @@ function ThreadNavigationSidebarPane( groupDisplayStates, groups, hasSearchQuery, - options.listMode, + options.threadGrouping, recentPendingEntries, + showFlatThreadList, + showProjectThreadList, visibleRecentEntries, ]); @@ -684,7 +700,7 @@ function ThreadNavigationSidebarPane( threadListV2Layout, ]); const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); - const listOrganization = options.listMode === "projects" && !threadListV2Enabled; + const listOrganization = showProjectThreadList && !threadListV2Enabled; const listMenuActions = useMemo( () => [ { @@ -729,8 +745,17 @@ function ThreadNavigationSidebarPane( ], }, ] satisfies MenuAction[])), - ...(options.listMode === "recent" || options.listMode === "projects" + ...(options.listMode === "threads" ? ([ + { + id: "grouping", + title: "Group threads", + subactions: HOME_THREAD_GROUPINGS.map((grouping) => ({ + id: `grouping:${grouping}`, + title: HOME_THREAD_GROUPING_LABELS[grouping], + state: options.threadGrouping === grouping ? ("on" as const) : ("off" as const), + })), + }, { id: "hide-settled", title: "Hide settled", @@ -738,7 +763,7 @@ function ThreadNavigationSidebarPane( }, ] satisfies MenuAction[]) : []), - // Sort controls only apply in Projects classic layout. v2/Recent/Board + // Sort controls only apply in project classic layout. v2/recency/flat/Board // use fixed order; environment multi-filter still scopes every mode. ...(listOrganization ? ([ @@ -770,6 +795,7 @@ function ThreadNavigationSidebarPane( options.listMode, options.projectSortOrder, options.selectedEnvironmentIds, + options.threadGrouping, options.threadSortOrder, projectFilterOptions, selectedProjectKey, @@ -793,6 +819,13 @@ function ThreadNavigationSidebarPane( setHideSettledThreads(!hideSettledThreads); return; } + if (event.startsWith("grouping:")) { + const grouping = event.slice("grouping:".length); + if (grouping === "recency" || grouping === "project" || grouping === "none") { + setThreadGrouping(grouping); + } + return; + } if (event === "project:all") { setSelectedProjectKey(null); return; @@ -826,6 +859,7 @@ function ThreadNavigationSidebarPane( projectFilterOptions, setHideSettledThreads, setProjectSortOrder, + setThreadGrouping, setThreadSortOrder, toggleSelectedEnvironmentId, ], @@ -1027,6 +1061,8 @@ function ThreadNavigationSidebarPane( title={item.group.title} /> ); + case "section-header": + return ; case "pending-task": return ( 0 || selectedProjectKey !== null || - ((options.listMode === "recent" || options.listMode === "projects") && - hideSettledThreads !== (options.listMode === "recent")) || + options.threadGrouping !== "project" || + (options.listMode === "threads" && + hideSettledThreads !== (options.threadGrouping !== "project")) || (listOrganization && hasCustomHomeListOptions({ ...options, selectedProjectKey })); const filterIcon = filterCustomized ? "line.3.horizontal.decrease.circle.fill" @@ -1139,7 +1176,9 @@ function ThreadNavigationSidebarPane( onThreadSortOrderChange: setThreadSortOrder, listOrganization, showProjectFilter: options.listMode !== "board", - ...(options.listMode === "recent" || options.listMode === "projects" + threadGrouping: options.listMode === "threads" ? options.threadGrouping : undefined, + onThreadGroupingChange: options.listMode === "threads" ? setThreadGrouping : undefined, + ...(options.listMode === "threads" ? { hideSettledThreads, onHideSettledThreadsChange: setHideSettledThreads, @@ -1154,11 +1193,13 @@ function ThreadNavigationSidebarPane( options.listMode, options.projectSortOrder, options.selectedEnvironmentIds, + options.threadGrouping, options.threadSortOrder, projectFilterOptions, selectedProjectKey, setHideSettledThreads, setProjectSortOrder, + setThreadGrouping, setThreadSortOrder, toggleSelectedEnvironmentId, ], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d938b81ea79..62793422e4e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -218,6 +218,7 @@ import { DEFAULT_HIDE_SETTLED_PROJECTS, DEFAULT_HIDE_SETTLED_RECENT, DEFAULT_WEB_LIST_MODE, + DEFAULT_WEB_THREAD_GROUPING, EMPTY_LIST_ENVIRONMENT_FILTER, LIST_ENVIRONMENT_FILTER_STORAGE_KEY, LIST_HIDE_SETTLED_PROJECTS_STORAGE_KEY, @@ -225,17 +226,27 @@ import { LIST_MODE_STORAGE_KEY, LIST_PROJECT_FILTER_ALL, LIST_PROJECT_FILTER_STORAGE_KEY, + LIST_THREAD_GROUPING_STORAGE_KEY, ListEnvironmentFilterSchema, ListHideSettledSchema, ListProjectFilterSchema, WEB_LIST_MODE_LABELS, WEB_LIST_MODES, + WEB_THREAD_GROUPING_LABELS, + WEB_THREAD_GROUPINGS, WebListModeSchema, + WebThreadGroupingSchema, + defaultThreadGroupingFromLegacyModeStorage, isWebListMode, + isWebThreadGrouping, matchesEnvironmentFilter, resolveSelectedEnvironmentIds, + usesFlatThreadGrouping, + usesProjectThreadGrouping, type WebListMode, + type WebThreadGrouping, } from "./listEnvironmentFilter"; +import { groupSortedThreadsByRecency } from "@t3tools/client-runtime/state/thread-recency-groups"; import { Toggle, ToggleGroup } from "./ui/toggle-group"; import { primaryServerKeybindingsAtom } from "../state/server"; import { @@ -2896,6 +2907,8 @@ interface SidebarProjectsContentProps { commandPaletteShortcutLabel: string | null; listMode: WebListMode; onListModeChange: (mode: WebListMode) => void; + threadGrouping: WebThreadGrouping; + onThreadGroupingChange: (grouping: WebThreadGrouping) => void; environmentFilterOptions: readonly { environmentId: EnvironmentId; label: string }[]; selectedEnvironmentIds: readonly EnvironmentId[]; onSelectedEnvironmentIdsChange: (next: readonly EnvironmentId[]) => void; @@ -3589,6 +3602,8 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { recentThreads: readonly SidebarRecentThread[]; + /** When true, wrap threads in Today / Yesterday / … section headers. */ + groupByRecency: boolean; routeThreadKey: string | null; navigateToThread: (threadRef: ScopedThreadRef) => void; handleNewThread: ReturnType; @@ -3603,7 +3618,7 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { if (props.recentThreads.length === 0) { return ( -
No recent threads
+
No threads
); } @@ -3611,33 +3626,65 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); + const renderThreadRow = (entry: SidebarRecentThread) => { + const threadKey = scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)); + return ( + + ); + }; + + if (!props.groupByRecency) { + return ( + + + {props.recentThreads.map(renderThreadRow)} + + + ); + } + + const recencyGroups = groupSortedThreadsByRecency( + props.recentThreads.map((entry) => entry.thread), + ); + const entryByThreadKey = new Map( + props.recentThreads.map((entry) => [ + scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), + entry, + ]), + ); + return ( - - - {props.recentThreads.map((entry) => { - const threadKey = scopedThreadKey( - scopeThreadRef(entry.thread.environmentId, entry.thread.id), - ); - return ( - - ); - })} - - + <> + {recencyGroups.map((group) => ( + +
+ {group.label} +
+ + {group.threads.flatMap((thread) => { + const entry = entryByThreadKey.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + return entry ? [renderThreadRow(entry)] : []; + })} + +
+ ))} + ); }); @@ -3677,6 +3724,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( commandPaletteShortcutLabel, listMode, onListModeChange, + threadGrouping, + onThreadGroupingChange, environmentFilterOptions, selectedEnvironmentIds, onSelectedEnvironmentIdsChange, @@ -3696,6 +3745,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( attachProjectListAutoAnimateRef, projectsLength, } = props; + const showThreadListChrome = listMode === "threads"; + const showProjectGroups = showThreadListChrome && usesProjectThreadGrouping(threadGrouping); + const showFlatOrRecencyList = showThreadListChrome && usesFlatThreadGrouping(threadGrouping); const projectFilterItems = useMemo( () => [ @@ -3795,8 +3847,44 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( triggerClassName="w-full" data-testid="sidebar-environment-filter" /> - {listMode === "recent" || listMode === "projects" ? ( + {showThreadListChrome ? ( <> + + + {WEB_THREAD_GROUPING_LABELS[threadGrouping]} + + + + +
+ Group threads +
+ { + if (isWebThreadGrouping(value)) { + onThreadGroupingChange(value); + } + }} + > + {WEB_THREAD_GROUPINGS.map((grouping) => ( + + {WEB_THREAD_GROUPING_LABELS[grouping]} + + ))} + +
+
+
{projectFilterOptions.length > 0 ? (