diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 16a3efde6dd..0023bd06bc6 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -44,6 +44,7 @@ export function HomeRouteScreen() { unsnoozeThread, pinThread, unpinThread, + reorderPinnedThread, unsettleThread, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); @@ -159,6 +160,7 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onReorderPinnedThread={reorderPinnedThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ba32dc6b609..dc99c5d9386 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -11,15 +11,20 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import { + pinnedThreadOrderUpdatesForMove, + sortPinnedThreads, +} from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, + PinnedThreadOrder, SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native"; +import { ActivityIndicator, Alert, FlatList, Platform, Pressable, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -28,7 +33,7 @@ import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { scopedProjectKey } from "../../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; @@ -113,6 +118,10 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onReorderPinnedThread: ( + thread: EnvironmentThreadShell, + pinnedOrder: PinnedThreadOrder, + ) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -598,6 +607,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const pinReorderingEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReordering === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -643,6 +661,98 @@ export function HomeScreen(props: HomeScreenProps) { threadListV2Enabled, v2ScopedProjectGroup, ]); + const orderedPinnedThreads = useMemo( + () => + sortPinnedThreads( + props.threads.filter((thread) => thread.archivedAt === null && thread.pinnedAt !== null), + ), + [props.threads], + ); + const pinnedOrderingFullyVisible = useMemo(() => { + const visible = threadListV2Layout.items + .filter((item) => item.pinned) + .map((item) => scopedThreadKey(item.thread.environmentId, item.thread.id)); + return ( + visible.length === orderedPinnedThreads.length && + visible.every( + (threadKey, index) => + threadKey === + scopedThreadKey( + orderedPinnedThreads[index]!.environmentId, + orderedPinnedThreads[index]!.id, + ), + ) + ); + }, [orderedPinnedThreads, threadListV2Layout.items]); + const pinnedReorderInFlightRef = useRef(false); + const [pinnedReorderInFlight, setPinnedReorderInFlight] = useState(false); + const handleMovePinnedThread = useCallback( + (thread: EnvironmentThreadShell, direction: "up" | "down") => { + if (pinnedReorderInFlightRef.current || !pinnedOrderingFullyVisible) return; + const index = orderedPinnedThreads.findIndex( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.id, + ); + const target = orderedPinnedThreads[index + (direction === "up" ? -1 : 1)]; + if (index < 0 || !target) return; + const updates = pinnedThreadOrderUpdatesForMove( + orderedPinnedThreads, + scopedThreadKey(thread.environmentId, thread.id), + scopedThreadKey(target.environmentId, target.id), + ); + if (updates === null) return; + const requests = updates.flatMap((update) => { + const updateThread = orderedPinnedThreads.find( + (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === update.threadId, + ); + return updateThread ? [{ update, thread: updateThread }] : []; + }); + if (requests.length !== updates.length) return; + if ( + requests.length > 1 && + requests.some((request) => !pinReorderingEnvironmentIds.has(request.thread.environmentId)) + ) { + Alert.alert( + "Could not reorder pinned thread", + "Pinned ordering needs compacting. Update all connected servers before trying again.", + ); + return; + } + pinnedReorderInFlightRef.current = true; + setPinnedReorderInFlight(true); + void (async () => { + try { + const completed: typeof requests = []; + for (const request of requests) { + const succeeded = await props.onReorderPinnedThread( + request.thread, + request.update.pinnedOrder, + ); + if (!succeeded) { + for (let index = completed.length - 1; index >= 0; index -= 1) { + const applied = completed[index]!; + await props.onReorderPinnedThread( + applied.thread, + applied.update.previousPinnedOrder, + ); + } + return; + } + completed.push(request); + } + } finally { + pinnedReorderInFlightRef.current = false; + setPinnedReorderInFlight(false); + } + })(); + }, + [ + orderedPinnedThreads, + pinnedOrderingFullyVisible, + pinReorderingEnvironmentIds, + props.onReorderPinnedThread, + ], + ); // Re-partition the moment the earliest snooze expires (clamped to the // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; @@ -741,6 +851,12 @@ export function HomeScreen(props: HomeScreenProps) { ); } const thread = item.item.thread; + const pinnedIndex = item.item.pinned + ? orderedPinnedThreads.findIndex( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.id, + ) + : -1; return ( 0} + canMovePinnedDown={ + pinnedOrderingFullyVisible && + !pinnedReorderInFlight && + pinnedIndex >= 0 && + pinnedIndex < orderedPinnedThreads.length - 1 + } onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} + onMovePinnedThread={handleMovePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -802,6 +927,7 @@ export function HomeScreen(props: HomeScreenProps) { handleChangeRequestState, handleDeleteThread, handlePinThread, + handleMovePinnedThread, handleSettleThread, handleSnoozeThread, handleUnpinThread, @@ -810,6 +936,10 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, pinningEnvironmentIds, + pinReorderingEnvironmentIds, + orderedPinnedThreads, + pinnedOrderingFullyVisible, + pinnedReorderInFlight, projectByKey, projectCwdByKey, props.onArchiveThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dcea2b6791b..d16bbc96cc7 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,4 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { PinnedThreadOrder } from "@t3tools/contracts"; import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; @@ -36,6 +37,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir ); } +function environmentSupportsPinReordering(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinReordering === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -211,12 +219,19 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly reorderPinnedThread: ( + thread: EnvironmentThreadShell, + pinnedOrder: PinnedThreadOrder, + ) => Promise; } { const executeAction = useThreadActionExecutor(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); + const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPinned, { + reportFailure: false, + }); const snoozeInFlightThreadKeys = useRef(new Set()); const archiveThread = useCallback( @@ -377,6 +392,34 @@ export function useThreadListActions(): { }, [unpinMutation], ); + const reorderPinnedThread = useCallback( + async (thread: EnvironmentThreadShell, pinnedOrder: PinnedThreadOrder) => { + if (!environmentSupportsPinReordering(thread.environmentId)) { + Alert.alert( + "Could not reorder pinned thread", + "This environment's server does not support pinned ordering yet. Update the server to reorder pins.", + ); + return false; + } + selectionHaptic(); + const result = await reorderPinnedMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, pinnedOrder }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not reorder pinned thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The pinned thread could not be reordered.", + ); + return false; + } + return true; + }, + [reorderPinnedMutation], + ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); @@ -389,6 +432,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + reorderPinnedThread, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 322ac60759d..098e5448790 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -7,13 +7,25 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import { + pinnedThreadOrderUpdatesForMove, + sortPinnedThreads, +} from "@t3tools/client-runtime/state/thread-sort"; import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; -import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native"; +import { + Alert, + Platform, + Pressable, + StyleSheet, + TextInput, + View, + useColorScheme, +} from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -207,6 +219,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + reorderPinnedThread, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); @@ -492,6 +505,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const pinReorderingEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReordering === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -536,6 +558,92 @@ function ThreadNavigationSidebarPane( threads, selectedProjectScope, ]); + const orderedPinnedThreads = useMemo( + () => + sortPinnedThreads( + threads.filter((thread) => thread.archivedAt === null && thread.pinnedAt !== null), + ), + [threads], + ); + const pinnedOrderingFullyVisible = useMemo(() => { + const visible = threadListV2Layout.items + .filter((item) => item.pinned) + .map((item) => scopedThreadKey(item.thread.environmentId, item.thread.id)); + return ( + visible.length === orderedPinnedThreads.length && + visible.every( + (threadKey, index) => + threadKey === + scopedThreadKey( + orderedPinnedThreads[index]!.environmentId, + orderedPinnedThreads[index]!.id, + ), + ) + ); + }, [orderedPinnedThreads, threadListV2Layout.items]); + const pinnedReorderInFlightRef = useRef(false); + const [pinnedReorderInFlight, setPinnedReorderInFlight] = useState(false); + const movePinnedThread = useCallback( + (thread: EnvironmentThreadShell, direction: "up" | "down") => { + if (pinnedReorderInFlightRef.current || !pinnedOrderingFullyVisible) return; + const index = orderedPinnedThreads.findIndex( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.id, + ); + const target = orderedPinnedThreads[index + (direction === "up" ? -1 : 1)]; + if (index < 0 || !target) return; + const updates = pinnedThreadOrderUpdatesForMove( + orderedPinnedThreads, + scopedThreadKey(thread.environmentId, thread.id), + scopedThreadKey(target.environmentId, target.id), + ); + if (updates === null) return; + const requests = updates.flatMap((update) => { + const updateThread = orderedPinnedThreads.find( + (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === update.threadId, + ); + return updateThread ? [{ update, thread: updateThread }] : []; + }); + if (requests.length !== updates.length) return; + if ( + requests.length > 1 && + requests.some((request) => !pinReorderingEnvironmentIds.has(request.thread.environmentId)) + ) { + Alert.alert( + "Could not reorder pinned thread", + "Pinned ordering needs compacting. Update all connected servers before trying again.", + ); + return; + } + pinnedReorderInFlightRef.current = true; + setPinnedReorderInFlight(true); + void (async () => { + try { + const completed: typeof requests = []; + for (const request of requests) { + const succeeded = await reorderPinnedThread(request.thread, request.update.pinnedOrder); + if (!succeeded) { + for (let index = completed.length - 1; index >= 0; index -= 1) { + const applied = completed[index]!; + await reorderPinnedThread(applied.thread, applied.update.previousPinnedOrder); + } + return; + } + completed.push(request); + } + } finally { + pinnedReorderInFlightRef.current = false; + setPinnedReorderInFlight(false); + } + })(); + }, + [ + orderedPinnedThreads, + pinnedOrderingFullyVisible, + pinReorderingEnvironmentIds, + reorderPinnedThread, + ], + ); // Re-partition the moment the earliest snooze expires (clamped to the // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; @@ -886,6 +994,12 @@ function ThreadNavigationSidebarPane( case "v2-thread": { const thread = item.item.thread; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); + const pinnedIndex = item.item.pinned + ? orderedPinnedThreads.findIndex( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.id, + ) + : -1; return ( 0 + } + canMovePinnedDown={ + pinnedOrderingFullyVisible && + !pinnedReorderInFlight && + pinnedIndex >= 0 && + pinnedIndex < orderedPinnedThreads.length - 1 + } onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onMovePinnedThread={movePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} @@ -1063,6 +1188,11 @@ function ThreadNavigationSidebarPane( openPendingTask, pinThread, pinningEnvironmentIds, + pinReorderingEnvironmentIds, + movePinnedThread, + orderedPinnedThreads, + pinnedOrderingFullyVisible, + pinnedReorderInFlight, projectByKey, projectCwdByKey, projectTitleByProjectKey, 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 24f7166916b..888cab57649 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -347,6 +347,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => void; readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; + readonly onMovePinnedThread: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; @@ -354,6 +355,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozeSupported: boolean; /** False on servers that predate thread.pin/unpin. */ readonly pinningSupported: boolean; + readonly pinReorderingSupported: boolean; + readonly canMovePinnedUp: boolean; + readonly canMovePinnedDown: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle @@ -382,6 +386,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onMovePinnedThread, onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; @@ -416,6 +421,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); + const handleMovePinnedUp = useCallback( + () => onMovePinnedThread(thread, "up"), + [onMovePinnedThread, thread], + ); + const handleMovePinnedDown = useCallback( + () => onMovePinnedThread(thread, "down"), + [onMovePinnedThread, thread], + ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Every settled @@ -459,12 +472,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { () => props.pinningSupported ? [ + ...(pinnedRow && props.pinReorderingSupported + ? [ + { + id: "move-pin-up", + title: "Move up", + image: "arrow.up", + attributes: { disabled: !props.canMovePinnedUp }, + } as MenuAction, + { + id: "move-pin-down", + title: "Move down", + image: "arrow.down", + attributes: { disabled: !props.canMovePinnedDown }, + } as MenuAction, + ] + : []), pinnedRow ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] : [], - [pinnedRow, props.pinningSupported], + [ + pinnedRow, + props.canMovePinnedDown, + props.canMovePinnedUp, + props.pinReorderingSupported, + props.pinningSupported, + ], ); const snoozableCardMenuActions = useMemo( () => [ @@ -491,6 +526,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); + if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); + if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ @@ -508,6 +545,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleArchive, handleDelete, handlePin, + handleMovePinnedDown, + handleMovePinnedUp, handleSettle, handleSnooze, handleUnpin, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 1316b3480c0..0cf887424a1 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -5,6 +5,7 @@ import { CommandId, EnvironmentId, MessageId, + PinnedThreadOrder, ProjectId, ProviderInstanceId, ThreadId, @@ -311,6 +312,32 @@ describe("buildThreadListV2Items", () => { expect(layout.settledCount).toBe(0); }); + it("renders pinned threads in their synced order", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("first-created"), + title: "First created", + createdAt: "2026-06-01T12:00:00.000Z", + pinnedAt: NOW, + pinnedOrder: PinnedThreadOrder.make("2/1"), + }), + makeThread({ + id: ThreadId.make("second-created"), + title: "Second created", + createdAt: "2026-06-01T11:00:00.000Z", + pinnedAt: NOW, + pinnedOrder: PinnedThreadOrder.make("1/1"), + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["second-created", "first-created"]); + }); + it("snooze hides a pinned thread and wake restores it to the pinned block", () => { const snoozedInput = { threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index fa5f58d5d0e..65be7d6e3c1 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,6 +9,7 @@ import { 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 { sortPinnedThreads } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -384,7 +385,7 @@ export function buildThreadListV2Items(input: { // Visibility parity with web: snooze outranks everything, including a // pin — a snoozed thread leaves the list until it wakes (or raises its // hand). The pin survives underneath, so a woken thread reappears at - // its original spot in the creation-ordered pinned block. + // its original spot in the synced pinned order. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -444,7 +445,7 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of sortThreadsForListV2(pinned)) { + for (const thread of sortPinnedThreads(pinned)) { items.push({ thread, variant: "card", diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b6eedb87e66..a8f97c03d29 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -146,6 +146,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadSnooze: true, threadPinning: true, + threadPinReordering: true, threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7776e374ee2..4e55f6d4f71 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -612,6 +612,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinnedOrder: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -743,6 +744,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: null, + pinnedOrder: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pin-reordered": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + pinnedOrder: event.payload.pinnedOrder, updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d5dda7aa86b..67181a0953b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -318,6 +318,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinnedOrder: null, titleRegeneration: null, deletedAt: null, messages: [ @@ -434,6 +435,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinnedOrder: null, titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 9633f162d2b..534a0686830 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -423,6 +423,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pinned_order AS "pinnedOrder", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -458,6 +459,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pinned_order AS "pinnedOrder", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -495,6 +497,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pinned_order AS "pinnedOrder", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -932,6 +935,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pinned_order AS "pinnedOrder", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1562,6 +1566,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinnedOrder: row.pinnedOrder, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], @@ -1766,6 +1771,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinnedOrder: row.pinnedOrder, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], @@ -1901,6 +1907,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinnedOrder: row.pinnedOrder, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2045,6 +2052,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinnedOrder: row.pinnedOrder, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2321,6 +2329,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinnedOrder: threadRow.value.pinnedOrder, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, @@ -2441,6 +2450,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinnedOrder: threadRow.value.pinnedOrder, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index ee96e422945..f29d9092a6f 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -14,6 +14,7 @@ import { ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, + ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, @@ -45,6 +46,7 @@ export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; +export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts index bed41e13a17..d4c8cb886fd 100644 --- a/apps/server/src/orchestration/decider.pinned.test.ts +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -1,6 +1,7 @@ import { CommandId, ProjectId, + PinnedThreadOrder, ProviderInstanceId, ThreadId, type OrchestrationReadModel, @@ -16,6 +17,7 @@ const PINNED_AT = "1969-12-30T00:00:00.000Z"; function makeReadModel(input: { readonly pinnedAt?: string | null; + readonly pinnedOrder?: PinnedThreadOrder | null; readonly archivedAt?: string | null; readonly settledOverride?: "settled" | "active" | null; readonly settledAt?: string | null; @@ -44,6 +46,7 @@ function makeReadModel(input: { snoozedUntil: input.snoozedUntil ?? null, snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null), pinnedAt: input.pinnedAt ?? null, + pinnedOrder: input.pinnedOrder ?? null, deletedAt: null, messages: [], proposedPlans: [], @@ -131,6 +134,42 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { }), ); + it.effect("reorders a pinned thread with a synced rational position", () => + Effect.gen(function* () { + const pinnedOrder = PinnedThreadOrder.make("3/7"); + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-pin"), + threadId: ThreadId.make("thread-1"), + pinnedOrder, + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.pinnedOrder).toBe(pinnedOrder); + } + }), + ); + + it.effect("rejects reordering an unpinned thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-unpinned"), + threadId: ThreadId.make("thread-1"), + pinnedOrder: PinnedThreadOrder.make("3/7"), + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + it.effect("pinning a settled thread also un-settles it", () => Effect.gen(function* () { const event = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 5e5579ae93d..64f169588ec 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -745,6 +745,37 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pin.reorder": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.pinnedAt == null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread ${command.threadId} is not pinned`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pin-reordered", + payload: { + threadId: command.threadId, + pinnedOrder: command.pinnedOrder, + // Reordering is presentation metadata, not thread activity: keep + // recency labels and lifecycle clocks unchanged. + updatedAt: thread.updatedAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.pinned.test.ts b/apps/server/src/orchestration/projector.pinned.test.ts index 35bd063667a..bdc431c91e5 100644 --- a/apps/server/src/orchestration/projector.pinned.test.ts +++ b/apps/server/src/orchestration/projector.pinned.test.ts @@ -2,6 +2,7 @@ import { CommandId, EventId, ProjectId, + PinnedThreadOrder, ThreadId, type OrchestrationEvent, } from "@t3tools/contracts"; @@ -64,14 +65,39 @@ it.effect("projects pin lifecycle events", () => ); expect(pinned.threads[0]?.pinnedAt).toBe(now); - const unpinned = yield* projectEvent( + const reordered = yield* projectEvent( pinned, makeEvent({ sequence: 3, + type: "thread.pin-reordered", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedOrder: PinnedThreadOrder.make("3/7"), + updatedAt: now, + }, + }), + ); + expect(reordered.threads[0]?.pinnedOrder).toBe("3/7"); + + const pinnedAgain = yield* projectEvent( + reordered, + makeEvent({ + sequence: 4, + type: "thread.pinned", + payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now }, + }), + ); + expect(pinnedAgain.threads[0]?.pinnedOrder).toBe("3/7"); + + const unpinned = yield* projectEvent( + pinnedAgain, + makeEvent({ + sequence: 5, type: "thread.unpinned", payload: { threadId: ThreadId.make("thread-1"), updatedAt: now }, }), ); expect(unpinned.threads[0]?.pinnedAt).toBeNull(); + expect(unpinned.threads[0]?.pinnedOrder).toBeNull(); }), ); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ed4b084e4f9..1d943f18557 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -24,6 +24,7 @@ import { ThreadRuntimeModeSetPayload, ThreadSettledPayload, ThreadPinnedPayload, + ThreadPinReorderedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -413,6 +414,18 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: null, + pinnedOrder: null, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.pin-reordered": + return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + pinnedOrder: payload.pinnedOrder, updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 71d7df566fd..a67a25bbf72 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; +import { PinnedThreadOrder, ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -96,6 +96,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinnedOrder: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -134,7 +135,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }), ); - it.effect("round-trips non-null settlement values through the thread row", () => + it.effect("round-trips lifecycle and pinned-order values through the thread row", () => Effect.gen(function* () { const threads = yield* ProjectionThreadRepository; @@ -159,6 +160,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { snoozedUntil: "2026-03-26T09:00:00.000Z", snoozedAt: "2026-03-25T00:00:00.000Z", pinnedAt: "2026-03-25T00:00:00.000Z", + pinnedOrder: PinnedThreadOrder.make("3/7"), latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -178,6 +180,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); assert.strictEqual(row.pinnedAt, "2026-03-25T00:00:00.000Z"); + assert.strictEqual(row.pinnedOrder, "3/7"); // Un-settle to the keep-active pin and wake the snooze; confirm the // flips persist. @@ -188,6 +191,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinnedOrder: null, }); const repersisted = yield* threads.getById({ threadId: ThreadId.make("thread-settled"), @@ -198,6 +202,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(updated?.snoozedUntil, null); assert.strictEqual(updated?.snoozedAt, null); assert.strictEqual(updated?.pinnedAt, null); + assert.strictEqual(updated?.pinnedOrder, null); }), ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 0e2adeecf3b..c5ae7d9cf3f 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -48,6 +48,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until, snoozed_at, pinned_at, + pinned_order, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -74,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, + ${row.pinnedOrder}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -100,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, + pinned_order = excluded.pinned_order, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -133,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pinned_order AS "pinnedOrder", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -168,6 +172,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pinned_order AS "pinnedOrder", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1f335bdfda7..1d03809af89 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -50,6 +50,7 @@ import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; +import Migration0038 from "./Migrations/038_ProjectionThreadsPinnedOrder.ts"; /** * Migration loader with all migrations defined inline. @@ -99,6 +100,7 @@ export const migrationEntries = [ [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionThreadsPinned", Migration0036], [37, "ProjectionTurnsKeysetIndex", Migration0037], + [38, "ProjectionThreadsPinnedOrder", Migration0038], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinnedOrder.ts b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinnedOrder.ts new file mode 100644 index 00000000000..376858eba7c --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinnedOrder.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "pinned_order")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pinned_order TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a0cee8e3298..3c5428b288b 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -11,6 +11,7 @@ import { IsoDateTime, ModelSelection, NonNegativeInt, + PinnedThreadOrder, ProjectId, ProviderInteractionMode, RuntimeMode, @@ -42,6 +43,7 @@ export const ProjectionThread = Schema.Struct({ snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), + pinnedOrder: Schema.NullOr(PinnedThreadOrder), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 677e85c3746..94948f10355 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -4,6 +4,7 @@ import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/c import { getThreadSortTimestamp, sortThreads, + sortPinnedThreads, toSortableTimestamp, type ThreadSortInput, } from "../lib/threadSort"; @@ -510,6 +511,8 @@ export function sortThreadsForSidebarV2< ); } +export { sortPinnedThreads }; + /** * Search the already-ordered sidebar thread collection by title only. * Keeping the input order means lifecycle ordering (active, snoozed, settled) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 89419d428f6..3fd50ba499c 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,4 +1,21 @@ import { autoAnimate } from "@formkit/auto-animate"; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { useAtomValue } from "@effect/atom-react"; import { canSnooze, @@ -12,7 +29,11 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { + PinnedThreadOrder, + ScopedThreadRef, + SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, @@ -27,6 +48,7 @@ import { FolderIcon, FolderPlusIcon, GitBranchIcon, + GripVerticalIcon, EllipsisIcon, MessageSquareIcon, PinIcon, @@ -122,9 +144,11 @@ import { resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, + sortPinnedThreads, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, } from "./Sidebar.logic"; +import { pinnedThreadOrderUpdatesForMove } from "../lib/threadSort"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { prStatusIndicator, @@ -389,6 +413,26 @@ function SnoozePopoverButton(props: { ); } +function PinnedThreadDndContext(props: { + readonly items: string[]; + readonly sensors: ReturnType; + readonly onDragEnd: (event: DragEndEvent) => void; + readonly children: ReactNode; +}) { + return ( + + + {props.children} + + + ); +} + const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; @@ -405,6 +449,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // card until wake with the pin intact underneath. Pin/unpin themselves // live in the context menu only. isPinned: boolean; + pinReorderingSupported: boolean; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -459,6 +504,23 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); + const { + attributes: sortableAttributes, + listeners: sortableListeners, + setNodeRef: setSortableNodeRef, + transform: sortableTransform, + transition: sortableTransition, + isDragging, + } = useSortable({ + id: threadKey, + disabled: !props.isPinned || !props.pinReorderingSupported, + }); + const sortableStyle = { + transform: CSS.Transform.toString(sortableTransform), + transition: sortableTransition, + zIndex: isDragging ? 20 : undefined, + opacity: isDragging ? 0.82 : undefined, + }; const isRegeneratingTitle = thread.titleRegeneration != null; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); @@ -829,6 +891,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { if (variant === "slim") { return (
  • @@ -961,6 +1025,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { return (
  • @@ -1000,11 +1066,25 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { )} {props.isPinned ? ( - + props.pinReorderingSupported ? ( + + ) : ( + + ) ) : null} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping @@ -1290,6 +1370,7 @@ export default function SidebarV2() { unsnoozeThread, pinThread, unpinThread, + reorderPinnedThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -1675,6 +1756,27 @@ export default function SidebarV2() { // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const [pendingPinnedOrderByKey, setPendingPinnedOrderByKey] = useState< + ReadonlyMap + >(() => new Map()); + useEffect(() => { + const threadByKey = new Map( + threads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ); + setPendingPinnedOrderByKey((current) => { + let next: Map | null = null; + for (const [threadKey, order] of current) { + const thread = threadByKey.get(threadKey); + if (thread && thread.pinnedAt != null && thread.pinnedOrder !== order) continue; + next ??= new Map(current); + next.delete(threadKey); + } + return next ?? current; + }); + }, [threads]); const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { const now = `${nowMinute}:00.000Z`; @@ -1707,9 +1809,9 @@ export default function SidebarV2() { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; // Snooze outranks everything, including a pin: "hide until Tuesday" - // temporarily suspends "keep on top". The pin survives underneath — - // pinned cards are creation-ordered, so on wake the thread reappears - // at its original spot in the pinned block. (For unpinned threads + // temporarily suspends "keep on top". The pin and its synced order + // survive underneath, so on wake the thread reappears at its original + // spot in the pinned block. (For unpinned threads // this is also the snooze-beats-auto-settle rule: the wake time is a // stronger statement about when the thread matters again.) if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { @@ -1719,7 +1821,12 @@ export default function SidebarV2() { // pin and the pin on settle, so pin-vs-settled conflicts only // arise from stale or raced writes.) } else if (thread.pinnedAt != null) { - pinned.push(thread); + const pendingPinnedOrder = pendingPinnedOrderByKey.get(threadKey); + pinned.push( + pendingPinnedOrder === undefined + ? thread + : { ...thread, pinnedOrder: pendingPinnedOrder }, + ); } else if ( supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) @@ -1730,9 +1837,9 @@ export default function SidebarV2() { } } return { - // Same static creation order as the inbox: a pin freezes prominence, - // it does not introduce a new ordering scheme. - pinnedThreads: sortThreadsForSidebarV2(pinned), + // Synced manual order with creation order as the compatibility + // fallback for pins written by older clients. + pinnedThreads: sortPinnedThreads(pinned), activeThreads: sortThreadsForSidebarV2(active), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( @@ -1747,12 +1854,170 @@ export default function SidebarV2() { autoSettleAfterDays, changeRequestStateByKey, nowMinute, + pendingPinnedOrderByKey, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads, ]); + const orderedPinnedThreads = useMemo( + () => + sortPinnedThreads( + threads + .filter((thread) => thread.archivedAt === null && thread.pinnedAt !== null) + .map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const pendingPinnedOrder = pendingPinnedOrderByKey.get(threadKey); + return pendingPinnedOrder === undefined + ? thread + : { ...thread, pinnedOrder: pendingPinnedOrder }; + }), + ), + [pendingPinnedOrderByKey, threads], + ); + const pinnedOrderingFullyVisible = useMemo( + () => + pinnedThreads.length === orderedPinnedThreads.length && + pinnedThreads.every( + (thread, index) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + scopedThreadKey( + scopeThreadRef( + orderedPinnedThreads[index]!.environmentId, + orderedPinnedThreads[index]!.id, + ), + ), + ), + [orderedPinnedThreads, pinnedThreads], + ); + + const pinnedThreadDnDSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const movePinnedThread = useCallback( + (thread: EnvironmentThreadShell, overThread: EnvironmentThreadShell) => { + if (!pinnedOrderingFullyVisible) return; + const updates = pinnedThreadOrderUpdatesForMove( + orderedPinnedThreads, + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + scopedThreadKey(scopeThreadRef(overThread.environmentId, overThread.id)), + ); + if (updates === null) return; + const requests = updates.flatMap((update) => { + const target = orderedPinnedThreads.find( + (candidate) => + scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)) === + update.threadId, + ); + return target ? [{ ...update, target }] : []; + }); + if (requests.length !== updates.length) return; + if ( + requests.length > 1 && + requests.some( + (request) => + serverConfigs.get(request.target.environmentId)?.environment.capabilities + .threadPinReordering !== true, + ) + ) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to reorder pinned thread", + description: + "Pinned ordering needs compacting. Update all connected servers before trying again.", + }), + ); + return; + } + setPendingPinnedOrderByKey((current) => { + const next = new Map(current); + for (const request of requests) next.set(request.threadId, request.pinnedOrder); + return next; + }); + void (async () => { + const results = await Promise.all( + requests.map(async (request) => ({ + request, + result: await reorderPinnedThread( + scopeThreadRef(request.target.environmentId, request.target.id), + request.pinnedOrder, + ), + })), + ); + const anyFailure = results.find(({ result }) => result._tag === "Failure"); + const reportableFailure = results.find( + ({ result }) => result._tag === "Failure" && !isAtomCommandInterrupted(result), + ); + const completed = results.filter(({ result }) => result._tag === "Success"); + const rollbackResults = + anyFailure && requests.length > 1 + ? await Promise.all( + completed.map(async ({ request }) => + reorderPinnedThread( + scopeThreadRef(request.target.environmentId, request.target.id), + request.previousPinnedOrder, + ), + ), + ) + : []; + const rollbackFailed = rollbackResults.some((result) => result._tag === "Failure"); + if (reportableFailure?.result._tag === "Failure" || rollbackFailed) { + const error = + reportableFailure?.result._tag === "Failure" + ? squashAtomCommandFailure(reportableFailure.result) + : null; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to reorder pinned thread", + description: rollbackFailed + ? "The reorder failed and some prior positions could not be restored. Try again." + : error instanceof Error + ? error.message + : "An error occurred.", + }), + ); + } + if (anyFailure) { + setPendingPinnedOrderByKey((current) => { + const next = new Map(current); + for (const { request } of results) { + if (next.get(request.threadId) === request.pinnedOrder) { + next.delete(request.threadId); + } + } + return next; + }); + } + })(); + }, + [orderedPinnedThreads, pinnedOrderingFullyVisible, reorderPinnedThread, serverConfigs], + ); + const handlePinnedThreadDragEnd = useCallback( + (event: DragEndEvent) => { + const overId = event.over?.id; + if (overId == null || event.active.id === overId) return; + const activeId = String(event.active.id); + const thread = pinnedThreads.find( + (candidate) => + scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)) === activeId, + ); + const overThread = pinnedThreads.find( + (candidate) => + scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)) === String(overId), + ); + if (thread && overThread) movePinnedThread(thread, overThread); + }, + [movePinnedThread, pinnedThreads], + ); + const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); @@ -2528,6 +2793,9 @@ export default function SidebarV2() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; + const supportsPinReordering = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReordering === + true; const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; @@ -2535,6 +2803,12 @@ export default function SidebarV2() { const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); const isPinned = thread.pinnedAt != null; + const pinnedIndex = isPinned + ? pinnedThreads.findIndex( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.id, + ) + : -1; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); const clicked = await settlePromise(() => @@ -2555,6 +2829,23 @@ export default function SidebarV2() { : { id: "pin", label: "Pin thread" }, ] : []), + ...(isPinned && supportsPinReordering + ? [ + { + id: "move-pin-up", + label: "Move pinned thread up", + disabled: !pinnedOrderingFullyVisible || pinnedIndex <= 0, + }, + { + id: "move-pin-down", + label: "Move pinned thread down", + disabled: + !pinnedOrderingFullyVisible || + pinnedIndex < 0 || + pinnedIndex >= pinnedThreads.length - 1, + }, + ] + : []), // Both lifecycle actions stay available on pinned threads: // settling clears the pin ("done" beats "keep on top"), and // snoozing hides the card until wake with the pin intact. @@ -2645,6 +2936,16 @@ export default function SidebarV2() { case "unpin": attemptUnpin(threadRef); return; + case "move-pin-up": { + const target = pinnedThreads[pinnedIndex - 1]; + if (target) movePinnedThread(thread, target); + return; + } + case "move-pin-down": { + const target = pinnedThreads[pinnedIndex + 1]; + if (target) movePinnedThread(thread, target); + return; + } case "rename": startThreadRename(threadRef, thread.title); return; @@ -2731,6 +3032,8 @@ export default function SidebarV2() { deleteThread, handleMultiSelectContextMenu, markThreadUnread, + movePinnedThread, + pinnedThreads, projectCwdByKey, serverConfigs, startThreadRename, @@ -3121,6 +3424,12 @@ export default function SidebarV2() { .threadSnooze === true } isPinned={section === "pinned"} + pinReorderingSupported={ + section === "pinned" && + pinnedOrderingFullyVisible && + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadPinReordering === true + } snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null ? snoozeWakeLabel(thread.snoozedUntil, { @@ -3168,9 +3477,18 @@ export default function SidebarV2() { // Pinned block: full cards above the inbox, closed by a // thin divider (the pin glyphs carry the meaning, so no // header text). Vanishes entirely at count 0. - const items: ReactNode[] = pinnedThreads.map((thread) => - renderThreadRow(thread, "pinned"), - ); + const items: ReactNode[] = [ + + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + )} + sensors={pinnedThreadDnDSensors} + onDragEnd={handlePinnedThreadDragEnd} + > + {pinnedThreads.map((thread) => renderThreadRow(thread, "pinned"))} + , + ]; if (pinnedThreads.length > 0) { items.push(
  • { it("keeps the blocked thread context with the fixed message", () => { @@ -17,3 +17,20 @@ describe("ThreadArchiveBlockedError", () => { expect(error.message).toBe("Cannot archive a running thread."); }); }); + +describe("ThreadPinReorderingUnsupportedError", () => { + it("describes the missing reorder capability", () => { + const error = new ThreadPinReorderingUnsupportedError({ + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }); + + expect(error).toMatchObject({ + environmentId: "environment-1", + threadId: "thread-1", + }); + expect(error.message).toBe( + "This environment's server does not support synced pinned ordering yet. Update the server to reorder pinned threads.", + ); + }); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index e47fce1d3bc..629e834c427 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -6,7 +6,12 @@ import { } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; -import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + type PinnedThreadOrder, + type ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -23,6 +28,7 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat import { readLocalApi } from "../localApi"; import { readEnvironmentSupportsPinning, + readEnvironmentSupportsPinReordering, readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentThreadRefs, @@ -109,6 +115,18 @@ export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass()( + "ThreadPinReorderingUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support synced pinned ordering yet. Update the server to reorder pinned threads."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -132,6 +150,9 @@ export function useThreadActions() { const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false, }); + const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPinned, { + reportFailure: false, + }); const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false, }); @@ -546,6 +567,26 @@ export function useThreadActions() { [unpinThreadMutation], ); + const reorderPinnedThread = useCallback( + async (target: ScopedThreadRef, pinnedOrder: PinnedThreadOrder) => { + if (!readEnvironmentSupportsPinReordering(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadPinReorderingUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return reorderPinnedThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, pinnedOrder }, + }); + }, + [reorderPinnedThreadMutation], + ); + const snoozeThread = useCallback( async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. @@ -641,12 +682,14 @@ export function useThreadActions() { unsnoozeThread, pinThread, unpinThread, + reorderPinnedThread, }), [ archiveThread, confirmAndDeleteThread, deleteThread, pinThread, + reorderPinnedThread, settleThread, snoozeThread, unarchiveThread, diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index ac3dea3aca5..61d93e9d115 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -2,6 +2,10 @@ export { getLatestThreadForProject, getThreadSortTimestamp, sortThreads, + sortPinnedThreads, + pinnedThreadOrderForMove, + pinnedThreadOrderUpdatesForMove, toSortableTimestamp, + type PinnedThreadSortInput, type ThreadSortInput, } from "@t3tools/client-runtime/state/thread-sort"; diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 3f82973045c..a19b28d949d 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -250,6 +250,13 @@ export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): bo ); } +export function readEnvironmentSupportsPinReordering(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinReordering === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); } diff --git a/docs/README.md b/docs/README.md index bc359826a04..b0006e954f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ - [Install and first run](./user/install.md) - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) +- [Organizing threads](./user/thread-sidebar.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md new file mode 100644 index 00000000000..5de17235c76 --- /dev/null +++ b/docs/user/thread-sidebar.md @@ -0,0 +1,12 @@ +# Organizing threads + +Pin a thread from its context menu to keep it in the global pinned section above your active work. +Pinned threads are shown independently of their project, including when you connect to more than one +environment. + +On web and desktop, drag a pinned thread by its grip to change its position. On mobile, open the +thread's menu and choose **Move up** or **Move down**. The order is stored by the server and appears +on your other connected devices. + +If the reorder controls are missing for one environment, update the T3 Code server running in that +environment. Older servers can still pin and unpin threads, but do not understand synced ordering. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index ee200d3a22d..cb74f117b77 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -41,6 +41,7 @@ export type SnoozeThreadInput = CommandInput<"thread.snooze">; export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; export type PinThreadInput = CommandInput<"thread.pin">; export type UnpinThreadInput = CommandInput<"thread.unpin">; +export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -219,6 +220,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn }); }); +export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.reorderPinnedThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.pin.reorder", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 2eabc5aec16..04a0307ad40 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -19,6 +19,7 @@ import { type StopThreadSessionInput, type UnarchiveThreadInput, type UnpinThreadInput, + type ReorderPinnedThreadInput, type UnsettleThreadInput, type UnsnoozeThreadInput, type UpdateThreadMetadataInput, @@ -38,6 +39,7 @@ import { stopThreadSession, unarchiveThread, unpinThread, + reorderPinnedThread, unsettleThread, unsnoozeThread, updateThreadMetadata, @@ -61,6 +63,7 @@ export type { StopThreadSessionInput, UnarchiveThreadInput, UnpinThreadInput, + ReorderPinnedThreadInput, UnsettleThreadInput, UnsnoozeThreadInput, UpdateThreadMetadataInput, @@ -136,6 +139,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + reorderPinned: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:reorder-pinned", + execute: (input: ReorderPinnedThreadInput) => reorderPinnedThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 30e8ef58248..b6124374fa3 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -62,6 +62,7 @@ export function mergeEnvironmentThread( snoozedUntil: shell.snoozedUntil, snoozedAt: shell.snoozedAt, pinnedAt: shell.pinnedAt, + pinnedOrder: shell.pinnedOrder, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 8b2479c7a34..c56d440b6e9 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -232,26 +232,67 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.pinned / thread.unpinned", () => { - it("sets pinnedAt", () => { + it("sets pinnedAt without clearing an existing pinned order", () => { const pinnedAt = "2026-04-01T05:00:00.000Z"; - const result = applyThreadDetailEvent(baseThread, { + const result = applyThreadDetailEvent( + { ...baseThread, pinnedOrder: "3/7" as never }, + { + ...baseEventFields, + sequence: 5, + occurredAt: pinnedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.pinned", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedAt, + updatedAt: pinnedAt, + }, + }, + ); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.pinnedAt).toBe(pinnedAt); + expect(result.thread.pinnedOrder).toBe("3/7"); + } + }); + + it("sets and clears pinnedOrder", () => { + const reordered = applyThreadDetailEvent( + { ...baseThread, pinnedAt: "2026-04-01T05:00:00.000Z" }, + { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.pin-reordered", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedOrder: "3/7" as never, + updatedAt: "2026-04-01T06:00:00.000Z", + }, + }, + ); + expect(reordered.kind).toBe("updated"); + if (reordered.kind !== "updated") return; + expect(reordered.thread.pinnedOrder).toBe("3/7"); + + const unpinned = applyThreadDetailEvent(reordered.thread, { ...baseEventFields, - sequence: 5, - occurredAt: pinnedAt, + sequence: 7, + occurredAt: "2026-04-01T07:00:00.000Z", aggregateKind: "thread", aggregateId: ThreadId.make("thread-1"), - type: "thread.pinned", + type: "thread.unpinned", payload: { threadId: ThreadId.make("thread-1"), - pinnedAt, - updatedAt: pinnedAt, + updatedAt: "2026-04-01T07:00:00.000Z", }, }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.pinnedAt).toBe(pinnedAt); - } + expect(unpinned.kind).toBe("updated"); + if (unpinned.kind === "updated") expect(unpinned.thread.pinnedOrder).toBeNull(); }); it("clears pinnedAt", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 0c6649f3868..74f6b512efc 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -183,6 +183,17 @@ export function applyThreadDetailEvent( thread: { ...thread, pinnedAt: null, + pinnedOrder: null, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.pin-reordered": + return { + kind: "updated", + thread: { + ...thread, + pinnedOrder: event.payload.pinnedOrder, updatedAt: event.payload.updatedAt, }, }; diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index dd6f8c3a295..ac4ab08c20b 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; +import type { PinnedThreadOrder } from "@t3tools/contracts"; -import { sortThreads, type ThreadSortInput } from "./threadSort.ts"; +import { + pinnedThreadOrderForMove, + pinnedThreadOrderUpdatesForMove, + sortPinnedThreads, + sortThreads, + type ThreadSortInput, +} from "./threadSort.ts"; type TestThread = { readonly id: string } & ThreadSortInput; @@ -69,3 +76,119 @@ describe("sortThreads", () => { expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]); }); }); + +describe("pinned thread ordering", () => { + const pinned = [ + { id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }, + { id: "middle", createdAt: "2026-03-09T11:00:00.000Z" }, + { id: "oldest", createdAt: "2026-03-09T10:00:00.000Z" }, + ] as const; + + it("keeps creation order until a synced order is assigned", () => { + expect(sortPinnedThreads(pinned.toReversed()).map((thread) => thread.id)).toEqual([ + "newest", + "middle", + "oldest", + ]); + }); + + it("moves one thread between neighbors without changing sibling values", () => { + const order = pinnedThreadOrderForMove(pinned, "oldest", "middle"); + expect(order).not.toBeNull(); + expect( + sortPinnedThreads( + pinned.map((thread) => + thread.id === "oldest" ? { ...thread, pinnedOrder: order } : thread, + ), + ).map((thread) => thread.id), + ).toEqual(["newest", "oldest", "middle"]); + }); + + it("can move to both list boundaries", () => { + const topOrder = pinnedThreadOrderForMove(pinned, "oldest", "newest"); + const bottomOrder = pinnedThreadOrderForMove(pinned, "newest", "oldest"); + expect( + sortPinnedThreads( + pinned.map((thread) => + thread.id === "oldest" ? { ...thread, pinnedOrder: topOrder } : thread, + ), + ).map((thread) => thread.id), + ).toEqual(["oldest", "newest", "middle"]); + expect( + sortPinnedThreads( + pinned.map((thread) => + thread.id === "newest" ? { ...thread, pinnedOrder: bottomOrder } : thread, + ), + ).map((thread) => thread.id), + ).toEqual(["middle", "oldest", "newest"]); + }); + + it("compacts the list before an order can exceed the wire limit", () => { + const large = `6${"0".repeat(255)}`; + const crowded = [ + { + id: "left", + createdAt: pinned[0].createdAt, + pinnedOrder: `${large}/1` as PinnedThreadOrder, + }, + { + id: "right", + createdAt: pinned[1].createdAt, + pinnedOrder: `${BigInt(large) + 1n}/1` as PinnedThreadOrder, + }, + { id: "moved", createdAt: pinned[2].createdAt }, + ]; + + expect(pinnedThreadOrderForMove(crowded, "moved", "right")).toBeNull(); + const updates = pinnedThreadOrderUpdatesForMove(crowded, "moved", "right"); + expect(updates).toEqual([ + { threadId: "left", pinnedOrder: "1/1", previousPinnedOrder: `${large}/1` }, + { + threadId: "moved", + pinnedOrder: "2/1", + previousPinnedOrder: expect.stringMatching(/^\d+\/1$/), + }, + { + threadId: "right", + pinnedOrder: "3/1", + previousPinnedOrder: `${BigInt(large) + 1n}/1`, + }, + ]); + expect( + sortPinnedThreads( + crowded.map((thread) => { + const update = updates?.find((candidate) => candidate.threadId === thread.id); + return update ? { ...thread, pinnedOrder: update.pinnedOrder } : thread; + }), + ).map((thread) => thread.id), + ).toEqual(["left", "moved", "right"]); + }); + + it("compacts when duplicate neighbor positions leave no strict gap", () => { + const duplicated = [ + { id: "a", createdAt: pinned[0].createdAt, pinnedOrder: "1/1" as PinnedThreadOrder }, + { id: "b", createdAt: pinned[1].createdAt, pinnedOrder: "1/1" as PinnedThreadOrder }, + { id: "c", createdAt: pinned[2].createdAt, pinnedOrder: "2/1" as PinnedThreadOrder }, + ]; + + expect(pinnedThreadOrderForMove(duplicated, "c", "b")).toBeNull(); + const updates = pinnedThreadOrderUpdatesForMove(duplicated, "c", "b"); + expect( + sortPinnedThreads( + duplicated.map((thread) => { + const update = updates?.find((candidate) => candidate.threadId === thread.id); + return update ? { ...thread, pinnedOrder: update.pinnedOrder } : thread; + }), + ).map((thread) => thread.id), + ).toEqual(["a", "c", "b"]); + }); + + it("breaks duplicate position ties independently of the client locale", () => { + const duplicated = [ + { id: "ä", createdAt: pinned[0].createdAt, pinnedOrder: "1/1" as PinnedThreadOrder }, + { id: "z", createdAt: pinned[1].createdAt, pinnedOrder: "1/1" as PinnedThreadOrder }, + ]; + + expect(sortPinnedThreads(duplicated).map((thread) => thread.id)).toEqual(["z", "ä"]); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index aed63cd442d..37c74d551a4 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -1,4 +1,4 @@ -import type { ProjectId } from "@t3tools/contracts"; +import type { PinnedThreadOrder, ProjectId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -102,3 +102,232 @@ export function getLatestThreadForProject< )[0] ?? null ); } + +export interface PinnedThreadSortInput { + readonly id: string; + readonly environmentId?: string | undefined; + readonly createdAt: string; + readonly pinnedOrder?: PinnedThreadOrder | null | undefined; +} + +function pinnedThreadIdentity(thread: PinnedThreadSortInput): string { + return thread.environmentId ? `${thread.environmentId}:${thread.id}` : thread.id; +} + +type RationalOrder = { + readonly numerator: bigint; + readonly denominator: bigint; +}; + +const DEFAULT_ORDER_TIMESTAMP_CEILING = 10_000_000_000_000_000n; +const DEFAULT_ORDER_ID_BUCKET = 1n << 64n; +const FNV_OFFSET_BASIS_64 = 14_695_981_039_346_656_037n; +const FNV_PRIME_64 = 1_099_511_628_211n; +const PINNED_THREAD_ORDER_MAX_DIGITS = 256; + +function stableIdHash(id: string): bigint { + let hash = FNV_OFFSET_BASIS_64; + for (let index = 0; index < id.length; index += 1) { + hash ^= BigInt(id.charCodeAt(index)); + hash = BigInt.asUintN(64, hash * FNV_PRIME_64); + } + return hash; +} + +function parsePinnedThreadOrder(order: PinnedThreadOrder): RationalOrder { + const separator = order.indexOf("/"); + return { + numerator: BigInt(order.slice(0, separator)), + denominator: BigInt(order.slice(separator + 1)), + }; +} + +function defaultPinnedThreadOrder(thread: PinnedThreadSortInput): RationalOrder { + const parsedTimestamp = Date.parse(thread.createdAt); + const timestamp = Number.isFinite(parsedTimestamp) ? Math.max(0, Math.trunc(parsedTimestamp)) : 0; + const invertedTimestamp = DEFAULT_ORDER_TIMESTAMP_CEILING - BigInt(timestamp); + return { + numerator: + invertedTimestamp * DEFAULT_ORDER_ID_BUCKET + stableIdHash(pinnedThreadIdentity(thread)) + 1n, + denominator: 1n, + }; +} + +function effectivePinnedThreadOrder(thread: PinnedThreadSortInput): RationalOrder { + return thread.pinnedOrder + ? parsePinnedThreadOrder(thread.pinnedOrder) + : defaultPinnedThreadOrder(thread); +} + +function compareRationalOrder(left: RationalOrder, right: RationalOrder): number { + const leftProduct = left.numerator * right.denominator; + const rightProduct = right.numerator * left.denominator; + return leftProduct < rightProduct ? -1 : leftProduct > rightProduct ? 1 : 0; +} + +/** Sorts pinned threads by their synced position, falling back to the v2 + * creation order for threads pinned by a pre-reordering client. */ +export function sortPinnedThreads(threads: readonly T[]): T[] { + return threads + .map((thread) => ({ + thread, + identity: pinnedThreadIdentity(thread), + order: effectivePinnedThreadOrder(thread), + })) + .sort((left, right) => { + const order = compareRationalOrder(left.order, right.order); + return order !== 0 + ? order + : left.identity < right.identity + ? -1 + : left.identity > right.identity + ? 1 + : 0; + }) + .map(({ thread }) => thread); +} + +function greatestCommonDivisor(left: bigint, right: bigint): bigint { + let dividend = left; + let divisor = right; + while (divisor !== 0n) { + const remainder = dividend % divisor; + dividend = divisor; + divisor = remainder; + } + return dividend; +} + +function serializePinnedThreadOrder(order: RationalOrder): PinnedThreadOrder | null { + const divisor = greatestCommonDivisor(order.numerator, order.denominator); + const numerator = order.numerator / divisor; + const denominator = order.denominator / divisor; + const numeratorText = numerator.toString(); + const denominatorText = denominator.toString(); + if ( + numerator <= 0n || + denominator <= 0n || + numeratorText.length > PINNED_THREAD_ORDER_MAX_DIGITS || + denominatorText.length > PINNED_THREAD_ORDER_MAX_DIGITS + ) { + return null; + } + return `${numeratorText}/${denominatorText}` as PinnedThreadOrder; +} + +function reorderPinnedThreads( + orderedThreads: readonly T[], + threadId: string, + overThreadId: string, +): { readonly reordered: T[]; readonly moved: T } | null { + const fromIndex = orderedThreads.findIndex((thread) => pinnedThreadIdentity(thread) === threadId); + const toIndex = orderedThreads.findIndex( + (thread) => pinnedThreadIdentity(thread) === overThreadId, + ); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null; + + const reordered = [...orderedThreads]; + const [moved] = reordered.splice(fromIndex, 1); + if (!moved) return null; + reordered.splice(toIndex, 0, moved); + return { reordered, moved }; +} + +function boundedOrderForMovedThread( + reordered: readonly T[], + moved: T, +): PinnedThreadOrder | null { + const movedIndex = reordered.indexOf(moved); + const previous = movedIndex > 0 ? reordered[movedIndex - 1] : undefined; + const next = movedIndex + 1 < reordered.length ? reordered[movedIndex + 1] : undefined; + + if (previous && next) { + const left = effectivePinnedThreadOrder(previous); + const right = effectivePinnedThreadOrder(next); + const candidate = { + numerator: left.numerator + right.numerator, + denominator: left.denominator + right.denominator, + }; + if (compareRationalOrder(left, candidate) >= 0 || compareRationalOrder(candidate, right) >= 0) { + return null; + } + return serializePinnedThreadOrder(candidate); + } + if (next) { + const right = effectivePinnedThreadOrder(next); + return serializePinnedThreadOrder({ + numerator: right.numerator, + denominator: right.numerator + right.denominator, + }); + } + if (previous) { + const left = effectivePinnedThreadOrder(previous); + return serializePinnedThreadOrder({ + numerator: left.numerator + left.denominator, + denominator: left.denominator, + }); + } + return "1/1" as PinnedThreadOrder; +} + +/** + * Returns a new stable position for one thread dragged over another. The + * mediant of adjacent rational positions is always strictly between them, so + * only the moved thread needs a server command and sibling environments do + * not need a coordinated renumber. + */ +export function pinnedThreadOrderForMove( + orderedThreads: readonly T[], + threadId: string, + overThreadId: string, +): PinnedThreadOrder | null { + const result = reorderPinnedThreads(orderedThreads, threadId, overThreadId); + return result ? boundedOrderForMovedThread(result.reordered, result.moved) : null; +} + +export interface PinnedThreadOrderUpdate { + readonly threadId: string; + readonly pinnedOrder: PinnedThreadOrder; + readonly previousPinnedOrder: PinnedThreadOrder; +} + +/** Returns the usual single-thread update, or a compact full-list rebalance + * when no rational within the wire contract remains between two neighbors. */ +export function pinnedThreadOrderUpdatesForMove( + orderedThreads: readonly T[], + threadId: string, + overThreadId: string, +): readonly PinnedThreadOrderUpdate[] | null { + const result = reorderPinnedThreads(orderedThreads, threadId, overThreadId); + if (!result) return null; + + const movedOrder = boundedOrderForMovedThread(result.reordered, result.moved); + if (movedOrder !== null) { + const previousPinnedOrder = serializePinnedThreadOrder( + effectivePinnedThreadOrder(result.moved), + ); + return previousPinnedOrder === null + ? null + : [ + { + threadId: pinnedThreadIdentity(result.moved), + pinnedOrder: movedOrder, + previousPinnedOrder, + }, + ]; + } + + const compacted = result.reordered.map((thread, index) => { + const pinnedOrder = serializePinnedThreadOrder({ + numerator: BigInt(index + 1), + denominator: 1n, + }); + const previousPinnedOrder = serializePinnedThreadOrder(effectivePinnedThreadOrder(thread)); + return pinnedOrder === null || previousPinnedOrder === null + ? null + : { threadId: pinnedThreadIdentity(thread), pinnedOrder, previousPinnedOrder }; + }); + return compacted.some((update) => update === null) + ? null + : (compacted as PinnedThreadOrderUpdate[]); +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 4c44a959655..c53cdfb47d8 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -50,6 +50,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.pin / thread.unpin commands. Same version-skew contract as threadSettlement. */ threadPinning: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.pin.reorder and persists a synced pinned + position. Kept separate from threadPinning so newer clients never send + the command to older pinning-capable servers. */ + threadPinReordering: 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/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index ecf7afa0610..7e0fdbb2f50 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -116,6 +116,30 @@ it.effect("rejects thread turn diff when fromTurnCount > toTurnCount", () => }), ); +it.effect("validates pinned thread reorder positions at the command boundary", () => + Effect.gen(function* () { + const valid = yield* decodeOrchestrationCommand({ + type: "thread.pin.reorder", + commandId: "command-reorder-pin", + threadId: "thread-1", + pinnedOrder: "3/7", + }); + assert.strictEqual(valid.type, "thread.pin.reorder"); + + for (const pinnedOrder of ["0/1", "1/0", "1", "1.5/2"]) { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "thread.pin.reorder", + commandId: "command-reorder-pin", + threadId: "thread-1", + pinnedOrder, + }), + ); + assert.strictEqual(result._tag, "Failure"); + } + }), +); + it.effect("trims branded ids and command string fields at decode boundaries", () => Effect.gen(function* () { const parsed = yield* decodeProjectCreateCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 26204961923..85e8ce1d102 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -127,6 +127,20 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; + +/** + * A positive rational used as a stable, environment-independent position in + * the global pinned-thread list. Clients compare values by cross-multiplying + * the numerator and denominator. Clients normally create another value + * between two positions and compact the list only when this bounded wire + * representation is exhausted. + */ +export const PinnedThreadOrder = TrimmedNonEmptyString.check( + Schema.isMaxLength(513), + Schema.isPattern(/^[1-9]\d{0,255}\/[1-9]\d{0,255}$/), +).pipe(Schema.brand("PinnedThreadOrder")); +export type PinnedThreadOrder = typeof PinnedThreadOrder.Type; + export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]); export type ProviderRequestKind = typeof ProviderRequestKind.Type; export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); @@ -379,6 +393,8 @@ export const OrchestrationThread = Schema.Struct({ // thread renders in the pinned block and never classifies into a shelf. // Optional so payloads from pre-pinning servers still decode. pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + // Optional so pre-reordering servers and clients continue to interoperate. + pinnedOrder: Schema.optional(Schema.NullOr(PinnedThreadOrder)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), @@ -434,6 +450,7 @@ export const OrchestrationThreadShell = Schema.Struct({ snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + pinnedOrder: Schema.optional(Schema.NullOr(PinnedThreadOrder)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), @@ -710,6 +727,13 @@ const ThreadUnpinCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadPinReorderCommand = Schema.Struct({ + type: Schema.Literal("thread.pin.reorder"), + commandId: CommandId, + threadId: ThreadId, + pinnedOrder: PinnedThreadOrder, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -865,6 +889,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUnsnoozeCommand, ThreadPinCommand, ThreadUnpinCommand, + ThreadPinReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -892,6 +917,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUnsnoozeCommand, ThreadPinCommand, ThreadUnpinCommand, + ThreadPinReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1009,6 +1035,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.unsnoozed", "thread.pinned", "thread.unpinned", + "thread.pin-reordered", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -1128,6 +1155,12 @@ export const ThreadUnpinnedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadPinReorderedPayload = Schema.Struct({ + threadId: ThreadId, + pinnedOrder: PinnedThreadOrder, + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1332,6 +1365,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unpinned"), payload: ThreadUnpinnedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.pin-reordered"), + payload: ThreadPinReorderedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"),