From 59b020158824d0c993f51243fb489fbbaf107d15 Mon Sep 17 00:00:00 2001 From: Valerii Kuznietsov Date: Mon, 21 Sep 2026 11:56:58 +0100 Subject: [PATCH 1/4] feat(web): restore thread sort options --- apps/web/src/components/Sidebar.logic.test.ts | 44 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 35 ++++++++++++-- apps/web/src/components/Sidebar.tsx | 46 +++++++++++++++++-- .../sidebar/SidebarThreadHeader.tsx | 5 +- docs/user/thread-sidebar.md | 10 +++- packages/contracts/src/settings.test.ts | 17 +++++++ packages/contracts/src/settings.ts | 7 +++ 7 files changed, 154 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 76960f3f533b..1006bad09638 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -930,6 +930,50 @@ describe("reduceSidebarProjectScopeMenuState", () => { }); describe("sortThreadsForSidebar", () => { + it("switches to last message and restores the saved arrangement without changing keys", () => { + const threads = [ + { + id: "first", + createdAt: "2026-03-09T08:00:00Z", + activeOrderKey: "b", + latestUserMessageAt: "2026-03-09T09:00:00Z", + }, + { + id: "second", + createdAt: "2026-03-09T07:00:00Z", + activeOrderKey: "c", + latestUserMessageAt: "2026-03-09T12:00:00Z", + }, + { id: "new", createdAt: "2026-03-09T10:00:00Z", activeOrderKey: null }, + ]; + expect(sortThreadsForSidebar(threads, "last_message").map((thread) => thread.id)).toEqual([ + "second", + "new", + "first", + ]); + expect(sortThreadsForSidebar(threads, "manual").map((thread) => thread.id)).toEqual([ + "new", + "first", + "second", + ]); + expect(threads.map((thread) => thread.activeOrderKey)).toEqual(["b", "c", null]); + }); + + it("uses creation for invalid message dates and preserves configured order for ties", () => { + const threads = [ + { + id: "same", + environmentId: "b", + createdAt: "2026-03-09T10:00:00Z", + latestUserMessageAt: "invalid", + }, + { id: "same", environmentId: "a", createdAt: "2026-03-09T10:00:00Z" }, + ]; + expect( + sortThreadsForSidebar(threads, "last_message").map((thread) => thread.environmentId), + ).toEqual(["a", "b"]); + }); + const sortable = (input: { id: string; createdAt: string }) => ({ id: input.id, createdAt: input.createdAt, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index fcedf46ce507..1812f71dd022 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -7,9 +7,16 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { + ActiveThreadSortOrder, + SidebarProjectSortOrder, + SidebarThreadSortOrder, +} from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/unstable/reactivity"; -import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; +import { + sortActiveThreadsByOrderKey, + planPinnedReorder, +} from "@t3tools/client-runtime/state/thread-sort"; import { effectiveSnoozed, type ThreadSnoozeShell, @@ -889,7 +896,29 @@ function firstValidTimestamp( return null; } -export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; +/** Last-message sorting is a view preference; saved arrangement keys stay intact. */ +export function sortThreadsForSidebar< + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + readonly activeOrderKey?: string | null | undefined; + readonly environmentId?: string | undefined; + readonly latestUserMessageAt?: string | null | undefined; + }, +>(threads: readonly T[], order: ActiveThreadSortOrder = "manual"): T[] { + const arranged = sortActiveThreadsByOrderKey(threads); + if (order === "manual") return arranged; + const timestamps = new Map( + arranged.map((thread) => [ + thread, + toSortableTimestamp(thread.latestUserMessageAt ?? undefined) ?? + toSortableTimestamp(thread.createdAt) ?? + 0, + ]), + ); + return arranged.sort((left, right) => timestamps.get(right)! - timestamps.get(left)!); +} // Pinned-reorder key math and the keyed sort live in client-runtime // (state/thread-sort) so web and mobile compute identical pinned orders. diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 23c55fe8ecce..af135b26f014 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -42,6 +42,7 @@ import { } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { + ArrowUpDownIcon, AlarmClockIcon, AlarmClockOffIcon, CheckIcon, @@ -123,7 +124,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -234,6 +235,7 @@ import { } from "./ui/combobox"; import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Menu, MenuTrigger, MenuPopup, MenuRadioGroup, MenuRadioItem } from "./ui/menu"; import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; @@ -2156,6 +2158,8 @@ export default function Sidebar() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); + const activeThreadSortOrder = useClientSettings((s) => s.activeThreadSortOrder); + const updateClientSettings = useUpdateClientSettings(); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -2565,7 +2569,9 @@ export default function Sidebar() { const supportsSettlement = capabilities?.threadSettlement === true; const supportsSnooze = capabilities?.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey); + if (activeThreadSortOrder === "manual" && capabilities?.threadActiveReorder === true) { + activeReorderable.add(threadKey); + } // Older servers retain their existing drag actions. Active placement // additionally requires its own ordering capability at the drop target. if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) { @@ -2605,7 +2611,7 @@ export default function Sidebar() { // sort, or mixed-version fleets would render different pinned orders on // web and mobile from the same data. const sortedPinned = sortPinnedThreadsForSidebar(pinned); - const sortedActive = sortThreadsForSidebar(active); + const sortedActive = sortThreadsForSidebar(active, activeThreadSortOrder); return { pinnedThreads: optimisticDrop?.section !== "pinned" || optimisticDrop.order === null @@ -2634,7 +2640,15 @@ export default function Sidebar() { settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; - }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); + }, [ + activeThreadSortOrder, + nowMinute, + optimisticDrop, + scopedProjectKeys, + serverConfigs, + snoozeWakeTick, + threads, + ]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -4559,6 +4573,30 @@ export default function Sidebar() { } + sortControl={ + + }> + + + +
+ Sort active threads +
+ { + if (value === "manual" || value === "last_message") { + setOptimisticDrop(null); + updateClientSettings({ activeThreadSortOrder: value }); + } + }} + > + Configured order + Last message + +
+
+ } onNewProject={openAddProjectCommandPalette} onNewThread={handleNewThreadClick} newThreadDisabled={projects.length === 0} diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index 6f9a686e5219..61a585113243 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -30,8 +30,9 @@ export interface SidebarThreadHeaderProps { searchFieldRef?: RefObject; /** Without projects there is nothing to scope, so those controls stay out. */ hasProjects: boolean; - /** The project scope combobox, rendered as the first icon of the group. */ + /** The project scope combobox. */ projectScope: ReactNode; + sortControl?: ReactNode; onNewProject: () => void; /** Receives the click so Shift+click can skip the project picker. */ onNewThread: (event: ReactMouseEvent) => void; @@ -54,6 +55,7 @@ export function SidebarThreadHeader({ searchFieldRef, hasProjects, projectScope, + sortControl, onNewProject, onNewThread, newThreadDisabled, @@ -128,6 +130,7 @@ export function SidebarThreadHeader({
{hasProjects ? ( <> + {sortControl} {projectScope} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 3d3215a193f6..ad5e57ce7198 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -77,8 +77,14 @@ after the drop. New threads appear above the active threads you have arranged. Settling clears a thread's active position, so using **Un-settle** returns it to the top. Pinning and snoozing preserve its active -position until you move it again. Thread activity does not change the order. The settled shelf -continues to use settlement time. +position until you move it again. By default, thread activity does not change the order. + +On web and desktop, use **Sort threads** beside search to choose **Last message**. Active +threads then sort by your most recent message, newest first. The choice is saved on this +client. Choose **Configured order** to restore your arrangement and enable drag reordering +within the active section again. Pinned and snoozed threads keep their existing order. + +The settled shelf continues to use settlement time. If dragging is unavailable for one environment, update the T3 Code server running in that environment. Pinned and active reordering require server support. Threads from older servers keep diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ff54b6c0aa53..fe20a041d67d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -20,6 +20,23 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); +describe("active thread sort preference", () => { + it("keeps configured ordering for existing clients regardless of the legacy sort", () => { + expect(decodeClientSettings({}).activeThreadSortOrder).toBe("manual"); + expect( + decodeClientSettings({ sidebarThreadSortOrder: "updated_at" }).activeThreadSortOrder, + ).toBe("manual"); + }); + it.each(["manual", "last_message"])( + "persists %s without changing the legacy preference", + (activeThreadSortOrder) => { + const patch = { activeThreadSortOrder }; + expect(decodeClientSettingsPatch(patch)).toEqual(patch); + expect(encodeClientSettings(decodeClientSettings(patch))).toMatchObject(patch); + }, + ); +}); + describe("storage cleanup settings", () => { it("keeps cleanup disabled for existing installations", () => { expect(decodeServerSettings({}).worktreeCleanup).toBeNull(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 73a2a2517d05..3efa04ac3d7c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -58,6 +58,9 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const ActiveThreadSortOrder = Schema.Literals(["manual", "last_message"]); +export type ActiveThreadSortOrder = typeof ActiveThreadSortOrder.Type; + export const SidebarProjectGroupingMode = Schema.Literals([ "repository", "repository_path", @@ -461,6 +464,9 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), ), + activeThreadSortOrder: ActiveThreadSortOrder.pipe( + Schema.withDecodingDefault(Effect.succeed("manual" as const)), + ), sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), @@ -1594,6 +1600,7 @@ export const ClientSettingsPatch = Schema.Struct({ Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), ), sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), + activeThreadSortOrder: Schema.optionalKey(ActiveThreadSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), From d00906dd6a04bf131b7c9c68eafed96c717810f6 Mon Sep 17 00:00:00 2001 From: Valerii Kuznietsov Date: Mon, 21 Sep 2026 13:50:23 +0100 Subject: [PATCH 2/4] feat(mobile): share thread sort preferences --- .../src/features/home/HomeHeader.android.tsx | 34 ++++++++-- apps/mobile/src/features/home/HomeHeader.tsx | 25 +++++-- apps/mobile/src/features/home/HomeScreen.tsx | 9 ++- .../features/home/home-list-filter-menu.ts | 24 ++++++- .../src/features/home/useThreadListActions.ts | 4 ++ .../threads/ThreadArrangementSheet.tsx | 10 ++- .../threads/ThreadNavigationSidebar.tsx | 67 ++++++++++++++++--- .../src/features/threads/threadListV2.test.ts | 40 +++++++++++ .../src/features/threads/threadListV2.ts | 25 ++++--- .../threads/use-active-thread-sort.ts | 47 +++++++++++++ .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/serverSettings.test.ts | 24 +++++++ apps/web/src/components/Sidebar.logic.ts | 35 +--------- apps/web/src/components/Sidebar.tsx | 21 ++++-- apps/web/src/hooks/useActiveThreadSort.ts | 51 ++++++++++++++ docs/user/thread-sidebar.md | 13 ++-- .../src/state/sharedSettings.test.ts | 32 +++++++++ .../src/state/sharedSettings.ts | 43 ++++++++++-- .../client-runtime/src/state/threadSort.ts | 30 ++++++++- packages/contracts/src/environment.ts | 2 + packages/contracts/src/settings.test.ts | 16 ++--- packages/contracts/src/settings.ts | 9 +-- 22 files changed, 471 insertions(+), 91 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-active-thread-sort.ts create mode 100644 apps/web/src/hooks/useActiveThreadSort.ts diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx index 78e87431b471..7596dd922aaf 100644 --- a/apps/mobile/src/features/home/HomeHeader.android.tsx +++ b/apps/mobile/src/features/home/HomeHeader.android.tsx @@ -1,3 +1,5 @@ +import { useActiveThreadSort } from "../threads/use-active-thread-sort"; +import { ACTIVE_THREAD_SORT_OPTIONS } from "@t3tools/client-runtime/state/shared-settings"; import type { MenuAction } from "@react-native-menu/menu"; import { useCallback, useMemo } from "react"; import { NativeStackScreenOptions } from "../../native/StackHeader"; @@ -17,15 +19,28 @@ function checkedMenuState(checked: boolean) { } export function HomeHeader(props: HomeHeaderProps) { - // Thread List v2 lays the list out in fixed creation order, so the - // sort/group filter controls would be silently ignored — hide them and - // key the "customized" icon state off the environment filter alone. const threadListV2Enabled = useThreadListV2Enabled(); + const { order, setOrder, available } = useActiveThreadSort(); const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + ? props.selectedEnvironmentId !== null || + props.selectedProjectKey !== null || + order !== "manual" : hasCustomHomeListOptions(props); const menuActions = useMemo( () => [ + ...(threadListV2Enabled && available + ? [ + { + id: "active-sort", + title: "Sort active threads", + subactions: ACTIVE_THREAD_SORT_OPTIONS.map((option) => ({ + id: `active-sort:${option.value}`, + title: option.label, + state: checkedMenuState(order === option.value), + })), + }, + ] + : []), { id: "environment", title: "Environment", @@ -86,6 +101,8 @@ export function HomeHeader(props: HomeHeaderProps) { ] satisfies MenuAction[])), ], [ + order, + available, props.environments, props.projectSortOrder, props.projects, @@ -98,6 +115,13 @@ export function HomeHeader(props: HomeHeaderProps) { const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { const id = event.nativeEvent.event; + const activeSort = ACTIVE_THREAD_SORT_OPTIONS.find( + (option) => id === `active-sort:${option.value}`, + ); + if (threadListV2Enabled && available && activeSort) { + setOrder(activeSort.value); + return; + } if (id === "environment:all") { props.onEnvironmentChange(null); return; @@ -141,7 +165,7 @@ export function HomeHeader(props: HomeHeaderProps) { return; } }, - [props], + [props, threadListV2Enabled, available, setOrder], ); return ( diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 2f044862f703..ece1ec53af19 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,3 +1,5 @@ +import { useActiveThreadSort } from "../threads/use-active-thread-sort"; +import { ACTIVE_THREAD_SORT_OPTIONS } from "@t3tools/client-runtime/state/shared-settings"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useRef } from "react"; import type { SearchBarCommands } from "react-native-screens"; @@ -22,12 +24,12 @@ export type { HomeHeaderEnvironment } from "./HomeHeader.types"; export function HomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useUniwindTheme()["--color-icon"]; - // Thread List v2 lays the list out in fixed creation order, so the - // sort/group filter controls would be silently ignored — hide them and - // key the "customized" icon state off the environment filter alone. const threadListV2Enabled = useThreadListV2Enabled(); + const { order, setOrder, available } = useActiveThreadSort(); const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + ? props.selectedEnvironmentId !== null || + props.selectedProjectKey !== null || + order !== "manual" : hasCustomHomeListOptions(props); const focusSearch = useCallback(() => { searchBarRef.current?.focus(); @@ -37,6 +39,7 @@ export function HomeHeader(props: HomeHeaderProps) { const filterMenu = buildHomeListFilterMenu({ ...props, listOrganization: !threadListV2Enabled, + ...(available ? { activeThreadSort: { order, onChange: setOrder } } : {}), }); return ( @@ -166,6 +169,20 @@ export function HomeHeader(props: HomeHeaderProps) { )} + {threadListV2Enabled && available ? ( + + Sort active threads + {ACTIVE_THREAD_SORT_OPTIONS.map((option) => ( + setOrder(option.value)} + > + {option.label} + + ))} + + ) : null} {threadListV2Enabled ? null : ( Sort threads diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index a01f5fbe606e..966b83514fd1 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,3 +1,4 @@ +import { useActiveThreadSort } from "../threads/use-active-thread-sort"; import type { ThreadMoveDestination } from "../threads/threadOrder"; import { createThreadMovePlanner } from "../threads/threadOrder"; import { @@ -655,6 +656,7 @@ export function HomeScreen(props: HomeScreenProps) { ), [serverConfigs], ); + const { order: activeThreadSortOrder } = useActiveThreadSort(); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); const threadMovePlanners = useMemo(() => { const sectionPlanner = (section: "pinned" | "active") => @@ -665,12 +667,14 @@ export function HomeScreen(props: HomeScreenProps) { [...serverConfigs].flatMap(([id, config]) => (section === "pinned" ? config.environment.capabilities.threadPinReorder - : config.environment.capabilities.threadActiveReorder) === true + : activeThreadSortOrder === "manual" && + config.environment.capabilities.threadActiveReorder) === true ? [id] : [], ), ), ordered: getThreadListV2OrderedSection({ + activeThreadSortOrder, threads: props.threads, section, pendingOrder, @@ -682,6 +686,7 @@ export function HomeScreen(props: HomeScreenProps) { }); return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; }, [ + activeThreadSortOrder, serverConfigs, props.threads, pendingOrder, @@ -705,6 +710,7 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ + activeThreadSortOrder, pendingOrder, threads: props.threads.filter((thread) => thread.archivedAt === null), environmentId: props.selectedEnvironmentId, @@ -721,6 +727,7 @@ export function HomeScreen(props: HomeScreenProps) { selectedThreadKey: null, }); }, [ + activeThreadSortOrder, pendingOrder, queuedThreadKeys, nowMinute, 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 edd0176f8627..b08aaad49a20 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,3 +1,5 @@ +import { ACTIVE_THREAD_SORT_OPTIONS } from "@t3tools/client-runtime/state/shared-settings"; +import type { ActiveThreadSortOrder } from "@t3tools/contracts/settings"; import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { HomeProjectSortOrder } from "./homeThreadList"; @@ -43,9 +45,11 @@ export function buildHomeListFilterMenu(props: { readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - /** False hides the sort/group submenus. Thread List v2 uses a fixed - creation-order layout, so offering those controls while it silently - ignores them would be a lie; the environment filter still applies. */ + readonly activeThreadSort?: { + order: ActiveThreadSortOrder; + onChange: (order: ActiveThreadSortOrder) => void; + }; + /** Legacy organization controls; v2 has its own active-thread sort. */ readonly listOrganization?: boolean; }): HomeListFilterMenu { const items: Array = []; @@ -120,6 +124,20 @@ export function buildHomeListFilterMenu(props: { ); } + if (props.listOrganization === false && props.activeThreadSort) { + const sort = props.activeThreadSort; + items.push({ + type: "submenu", + title: "Sort active threads", + items: ACTIVE_THREAD_SORT_OPTIONS.map((option) => ({ + type: "action", + title: option.label, + state: sort.order === option.value ? "on" : "off", + onPress: () => sort.onChange(option.value), + })), + }); + } + 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 9b599110883c..d27198263c62 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,3 +1,4 @@ +import { useActiveThreadSort } from "../threads/use-active-thread-sort"; import type { ThreadMoveDestination } from "../threads/threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; @@ -245,6 +246,7 @@ export function useThreadListActions(): { readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); + const { order: activeThreadSortOrder } = useActiveThreadSort(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); @@ -553,6 +555,7 @@ export function useThreadListActions(): { } } const configs = appAtomRegistry.get(environmentServerConfigsAtom); + if (section === "active" && activeThreadSortOrder !== "manual") return false; const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { const capabilities = configs.get(environmentId)?.environment.capabilities; return section === "pinned" @@ -677,6 +680,7 @@ export function useThreadListActions(): { } }, [ + activeThreadSortOrder, settleThread, reorderActiveMutation, reorderPinnedMutation, diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 65816f3873f9..0258c3d01791 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -1,3 +1,4 @@ +import { useActiveThreadSort } from "./use-active-thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -150,6 +151,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { const insets = useSafeAreaInsets(); const threads = useAtomValue(environmentThreadShells.threadShellsAtom); const configs = useAtomValue(environmentServerConfigsAtom); + const { order: activeThreadSortOrder } = useActiveThreadSort(); const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom); const pendingOrder = useAtomValue(pendingThreadOrderAtom); const dropBusy = useAtomValue(threadDropBusyAtom); @@ -172,6 +174,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { }, [threads, now]); const sections = useMemo(() => { const shared = { + activeThreadSortOrder, threads, now, queuedThreadKeys, @@ -199,7 +202,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), }; - }, [threads, configs, now, queuedThreadKeys, pendingOrder]); + }, [activeThreadSortOrder, threads, configs, now, queuedThreadKeys, pendingOrder]); const planners = useMemo(() => { const planner = (section: "pinned" | "active") => createThreadMovePlanner({ @@ -211,7 +214,8 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { ( section === "pinned" ? config.environment.capabilities.threadPinReorder - : config.environment.capabilities.threadActiveReorder + : activeThreadSortOrder === "manual" && + config.environment.capabilities.threadActiveReorder ) ? [id] : [], @@ -219,7 +223,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { ), }); return { pinned: planner("pinned"), active: planner("active") }; - }, [sections, threads, configs]); + }, [activeThreadSortOrder, sections, threads, configs]); const rows = useMemo(() => { const result: Row[] = []; let offset = 0; diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index bf957ad54195..85ee4fa06b9a 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,3 +1,5 @@ +import { ACTIVE_THREAD_SORT_OPTIONS } from "@t3tools/client-runtime/state/shared-settings"; +import { useActiveThreadSort } from "./use-active-thread-sort"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { createThreadMovePlanner } from "./threadOrder"; import type { @@ -483,6 +485,11 @@ function ThreadNavigationSidebarPane( ), [serverConfigs], ); + const { + order: activeThreadSortOrder, + setOrder: setActiveThreadSortOrder, + available: sortAvailable, + } = useActiveThreadSort(); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); const threadMovePlanners = useMemo(() => { const sectionPlanner = (section: "pinned" | "active") => @@ -493,12 +500,14 @@ function ThreadNavigationSidebarPane( [...serverConfigs].flatMap(([id, config]) => (section === "pinned" ? config.environment.capabilities.threadPinReorder - : config.environment.capabilities.threadActiveReorder) === true + : activeThreadSortOrder === "manual" && + config.environment.capabilities.threadActiveReorder) === true ? [id] : [], ), ), ordered: getThreadListV2OrderedSection({ + activeThreadSortOrder, threads, section, pendingOrder, @@ -510,6 +519,7 @@ function ThreadNavigationSidebarPane( }); return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; }, [ + activeThreadSortOrder, serverConfigs, threads, pendingOrder, @@ -531,6 +541,7 @@ function ThreadNavigationSidebarPane( nextSnoozeWakeAt: null, }; return buildThreadListV2Items({ + activeThreadSortOrder, pendingOrder, threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, @@ -547,6 +558,7 @@ function ThreadNavigationSidebarPane( selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ + activeThreadSortOrder, pendingOrder, queuedThreadKeys, nowMinute, @@ -630,6 +642,19 @@ function ThreadNavigationSidebarPane( ]); const listMenuActions = useMemo( () => [ + ...(threadListV2Enabled && sortAvailable + ? [ + { + id: "active-sort", + title: "Sort active threads", + subactions: ACTIVE_THREAD_SORT_OPTIONS.map((option) => ({ + id: `active-sort:${option.value}`, + title: option.label, + state: activeThreadSortOrder === option.value ? ("on" as const) : ("off" as const), + })), + }, + ] + : []), { id: "environment", title: "Environment", @@ -671,9 +696,6 @@ function ThreadNavigationSidebarPane( ], }, ] satisfies MenuAction[])), - // v2 lays the list out in fixed creation order — offering sort/group - // controls it silently ignores would be a lie. Environment still - // scopes the v2 partition, so it stays. ...(threadListV2Enabled ? [] : ([ @@ -697,11 +719,26 @@ function ThreadNavigationSidebarPane( }, ] satisfies MenuAction[])), ], - [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], + [ + activeThreadSortOrder, + sortAvailable, + environments, + options, + projectFilterOptions, + selectedProjectKey, + threadListV2Enabled, + ], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { const event = nativeEvent.event; + const activeSort = ACTIVE_THREAD_SORT_OPTIONS.find( + (option) => event === `active-sort:${option.value}`, + ); + if (threadListV2Enabled && sortAvailable && activeSort) { + setActiveThreadSortOrder(activeSort.value); + return; + } if (event === "environment:all") { setSelectedEnvironmentId(null); return; @@ -740,6 +777,9 @@ function ThreadNavigationSidebarPane( } }, [ + threadListV2Enabled, + sortAvailable, + setActiveThreadSortOrder, environments, projectFilterOptions, setProjectSortOrder, @@ -1123,10 +1163,10 @@ function ThreadNavigationSidebarPane( updateGroupDisplay, ], ); - // v2 ignores the sort/group options, so only the environment filter can - // light the "customized" state while the beta is on. const filterCustomized = threadListV2Enabled - ? options.selectedEnvironmentId !== null || selectedProjectKey !== null + ? options.selectedEnvironmentId !== null || + selectedProjectKey !== null || + activeThreadSortOrder !== "manual" : hasCustomHomeListOptions({ ...options, selectedProjectKey }); const filterIcon = filterCustomized ? "line.3.horizontal.decrease.circle.fill" @@ -1145,8 +1185,19 @@ function ThreadNavigationSidebarPane( onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, listOrganization: !threadListV2Enabled, + ...(sortAvailable + ? { + activeThreadSort: { + order: activeThreadSortOrder, + onChange: setActiveThreadSortOrder, + }, + } + : {}), }), [ + activeThreadSortOrder, + setActiveThreadSortOrder, + sortAvailable, environments, options, projectFilterOptions, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index afc00ef9ab40..6b4870f6a223 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1482,3 +1482,43 @@ describe("cross-section thread drops", () => { ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false }); }); }); + +describe("shared active-thread sorting", () => { + it("uses message order in the list and arrangement sheet, then restores saved keys", () => { + const threads = [ + makeThread({ + id: ThreadId.make("first"), + title: "First", + activeOrderKey: "b", + latestUserMessageAt: "2026-06-01T09:00:00Z", + }), + makeThread({ + id: ThreadId.make("second"), + title: "Second", + activeOrderKey: "c", + latestUserMessageAt: "2026-06-01T12:00:00Z", + }), + makeThread({ id: ThreadId.make("pin"), title: "Pinned", pinnedAt: NOW }), + ]; + const input = { threads, environmentId: null, searchQuery: "", now: NOW }; + expect( + buildThreadListV2Items({ ...input, activeThreadSortOrder: "last_message" }).items.map( + (row) => row.thread.id, + ), + ).toEqual(["pin", "second", "first"]); + expect( + getThreadListV2OrderedSection({ + threads, + section: "active", + now: NOW, + activeThreadSortOrder: "last_message", + }).map((row) => row.id), + ).toEqual(["second", "first"]); + expect( + buildThreadListV2Items({ ...input, activeThreadSortOrder: "manual" }).items.map( + (row) => row.thread.id, + ), + ).toEqual(["pin", "first", "second"]); + expect(threads.map((row) => row.activeOrderKey)).toEqual(["b", "c", undefined]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index e1cb8e9ece7f..65cd5ed26948 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,3 +1,4 @@ +import type { ActiveThreadSortOrder } from "@t3tools/contracts/settings"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { effectiveSnoozed, @@ -10,7 +11,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { - sortActiveThreadsByOrderKey, + sortActiveThreads, resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; @@ -157,8 +158,7 @@ function parseTimestampMs(isoDate: string): number { return Number.isNaN(parsed) ? 0 : parsed; } -/** The active order shared by web and native: new/reopened rows, then the - saved arrangement. Activity does not move a thread. */ +/** Active rows use the same saved arrangement or message order on every client. */ export function sortThreadsForListV2< T extends { readonly id: string; @@ -166,13 +166,15 @@ export function sortThreadsForListV2< readonly unsettledAt?: string | null | undefined; readonly activeOrderKey?: string | null | undefined; readonly environmentId?: string | undefined; + readonly latestUserMessageAt?: string | null | undefined; }, ->(threads: readonly T[]): T[] { - return sortActiveThreadsByOrderKey(threads); +>(threads: readonly T[], order: ActiveThreadSortOrder = "manual"): T[] { + return sortActiveThreads(threads, order); } /** Canonical card section for Move up/down, independent of search or scope. */ export function getThreadListV2OrderedSection(input: { + readonly activeThreadSortOrder?: ActiveThreadSortOrder; readonly threads: readonly EnvironmentThreadShell[]; readonly section: "pinned" | "active"; readonly pendingOrder?: PendingThreadOrder | null; @@ -201,12 +203,14 @@ export function getThreadListV2OrderedSection(input: { const ordered = input.section === "pinned" ? sortPinnedThreadsByOrderKey(threads) - : sortActiveThreadsByOrderKey(threads); + : sortActiveThreads(threads, input.activeThreadSortOrder); const pending = input.pendingOrder?.section === input.section ? reconcilePendingThreadOrder(input.pendingOrder, ordered) : null; - return applyPendingThreadOrder(ordered, input.section, pending); + return input.section === "active" && input.activeThreadSortOrder === "last_message" + ? ordered + : applyPendingThreadOrder(ordered, input.section, pending); } export interface ThreadListV2Item { @@ -338,6 +342,7 @@ export function buildThreadListV2ListItems(input: { * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { + readonly activeThreadSortOrder?: ActiveThreadSortOrder; readonly pendingOrder?: PendingThreadOrder | null; readonly threads: ReadonlyArray; readonly environmentId: EnvironmentId | null; @@ -438,7 +443,11 @@ export function buildThreadListV2Items(input: { } } - const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); + const sortedActive = sortThreadsForListV2(active, input.activeThreadSortOrder); + const orderedActive = + input.activeThreadSortOrder === "last_message" + ? sortedActive + : applyPendingThreadOrder(sortedActive, "active", pending); const orderedSnoozed = [...snoozed].sort( (left, right) => parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), diff --git a/apps/mobile/src/features/threads/use-active-thread-sort.ts b/apps/mobile/src/features/threads/use-active-thread-sort.ts new file mode 100644 index 000000000000..46b30a9d1e43 --- /dev/null +++ b/apps/mobile/src/features/threads/use-active-thread-sort.ts @@ -0,0 +1,47 @@ +import { Alert } from "react-native"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useMemo } from "react"; +import type { ActiveThreadSortOrder } from "@t3tools/contracts/settings"; +import { resolveActiveThreadSortOrder } from "@t3tools/client-runtime/state/shared-settings"; +import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server"; +import { useEnvironments } from "../../state/environments"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function useActiveThreadSort() { + const configs = useAtomValue(environmentServerConfigsAtom); + const { environments } = useEnvironments(); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "thread sort setting"); + const targets = useMemo( + () => + new Map( + environments.flatMap((environment) => { + const config = configs.get(environment.environmentId); + return environment.connection.phase === "connected" && + config?.environment.capabilities.threadSortOrder === true + ? [[environment.environmentId, config] as const] + : []; + }), + ), + [configs, environments], + ); + const setOrder = useCallback( + (activeThreadSortOrder: ActiveThreadSortOrder) => { + void Promise.all( + [...targets.keys()].map((environmentId) => + updateSettings({ environmentId, input: { patch: { activeThreadSortOrder } } }), + ), + ).then((results) => { + if (results.some((result) => !AsyncResult.isSuccess(result))) { + Alert.alert("Thread order not saved", "Could not save to all environments. Try again."); + } + }); + }, + [targets, updateSettings], + ); + return { + order: resolveActiveThreadSortOrder(targets.size > 0 ? targets : configs), + setOrder, + available: targets.size > 0, + }; +} diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a36c0a03b6f2..75b5fdfb94ec 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -236,6 +236,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadActiveReorder: true, + threadSortOrder: true, threadTitleRegeneration: true, threadPullRequests: true, pullRequestStackActions: true, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index b2caba4a0347..1439857bb42f 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -303,6 +303,30 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists and broadcasts the active thread sort preference to subscribers", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const settings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* settings.subscribeChanges; + const otherClientChanges = yield* settings.subscribeChanges; + yield* settings.updateSettings({ activeThreadSortOrder: "last_message" }); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + const persisted = yield* fs + .readFileString(config.settingsPath) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)))); + assert.strictEqual(change?.activeThreadSortOrder, "last_message"); + assert.strictEqual(persisted.activeThreadSortOrder, "last_message"); + yield* settings.updateSettings({ activeThreadSortOrder: "manual" }); + const restored = yield* fs + .readFileString(config.settingsPath) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)))); + assert.strictEqual(restored.activeThreadSortOrder, "manual"); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("persists and broadcasts thread settlement settings", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1812f71dd022..e061c41e7d32 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -7,16 +7,9 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import type { - ActiveThreadSortOrder, - SidebarProjectSortOrder, - SidebarThreadSortOrder, -} from "@t3tools/contracts/settings"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/unstable/reactivity"; -import { - sortActiveThreadsByOrderKey, - planPinnedReorder, -} from "@t3tools/client-runtime/state/thread-sort"; +import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; import { effectiveSnoozed, type ThreadSnoozeShell, @@ -896,29 +889,7 @@ function firstValidTimestamp( return null; } -/** Last-message sorting is a view preference; saved arrangement keys stay intact. */ -export function sortThreadsForSidebar< - T extends { - readonly id: string; - readonly createdAt: string; - readonly unsettledAt?: string | null | undefined; - readonly activeOrderKey?: string | null | undefined; - readonly environmentId?: string | undefined; - readonly latestUserMessageAt?: string | null | undefined; - }, ->(threads: readonly T[], order: ActiveThreadSortOrder = "manual"): T[] { - const arranged = sortActiveThreadsByOrderKey(threads); - if (order === "manual") return arranged; - const timestamps = new Map( - arranged.map((thread) => [ - thread, - toSortableTimestamp(thread.latestUserMessageAt ?? undefined) ?? - toSortableTimestamp(thread.createdAt) ?? - 0, - ]), - ); - return arranged.sort((left, right) => timestamps.get(right)! - timestamps.get(left)!); -} +export { sortActiveThreads as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; // Pinned-reorder key math and the keyed sort live in client-runtime // (state/thread-sort) so web and mobile compute identical pinned orders. diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af135b26f014..eee55011ec82 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { useActiveThreadSort } from "../hooks/useActiveThreadSort"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; @@ -124,7 +125,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; +import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -2158,8 +2159,11 @@ export default function Sidebar() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); - const activeThreadSortOrder = useClientSettings((s) => s.activeThreadSortOrder); - const updateClientSettings = useUpdateClientSettings(); + const { + order: activeThreadSortOrder, + setOrder: setActiveThreadSortOrder, + available: sortAvailable, + } = useActiveThreadSort(); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -2624,7 +2628,9 @@ export default function Sidebar() { draggableThreadKeys: draggable, activeReorderableThreadKeys: activeReorderable, activeThreads: - optimisticDrop?.section !== "active" || optimisticDrop.order === null + activeThreadSortOrder === "last_message" || + optimisticDrop?.section !== "active" || + optimisticDrop.order === null ? sortedActive : orderItemsByPreferredIds({ items: sortedActive, @@ -4575,7 +4581,10 @@ export default function Sidebar() { } sortControl={ - }> + } + > @@ -4587,7 +4596,7 @@ export default function Sidebar() { onValueChange={(value) => { if (value === "manual" || value === "last_message") { setOptimisticDrop(null); - updateClientSettings({ activeThreadSortOrder: value }); + setActiveThreadSortOrder(value); } }} > diff --git a/apps/web/src/hooks/useActiveThreadSort.ts b/apps/web/src/hooks/useActiveThreadSort.ts new file mode 100644 index 000000000000..57cacdad74c3 --- /dev/null +++ b/apps/web/src/hooks/useActiveThreadSort.ts @@ -0,0 +1,51 @@ +import { toastManager } from "../components/ui/toast"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useMemo } from "react"; +import type { ActiveThreadSortOrder } from "@t3tools/contracts/settings"; +import { resolveActiveThreadSortOrder } from "@t3tools/client-runtime/state/shared-settings"; +import { environmentServerConfigsAtom, serverEnvironment } from "../state/server"; +import { useEnvironments } from "../state/environments"; +import { useAtomCommand } from "../state/use-atom-command"; + +export function useActiveThreadSort() { + const configs = useAtomValue(environmentServerConfigsAtom); + const { environments } = useEnvironments(); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "thread sort setting"); + const targets = useMemo( + () => + new Map( + environments.flatMap((environment) => { + const config = configs.get(environment.environmentId); + return environment.connection.phase === "connected" && + config?.environment.capabilities.threadSortOrder === true + ? [[environment.environmentId, config] as const] + : []; + }), + ), + [configs, environments], + ); + const setOrder = useCallback( + (activeThreadSortOrder: ActiveThreadSortOrder) => { + void Promise.all( + [...targets.keys()].map((environmentId) => + updateSettings({ environmentId, input: { patch: { activeThreadSortOrder } } }), + ), + ).then((results) => { + if (results.some((result) => !AsyncResult.isSuccess(result))) { + toastManager.add({ + type: "error", + title: "Thread order not saved", + description: "Could not save to all environments. Try again.", + }); + } + }); + }, + [targets, updateSettings], + ); + return { + order: resolveActiveThreadSortOrder(targets.size > 0 ? targets : configs), + setOrder, + available: targets.size > 0, + }; +} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index ad5e57ce7198..d39d14240c45 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -79,10 +79,15 @@ New threads appear above the active threads you have arranged. Settling clears a position, so using **Un-settle** returns it to the top. Pinning and snoozing preserve its active position until you move it again. By default, thread activity does not change the order. -On web and desktop, use **Sort threads** beside search to choose **Last message**. Active -threads then sort by your most recent message, newest first. The choice is saved on this -client. Choose **Configured order** to restore your arrangement and enable drag reordering -within the active section again. Pinned and snoozed threads keep their existing order. +Use **Sort threads** beside search on web and desktop, or **Sort active threads** in the +mobile thread list options, to choose **Last message**. Active threads then sort by your +most recent message, newest first. Choose **Configured order** to restore your arrangement +and enable manual reordering of active threads again. Pinned and snoozed threads keep their +existing order. + +The environment saves the choice and shares it with connected web, desktop, iOS, and Android +clients. Changes apply to all connected environments that support this setting. Disconnected +environments keep their previous choice. If connected environments disagree, choose an option to make their settings agree. Older servers must be updated to save and share this preference. The settled shelf continues to use settlement time. diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 3e9934f6f782..b59ae0aaf727 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "@effect/vitest"; import { + resolveActiveThreadSortOrder, filterSharedServerPatch, findSharedSettingsMismatches, pickSharedServerSettings, @@ -400,3 +401,34 @@ describe("findSharedSettingsMismatches", () => { expect(mismatches).toEqual([]); }); }); + +describe("active thread sort across clients", () => { + it("fans out supported sort settings and excludes servers that cannot persist them", () => { + const patch = { activeThreadSortOrder: "last_message" as const }; + expect(splitSharedServerPatch(patch)).toEqual({ sharedPatch: patch, localPatch: {} }); + expect(filterSharedServerPatch(patch, {})).toEqual({}); + expect(filterSharedServerPatch(patch, { threadSortOrder: true })).toEqual(patch); + }); + + const config = (order: "manual" | "last_message", supported = true) => ({ + settings: { activeThreadSortOrder: order }, + environment: { capabilities: { threadSortOrder: supported } }, + }); + it("reads a returning client's preference from the server and ignores older servers", () => { + expect(resolveActiveThreadSortOrder(new Map([[primaryId, config("last_message")]]))).toBe( + "last_message", + ); + expect( + resolveActiveThreadSortOrder(new Map([[primaryId, config("last_message", false)]])), + ).toBe("manual"); + expect(resolveActiveThreadSortOrder(new Map())).toBe("manual"); + }); + it("resolves differing servers independently of their connection arrival order", () => { + const entries = [ + [boxId, config("last_message")], + [laptopId, config("manual")], + ] as const; + expect(resolveActiveThreadSortOrder(new Map(entries))).toBe("last_message"); + expect(resolveActiveThreadSortOrder(new Map(entries.toReversed()))).toBe("last_message"); + }); +}); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 9845764d5e07..aeeae6c08820 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -22,6 +22,7 @@ import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; /** Server keys that hold a user preference rather than machine config. */ const SHARED_SERVER_SETTING_KEYS = [ + "activeThreadSortOrder", "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", @@ -57,11 +58,16 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): { /** Filter unsupported preferences; direct model writes retain the server's fallback behavior. */ export function filterSharedServerPatch( patch: ServerSettingsPatch, - capabilities: Pick | undefined, + capabilities: + | Pick + | undefined, settings?: ServerSettings, sourceSettings = settings, targetIsSource = false, ): ServerSettingsPatch { + if (capabilities?.threadSortOrder !== true) { + patch = Struct.omit(patch, ["activeThreadSortOrder"]); + } const instanceId = patch.textGenerationModelSelection?.instanceId ?? sourceSettings?.textGenerationModelSelection.instanceId; @@ -87,7 +93,10 @@ export function filterSharedServerPatch( /** The shared subset supported by one environment. */ export function pickSharedServerSettings( settings: ServerSettings, - capabilities?: Pick, + capabilities?: Pick< + ExecutionEnvironmentCapabilities, + "threadRestartContinuation" | "threadSortOrder" + >, ): ServerSettingsPatch { return filterSharedServerPatch( Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), @@ -120,7 +129,7 @@ export interface SharedSettingsEnvironment { readonly syncEligible: boolean; readonly settings: ServerSettings | null; readonly capabilities?: - | Pick + | Pick | undefined; } @@ -136,7 +145,7 @@ export function findSharedSettingsMismatches(input: { readonly primaryEnvironmentId: EnvironmentId | null; readonly primarySettings: ServerSettings | null; readonly primaryCapabilities?: - | Pick + | Pick | undefined; readonly environments: ReadonlyArray; }): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { @@ -174,3 +183,29 @@ export function findSharedSettingsMismatches(input: { : [{ environmentId: environment.environmentId, label: environment.label }]; }); } + +/** Stable source for a merged list: independent of connection arrival order. + * Writes fan out to connected capable environments; clients of each server + * receive its authoritative preference through the existing config stream. + */ +export function resolveActiveThreadSortOrder( + configs: ReadonlyMap< + EnvironmentId, + { + readonly settings: Pick; + readonly environment: { + readonly capabilities: Pick; + }; + } + >, +) { + const source = [...configs] + .filter(([, config]) => config.environment.capabilities.threadSortOrder === true) + .sort(([left], [right]) => left.localeCompare(right))[0]; + return source?.[1].settings.activeThreadSortOrder ?? "manual"; +} + +export const ACTIVE_THREAD_SORT_OPTIONS = [ + { value: "manual", label: "Configured order" }, + { value: "last_message", label: "Last message" }, +] as const; diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 93878310839f..9a39d3b372bd 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -1,5 +1,9 @@ import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts"; -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { + ActiveThreadSortOrder, + SidebarProjectSortOrder, + SidebarThreadSortOrder, +} from "@t3tools/contracts/settings"; export interface ThreadSortInput { readonly createdAt: string; @@ -377,3 +381,27 @@ export function planPinnedMove(input: { newOrder.splice(to, 0, movedId); return planPinnedReorder({ orderedIds: newOrder, keysById, movedId }); } + +/** Last-message sorting is a view preference; saved arrangement keys stay intact. */ +export function sortActiveThreads< + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + readonly activeOrderKey?: string | null | undefined; + readonly environmentId?: string | undefined; + readonly latestUserMessageAt?: string | null | undefined; + }, +>(threads: readonly T[], order: ActiveThreadSortOrder = "manual"): T[] { + const arranged = sortActiveThreadsByOrderKey(threads); + if (order === "manual") return arranged; + const timestamps = new Map( + arranged.map((thread) => [ + thread, + toSortableTimestamp(thread.latestUserMessageAt ?? undefined) ?? + toSortableTimestamp(thread.createdAt) ?? + 0, + ]), + ); + return arranged.sort((left, right) => timestamps.get(right)! - timestamps.get(left)!); +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 70888f2988e6..749cf003f400 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -134,6 +134,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPinReorder: Schema.optionalKey(Schema.Boolean), /** Server persists manual Active order through thread.active.reorder. */ threadActiveReorder: Schema.optionalKey(Schema.Boolean), + /** Server persists and broadcasts the active thread sort preference. */ + threadSortOrder: Schema.optionalKey(Schema.Boolean), /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index fe20a041d67d..0c8faf99e7f0 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -21,18 +21,18 @@ const encodeServerSettings = Schema.encodeSync(ServerSettings); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); describe("active thread sort preference", () => { - it("keeps configured ordering for existing clients regardless of the legacy sort", () => { - expect(decodeClientSettings({}).activeThreadSortOrder).toBe("manual"); - expect( - decodeClientSettings({ sidebarThreadSortOrder: "updated_at" }).activeThreadSortOrder, - ).toBe("manual"); + it("keeps configured ordering for existing servers", () => { + expect(decodeServerSettings({}).activeThreadSortOrder).toBe("manual"); + expect(decodeServerSettings({ sidebarAutoSettleOnMerge: false }).activeThreadSortOrder).toBe( + "manual", + ); }); it.each(["manual", "last_message"])( - "persists %s without changing the legacy preference", + "persists %s as a shared server preference", (activeThreadSortOrder) => { const patch = { activeThreadSortOrder }; - expect(decodeClientSettingsPatch(patch)).toEqual(patch); - expect(encodeClientSettings(decodeClientSettings(patch))).toMatchObject(patch); + expect(decodeServerSettingsPatch(patch)).toEqual(patch); + expect(encodeServerSettings(decodeServerSettings(patch))).toMatchObject(patch); }, ); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3efa04ac3d7c..49a009dca355 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -464,9 +464,6 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), ), - activeThreadSortOrder: ActiveThreadSortOrder.pipe( - Schema.withDecodingDefault(Effect.succeed("manual" as const)), - ), sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), @@ -1060,6 +1057,10 @@ export const StorageCleanupSettings = Schema.Struct({ export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; export const ServerSettings = Schema.Struct({ + activeThreadSortOrder: ActiveThreadSortOrder.pipe( + Schema.withDecodingDefault(Effect.succeed("manual" as const)), + ), + worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))), storageCleanup: StorageCleanupSettings.pipe( Schema.withDecodingDefault( @@ -1405,6 +1406,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ }); export const ServerSettingsPatch = Schema.Struct({ + activeThreadSortOrder: Schema.optionalKey(ActiveThreadSortOrder), worktreeCleanup: Schema.optionalKey( Schema.NullOr( Schema.Union([ @@ -1600,7 +1602,6 @@ export const ClientSettingsPatch = Schema.Struct({ Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), ), sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), - activeThreadSortOrder: Schema.optionalKey(ActiveThreadSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), From 078aa35c8fbf49537f0d43312d74b747860440b7 Mon Sep 17 00:00:00 2001 From: Valerii Kuznietsov Date: Thu, 24 Sep 2026 13:04:50 +0100 Subject: [PATCH 3/4] docs: clarify shared thread sort behavior --- apps/mobile/src/features/home/HomeHeader.android.tsx | 2 ++ apps/mobile/src/features/home/HomeHeader.tsx | 2 ++ apps/mobile/src/features/home/HomeScreen.tsx | 2 ++ apps/mobile/src/features/home/home-list-filter-menu.ts | 2 ++ apps/mobile/src/features/home/useThreadListActions.ts | 2 ++ apps/mobile/src/features/threads/ThreadArrangementSheet.tsx | 2 ++ apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx | 2 ++ apps/mobile/src/features/threads/use-active-thread-sort.ts | 4 ++++ apps/web/src/components/Sidebar.tsx | 2 ++ apps/web/src/hooks/useActiveThreadSort.ts | 4 ++++ 10 files changed, 24 insertions(+) diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx index 7596dd922aaf..7ca0311a2a63 100644 --- a/apps/mobile/src/features/home/HomeHeader.android.tsx +++ b/apps/mobile/src/features/home/HomeHeader.android.tsx @@ -18,6 +18,8 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } +/** Android thread-list controls. Active sorting uses the shared server preference; + * legacy project and thread sort controls retain their device-local behavior. */ export function HomeHeader(props: HomeHeaderProps) { const threadListV2Enabled = useThreadListV2Enabled(); const { order, setOrder, available } = useActiveThreadSort(); diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index ece1ec53af19..e312ab63f46b 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -21,6 +21,8 @@ import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; +/** Keeps the native search toolbar and the older iOS toolbar on the same + * filter and sort choices, including the server-backed active-thread order. */ export function HomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useUniwindTheme()["--color-icon"]; diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 966b83514fd1..d2432a1e2cc4 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -220,6 +220,8 @@ function HomeTopContentSpacer() { /* ─── Main screen ────────────────────────────────────────────────────── */ +/** Home thread list, including scoped search and parked-thread shelves. + * Message sorting changes only active rows and suppresses manual move plans. */ export function HomeScreen(props: HomeScreenProps) { const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap 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 b08aaad49a20..a2a67d5da7f7 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -34,6 +34,8 @@ export interface HomeListFilterMenu { readonly items: Array; } +/** Builds the menu shared by native Home and sidebar headers. Legacy sort + * controls and active-thread sorting are separate so neither offers ignored choices. */ export function buildHomeListFilterMenu(props: { readonly environments: ReadonlyArray; readonly projects: ReadonlyArray; diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index d27198263c62..85b7d7a8bce3 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -229,6 +229,8 @@ function useConfirmDeleteThread( ); } +/** Shared actions for Home, the sidebar, and the arrangement sheet. Rechecks + * server support and the current sort mode before writing manual order keys. */ export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 0258c3d01791..3528fe0acbbc 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -147,6 +147,8 @@ function DragHandle(props: { ); } +/** Shows the same section order as the thread list. Active placement is disabled + * in message mode so a drag cannot overwrite the saved configured arrangement. */ export function ThreadArrangementSheet(props: { onClose: () => void }) { const insets = useSafeAreaInsets(); const threads = useAtomValue(environmentThreadShells.threadShellsAtom); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 85ee4fa06b9a..acd38a169f35 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -150,6 +150,8 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { ); } +/** Sidebar list and controls for split-view navigation. Shares the active sort + * preference with Home while retaining its own project scope and search query. */ function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { diff --git a/apps/mobile/src/features/threads/use-active-thread-sort.ts b/apps/mobile/src/features/threads/use-active-thread-sort.ts index 46b30a9d1e43..b31de6809698 100644 --- a/apps/mobile/src/features/threads/use-active-thread-sort.ts +++ b/apps/mobile/src/features/threads/use-active-thread-sort.ts @@ -8,6 +8,10 @@ import { environmentServerConfigsAtom, serverEnvironment } from "../../state/ser import { useEnvironments } from "../../state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; +/** Reads the shared sort preference and writes changes to connected capable servers. + * With no live target, cached settings preserve the visible order but saving is disabled. + * Partial write failures are reported so clients do not mistake them for a completed sync. + */ export function useActiveThreadSort() { const configs = useAtomValue(environmentServerConfigsAtom); const { environments } = useEnvironments(); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index eee55011ec82..46b9ce54a985 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2150,6 +2150,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { ); }); +/** Thread navigation across connected environments. Active sorting is shared + * with native clients; pinned, snoozed, and settled sections keep their own order. */ export default function Sidebar() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); diff --git a/apps/web/src/hooks/useActiveThreadSort.ts b/apps/web/src/hooks/useActiveThreadSort.ts index 57cacdad74c3..0bef539aac86 100644 --- a/apps/web/src/hooks/useActiveThreadSort.ts +++ b/apps/web/src/hooks/useActiveThreadSort.ts @@ -8,6 +8,10 @@ import { environmentServerConfigsAtom, serverEnvironment } from "../state/server import { useEnvironments } from "../state/environments"; import { useAtomCommand } from "../state/use-atom-command"; +/** Reads the shared sort preference and writes changes to connected capable servers. + * With no live target, cached settings preserve the visible order but saving is disabled. + * Partial write failures are reported so clients do not mistake them for a completed sync. + */ export function useActiveThreadSort() { const configs = useAtomValue(environmentServerConfigsAtom); const { environments } = useEnvironments(); From 8673ad5796f45b92dd3373371ac5d3277e14062a Mon Sep 17 00:00:00 2001 From: Valerii Kuznietsov Date: Thu, 24 Sep 2026 13:23:12 +0100 Subject: [PATCH 4/4] fix(web): keep thread sorting available while loading --- apps/web/src/components/sidebar/SidebarThreadHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index 09364f76039e..49e7675d6079 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -126,9 +126,9 @@ export function SidebarThreadHeader({ hover states, and a background well reads far louder on themed palettes than on the base light and dark ones. */}
+ {sortControl} {hasProjects ? ( <> - {sortControl} {projectScope}