From ab5a601eba8fbee04b3f077d49670e03a1e7d028 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Tue, 6 Oct 2026 23:56:55 +0100 Subject: [PATCH 1/5] feat(threads): hide threads, group them, and filter the sidebar by page Threads can be hidden or moved into user-named groups, both server-owned (`hiddenAt`, `groupName`) so they sync across devices and agents can use them through t3_thread_organize. On web and desktop one filter button replaces the project picker: Show picks Threads, groups, Snoozed, Hidden and Settled; Projects scopes the list. Mobile's filter menu gains the same Show submenu. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/home/HomeHeader.android.tsx | 29 +- apps/mobile/src/features/home/HomeHeader.tsx | 17 +- .../src/features/home/HomeHeader.types.ts | 5 + .../src/features/home/HomeRouteScreen.tsx | 13 + apps/mobile/src/features/home/HomeScreen.tsx | 113 ++- .../home/home-list-filter-menu.test.ts | 46 + .../features/home/home-list-filter-menu.ts | 18 + .../src/features/home/useThreadListActions.ts | 113 +++ .../threads/ThreadArrangementSheet.tsx | 8 +- .../threads/ThreadNavigationSidebar.tsx | 130 ++- .../features/threads/thread-list-v2-items.tsx | 176 +++- .../src/features/threads/threadListV2.test.ts | 148 ++++ .../src/features/threads/threadListV2.ts | 270 +++++- .../features/threads/use-thread-list-pages.ts | 43 + .../use-thread-list-v2-shelf-preferences.ts | 27 +- apps/mobile/src/lib/storage.test.ts | 10 - .../src/persistence/mobile-preferences.ts | 10 - .../src/state/thread-list-environments.ts | 8 + apps/mobile/src/state/use-thread-selection.ts | 2 + .../src/environment/ServerEnvironment.ts | 2 + .../src/mcp/toolkits/thread/handlers.ts | 16 + apps/server/src/mcp/toolkits/thread/tools.ts | 8 +- .../Orchestrator.control-reads.test.ts | 84 ++ .../src/orchestration-v2/Orchestrator.ts | 26 + .../orchestration-v2/ProjectionMaintenance.ts | 2 + .../src/orchestration-v2/ProjectionStore.ts | 14 + .../testkit/OrchestratorScenario.ts | 2 + apps/server/src/relay/AgentAwarenessRelay.ts | 2 + .../src/components/NewThreadGroupDialog.tsx | 86 ++ apps/web/src/components/Sidebar.drag.ts | 2 + apps/web/src/components/Sidebar.logic.test.ts | 125 +-- apps/web/src/components/Sidebar.logic.ts | 132 +-- apps/web/src/components/Sidebar.tsx | 784 ++++++++---------- .../components/sidebar/SidebarFilterMenu.tsx | 301 +++++++ .../sidebar/SidebarThreadHeader.tsx | 27 +- .../components/threadActionMenu.logic.test.ts | 56 ++ .../src/components/threadActionMenu.logic.ts | 67 ++ apps/web/src/contextMenuFallback.ts | 25 + apps/web/src/hooks/useThreadActionMenu.ts | 33 + apps/web/src/hooks/useThreadActions.ts | 30 + apps/web/src/routes/__root.tsx | 3 + apps/web/src/state/entities.ts | 16 + docs/fork-differences.md | 57 +- docs/user/agent-access.md | 2 +- docs/user/thread-sidebar.md | 53 +- .../client-runtime/src/operations/commands.ts | 28 + packages/client-runtime/src/state/models.ts | 4 + .../src/state/orchestrationV2Projection.ts | 2 + .../src/state/threadCommands.ts | 26 + packages/contracts/src/environment.ts | 4 + packages/contracts/src/orchestrationV2.ts | 27 + 51 files changed, 2434 insertions(+), 798 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-thread-list-pages.ts create mode 100644 apps/web/src/components/NewThreadGroupDialog.tsx create mode 100644 apps/web/src/components/sidebar/SidebarFilterMenu.tsx diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx index 0c15bcc09ce7b..f2826d4fb75c5 100644 --- a/apps/mobile/src/features/home/HomeHeader.android.tsx +++ b/apps/mobile/src/features/home/HomeHeader.android.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; import type { HomeHeaderProps } from "./HomeHeader.types"; +import { isDefaultThreadListPages, threadListPageLabel } from "../threads/threadListV2"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; @@ -15,7 +16,9 @@ export function HomeHeader(props: HomeHeaderProps) { // the filter menu only carries the filters and the "customized" icon state // keys off those alone. const hasCustomListOptions = - props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; + props.selectedEnvironmentId !== null || + props.selectedProjectKey !== null || + !isDefaultThreadListPages(props.pages); const menuActions = useMemo( () => [ { @@ -54,8 +57,24 @@ export function HomeHeader(props: HomeHeaderProps) { ], }, ] satisfies MenuAction[])), + { + id: "page", + title: "Show", + subactions: props.availablePages.map((page) => ({ + id: `page:${page}`, + title: threadListPageLabel(page), + state: checkedMenuState(props.pages.includes(page)), + })), + }, + ], + [ + props.environments, + props.availablePages, + props.pages, + props.projects, + props.selectedEnvironmentId, + props.selectedProjectKey, ], - [props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey], ); const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { @@ -76,6 +95,12 @@ export function HomeHeader(props: HomeHeaderProps) { return; } + const page = props.availablePages.find((candidate) => id === `page:${candidate}`); + if (page !== undefined) { + props.onTogglePage(page); + return; + } + if (id === "project:all") { props.onProjectChange(null); return; diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e1cc7448eb591..d60650978819b 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -8,6 +8,7 @@ import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; +import { isDefaultThreadListPages, threadListPageLabel } from "../threads/threadListV2"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; import type { HomeHeaderProps } from "./HomeHeader.types"; @@ -20,7 +21,9 @@ export function HomeHeader(props: HomeHeaderProps) { // the filter menu only carries the filters and the "customized" icon state // keys off those alone. const hasCustomListOptions = - props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; + props.selectedEnvironmentId !== null || + props.selectedProjectKey !== null || + !isDefaultThreadListPages(props.pages); const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; @@ -139,6 +142,18 @@ export function HomeHeader(props: HomeHeaderProps) { ))} ) : null} + + Show + {props.availablePages.map((page) => ( + props.onTogglePage(page)} + > + {threadListPageLabel(page)} + + ))} + void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; + /** Filter-menu pages (multi-select). */ + readonly pages: ReadonlyArray; + readonly availablePages: ReadonlyArray; + readonly onTogglePage: (page: ThreadListPage) => void; readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index bb385c672f332..7b8637e439fac 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -8,6 +8,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useNavigationThreadShells } from "../../state/entities"; +import { useThreadListPages } from "../threads/use-thread-list-pages"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -72,6 +73,9 @@ export function HomeRouteScreen() { pinThread, unpinThread, setThreadAutoSettle, + setThreadHidden, + setThreadGroup, + moveThreadToNewGroup, moveThread, renameThread, regenerateThreadTitle, @@ -103,6 +107,7 @@ export function HomeRouteScreen() { useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const { pages, availablePages, groupNames, togglePage } = useThreadListPages(visibleThreads); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ @@ -196,6 +201,9 @@ export function HomeRouteScreen() { selectedProjectKey={selectedProjectKey} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} + pages={pages} + availablePages={availablePages} + onTogglePage={togglePage} onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsContent", @@ -230,6 +238,9 @@ export function HomeRouteScreen() { onPinThread={pinThread} onUnpinThread={unpinThread} onSetThreadAutoSettle={setThreadAutoSettle} + onSetThreadHidden={setThreadHidden} + onSetThreadGroup={setThreadGroup} + onMoveThreadToNewGroup={moveThreadToNewGroup} onMoveThread={moveThread} onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} @@ -265,6 +276,8 @@ export function HomeRouteScreen() { searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} selectedProjectKey={selectedProjectKey} + pages={pages} + groupNames={groupNames} threads={visibleThreads} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 6538697da2975..9ac1a1fcfff15 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -41,9 +41,8 @@ import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { ThreadListV2PendingRow, ThreadListV2Row, - ThreadListV2SettledShelfHeader, + ThreadListV2SectionDivider, ThreadListV2ShowMoreRow, - ThreadListV2SnoozedShelfHeader, ThreadListV2WorkingShelfHeader, } from "../threads/thread-list-v2-items"; import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; @@ -51,10 +50,12 @@ import { buildThreadListV2Items, getThreadListV2OrderedSection, buildThreadListV2ListItems, + isDefaultThreadListPages, threadListV2ListItemsAreEqual, threadListInboxReturns, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, + type ThreadListPage, type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; @@ -82,6 +83,10 @@ interface HomeScreenProps { readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; + /** Filter-menu pages: which groups the list shows. */ + readonly pages: ReadonlyArray; + /** Every group in use across the list, for the row Move to group menu. */ + readonly groupNames: ReadonlyArray; readonly projectSortOrder: HomeProjectSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; @@ -107,6 +112,12 @@ interface HomeScreenProps { thread: EnvironmentThreadShell, enabled: boolean, ) => Promise; + readonly onSetThreadHidden: (thread: EnvironmentThreadShell, hidden: boolean) => Promise; + readonly onSetThreadGroup: ( + thread: EnvironmentThreadShell, + groupName: string | null, + ) => Promise; + readonly onMoveThreadToNewGroup: (thread: EnvironmentThreadShell) => void; readonly onMoveThread: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, @@ -437,6 +448,18 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onSetThreadAutoSettle], ); + const handleSetThreadHidden = useCallback( + (thread: EnvironmentThreadShell, hidden: boolean) => { + void props.onSetThreadHidden(thread, hidden); + }, + [props.onSetThreadHidden], + ); + const handleSetThreadGroup = useCallback( + (thread: EnvironmentThreadShell, groupName: string | null) => { + void props.onSetThreadGroup(thread, groupName); + }, + [props.onSetThreadGroup], + ); const handleRegenerateThreadTitle = useCallback( (thread: EnvironmentThreadShell) => { void props.onRegenerateThreadTitle(thread); @@ -466,14 +489,13 @@ export function HomeScreen(props: HomeScreenProps) { ); const { loaded: shelfPreferencesLoaded, - settledShelfExpanded, - snoozedShelfExpanded, workingShelfEnabled, workingShelfExpanded, - toggleSettledShelf, - toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); + // Snoozed and Settled are opted into from the Show menu, so they render open. + const snoozedShelfExpanded = true; + const settledShelfExpanded = true; // The queued-start and snooze helpers need a clock while the list stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the @@ -498,10 +520,13 @@ export function HomeScreen(props: HomeScreenProps) { snoozeEnvironmentIds, pinningEnvironmentIds, autoSettleOptOutEnvironmentIds, + hidingEnvironmentIds, + groupEnvironmentIds, pinReorderEnvironmentIds, activeReorderEnvironmentIds, titleRegenerationEnvironmentIds, } = listEnvironments; + const showThreadsPage = props.pages.includes("threads"); const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); // Up/down menu availability for every card, computed once per section per @@ -522,6 +547,8 @@ export function HomeScreen(props: HomeScreenProps) { now: new Date().toISOString(), settlementEnvironmentIds, snoozeEnvironmentIds, + hidingEnvironmentIds, + groupEnvironmentIds, queuedThreadKeys, }), }); @@ -539,6 +566,8 @@ export function HomeScreen(props: HomeScreenProps) { queuedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, + hidingEnvironmentIds, + groupEnvironmentIds, nowMinute, snoozeWakeTick, ]); @@ -555,6 +584,9 @@ export function HomeScreen(props: HomeScreenProps) { matchedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, + hidingEnvironmentIds, + groupEnvironmentIds, + pages: props.pages, queuedThreadKeys, settledLimit: settledVisibleCount, now: new Date().toISOString(), @@ -566,6 +598,9 @@ export function HomeScreen(props: HomeScreenProps) { selectedThreadKey: null, }); }, [ + hidingEnvironmentIds, + groupEnvironmentIds, + props.pages, workingShelfEnabled, workingShelfExpanded, pendingOrder, @@ -606,6 +641,7 @@ export function HomeScreen(props: HomeScreenProps) { () => props.pendingTasks.filter( (pendingTask) => + showThreadsPage && (props.selectedEnvironmentId === null || pendingTask.environmentId === props.selectedEnvironmentId) && (v2ScopedProjectKeys === null || @@ -615,7 +651,13 @@ export function HomeScreen(props: HomeScreenProps) { (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ), - [props.pendingTasks, props.selectedEnvironmentId, v2ScopedProjectKeys, v2SearchQuery], + [ + props.pendingTasks, + props.selectedEnvironmentId, + showThreadsPage, + v2ScopedProjectKeys, + v2SearchQuery, + ], ); const threadListV2Items = useMemo( () => @@ -631,6 +673,8 @@ export function HomeScreen(props: HomeScreenProps) { settledCount: threadListV2Layout.settledCount, settledShelfExpanded, settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, + hiddenSectionHeaderIndex: threadListV2Layout.hiddenSectionHeaderIndex, + groupSections: threadListV2Layout.groupSections, snoozeLabelNow: `${nowMinute}:00.000Z`, snoozeEnvironmentIds, queuedThreadKeys, @@ -692,25 +736,17 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-group-section") { + return ; + } if (item.type === "v2-snoozed-shelf") { - return ( - - ); + return ; + } + if (item.type === "v2-hidden-section") { + return ; } if (item.type === "v2-settled-shelf") { - return ( - - ); + return ; } const thread = item.item.thread; return ( @@ -721,6 +757,7 @@ export function HomeScreen(props: HomeScreenProps) { hasQueuedMessages={item.hasQueuedMessages} snoozed={item.item.snoozed} pinned={item.item.pinned} + hidden={item.item.hidden} snoozePresetMinute={item.snoozePresetMinute ?? ""} snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} @@ -757,10 +794,15 @@ export function HomeScreen(props: HomeScreenProps) { snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} + hidingSupported={hidingEnvironmentIds.has(thread.environmentId)} + groupsSupported={groupEnvironmentIds.has(thread.environmentId)} + groupNames={props.groupNames} reorderSupported={ - item.item.pinned - ? pinReorderEnvironmentIds.has(thread.environmentId) - : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId) + item.item.grouped + ? false + : item.item.pinned + ? pinReorderEnvironmentIds.has(thread.environmentId) + : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId) } canMoveUp={item.canMoveUp} canMoveDown={item.canMoveDown} @@ -770,6 +812,9 @@ export function HomeScreen(props: HomeScreenProps) { onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} onSetThreadAutoSettle={handleSetThreadAutoSettle} + onSetThreadHidden={handleSetThreadHidden} + onSetThreadGroup={handleSetThreadGroup} + onMoveThreadToNewGroup={props.onMoveThreadToNewGroup} onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -792,6 +837,12 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, handleSetThreadAutoSettle, + handleSetThreadHidden, + handleSetThreadGroup, + hidingEnvironmentIds, + groupEnvironmentIds, + props.groupNames, + props.onMoveThreadToNewGroup, autoSettleOptOutEnvironmentIds, pinningEnvironmentIds, autoSettleOptOutEnvironmentIds, @@ -810,8 +861,6 @@ export function HomeScreen(props: HomeScreenProps) { snoozeEnvironmentIds, threadSearchMatchByKey, titleRegenerationEnvironmentIds, - toggleSettledShelf, - toggleSnoozedShelf, toggleWorkingShelf, v2ProjectTitleByProjectKey, props.searchQuery, @@ -834,8 +883,10 @@ export function HomeScreen(props: HomeScreenProps) { threadSearchMatchByKey, // Rows read it for their reorder menu items. workingShelfEnabled, + groupNames: props.groupNames, }), [ + props.groupNames, projectByKey, props.searchQuery, props.savedConnectionsById, @@ -924,6 +975,12 @@ export function HomeScreen(props: HomeScreenProps) { detail={`No threads matching "${props.searchQuery}".`} variant={Platform.OS === "android" ? "plain" : undefined} /> + ) : !isDefaultThreadListPages(props.pages) ? ( + ) : v2ScopedProjectGroup !== null ? ( { @@ -15,6 +16,9 @@ describe("buildHomeListFilterMenu", () => { selectedProjectKey: "environment-1:project-1", onEnvironmentChange: vi.fn(), onProjectChange, + pages: ["threads"], + availablePages: ["threads", "snoozed", "settled"], + onTogglePage: vi.fn(), }); const projectMenu = menu.items.find( @@ -36,4 +40,46 @@ describe("buildHomeListFilterMenu", () => { expect(onProjectChange).toHaveBeenNthCalledWith(1, null); expect(onProjectChange).toHaveBeenNthCalledWith(2, "environment-1:project-2"); }); + + it("adds a multi-select Show submenu with group pages after Threads", () => { + const onTogglePage = vi.fn(); + const build = (hidingSupported: boolean) => + buildHomeListFilterMenu({ + environments: [], + projects: [], + selectedEnvironmentId: null, + selectedProjectKey: null, + onEnvironmentChange: vi.fn(), + onProjectChange: vi.fn(), + pages: ["threads", "group:Ops", "settled"], + availablePages: availableThreadListPages({ hidingSupported, groupNames: ["Ops", "Web"] }), + onTogglePage, + }).items.find((item) => item.type === "submenu" && item.title === "Show"); + + const show = build(true); + expect(show).toMatchObject({ + items: [ + { title: "Threads", state: "on" }, + { title: "Ops", state: "on" }, + { title: "Web", state: "off" }, + { title: "Snoozed", state: "off" }, + { title: "Hidden", state: "off" }, + { title: "Settled", state: "on" }, + ], + }); + if (show?.type !== "submenu") throw new Error("Expected show submenu"); + show.items[2]?.onPress(); + show.items[4]?.onPress(); + expect(onTogglePage).toHaveBeenNthCalledWith(1, "group:Web"); + expect(onTogglePage).toHaveBeenNthCalledWith(2, "hidden"); + expect(build(false)).toMatchObject({ + items: [ + { title: "Threads" }, + { title: "Ops" }, + { title: "Web" }, + { title: "Snoozed" }, + { title: "Settled" }, + ], + }); + }); }); 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 ac53e427d21f3..48fdde52d5386 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,5 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import { threadListPageLabel, type ThreadListPage } from "../threads/threadListV2"; + export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; readonly label: string; @@ -36,6 +38,10 @@ export function buildHomeListFilterMenu(props: { readonly selectedProjectKey: string | null; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; + readonly pages: ReadonlyArray; + /** Pages offered in the Show submenu, from availableThreadListPages. */ + readonly availablePages: ReadonlyArray; + readonly onTogglePage: (page: ThreadListPage) => void; }): HomeListFilterMenu { const items: Array = []; @@ -84,6 +90,18 @@ export function buildHomeListFilterMenu(props: { }); } + // Multi-select: each page toggles; the last selected page stays on. + items.push({ + type: "submenu", + title: "Show", + items: props.availablePages.map((page) => ({ + type: "action" as const, + title: threadListPageLabel(page), + state: props.pages.includes(page) ? ("on" as const) : ("off" as const), + onPress: () => props.onTogglePage(page), + })), + }); + return { title: "Thread list options", items, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 38a24e8b1656e..1ab1c5eb520eb 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -69,6 +69,20 @@ function environmentSupportsAutoSettleOptOut( ); } +function environmentSupportsHiding(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadHiding === true + ); +} + +function environmentSupportsGroups(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadGroups === true + ); +} + function environmentSupportsTitleRegeneration( environmentId: EnvironmentThreadShell["environmentId"], ) { @@ -248,6 +262,15 @@ export function useThreadListActions(): { thread: EnvironmentThreadShell, enabled: boolean, ) => Promise; + /** Hides the thread from the main sections, or brings it back. */ + readonly setThreadHidden: (thread: EnvironmentThreadShell, hidden: boolean) => Promise; + /** Moves the thread into a named group, or out of its group with null. */ + readonly setThreadGroup: ( + thread: EnvironmentThreadShell, + groupName: string | null, + ) => Promise; + /** Prompts for a new group name, then moves the thread into it. */ + readonly moveThreadToNewGroup: (thread: EnvironmentThreadShell) => void; readonly moveThread: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, @@ -263,6 +286,12 @@ export function useThreadListActions(): { const setAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, { reportFailure: false, }); + const setHiddenMutation = useAtomCommand(threadEnvironment.setHidden, { + reportFailure: false, + }); + const setGroupMutation = useAtomCommand(threadEnvironment.setGroup, { + reportFailure: false, + }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -477,6 +506,87 @@ export function useThreadListActions(): { }, [setAutoSettleMutation], ); + const setThreadHidden = useCallback( + async (thread: EnvironmentThreadShell, hidden: boolean) => { + const title = hidden ? "Could not hide thread" : "Could not unhide thread"; + if (!environmentSupportsHiding(thread.environmentId)) { + Alert.alert( + title, + "This environment's server does not support hiding threads yet. Update the server to use it.", + ); + return false; + } + selectionHaptic(); + const result = await setHiddenMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, hidden }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + title, + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be updated.", + ); + return false; + } + return true; + }, + [setHiddenMutation], + ); + const setThreadGroup = useCallback( + async (thread: EnvironmentThreadShell, groupName: string | null) => { + if (!environmentSupportsGroups(thread.environmentId)) { + Alert.alert( + "Could not move thread", + "This environment's server does not support thread groups yet. Update the server to use them.", + ); + return false; + } + selectionHaptic(); + const result = await setGroupMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, groupName }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not move thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread's group could not be changed.", + ); + return false; + } + return true; + }, + [setGroupMutation], + ); + const moveThreadToNewGroup = useCallback( + (thread: EnvironmentThreadShell) => { + const commit = (name: string) => { + const groupName = name.trim(); + if (groupName.length === 0) { + Alert.alert("Could not create group", "Group name cannot be empty."); + return; + } + void setThreadGroup(thread, groupName); + }; + // Same prompt split as Rename: Alert.prompt exists only on iOS. + if (Platform.OS === "ios") { + Alert.prompt("New group", undefined, (name) => commit(name ?? ""), "plain-text", ""); + return; + } + showTextInputDialog({ + title: "New group", + initialValue: "", + confirmText: "Create", + onConfirm: commit, + }); + }, + [setThreadGroup], + ); const regenerateThreadTitle = useCallback( async (thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); @@ -741,6 +851,9 @@ export function useThreadListActions(): { pinThread, unpinThread, setThreadAutoSettle, + setThreadHidden, + setThreadGroup, + moveThreadToNewGroup, moveThread, renameThread, regenerateThreadTitle, diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 97dc6cf85eca7..6e8aae9717059 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -193,8 +193,14 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { const pinned = getThreadListV2OrderedSection({ ...shared, section: "pinned" }); const active = getThreadListV2OrderedSection({ ...shared, section: "active" }); const visible = new Set([...pinned, ...active].map(keyOf)); + // Hidden and grouped threads live on their own pages, so they are not + // arrangeable. const parked = threads.filter( - (thread) => thread.archivedAt === null && !visible.has(keyOf(thread)), + (thread) => + thread.archivedAt === null && + thread.hiddenAt == null && + thread.groupName == null && + !visible.has(keyOf(thread)), ); return { pinned, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 391da82dfb50f..c7fb53fd2c140 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -31,6 +31,7 @@ import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useNavigationThreadShells } from "../../state/entities"; import { useHiddenTabThreads } from "./useHiddenTabThreads"; import { useThreadSearch } from "../../state/queries"; +import { useThreadListPages } from "./use-thread-list-pages"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { usePendingThreadOrder } from "../../state/thread-order"; import { threadListEnvironmentsAtom } from "../../state/server"; @@ -60,9 +61,8 @@ import { SidebarNavigationShell } from "./sidebar-navigation-shell"; import { ThreadListV2PendingRow, ThreadListV2Row, - ThreadListV2SettledShelfHeader, + ThreadListV2SectionDivider, ThreadListV2ShowMoreRow, - ThreadListV2SnoozedShelfHeader, ThreadListV2WorkingShelfHeader, } from "./thread-list-v2-items"; import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; @@ -75,6 +75,8 @@ import { threadListInboxReturns, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, + isDefaultThreadListPages, + threadListPageLabel, type ThreadListV2ListItem, } from "./threadListV2"; @@ -166,6 +168,9 @@ function ThreadNavigationSidebarPane( pinThread, unpinThread, setThreadAutoSettle, + setThreadHidden, + setThreadGroup, + moveThreadToNewGroup, moveThread, renameThread, regenerateThreadTitle, @@ -303,14 +308,13 @@ function ThreadNavigationSidebarPane( ); const { loaded: shelfPreferencesLoaded, - settledShelfExpanded, - snoozedShelfExpanded, workingShelfEnabled, workingShelfExpanded, - toggleSettledShelf, - toggleSnoozedShelf, toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); + // Snoozed and Settled are opted into from the Show menu, so they render open. + const snoozedShelfExpanded = true; + const settledShelfExpanded = true; // The queued-start and snooze helpers need a clock while the pane stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the @@ -333,10 +337,14 @@ function ThreadNavigationSidebarPane( snoozeEnvironmentIds, pinningEnvironmentIds, autoSettleOptOutEnvironmentIds, + hidingEnvironmentIds, + groupEnvironmentIds, pinReorderEnvironmentIds, activeReorderEnvironmentIds, titleRegenerationEnvironmentIds, } = listEnvironments; + const { pages, availablePages, groupNames, togglePage } = useThreadListPages(threads); + const showThreadsPage = pages.includes("threads"); const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); // Up/down menu availability for every card, computed once per section per @@ -357,6 +365,8 @@ function ThreadNavigationSidebarPane( now: new Date().toISOString(), settlementEnvironmentIds, snoozeEnvironmentIds, + hidingEnvironmentIds, + groupEnvironmentIds, queuedThreadKeys, }), }); @@ -374,6 +384,8 @@ function ThreadNavigationSidebarPane( queuedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, + hidingEnvironmentIds, + groupEnvironmentIds, nowMinute, snoozeWakeTick, ]); @@ -388,6 +400,9 @@ function ThreadNavigationSidebarPane( matchedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, + hidingEnvironmentIds, + groupEnvironmentIds, + pages, queuedThreadKeys, settledLimit: settledVisibleCount, now: new Date().toISOString(), @@ -399,6 +414,9 @@ function ThreadNavigationSidebarPane( selectedThreadKey: sidebarSelectedThreadKey, }); }, [ + hidingEnvironmentIds, + groupEnvironmentIds, + pages, workingShelfEnabled, workingShelfExpanded, pendingOrder, @@ -440,6 +458,7 @@ function ThreadNavigationSidebarPane( const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); const v2PendingTasks = pendingTasks.filter( (pendingTask) => + showThreadsPage && (options.selectedEnvironmentId === null || pendingTask.environmentId === options.selectedEnvironmentId) && (selectedProjectRefs === null || @@ -461,6 +480,8 @@ function ThreadNavigationSidebarPane( settledCount: threadListV2Layout.settledCount, settledShelfExpanded, settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, + hiddenSectionHeaderIndex: threadListV2Layout.hiddenSectionHeaderIndex, + groupSections: threadListV2Layout.groupSections, snoozeLabelNow: `${nowMinute}:00.000Z`, snoozeEnvironmentIds, queuedThreadKeys, @@ -485,6 +506,7 @@ function ThreadNavigationSidebarPane( selectedProjectRefs, settledShelfExpanded, shelfPreferencesLoaded, + showThreadsPage, snoozedShelfExpanded, snoozeEnvironmentIds, threadListV2Layout, @@ -533,8 +555,17 @@ function ThreadNavigationSidebarPane( ], }, ] satisfies MenuAction[])), + { + id: "page", + title: "Show", + subactions: availablePages.map((page) => ({ + id: `page:${page}`, + title: threadListPageLabel(page), + state: pages.includes(page) ? ("on" as const) : ("off" as const), + })), + }, ], - [environments, options, projectFilterOptions, selectedProjectKey], + [availablePages, environments, options, pages, projectFilterOptions, selectedProjectKey], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -550,6 +581,11 @@ function ThreadNavigationSidebarPane( if (environment) setSelectedEnvironmentId(environment.environmentId); return; } + const page = availablePages.find((candidate) => event === `page:${candidate}`); + if (page !== undefined) { + togglePage(page); + return; + } if (event === "project:all") { setSelectedProjectKey(null); return; @@ -562,7 +598,7 @@ function ThreadNavigationSidebarPane( return; } }, - [environments, projectFilterOptions, setSelectedEnvironmentId], + [availablePages, environments, projectFilterOptions, setSelectedEnvironmentId, togglePage], ); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); @@ -631,8 +667,10 @@ function ThreadNavigationSidebarPane( threadSearchMatchByKey, // Rows read it for their reorder menu items. workingShelfEnabled, + groupNames, }), [ + groupNames, sidebarSelectedThreadKey, projectByKey, projectTitleByProjectKey, @@ -718,6 +756,7 @@ function ThreadNavigationSidebarPane( hasQueuedMessages={item.hasQueuedMessages} snoozed={item.item.snoozed} pinned={item.item.pinned} + hidden={item.item.hidden} snoozePresetMinute={item.snoozePresetMinute ?? ""} snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} @@ -754,10 +793,15 @@ function ThreadNavigationSidebarPane( snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} + hidingSupported={hidingEnvironmentIds.has(thread.environmentId)} + groupsSupported={groupEnvironmentIds.has(thread.environmentId)} + groupNames={groupNames} reorderSupported={ - item.item.pinned - ? pinReorderEnvironmentIds.has(thread.environmentId) - : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId) + item.item.grouped + ? false + : item.item.pinned + ? pinReorderEnvironmentIds.has(thread.environmentId) + : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId) } canMoveUp={item.canMoveUp} canMoveDown={item.canMoveDown} @@ -767,6 +811,9 @@ function ThreadNavigationSidebarPane( onPinThread={pinThread} onUnpinThread={unpinThread} onSetThreadAutoSettle={setThreadAutoSettle} + onSetThreadHidden={setThreadHidden} + onSetThreadGroup={setThreadGroup} + onMoveThreadToNewGroup={moveThreadToNewGroup} onMoveThread={moveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -784,26 +831,14 @@ function ThreadNavigationSidebarPane( pane="sidebar" /> ); + case "v2-group-section": + return ; case "v2-snoozed-shelf": - return ( - - ); + return ; + case "v2-hidden-section": + return ; case "v2-settled-shelf": - return ( - - ); + return ; case "v2-show-more": return ( @@ -904,9 +958,11 @@ function ThreadNavigationSidebarPane( ? threadSearch.isPending ? "Searching thread messages…" : "No matching threads" - : selectedProjectScope !== null - ? `No threads in ${selectedProjectScope.title}` - : "No threads yet"} + : !isDefaultThreadListPages(pages) + ? "Nothing to show in the picked pages" + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 92ffbcd23f564..746f39f4ca37f 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -89,6 +89,11 @@ const SNOOZED_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; +const HIDDEN_MENU_ACTIONS: MenuAction[] = [ + { id: "unhide", title: "Unhide thread", image: "eye" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + // Pre-settlement servers: no lifecycle items, archive fills the gap. const LEGACY_MENU_ACTIONS: MenuAction[] = [ { id: "archive", title: "Archive", image: "archivebox" }, @@ -179,6 +184,7 @@ function ThreadListV2Section(props: { export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: { readonly label: string; readonly pane?: "screen" | "sidebar"; + readonly tone?: "default" | "snoozed"; }) { return ; }); @@ -191,44 +197,22 @@ type ThreadListV2ShelfHeaderProps = { readonly pane?: "screen" | "sidebar"; }; -const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const; - -function ThreadListV2ShelfHeader( - props: ThreadListV2ShelfHeaderProps & { readonly kind: keyof typeof SHELF_LABEL }, +export const ThreadListV2WorkingShelfHeader = memo(function ThreadListV2WorkingShelfHeader( + props: ThreadListV2ShelfHeaderProps, ) { - const label = SHELF_LABEL[props.kind]; return ( ); -} - -export const ThreadListV2WorkingShelfHeader = memo(function ThreadListV2WorkingShelfHeader( - props: ThreadListV2ShelfHeaderProps, -) { - return ; -}); - -export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader( - props: ThreadListV2ShelfHeaderProps, -) { - return ; -}); - -export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader( - props: ThreadListV2ShelfHeaderProps, -) { - return ; }); export const ThreadListV2ShowMoreRow = memo(function ThreadListV2ShowMoreRow(props: { @@ -458,6 +442,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozed?: boolean; /** Pinned-block row: shows the pin glyph and offers Unpin. */ readonly pinned?: boolean; + /** Hidden-page row: offers Unhide in place of the lifecycle actions. */ + readonly hidden?: boolean; /** Preformatted against the parent minute tick so this memoized row's countdown keeps moving. */ readonly snoozeWakeLabelText?: string; @@ -510,6 +496,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onPinThread: (thread: EnvironmentThreadShell) => void; readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void; + readonly onSetThreadHidden: (thread: EnvironmentThreadShell, hidden: boolean) => void; + readonly onSetThreadGroup: (thread: EnvironmentThreadShell, groupName: string | null) => void; + readonly onMoveThreadToNewGroup: (thread: EnvironmentThreadShell) => void; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; @@ -519,6 +508,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly pinningSupported: boolean; /** False on servers that predate thread.auto-settle.set. */ readonly autoSettleOptOutSupported: boolean; + /** False on servers that predate thread.hidden.set. */ + readonly hidingSupported: boolean; + /** False on servers that predate thread.group.set. */ + readonly groupsSupported: boolean; + /** Every group in use across the list, alphabetical, for Move to group. */ + readonly groupNames: ReadonlyArray; /** False on servers that predate thread title regeneration. */ readonly titleRegenerationSupported: boolean; /** Server supports reordering this card's section. */ @@ -556,9 +551,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPinThread, onUnpinThread, onSetThreadAutoSettle, + onSetThreadHidden, + onSetThreadGroup, + onMoveThreadToNewGroup, onMoveThread, } = props; const snoozedRow = props.snoozed === true; + const hiddenRow = props.hidden === true; const pinnedRow = props.pinned === true; const dormant = useSwipeRowDormant(props.activationKey); @@ -631,6 +630,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { (enabled: boolean) => onSetThreadAutoSettle(thread, enabled), [onSetThreadAutoSettle, thread], ); + const handleHide = useCallback( + () => onSetThreadHidden(thread, true), + [onSetThreadHidden, thread], + ); + const handleUnhide = useCallback( + () => onSetThreadHidden(thread, false), + [onSetThreadHidden, thread], + ); + const handleSetGroup = useCallback( + (groupName: string | null) => onSetThreadGroup(thread, groupName), + [onSetThreadGroup, thread], + ); + const handleNewGroup = useCallback( + () => onMoveThreadToNewGroup(thread), + [onMoveThreadToNewGroup, thread], + ); const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); @@ -654,6 +669,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { snoozeSupported: props.snoozeSupported, snoozable: canSnooze(thread, { now: new Date().toISOString() }), snoozed: snoozedRow, + hidden: hiddenRow, }); const snoozePresets = useMemo( () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), @@ -736,6 +752,35 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : [], [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt], ); + const hideMenuItems = useMemo( + () => (props.hidingSupported ? [{ id: "hide", title: "Hide thread", image: "eye.slash" }] : []), + [props.hidingSupported], + ); + // Current group checked, like the auto-settle submenu. + const groupMenuItems = useMemo( + () => + props.groupsSupported + ? [ + { + id: "group", + title: "Move to group", + image: "folder", + subactions: [ + ...props.groupNames.map((groupName) => ({ + id: `group:set:${groupName}`, + title: groupName, + state: thread.groupName === groupName ? ("on" as const) : ("off" as const), + })), + { id: "group:new", title: "New group…", image: "plus" }, + ...(thread.groupName != null + ? [{ id: "group:remove", title: "Remove from group", image: "xmark" }] + : []), + ], + } satisfies MenuAction, + ] + : [], + [props.groupNames, props.groupsSupported, thread.groupName], + ); const titleMenuItems = useMemo( () => [ { id: "rename", title: "Rename", image: "square.and.pencil" }, @@ -758,9 +803,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...arrangementMenuItems, ...titleMenuItems, ...autoSettleMenuItems, + ...groupMenuItems, + ...hideMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems], + [ + arrangementMenuItems, + autoSettleMenuItems, + groupMenuItems, + hideMenuItems, + snoozePresetActions, + titleMenuItems, + ], ); const cardMenuActions = useMemo( () => [ @@ -768,9 +822,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...arrangementMenuItems, ...titleMenuItems, ...autoSettleMenuItems, + ...groupMenuItems, + ...hideMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, groupMenuItems, hideMenuItems, titleMenuItems], ); // Settled and snoozed rows keep the setting too, matching web where every // row shares one menu builder. @@ -782,18 +838,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ), ...titleMenuItems, ...autoSettleMenuItems, + ...groupMenuItems, + ...hideMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, groupMenuItems, hideMenuItems, titleMenuItems], ); const snoozedMenuActions = useMemo( () => [ SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, ...autoSettleMenuItems, + ...groupMenuItems, + ...hideMenuItems, SNOOZED_MENU_ACTIONS[1]!, ], - [autoSettleMenuItems, titleMenuItems], + [autoSettleMenuItems, groupMenuItems, hideMenuItems, titleMenuItems], + ); + // Hidden rows offer Unhide instead of lifecycle verbs; the thread keeps + // its pin/settle/snooze state underneath and returns to it on unhide. + const hiddenMenuActions = useMemo( + () => [ + HIDDEN_MENU_ACTIONS[0]!, + ...titleMenuItems, + ...autoSettleMenuItems, + ...groupMenuItems, + HIDDEN_MENU_ACTIONS[1]!, + ], + [autoSettleMenuItems, groupMenuItems, titleMenuItems], ); const legacyMenuActions = useMemo( () => [ @@ -814,6 +886,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unpin") handleUnpin(); if (nativeEvent.event === "auto-settle:enabled") handleSetAutoSettle(true); if (nativeEvent.event === "auto-settle:disabled") handleSetAutoSettle(false); + if (nativeEvent.event === "hide") handleHide(); + if (nativeEvent.event === "unhide") handleUnhide(); + if (nativeEvent.event === "group:new") handleNewGroup(); + if (nativeEvent.event === "group:remove") handleSetGroup(null); + if (nativeEvent.event.startsWith("group:set:")) { + handleSetGroup(nativeEvent.event.slice("group:set:".length)); + } if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true); if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); @@ -852,6 +931,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleSettle, handleSnooze, handleSetAutoSettle, + handleHide, + handleUnhide, + handleNewGroup, + handleSetGroup, handleUnpin, handleUnsettle, handleUnsnooze, @@ -871,6 +954,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPress: handleArchive, }; } + if (swipeActions.primary === "unhide") { + return { + accessibilityLabel: `Unhide ${thread.title}`, + icon: "eye" as const, + label: "Unhide", + onPress: handleUnhide, + }; + } if (swipeActions.primary === "unsnooze") { return { accessibilityLabel: `Wake ${thread.title} now`, @@ -895,6 +986,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, [ handleArchive, handleSettle, + handleUnhide, handleUnsettle, handleUnsnooze, swipeActions.primary, @@ -1245,7 +1337,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSwipeableWillOpen={props.onSwipeableWillOpen} primaryAction={primaryAction} secondaryAction={secondaryAction} - resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}`} + resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${hiddenRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}`} simultaneousWith={props.simultaneousSwipeGesture} threadTitle={thread.title} > @@ -1262,15 +1354,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ] : []), { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, - ...(snoozedRow - ? snoozedMenuActions - : !props.settlementSupported - ? legacyMenuActions - : canUnsettle - ? slimMenuActions - : swipeActions.secondary === "snooze" - ? snoozableCardMenuActions - : cardMenuActions), + ...(hiddenRow + ? hiddenMenuActions + : snoozedRow + ? snoozedMenuActions + : !props.settlementSupported + ? legacyMenuActions + : canUnsettle + ? slimMenuActions + : swipeActions.secondary === "snooze" + ? snoozableCardMenuActions + : cardMenuActions), ]} onPressAction={handleMenuAction} shouldOpenOnLongPress diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 7459b17672f9b..e47e9b02632c0 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -30,7 +30,11 @@ import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; import { buildThreadListV2Items, buildThreadListV2ListItems, + availableThreadListPages, + collectThreadGroupNames, + DEFAULT_THREAD_LIST_PAGES, getThreadListV2OrderedSection, + resolveThreadListPages, isThreadListV2ListItem, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -39,6 +43,8 @@ import { sortThreadsForListV2, threadListV2ListItemsAreEqual, threadHasUnseenCompletion, + toggleThreadListPage, + type ThreadListPage, type ThreadListV2ListItem, } from "./threadListV2"; @@ -436,6 +442,148 @@ describe("buildThreadListV2Items", () => { expect(layout.items[0]?.variant).toBe("slim"); }); + it("shows only the picked pages; hidden outranks a group, a group outranks the rest", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + settledAt: NOW, + }), + makeThread({ + id: ThreadId.make("hidden-pinned"), + title: "Hidden pinned", + pinnedAt: NOW, + hiddenAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("hidden-settled"), + title: "Hidden settled", + settledOverride: "settled", + settledAt: NOW, + hiddenAt: "2026-06-02T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("grouped-pinned-snoozed"), + title: "Grouped pinned snoozed", + pinnedAt: NOW, + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + groupName: "Beta", + }), + makeThread({ + id: ThreadId.make("grouped-settled"), + title: "Grouped settled", + settledOverride: "settled", + settledAt: NOW, + groupName: "Alpha", + }), + makeThread({ + id: ThreadId.make("grouped-hidden"), + title: "Grouped hidden", + groupName: "Alpha", + hiddenAt: "2026-06-01T06:00:00.000Z", + }), + ]; + const groupNames = collectThreadGroupNames(threads, new Set([environmentId])); + expect(groupNames).toEqual(["Alpha", "Beta"]); + const available = availableThreadListPages({ hidingSupported: true, groupNames }); + expect(available).toEqual([ + "threads", + "group:Alpha", + "group:Beta", + "snoozed", + "hidden", + "settled", + ]); + const build = (pages: ReadonlyArray) => + buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now: NOW, + pages, + snoozedShelfExpanded: true, + }); + const ids = (layout: ReturnType) => layout.items.map((item) => item.thread.id); + + expect(ids(build(DEFAULT_THREAD_LIST_PAGES))).toEqual(["active"]); + expect(ids(build(["settled"]))).toEqual(["settled"]); + expect(ids(build(["group:Beta"]))).toEqual(["grouped-pinned-snoozed"]); + // Groups follow Threads; Hidden sits between Snoozed and Settled, most + // recently hidden first. + const all = build(available); + expect(ids(all)).toEqual([ + "active", + "grouped-settled", + "grouped-pinned-snoozed", + "snoozed", + "hidden-settled", + "hidden-pinned", + "grouped-hidden", + "settled", + ]); + expect(all.items.filter((item) => item.hidden).length).toBe(3); + expect(all.items.find((item) => item.thread.id === "grouped-settled")?.variant).toBe("slim"); + expect( + buildThreadListV2ListItems({ + items: all.items, + pendingTasks: [], + snoozedCount: all.snoozedCount, + snoozedShelfExpanded: true, + snoozedShelfHeaderIndex: all.snoozedShelfHeaderIndex, + hiddenSectionHeaderIndex: all.hiddenSectionHeaderIndex, + groupSections: all.groupSections, + settledCount: all.settledCount, + settledShelfHeaderIndex: all.settledShelfHeaderIndex, + }).map((item) => (item.type === "v2-thread" ? item.item.thread.id : item.type)), + ).toEqual([ + "active", + "v2-group-section", + "grouped-settled", + "v2-group-section", + "grouped-pinned-snoozed", + "v2-snoozed-shelf", + "snoozed", + "v2-hidden-section", + "hidden-settled", + "hidden-pinned", + "grouped-hidden", + "v2-settled-shelf", + "settled", + ]); + // Hidden and grouped threads are never arrangeable. + expect( + getThreadListV2OrderedSection({ threads, section: "pinned", now: NOW }).map( + (thread) => thread.id, + ), + ).toEqual([]); + }); + + it("toggles pages but never deselects the last one", () => { + const available = availableThreadListPages({ hidingSupported: true, groupNames: ["Ops"] }); + expect(toggleThreadListPage(["threads"], "settled", available)).toEqual(["threads", "settled"]); + expect(toggleThreadListPage(["settled"], "group:Ops", available)).toEqual([ + "group:Ops", + "settled", + ]); + expect(toggleThreadListPage(["threads", "settled"], "threads", available)).toEqual(["settled"]); + expect(toggleThreadListPage(["settled"], "settled", available)).toEqual(["settled"]); + }); + + it("drops picked groups that no longer exist and falls back to Threads", () => { + const available = availableThreadListPages({ hidingSupported: true, groupNames: [] }); + expect(resolveThreadListPages(["group:Gone", "settled"], available)).toEqual(["settled"]); + expect(resolveThreadListPages(["group:Gone"], available)).toEqual(["threads"]); + }); + it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index f7327706ef575..296a4aa30e531 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -84,7 +84,13 @@ export type ThreadListV2Status = | "failed" | "limited" | "ready"; -export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; +export type ThreadListV2SwipeAction = + | "archive" + | "settle" + | "unsettle" + | "snooze" + | "unsnooze" + | "unhide"; export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; @@ -117,10 +123,15 @@ export function resolveThreadListV2SwipeActions(input: { readonly snoozable: boolean; /** Row is on the snoozed shelf. */ readonly snoozed?: boolean; + /** Row is on the Hidden page. */ + readonly hidden?: boolean; }): { readonly primary: Exclude; readonly secondary: "snooze" | null; } { + if (input.hidden === true) { + return { primary: "unhide", secondary: null }; + } if (input.snoozed === true) { return { primary: "unsnooze", secondary: null }; } @@ -235,11 +246,15 @@ export function getThreadListV2OrderedSection(input: { readonly now: string; readonly settlementEnvironmentIds?: ReadonlySet; readonly snoozeEnvironmentIds?: ReadonlySet; + readonly hidingEnvironmentIds?: ReadonlySet; + readonly groupEnvironmentIds?: ReadonlySet; readonly queuedThreadKeys?: ReadonlySet; }): EnvironmentThreadShell[] { const threads = input.threads.filter((thread) => { if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") return false; + if (isThreadListV2Hidden(thread, input.hidingEnvironmentIds)) return false; + if (isThreadListV2Grouped(thread, input.groupEnvironmentIds)) return false; if ( (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && thread.settledOverride === "settled" && @@ -266,6 +281,108 @@ export function getThreadListV2OrderedSection(input: { return applyPendingThreadOrder(ordered, input.section, pending); } +/** Filter-menu pages. Threads covers the pinned, active and working groups; + each custom thread group is its own `group:` page. Mirrors the web + sidebar picker. */ +export type ThreadListPage = "threads" | "snoozed" | "hidden" | "settled" | `group:${string}`; +export const DEFAULT_THREAD_LIST_PAGES: ReadonlyArray = ["threads"]; + +export function threadListGroupPage(groupName: string): ThreadListPage { + return `group:${groupName}`; +} + +const BUILTIN_PAGE_LABEL: Record = { + threads: "Threads", + snoozed: "Snoozed", + hidden: "Hidden", + settled: "Settled", +}; + +export function threadListPageLabel(page: ThreadListPage): string { + return page.startsWith("group:") ? page.slice("group:".length) : BUILTIN_PAGE_LABEL[page]!; +} + +/** Picker and render order: Threads, groups (alphabetical), Snoozed, Hidden, + Settled. Hidden is offered only once an environment supports hiding. */ +export function availableThreadListPages(input: { + readonly hidingSupported: boolean; + readonly groupNames: ReadonlyArray; +}): ReadonlyArray { + return [ + "threads", + ...input.groupNames.map(threadListGroupPage), + "snoozed", + ...(input.hidingSupported ? (["hidden"] as const) : []), + "settled", + ]; +} + +/** Picked pages that still exist, in render order. A group whose last thread + left drops out silently; an emptied selection falls back to Threads. */ +export function resolveThreadListPages( + pages: ReadonlyArray, + available: ReadonlyArray, +): ReadonlyArray { + const resolved = available.filter((page) => pages.includes(page)); + return resolved.length === pages.length && resolved.every((page, index) => page === pages[index]) + ? pages + : resolved.length > 0 + ? resolved + : DEFAULT_THREAD_LIST_PAGES; +} + +/** Multi-select toggle; deselecting the last page is ignored. */ +export function toggleThreadListPage( + pages: ReadonlyArray, + page: ThreadListPage, + available: ReadonlyArray, +): ReadonlyArray { + if (pages.includes(page)) { + return pages.length === 1 ? pages : pages.filter((candidate) => candidate !== page); + } + return available.filter((candidate) => candidate === page || pages.includes(candidate)); +} + +export function isDefaultThreadListPages(pages: ReadonlyArray): boolean { + return pages.length === 1 && pages[0] === "threads"; +} + +/** Every group name in use, alphabetical. A group exists while at least one + thread has it, on environments that understand thread.group.set. */ +export function collectThreadGroupNames( + threads: ReadonlyArray< + Pick + >, + groupEnvironmentIds: ReadonlySet, +): ReadonlyArray { + const names = new Set(); + for (const thread of threads) { + if (thread.archivedAt === null && isThreadListV2Grouped(thread, groupEnvironmentIds)) { + names.add(thread.groupName!); + } + } + return [...names].sort((left, right) => left.localeCompare(right)); +} + +/** Hidden threads leave every other group (and arrangement) and show only + under the Hidden page. Only environments that understand + thread.hidden.set count; absent = no gating. */ +function isThreadListV2Hidden( + thread: Pick, + hidingEnvironmentIds: ReadonlySet | undefined, +): boolean { + return thread.hiddenAt != null && (hidingEnvironmentIds?.has(thread.environmentId) ?? true); +} + +/** Grouped threads leave the Threads, Snoozed and Settled pages for their + group's page (hidden still wins). Same gating contract as hiding. */ +function isThreadListV2Grouped( + thread: Pick, + groupEnvironmentIds: ReadonlySet | undefined, +): boolean { + return thread.groupName != null && (groupEnvironmentIds?.has(thread.environmentId) ?? true); +} + export interface ThreadListV2Item { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; @@ -273,9 +390,18 @@ export interface ThreadListV2Item { readonly snoozed: boolean; /** Pinned-block row: renders the pin glyph and offers Unpin. */ readonly pinned: boolean; + /** Hidden-page row: offers Unhide. */ + readonly hidden: boolean; + /** Group-section row: never part of an arrangeable section. */ + readonly grouped: boolean; readonly isLast: boolean; } +export interface ThreadListV2GroupSection { + readonly name: string; + readonly headerIndex: number; +} + export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; /** Settled threads beyond the render limit (behind "Show more"). */ @@ -293,6 +419,13 @@ export interface ThreadListV2Layout { readonly settledCount: number; /** Index in `items` where the Settled shelf header belongs. */ readonly settledShelfHeaderIndex: number | null; + /** Hidden threads shown (only when the Hidden page is picked). */ + readonly hiddenCount: number; + /** Index in `items` where the Hidden section title belongs. */ + readonly hiddenSectionHeaderIndex: number | null; + /** Picked group sections with rows, in render order, each with the index + in `items` where its title belongs. */ + readonly groupSections: ReadonlyArray; /** Soonest wake time among snoozed threads, or null. Callers arm a timeout at this boundary so the list re-partitions the moment a snooze expires instead of on the next minute tick. */ @@ -370,11 +503,24 @@ export interface ThreadListV2SettledShelfListItem { readonly disabled: boolean; } +export interface ThreadListV2GroupSectionListItem { + readonly type: "v2-group-section"; + readonly key: string; + readonly name: string; +} + +export interface ThreadListV2HiddenSectionListItem { + readonly type: "v2-hidden-section"; + readonly key: "v2-hidden-section"; +} + export type ThreadListV2ListItem = | ThreadListV2ThreadListItem | ThreadListV2PendingListItem | ThreadListV2WorkingShelfListItem + | ThreadListV2GroupSectionListItem | ThreadListV2SnoozedShelfListItem + | ThreadListV2HiddenSectionListItem | ThreadListV2SettledShelfListItem; /** Narrows a wider list-item union (e.g. the sidebar's legacy + v2 mix) to @@ -387,6 +533,8 @@ export function isThreadListV2ListItem(value: { value.type === "v2-pending" || value.type === "v2-working-shelf" || value.type === "v2-snoozed-shelf" || + value.type === "v2-group-section" || + value.type === "v2-hidden-section" || value.type === "v2-settled-shelf" ); } @@ -411,6 +559,8 @@ export function threadListV2ListItemsAreEqual( previous.item.variant === item.item.variant && previous.item.snoozed === item.item.snoozed && previous.item.pinned === item.item.pinned && + previous.item.hidden === item.item.hidden && + previous.item.grouped === item.item.grouped && previous.snoozeWakeLabelText === item.snoozeWakeLabelText && previous.timeLabel === item.timeLabel && previous.snoozePresetMinute === item.snoozePresetMinute && @@ -441,6 +591,10 @@ export function threadListV2ListItemsAreEqual( previous.expanded === item.expanded && previous.disabled === item.disabled ); + case "v2-group-section": + return previous.type === "v2-group-section" && previous.name === item.name; + case "v2-hidden-section": + return previous.type === "v2-hidden-section"; case "v2-settled-shelf": return ( previous.type === "v2-settled-shelf" && @@ -475,9 +629,9 @@ function resolveThreadListV2ItemTimeLabel( /** * Builds the shared mobile order: active → pending → working shelf (beta) → - * snoozed shelf → settled. Pending tasks are waiting rather than asking, and - * busy or parked work remains reachable without competing with either the - * inbox or settled history. + * groups → snoozed → hidden → settled. Pending tasks are waiting rather than + * asking, and busy or parked work remains reachable without competing with + * either the inbox or settled history. */ export function buildThreadListV2ListItems(input: { readonly items: ReadonlyArray; @@ -491,6 +645,8 @@ export function buildThreadListV2ListItems(input: { readonly settledCount?: number; readonly settledShelfExpanded?: boolean; readonly settledShelfHeaderIndex?: number | null; + readonly hiddenSectionHeaderIndex?: number | null; + readonly groupSections?: ReadonlyArray; readonly snoozeLabelNow?: string; /** Environments whose server supports thread.snooze. Rows on other environments never carry the minute clock that feeds the snooze menu. @@ -557,8 +713,12 @@ export function buildThreadListV2ListItems(input: { const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; const settledCount = input.settledCount ?? 0; const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null; - const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; - const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd; + const hiddenSectionHeaderIndex = input.hiddenSectionHeaderIndex ?? null; + const hiddenEnd = settledShelfHeaderIndex ?? threadItems.length; + const snoozedEnd = hiddenSectionHeaderIndex ?? hiddenEnd; + const groupsEnd = snoozedShelfHeaderIndex ?? snoozedEnd; + const groupSections = input.groupSections ?? []; + const workingEnd = groupSections[0]?.headerIndex ?? groupsEnd; const activeEnd = workingShelfHeaderIndex ?? workingEnd; const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; const shelfDisabled = input.shelfPreferencesLoading === true; @@ -572,6 +732,16 @@ export function buildThreadListV2ListItems(input: { }); result.push(...threadItems.slice(workingShelfHeaderIndex, workingEnd)); } + groupSections.forEach((section, index) => { + result.push({ + type: "v2-group-section", + key: `v2-group-section:${section.name}`, + name: section.name, + }); + result.push( + ...threadItems.slice(section.headerIndex, groupSections[index + 1]?.headerIndex ?? groupsEnd), + ); + }); if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { result.push({ type: "v2-snoozed-shelf", @@ -582,6 +752,10 @@ export function buildThreadListV2ListItems(input: { }); result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd)); } + if (hiddenSectionHeaderIndex !== null) { + result.push({ type: "v2-hidden-section", key: "v2-hidden-section" }); + result.push(...threadItems.slice(hiddenSectionHeaderIndex, hiddenEnd)); + } if (settledShelfHeaderIndex !== null && settledCount > 0) { result.push({ type: "v2-settled-shelf", @@ -626,6 +800,15 @@ export function buildThreadListV2Items(input: { /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; + /** Environments whose server supports thread.hidden.set. Same contract as + settlementEnvironmentIds. */ + readonly hidingEnvironmentIds?: ReadonlySet; + /** Environments whose server supports thread.group.set. Same contract as + settlementEnvironmentIds. */ + readonly groupEnvironmentIds?: ReadonlySet; + /** Pages picked in the filter menu. Absent = Threads, Snoozed and Settled + (tests). */ + readonly pages?: ReadonlyArray; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Second-precise clock used for time-based classification. */ @@ -674,6 +857,8 @@ export function buildThreadListV2Items(input: { const working: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; + const hidden: EnvironmentThreadShell[] = []; + const grouped = new Map(); let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") continue; @@ -699,6 +884,16 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; + if (isThreadListV2Hidden(thread, input.hidingEnvironmentIds)) { + hidden.push(thread); + continue; + } + // A group outranks snooze, settlement and pinning. + if (isThreadListV2Grouped(thread, input.groupEnvironmentIds)) { + const groupName = thread.groupName!; + grouped.set(groupName, [...(grouped.get(groupName) ?? []), thread]); + continue; + } // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now })) { snoozed.push(thread); @@ -724,6 +919,19 @@ export function buildThreadListV2Items(input: { } } + // Groups outside the picked pages are dropped after classification, so a + // thread never falls through into another group. + const showPage = (page: ThreadListPage) => + input.pages?.includes(page) ?? (page !== "hidden" && !page.startsWith("group:")); + if (!showPage("threads")) { + pinned.length = 0; + active.length = 0; + working.length = 0; + } + if (!showPage("snoozed")) snoozed.length = 0; + if (!showPage("hidden")) hidden.length = 0; + if (!showPage("settled")) settled.length = 0; + // The beta inbox is time-ordered, so the saved arrangement (and any move in // flight) is kept but not applied until the beta is off again. const orderedActive = workingShelfEnabled @@ -774,6 +982,8 @@ export function buildThreadListV2Items(input: { variant: "card", snoozed: false, pinned: true, + hidden: false, + grouped: false, isLast: false, }); } @@ -783,6 +993,8 @@ export function buildThreadListV2Items(input: { variant: "card", snoozed: false, pinned: false, + hidden: false, + grouped: false, isLast: false, }); } @@ -793,9 +1005,32 @@ export function buildThreadListV2Items(input: { variant: "card", snoozed: false, pinned: false, + hidden: false, + grouped: false, isLast: false, }); } + const groupSections: ThreadListV2GroupSection[] = []; + for (const groupName of [...grouped.keys()].sort((left, right) => left.localeCompare(right))) { + if (!showPage(threadListGroupPage(groupName))) continue; + groupSections.push({ name: groupName, headerIndex: items.length }); + for (const thread of sortThreadsForListV2(grouped.get(groupName)!)) { + items.push({ + thread, + // Settled members keep the slim row and its Un-settle action. + variant: + (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && + thread.settledOverride === "settled" + ? "slim" + : "card", + snoozed: false, + pinned: false, + hidden: false, + grouped: true, + isLast: false, + }); + } + } const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null; for (const thread of visibleSnoozed) { items.push({ @@ -803,6 +1038,24 @@ export function buildThreadListV2Items(input: { variant: "slim", snoozed: true, pinned: false, + hidden: false, + grouped: false, + isLast: false, + }); + } + // Most recently hidden first. + const orderedHidden = [...hidden].sort( + (left, right) => parseTimestampMs(right.hiddenAt ?? "") - parseTimestampMs(left.hiddenAt ?? ""), + ); + const hiddenSectionHeaderIndex = orderedHidden.length > 0 ? items.length : null; + for (const thread of orderedHidden) { + items.push({ + thread, + variant: "slim", + snoozed: false, + pinned: false, + hidden: true, + grouped: false, isLast: false, }); } @@ -813,6 +1066,8 @@ export function buildThreadListV2Items(input: { variant: "slim", snoozed: false, pinned: false, + hidden: false, + grouped: false, isLast: false, }); } @@ -829,6 +1084,9 @@ export function buildThreadListV2Items(input: { snoozedShelfHeaderIndex, settledCount: orderedSettled.length, settledShelfHeaderIndex, + hiddenCount: orderedHidden.length, + hiddenSectionHeaderIndex, + groupSections, nextSnoozeWakeAt, }; } diff --git a/apps/mobile/src/features/threads/use-thread-list-pages.ts b/apps/mobile/src/features/threads/use-thread-list-pages.ts new file mode 100644 index 0000000000000..7f4bce1fc56a8 --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-pages.ts @@ -0,0 +1,43 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { useCallback, useMemo, useState } from "react"; + +import { threadListEnvironmentsAtom } from "../../state/server"; +import { + availableThreadListPages, + collectThreadGroupNames, + DEFAULT_THREAD_LIST_PAGES, + resolveThreadListPages, + toggleThreadListPage, + type ThreadListPage, +} from "./threadListV2"; + +/** + * The Show picker for the compact Home list and iPad sidebar: which pages are + * picked, which exist, and the group names offered by Move to group. Kept in + * memory, like the project filter. + */ +export function useThreadListPages(threads: ReadonlyArray) { + const { hidingEnvironmentIds, groupEnvironmentIds } = useAtomValue(threadListEnvironmentsAtom); + const groupNames = useMemo( + () => collectThreadGroupNames(threads, groupEnvironmentIds), + [threads, groupEnvironmentIds], + ); + const availablePages = useMemo( + () => availableThreadListPages({ hidingSupported: hidingEnvironmentIds.size > 0, groupNames }), + [hidingEnvironmentIds, groupNames], + ); + const [pickedPages, setPickedPages] = useState(DEFAULT_THREAD_LIST_PAGES); + const pages = useMemo( + () => resolveThreadListPages(pickedPages, availablePages), + [pickedPages, availablePages], + ); + const togglePage = useCallback( + (page: ThreadListPage) => + setPickedPages((current) => + toggleThreadListPage(resolveThreadListPages(current, availablePages), page, availablePages), + ), + [availablePages], + ); + return { pages, availablePages, groupNames, togglePage } as const; +} diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts index 1ee46cbff89dc..53992b573da3b 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -5,7 +5,8 @@ import { useCallback, useRef } from "react"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; /** - * Shared persisted shelf state for the compact Home list and iPad sidebar. + * Shared persisted Working shelf state for the compact Home list and iPad + * sidebar. Snoozed and Settled are picked in the Show menu and always open. * Refs advance before persistence starts so consecutive presses always toggle * the latest value, even if React has not rendered the optimistic patch yet. */ @@ -13,33 +14,13 @@ export function useThreadListV2ShelfPreferences() { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const loaded = AsyncResult.isSuccess(preferencesResult); - const snoozedShelfExpanded = - loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true; - const settledShelfExpanded = - loaded && preferencesResult.value.threadListSettledShelfExpanded === true; // Working section beta: off until the preference loads and is enabled. const workingShelfEnabled = loaded && preferencesResult.value.workingShelfEnabled === true; const workingShelfExpanded = loaded && preferencesResult.value.threadListWorkingShelfExpanded === true; - const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); - const settledShelfExpandedRef = useRef(settledShelfExpanded); const workingShelfExpandedRef = useRef(workingShelfExpanded); - snoozedShelfExpandedRef.current = snoozedShelfExpanded; - settledShelfExpandedRef.current = settledShelfExpanded; workingShelfExpandedRef.current = workingShelfExpanded; - const toggleSnoozedShelf = useCallback(() => { - if (!loaded) return; - const expanded = !snoozedShelfExpandedRef.current; - snoozedShelfExpandedRef.current = expanded; - savePreferences({ threadListSnoozedShelfExpanded: expanded }); - }, [loaded, savePreferences]); - const toggleSettledShelf = useCallback(() => { - if (!loaded) return; - const expanded = !settledShelfExpandedRef.current; - settledShelfExpandedRef.current = expanded; - savePreferences({ threadListSettledShelfExpanded: expanded }); - }, [loaded, savePreferences]); const toggleWorkingShelf = useCallback(() => { if (!loaded) return; const expanded = !workingShelfExpandedRef.current; @@ -49,12 +30,8 @@ export function useThreadListV2ShelfPreferences() { return { loaded, - settledShelfExpanded, - snoozedShelfExpanded, workingShelfEnabled, workingShelfExpanded, - toggleSettledShelf, - toggleSnoozedShelf, toggleWorkingShelf, } as const; } diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index f56c3a7eff0ef..c59a7583251ad 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -235,27 +235,19 @@ describe("mobile connection storage", () => { it("persists thread list shelf preferences", async () => { await expect( savePreferencesPatch({ - threadListSettledShelfExpanded: false, - threadListSnoozedShelfExpanded: true, threadListWorkingShelfExpanded: true, workingShelfEnabled: true, }), ).resolves.toEqual({ - threadListSettledShelfExpanded: false, - threadListSnoozedShelfExpanded: true, threadListWorkingShelfExpanded: true, workingShelfEnabled: true, }); await expect(loadPreferences()).resolves.toEqual({ - threadListSettledShelfExpanded: false, - threadListSnoozedShelfExpanded: true, threadListWorkingShelfExpanded: true, workingShelfEnabled: true, }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ - threadListSettledShelfExpanded: false, - threadListSnoozedShelfExpanded: true, threadListWorkingShelfExpanded: true, workingShelfEnabled: true, }); @@ -267,8 +259,6 @@ describe("mobile connection storage", () => { baseFontSize: 17, threadListV2SettledShelfExpanded: true, threadListV2SnoozedShelfExpanded: true, - threadListSettledShelfExpanded: "false", - threadListSnoozedShelfExpanded: 1, }), 10, ); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index ee96bce2d5cec..e7e9c10370082 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -49,8 +49,6 @@ export interface Preferences { readonly model: string; }>; /** Fresh keys reset both shelves to collapsed when users update. */ - readonly threadListSettledShelfExpanded?: boolean; - readonly threadListSnoozedShelfExpanded?: boolean; readonly threadListWorkingShelfExpanded?: boolean; } @@ -112,8 +110,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { planModeEnabled?: boolean; workingShelfEnabled?: boolean; modelFavorites?: Preferences["modelFavorites"]; - threadListSettledShelfExpanded?: boolean; - threadListSnoozedShelfExpanded?: boolean; threadListWorkingShelfExpanded?: boolean; } = {}; @@ -199,12 +195,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { favorite.model.trim().length > 0, ); } - if (typeof parsed.threadListSettledShelfExpanded === "boolean") { - preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded; - } - if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") { - preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded; - } if (typeof parsed.threadListWorkingShelfExpanded === "boolean") { preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded; } diff --git a/apps/mobile/src/state/thread-list-environments.ts b/apps/mobile/src/state/thread-list-environments.ts index 9df8a12ac5023..afe9adbc51d82 100644 --- a/apps/mobile/src/state/thread-list-environments.ts +++ b/apps/mobile/src/state/thread-list-environments.ts @@ -17,6 +17,8 @@ const capabilityKeys = [ "threadSnooze", "threadPinning", "threadAutoSettleOptOut", + "threadHiding", + "threadGroups", "threadPinReorder", "threadActiveReorder", "threadTitleRegeneration", @@ -66,6 +68,8 @@ function collectEnvironments(environments: ReadonlyMap(); const pinningEnvironmentIds = new Set(); const autoSettleOptOutEnvironmentIds = new Set(); + const hidingEnvironmentIds = new Set(); + const groupEnvironmentIds = new Set(); const pinReorderEnvironmentIds = new Set(); const activeReorderEnvironmentIds = new Set(); const titleRegenerationEnvironmentIds = new Set(); @@ -75,6 +79,8 @@ function collectEnvironments(environments: ReadonlyMap + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:group"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-group"), + threadId, + projectId: ProjectId.make("project:group"), + title: "Group me", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const groupName = Effect.map(projections.getThreadShell(threadId), (shell) => shell?.groupName); + for (const [id, name] of [ + ["group-research", "Research"], + ["group-later", "Later"], + ["group-none", null], + ] as const) { + yield* orchestrator.dispatch({ + type: "thread.group.set", + commandId: CommandId.make(id), + threadId, + groupName: name === null ? null : TrimmedNonEmptyString.make(name), + }); + assert.equal(yield* groupName, name); + } + }).pipe(Effect.provide(layerTest)), +); + +it.effect("hides a thread until it is unhidden, and leaves archived threads alone", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:hide"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-hide"), + threadId, + projectId: ProjectId.make("project:hide"), + title: "Hide me", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const setHidden = (hidden: boolean, id: string) => + orchestrator.dispatch({ + type: "thread.hidden.set", + commandId: CommandId.make(id), + threadId, + hidden, + }); + const hiddenAt = Effect.map(projections.getThreadShell(threadId), (shell) => shell?.hiddenAt); + + yield* setHidden(true, "hide-1"); + const firstHiddenAt = yield* hiddenAt; + assert.ok(firstHiddenAt); + // Hiding again keeps the original timestamp. + yield* setHidden(true, "hide-2"); + assert.deepStrictEqual(yield* hiddenAt, firstHiddenAt); + + yield* setHidden(false, "unhide"); + assert.equal(yield* hiddenAt, null); + + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("archive-hide"), + threadId, + }); + assert.equal((yield* Effect.exit(setHidden(true, "hide-archived")))._tag, "Failure"); + }).pipe(Effect.provide(layerTest)), +); + it.effect( "dispatches metadata, queue resume and request controls without hydrating unrelated history", () => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 928583178a1d3..02776b880c787 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -396,6 +396,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.snooze": case "thread.unsnooze": case "thread.auto-settle.set": + case "thread.hidden.set": + case "thread.group.set": case "thread.pin": case "thread.unpin": case "thread.pin.reorder": @@ -2352,6 +2354,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.snooze" | "thread.unsnooze" | "thread.auto-settle.set" + | "thread.hidden.set" + | "thread.group.set" | "thread.pin" | "thread.unpin" | "thread.pin.reorder" @@ -2490,6 +2494,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.type === "thread.snooze" || command.type === "thread.unsnooze" || command.type === "thread.auto-settle.set" || + command.type === "thread.hidden.set" || + command.type === "thread.group.set" || command.type === "thread.pin" || command.type === "thread.unpin" || command.type === "thread.pin.reorder" || @@ -2829,6 +2835,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: unchanged ? thread.updatedAt : now, }; } + case "thread.hidden.set": { + const unchanged = command.hidden === (thread.hiddenAt != null); + return { + ...thread, + hiddenAt: command.hidden ? (thread.hiddenAt ?? now) : null, + updatedAt: unchanged ? thread.updatedAt : now, + }; + } + case "thread.group.set": + return { + ...thread, + groupName: command.groupName, + updatedAt: command.groupName === (thread.groupName ?? null) ? thread.updatedAt : now, + }; case "thread.pin": { // Pinning is a promotion: it clears the parked states rather than // silently outranking them — an explicit settle is un-settled and a @@ -3186,6 +3206,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return "thread.unsnoozed" as const; case "thread.auto-settle.set": return "thread.auto-settle-set" as const; + case "thread.hidden.set": + return "thread.hidden-set" as const; + case "thread.group.set": + return "thread.group-set" as const; case "thread.pin": return "thread.pinned" as const; case "thread.unpin": @@ -10291,6 +10315,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.snooze": case "thread.unsnooze": case "thread.auto-settle.set": + case "thread.hidden.set": + case "thread.group.set": case "thread.pin": case "thread.unpin": case "thread.pin.reorder": diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index 8c10de3142028..db8c23586b26b 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -216,6 +216,8 @@ export const layer: Layer.Layer< "thread.unsnoozed", "thread.pinned", "thread.auto-settle-set", + "thread.hidden-set", + "thread.group-set", "thread.unpinned", "thread.pin-reordered", "thread.active-reordered", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 1f95f012eb98d..d6bbec37bd477 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -179,6 +179,8 @@ export type ProjectionSettlementCandidate = Pick< | "settledOverride" | "pinnedAt" | "autoSettleDisabledAt" + | "hiddenAt" + | "groupName" | "snoozedUntil" | "snoozedAt" | "latestRunId" @@ -662,6 +664,8 @@ export function applyToProjection( case "thread.snoozed": case "thread.unsnoozed": case "thread.auto-settle-set": + case "thread.hidden-set": + case "thread.group-set": case "thread.pinned": case "thread.unpinned": case "thread.pin-reordered": @@ -1484,6 +1488,8 @@ export function threadShellFromProjection( pinnedAt: projection.thread.pinnedAt ?? null, autoSettleDisabledAt: projection.thread.autoSettleDisabledAt ?? null, + hiddenAt: projection.thread.hiddenAt ?? null, + groupName: projection.thread.groupName ?? null, pinOrderKey: projection.thread.pinOrderKey ?? null, lastVisitedAt: projection.thread.lastVisitedAt, titleRegeneration: projection.thread.titleRegeneration ?? null, @@ -1742,6 +1748,8 @@ function shellFromState(input: { pinnedAt: input.state.thread.pinnedAt ?? null, autoSettleDisabledAt: input.state.thread.autoSettleDisabledAt ?? null, + hiddenAt: input.state.thread.hiddenAt ?? null, + groupName: input.state.thread.groupName ?? null, pinOrderKey: input.state.thread.pinOrderKey ?? null, lastVisitedAt: input.state.thread.lastVisitedAt, titleRegeneration: input.state.thread.titleRegeneration ?? null, @@ -1781,6 +1789,8 @@ export const layer: Layer.Layer = case "thread.snoozed": case "thread.unsnoozed": case "thread.auto-settle-set": + case "thread.hidden-set": + case "thread.group-set": case "thread.pinned": case "thread.unpinned": case "thread.pin-reordered": @@ -2613,6 +2623,8 @@ export const layer: Layer.Layer = event.type !== "thread.snoozed" && event.type !== "thread.unsnoozed" && event.type !== "thread.auto-settle-set" && + event.type !== "thread.hidden-set" && + event.type !== "thread.group-set" && event.type !== "thread.pinned" && event.type !== "thread.unpinned" && event.type !== "thread.pin-reordered" && @@ -5318,6 +5330,8 @@ export const layer: Layer.Layer = pinnedAt: thread.pinnedAt ?? null, autoSettleDisabledAt: thread.autoSettleDisabledAt ?? null, + hiddenAt: thread.hiddenAt ?? null, + groupName: thread.groupName ?? null, snoozedUntil: thread.snoozedUntil ?? null, snoozedAt: thread.snoozedAt ?? null, status, diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 49397f15ea67a..b0de8d854f6d7 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -143,6 +143,8 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray void }; +const useRequest = create<{ request: Request | null }>(() => ({ request: null })); + +/** Asks for a new sidebar group's name; resolves null when cancelled. */ +export function requestNewThreadGroupName(): Promise { + useRequest.getState().request?.resolve(null); + return new Promise((resolve) => useRequest.setState({ request: { resolve } })); +} + +function finish(groupName: string | null) { + const request = useRequest.getState().request; + useRequest.setState({ request: null }); + request?.resolve(groupName); +} + +export function NewThreadGroupDialogHost() { + const request = useRequest((state) => state.request); + useEffect(() => () => finish(null), []); + return request ? : null; +} + +function NewThreadGroupDialog() { + const id = useId(); + const [name, setName] = useState(""); + const trimmed = name.trim(); + return ( + { + if (!open) finish(null); + }} + > + +
{ + event.preventDefault(); + if (trimmed.length > 0) finish(trimmed); + }} + > + + New group + + The thread moves into this group. Pick it under Show in the sidebar filters to see it. + + + +
+ + setName(event.currentTarget.value)} + placeholder="Research" + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 6664476e8c69b..ba3394ef7339d 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -131,6 +131,8 @@ export function createSidebarSortingStrategy(input: { active: [], working: [], snoozed: [], + grouped: [], + hidden: [], settled: [], }; let cardHeight = input.cardHeight; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4a91803890489..1b887e2a38db4 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -13,7 +13,6 @@ import { buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, deleteSelectedThreadEntries, - filterSidebarProjectScopeItems, filterSidebarV2VisibleThreads, foldedSidebarTabThreads, formatWorkingDurationLabel, @@ -22,7 +21,6 @@ import { isSidebarTabGroupOpen, setSidebarTabGroupOverride, sidebarTabNeighbourKey, - resolveSidebarProjectScopeKeys, getFallbackThreadIdAfterDelete, getProjectSortTimestamp, getSidebarForkParentThreadId, @@ -34,12 +32,12 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, pinOrderKeyBetween, - reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveProjectStatusIndicator, resolveSidebarSweepKeys, resolveSidebarStageBadgeLabel, resolveSidebarThreadSection, + resolveSidebarPages, resolveSidebarRowAccessibility, resolveSidebarThreadStatus, resolveSidebarV2TopStatus, @@ -173,6 +171,27 @@ describe("resolveSidebarThreadSection", () => { "settled", ); }); + + it("files a grouped thread under its group unless it is hidden", () => { + expect( + resolveSidebarThreadSection({ grouped: true, snoozed: true, settled: true, pinned: true }), + ).toBe("grouped"); + expect( + resolveSidebarThreadSection({ + hidden: true, + grouped: true, + snoozed: false, + settled: false, + pinned: false, + }), + ).toBe("hidden"); + }); + + it("files a hidden thread under Hidden whatever else it is", () => { + expect( + resolveSidebarThreadSection({ hidden: true, snoozed: true, settled: true, pinned: true }), + ).toBe("hidden"); + }); }); describe("deleteSelectedThreadEntries", () => { @@ -1330,89 +1349,6 @@ describe("searchSidebarThreads", () => { }); }); -describe("filterSidebarProjectScopeItems", () => { - const items = [ - { value: "all", label: "All projects" }, - { value: "alpha", label: "Alpha workspace" }, - { value: "beta", label: "Beta tools" }, - ] as const; - const filter = (query: string) => - filterSidebarProjectScopeItems({ - items, - query, - matches: (item, candidate) => - item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()), - }); - - it("shows the default row first while the query is empty", () => { - expect(filter("")).toEqual(items); - expect(filter(" ")).toEqual(items); - }); - - it("hides the default row while filtering", () => { - expect(filter("all")).toEqual([]); - }); - - it("returns matching projects in source order and supports no-match results", () => { - expect(filter("WORK")).toEqual([items[1]]); - expect(filter("missing")).toEqual([]); - }); -}); - -describe("resolveSidebarProjectScopeKeys", () => { - it("replaces the default row with the first picked project", () => { - expect(resolveSidebarProjectScopeKeys({ current: [], next: ["all", "alpha"] })).toEqual([ - "alpha", - ]); - }); - - it("adds and removes projects while scoped", () => { - expect(resolveSidebarProjectScopeKeys({ current: ["alpha"], next: ["alpha", "beta"] })).toEqual( - ["alpha", "beta"], - ); - expect(resolveSidebarProjectScopeKeys({ current: ["alpha", "beta"], next: ["beta"] })).toEqual([ - "beta", - ]); - }); - - it("clears the scope when the default row is picked", () => { - expect(resolveSidebarProjectScopeKeys({ current: ["alpha"], next: ["alpha", "all"] })).toEqual( - [], - ); - expect(resolveSidebarProjectScopeKeys({ current: [], next: [] })).toEqual([]); - }); -}); - -describe("reduceSidebarProjectScopeMenuState", () => { - const queriedOpenState = { open: true, query: "alpha" }; - - it("clears the query when the combobox closes through onOpenChange", () => { - expect( - reduceSidebarProjectScopeMenuState(queriedOpenState, { - type: "open-changed", - open: false, - }), - ).toEqual({ open: false, query: "" }); - }); - - it("clears the query when project settings closes the combobox", () => { - expect( - reduceSidebarProjectScopeMenuState(queriedOpenState, { - type: "project-settings-opened", - }), - ).toEqual({ open: false, query: "" }); - }); - - it("keeps the popup open while the query changes", () => { - expect( - reduceSidebarProjectScopeMenuState( - { open: true, query: "" }, - { type: "query-changed", query: "beta" }, - ), - ).toEqual({ open: true, query: "beta" }); - }); -}); - describe("resolveWorkingStartedAt", () => { const runtime = { status: "running" as const, @@ -2569,6 +2505,7 @@ describe("Working shelf (beta)", () => { activeOrder: ["a1", "a2", "p1"], }); expect(resolveSidebarDropVerb("active", "working")).toBeNull(); + expect(resolveSidebarDropVerb("active", "hidden")).toBeNull(); }); it("arranges the rows it can write when another row's server cannot store an order", () => { @@ -2652,3 +2589,19 @@ describe("Working shelf (beta)", () => { }); }); }); + +describe("resolveSidebarPages", () => { + it("keeps picked pages in list order, groups by name between threads and shelves", () => { + expect( + resolveSidebarPages( + ["settled", "group:Research", "threads", "group:Later"], + ["Later", "Research"], + ), + ).toEqual(["threads", "group:Later", "group:Research", "settled"]); + }); + + it("drops groups that no longer have threads and falls back to live threads", () => { + expect(resolveSidebarPages(["group:Gone", "hidden"], [])).toEqual(["hidden"]); + expect(resolveSidebarPages(["group:Gone", "nonsense"], [])).toEqual(["threads"]); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9d2f8bac92109..471dcaa04562e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -146,17 +146,31 @@ export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) => // and active threads keep the dragged position; settled threads use time // order. Snoozed rows can leave the shelf, but dropping into it is not // supported because snoozing requires a wake time. The Working shelf (beta) -// follows live status, so it is neither a drag source nor a destination. +// follows live status, and hidden rows leave through Unhide, so neither is a +// drag source or destination. -export type SidebarSection = "pinned" | "active" | "working" | "snoozed" | "settled"; - -/** Resolve the shelf a visible thread belongs to. Snooze is temporary and - * wins until its wake boundary; settlement then wins over a stale pin. */ +export type SidebarSection = + | "pinned" + | "active" + | "working" + | "grouped" + | "snoozed" + | "hidden" + | "settled"; + +/** Resolve the shelf a visible thread belongs to. Hiding outranks everything + * until the user unhides, then a user-made group holds the thread until it + * moves out; snooze is temporary and wins until its wake boundary; + * settlement then wins over a stale pin. */ export function resolveSidebarThreadSection(input: { + readonly hidden?: boolean; + readonly grouped?: boolean; readonly snoozed: boolean; readonly settled: boolean; readonly pinned: boolean; }): SidebarSection { + if (input.hidden) return "hidden"; + if (input.grouped) return "grouped"; if (input.snoozed) return "snoozed"; if (input.settled) return "settled"; if (input.pinned) return "pinned"; @@ -177,7 +191,10 @@ export type SidebarListMarker = | "pinned-divider" | "working-header" | "snoozed-header" - | "settled-header"; + | "hidden-header" + | "settled-header" + /** Titles one user-made group; the item carries the group's name. */ + | "group-header"; export function sidebarMarkerId(marker: SidebarListMarker): string { return `${SIDEBAR_MARKER_PREFIX}${marker}`; @@ -185,10 +202,14 @@ export function sidebarMarkerId(marker: SidebarListMarker): string { export type SidebarListItem = | { readonly kind: "thread"; readonly key: string; readonly section: SidebarSection } - | { readonly kind: "marker"; readonly marker: SidebarListMarker }; + | { readonly kind: "marker"; readonly marker: SidebarListMarker; readonly group?: string }; export function sidebarListItemId(item: SidebarListItem): string { - return item.kind === "thread" ? item.key : sidebarMarkerId(item.marker); + if (item.kind === "thread") return item.key; + // Encoded so a group name can never add the colon that marks thread keys. + return item.group === undefined + ? sidebarMarkerId(item.marker) + : `${sidebarMarkerId(item.marker)}-${encodeURIComponent(item.group)}`; } /** The section a slot belongs to, read off the markers around it: from @@ -227,7 +248,13 @@ export function resolveSidebarDropTarget( const moved = items.filter((_, index) => index !== activeIndex); moved.splice(overIndex, 0, items[activeIndex]!); const section = sectionAtSidebarSlot(moved, overIndex); - if (section === "working" || section === "snoozed") return null; + if ( + section === "working" || + section === "grouped" || + section === "snoozed" || + section === "hidden" + ) + return null; const pinnedOrder: string[] = []; const activeOrder: string[] = []; let currentSection: SidebarSection = "pinned"; @@ -283,7 +310,8 @@ export function resolveSidebarDropVerb( from: SidebarSection, to: SidebarSection | null, ): SidebarDropVerb | null { - if (to === null || to === from || to === "working" || to === "snoozed") return null; + if (to === null || to === from || to === "working" || to === "snoozed" || to === "hidden") + return null; if (to === "pinned") return "pin"; if (to === "settled") return "settle"; if (from === "pinned") return "unpin"; @@ -1391,7 +1419,14 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea return status === "working"; } -export type SidebarThreadShelf = "snoozed" | "settled" | "pinned" | "working" | "active"; +export type SidebarThreadShelf = + | "hidden" + | "grouped" + | "snoozed" + | "settled" + | "pinned" + | "working" + | "active"; /** * The shelf a standalone sidebar row sits on. Pinned and active rows render as cards, whose tab @@ -1400,7 +1435,7 @@ export type SidebarThreadShelf = "snoozed" | "settled" | "pinned" | "working" | export function sidebarThreadShelf( thread: ThreadStatusInput & ThreadSnoozeShell & - Pick, + Pick, input: { supportsSnooze: boolean; supportsSettlement: boolean; @@ -1408,8 +1443,11 @@ export function sidebarThreadShelf( now: string; }, ): SidebarThreadShelf { - // Snooze outranks settlement and pinning until the thread wakes. + // Snooze outranks settlement and pinning until the thread wakes. Only servers + // that understand hiding ever set hiddenAt, so it needs no capability check. const section = resolveSidebarThreadSection({ + hidden: thread.hiddenAt != null, + grouped: thread.groupName != null, snoozed: input.supportsSnooze && effectiveSnoozed(thread, { now: input.now }), settled: input.supportsSettlement && thread.settledOverride === "settled", pinned: thread.pinnedAt != null, @@ -1487,51 +1525,6 @@ export function searchSidebarThreads< return [...titleMatches, ...contentMatches]; } -export function filterSidebarProjectScopeItems(input: { - items: readonly TItem[]; - query: string; - matches: (item: TItem, query: string) => boolean; -}): readonly TItem[] { - const query = input.query.trim(); - if (query.length === 0) return input.items; - return input.items.filter((item) => item.value !== "all" && input.matches(item, query)); -} - -/** Maps the multi-select picker's next value onto scope keys. "All projects" - is selected exactly when nothing is scoped, so picking it clears the scope - and picking any project drops it. */ -export function resolveSidebarProjectScopeKeys(input: { - readonly current: readonly string[]; - readonly next: readonly string[]; -}): string[] { - if (input.current.length > 0 && input.next.includes("all")) return []; - return input.next.filter((value) => value !== "all"); -} - -export interface SidebarProjectScopeMenuState { - readonly open: boolean; - readonly query: string; -} - -export type SidebarProjectScopeMenuAction = - | { readonly type: "query-changed"; readonly query: string } - | { readonly type: "open-changed"; readonly open: boolean } - | { readonly type: "project-settings-opened" }; - -export function reduceSidebarProjectScopeMenuState( - state: SidebarProjectScopeMenuState, - action: SidebarProjectScopeMenuAction, -): SidebarProjectScopeMenuState { - switch (action.type) { - case "query-changed": - return { ...state, query: action.query }; - case "open-changed": - return { open: action.open, query: "" }; - case "project-settings-opened": - return { open: false, query: "" }; - } -} - /** The timestamp a working thread's elapsed label counts from: when its current work started (request time until adoption). Background wakes do not reset it. Malformed timestamps fall through to the next candidate. */ @@ -1828,3 +1821,26 @@ export function sortScopedProjectsForSidebar< left.id.localeCompare(right.id), ); } + +/** What the sidebar list can show: live threads, one user-made group, or a parked shelf. */ +export type SidebarPage = "threads" | `group:${string}` | "snoozed" | "hidden" | "settled"; + +export const sidebarGroupPage = (groupName: string): SidebarPage => `group:${groupName}`; + +/** Every page the Show filter offers, in list order: live work, groups by name, then shelves. */ +export function availableSidebarPages(groupNames: readonly string[]): SidebarPage[] { + return ["threads", ...groupNames.map(sidebarGroupPage), "snoozed", "hidden", "settled"]; +} + +/** + * The stored Show selection, kept to pages that still exist and put in list order. A group + * whose last thread moved out drops away; an emptied selection falls back to live threads. + */ +export function resolveSidebarPages( + stored: readonly string[], + groupNames: readonly string[], +): SidebarPage[] { + const picked = new Set(stored); + const pages = availableSidebarPages(groupNames).filter((page) => picked.has(page)); + return pages.length > 0 ? pages : ["threads"]; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 34ca83d9c608c..0a952408da07e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -76,6 +76,8 @@ import type { import { AlarmClockIcon, AlarmClockOffIcon, + EyeIcon, + FolderOpenIcon, ArrowRightLeftIcon, BotIcon, CheckIcon, @@ -85,14 +87,11 @@ import { CircleCheckIcon, ClockIcon, Columns2Icon, - FolderIcon, - FoldersIcon, GitBranchIcon, LayersIcon, PinIcon, PinOffIcon, PlusIcon, - SettingsIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -106,7 +105,6 @@ import { useEffect, useLayoutEffect, useMemo, - useReducer, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, @@ -201,7 +199,12 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; -import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { + buildDraftActionMenuItems, + buildThreadActionMenuItems, + collectThreadGroupNames, + resolveThreadGroupMenuPick, +} from "./threadActionMenu.logic"; import { openLinearIssuePicker } from "./chat/LinearIssuePicker"; import { openTranscriptExportDialog } from "./TranscriptExportDialog"; import { StartedByHoverLine } from "./chat/StartedByChip"; @@ -212,7 +215,6 @@ import { buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, deleteSelectedThreadEntries, - filterSidebarProjectScopeItems, formatWorkingDurationLabel, firstValidTimestampMs, foldedSidebarTabThreads, @@ -236,8 +238,6 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, - reduceSidebarProjectScopeMenuState, - resolveSidebarProjectScopeKeys, resolveAdjacentThreadId, resolveSidebarSweepKeys, resolveSidebarDropTarget, @@ -252,8 +252,12 @@ import { shouldNavigateAfterThreadPark, shouldRecedeSidebarThread, resolveWorkingStartedAt, + availableSidebarPages, + resolveSidebarPages, + sidebarGroupPage, sidebarListItemId, sidebarMarkerId, + type SidebarPage, sidebarThreadKeyAtY, sortInboxThreadsByReturn, sortPinnedThreadsForSidebar, @@ -315,21 +319,17 @@ import { } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { Button, InlineButton } from "./ui/button"; -import { - Combobox, - ComboboxEmpty, - ComboboxSearchInput, - ComboboxItem, - ComboboxList, - ComboboxPopup, - ComboboxTrigger, - useComboboxFilter, -} from "./ui/combobox"; +import { InlineButton } from "./ui/button"; import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { SidebarAttentionInbox } from "./sidebar/SidebarAttentionInbox"; -import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; +import { SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; +import { + SidebarFilterMenu, + SidebarPagesSchema, + sidebarPageLabel, +} from "./sidebar/SidebarFilterMenu"; +import { requestNewThreadGroupName } from "./NewThreadGroupDialog"; import { SIDEBAR_TAB_SORT_ORDER_LABELS, sidebarTabSortDirectionLabel, @@ -356,11 +356,16 @@ const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new M // give the sidebar list a new identity. const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; +const SIDEBAR_PAGES_KEY = "t3code:sidebar:pages"; +const DEFAULT_SIDEBAR_PAGES: readonly string[] = ["threads"]; +const SIDEBAR_PAGE_BY_MARKER = { + "snoozed-header": "snoozed", + "hidden-header": "hidden", + "settled-header": "settled", +} as const; const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; // Fresh keys deliberately reset both shelves to collapsed for existing users. -const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; -const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; // Per-group exceptions to Show tabs. Client-local, like the shelves: a view preference. // Stored with the Show tabs value they were made under, so changing that setting from // anywhere (header, Settings, palette, keybinding) resets every group to it. @@ -739,12 +744,14 @@ const draftPenClassName = "size-3 shrink-0 text-warning-foreground"; // which resolveSidebarDropTarget turns into the section the gap sits in. function SortableSidebarMarker(props: { marker: SidebarListMarker; + /** Overrides the marker's id when several items share one marker kind. */ + id?: string; className?: string; children?: ReactNode; "data-testid"?: string; }) { const { setNodeRef, transform, transition } = useSortable({ - id: sidebarMarkerId(props.marker), + id: props.id ?? sidebarMarkerId(props.marker), disabled: { draggable: true }, animateLayoutChanges: animateSidebarLayoutChanges, }); @@ -799,6 +806,8 @@ function SidebarSectionPlaceholder(props: { const SIDEBAR_DRAG_DISTANCE = 6; type SidebarSweepAction = "settle" | "unsettle" | "unsnooze"; +// Unhide is a row action but not a sweep: hidden rows are few and deliberate. +type SidebarRowAction = SidebarSweepAction | "unhide"; // Zero-height markers reserve no label space at rest. During a drag the // sorting strategy opens 24px for a 16px label with 4px clearance on each side. @@ -839,24 +848,13 @@ function SidebarDragBoundary(props: { ); } -// Shelf headers stay visible and keep their measured height while dragging. +// The Working shelf header stays visible and keeps its measured height while dragging. function SidebarSectionHeader(props: { - marker: "working-header" | "snoozed-header" | "settled-header"; + marker: "working-header"; label: string; className?: string; - // While dragging, the settled header reads at full strength and takes the - // accent while the lifted row is over it. - dragging?: boolean; - isDropTarget?: boolean; toggle: { expanded: boolean; onToggle: () => void }; }) { - const shelf = - props.marker === "working-header" - ? "working" - : props.marker === "snoozed-header" - ? "snoozed" - : "settled"; - const snoozed = shelf === "snoozed"; return ( {props.label} @@ -1173,8 +1169,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /** The tab the hidden-tabs row opens; the group thread still owns ordering and selection. */ displayThread: SidebarThreadSummary; variant: "card" | "slim"; - // Settled rows un-settle, snoozed rows wake, and cards settle. - variantAction: SidebarSweepAction; + // Settled rows un-settle, snoozed rows wake, hidden rows unhide, and cards settle. + variantAction: SidebarRowAction; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; @@ -1242,6 +1238,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; + onUnhide: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onPin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; @@ -1286,6 +1283,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onThreadClick, onUnsettle, onUnsnooze, + onUnhide, onUnpin, onPin, openPullRequestsInRightPanel, @@ -1534,6 +1532,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { if (!event.isPrimary || event.button !== 0) return; // Action buttons sweep their section rather than picking up the row. event.stopPropagation(); + if (variantAction === "unhide") return; onActionSweepStart(threadRef, variantAction, event.nativeEvent); }, [onActionSweepStart, threadRef, variantAction], @@ -1554,6 +1553,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onUnsnooze, rowThreadRef], ); + const handleUnhideClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnhide(rowThreadRef); + }, + [onUnhide, rowThreadRef], + ); const handleUnpinClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -1987,7 +1994,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} - {variantAction === "unsnooze" ? ( + {variantAction === "unhide" ? ( + + + } + > + + + Unhide thread + + ) : variantAction === "unsnooze" ? ( !props.snoozeSupported ? null : ( - ) : null} -