diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1526965427f..a69cda53bf4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.31", + "version": "0.0.32", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index f0710e85d36..28f7cfe57a7 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -16,6 +16,18 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; +/** + * Horizontal correction applied to content rendered in the brand title slot, + * shared with the connection-status swap so both align identically. + */ +export function brandTitleOffset(nativeLeadingItem: boolean): number { + if (Platform.OS !== "ios") return 0; + if (nativeLeadingItem) { + return Platform.isPad ? IPAD_NATIVE_LEADING_TITLE_OFFSET : IOS_NATIVE_LEADING_TITLE_OFFSET; + } + return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; +} + /** * Compact brand lockup sized for native navigation bars. */ @@ -28,16 +40,7 @@ export function CompactBrandTitle( const mutedColor = useThemeColor("--color-foreground-muted"); const subtleColor = useThemeColor("--color-subtle"); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); - const titleOffset = - Platform.OS !== "ios" - ? 0 - : props.nativeLeadingItem - ? Platform.isPad - ? IPAD_NATIVE_LEADING_TITLE_OFFSET - : IOS_NATIVE_LEADING_TITLE_OFFSET - : Platform.isPad - ? IPAD_HOME_TITLE_OFFSET - : 0; + const titleOffset = brandTitleOffset(props.nativeLeadingItem === true); return ( void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; }) { @@ -336,17 +341,27 @@ function AndroidHomeHeader(props: HomeHeaderProps) { > - - - - Code - - - - Alpha - - - + {/* Brand slot doubles as the connection status surface: while an + environment reconnects, the lockup fades to a status label in + place (no layout shift in the list below). */} + + {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} + + + Code + + + + {stageLabel} + + + + } + /> {alternateModes.map((mode) => ( + {headerTitle} + + ), + }), headerTintColor: iconColor, // Explicitly toggle glass ↔ solid when switching modes so board // underlap does not stick after leaving Board, and vice versa. diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 4b662966423..d94f881b32f 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -9,7 +9,6 @@ import { import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; import { @@ -57,6 +56,7 @@ export function HomeRouteScreen() { unsnoozeThread, pinThread, unpinThread, + movePinnedThread, unsettleThread, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); @@ -187,7 +187,9 @@ export function HomeRouteScreen() { onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Title is owned by HomeHeader (tracks list mode). */} + {/* Title is owned by HomeHeader (tracks list mode), which carries the + connection-aware brand slot — no native-stack title to avoid + showing the status twice. */} + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + } onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} @@ -235,10 +240,8 @@ export function HomeRouteScreen() { onUnpinThread={unpinThread} onClearEnvironments={clearSelectedEnvironments} onToggleEnvironment={toggleSelectedEnvironmentId} + onMovePinnedThread={movePinnedThread} onProjectChange={setSelectedProjectKey} - onOpenEnvironments={() => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) - } onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 2a547a89e68..cd34a76e196 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -12,6 +12,7 @@ import { type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, SidebarProjectGroupingMode, @@ -84,8 +85,6 @@ import { type HomeProjectSortOrder, } from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; -import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -123,7 +122,6 @@ interface HomeScreenProps { readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onAddConnection: () => void; - readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; @@ -139,6 +137,10 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onMovePinnedThread: ( + thread: EnvironmentThreadShell, + direction: "up" | "down", + ) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -558,6 +560,12 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onPinThread], ); + const handleMovePinnedThread = useCallback( + (thread: EnvironmentThreadShell, direction: "up" | "down") => { + void props.onMovePinnedThread(thread, direction); + }, + [props.onMovePinnedThread], + ); const handleUnpinThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onUnpinThread(thread); @@ -814,6 +822,29 @@ export function HomeScreen(props: HomeScreenProps) { showProjectThreadList, visibleRecentEntries, ]); + const pinReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + // Canonical arranged pinned order (reorder-capable threads only) for the + // Move up/down position flags. Computed from all shells, not the rendered + // list, so search/scope filtering never disables or misdirects a move. + const arrangedPinnedKeys = useMemo(() => { + const pinned = sortPinnedThreadsByOrderKey( + props.threads.filter( + (thread) => + thread.pinnedAt != null && + thread.archivedAt === null && + pinReorderEnvironmentIds.has(thread.environmentId), + ), + ); + return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); + }, [pinReorderEnvironmentIds, props.threads]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -1000,11 +1031,18 @@ export function HomeScreen(props: HomeScreenProps) { onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} + canMovePinnedUp={arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0} + canMovePinnedDown={(() => { + const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); + return index !== -1 && index < arrangedPinnedKeys.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 @@ -1017,6 +1055,8 @@ export function HomeScreen(props: HomeScreenProps) { [ handleChangeRequestState, handleDeleteThread, + arrangedPinnedKeys, + handleMovePinnedThread, handlePinThread, handleSettleThread, handleSnoozeThread, @@ -1026,6 +1066,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleUnsettleThread, pinningEnvironmentIds, + pinReorderEnvironmentIds, projectByKey, projectCwdByKey, props.onArchiveThread, @@ -1194,20 +1235,13 @@ export function HomeScreen(props: HomeScreenProps) { } return map; }, [props.savedConnectionsById]); - const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState); + // Connection state surfaces in the header title slot + // (WorkspaceConnectionTitle) — nothing renders inside the list, so + // reconnects never shift the rows. const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); - const connectionStatus = - shouldShowConnectionStatus && Platform.OS !== "ios" ? ( - - - - ) : null; // Board owns its empty chrome; connection-level empty still applies below // for Recent/Projects when the workspace has no threads at all. @@ -1228,41 +1262,17 @@ export function HomeScreen(props: HomeScreenProps) { onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} variant="plain" /> - {emptyState.loading && !shouldShowConnectionStatus ? ( + {emptyState.loading ? ( ) : null} - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - {connectionStatus} ); } - const listHeader = ( - <> - {Platform.OS === "ios" ? null : } - - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - - ); + const listHeader = Platform.OS === "ios" ? null : ; // Project scoping lives in the header filter menu (no inline chip row on // mobile — the menu is the one filter surface). @@ -1343,7 +1353,6 @@ export function HomeScreen(props: HomeScreenProps) { onSettleThread={props.onSettleThread} onUnsettleThread={props.onUnsettleThread} /> - {connectionStatus} ); } @@ -1394,7 +1403,6 @@ export function HomeScreen(props: HomeScreenProps) { }} /> - {connectionStatus} ); } @@ -1465,7 +1473,6 @@ export function HomeScreen(props: HomeScreenProps) { } /> - {connectionStatus} ); } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx deleted file mode 100644 index 1e986ad1a50..00000000000 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { SymbolView } from "../../components/AppSymbol"; -import { ActivityIndicator, Pressable } from "react-native"; - -import { AppText as Text } from "../../components/AppText"; -import { useThemeColor } from "../../lib/useThemeColor"; -import type { WorkspaceState } from "../../state/workspaceModel"; -import { workspaceConnectionStatusLabel } from "./workspace-connection-status"; - -export function WorkspaceConnectionStatus(props: { - readonly state: WorkspaceState; - readonly onPress: () => void; - readonly variant?: "floating" | "sidebar"; -}) { - const iconColor = useThemeColor("--color-icon-muted"); - const isSynchronizing = - props.state.networkStatus !== "offline" && - props.state.connectionError === null && - (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot); - const variant = props.variant ?? "floating"; - - return ( - - {isSynchronizing ? ( - - ) : ( - - )} - - {workspaceConnectionStatusLabel(props.state)} - - {variant === "sidebar" ? ( - - ) : null} - - ); -} diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx new file mode 100644 index 00000000000..c200a286a1b --- /dev/null +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -0,0 +1,203 @@ +import type { + NativeStackHeaderItem, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { useWorkspaceState } from "../../state/workspace"; +import { + workspaceConnectionStatusPresentation, + type WorkspaceConnectionStatusPresentation, +} from "./workspace-connection-status"; + +/** + * Delay before a connection interruption surfaces in the title slot. Sub-second + * blips (the common reconnect case) resolve without any UI at all. + */ +const STATUS_SHOW_DELAY_MS = 800; +const FADE_IN_MS = 250; + +/** + * Connection status presentation, debounced for display: null until the + * workspace has been in a non-connected state for STATUS_SHOW_DELAY_MS, + * then live-updating until the workspace reconnects (null again immediately). + */ +function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | null { + const { state } = useWorkspaceState(); + const presentation = workspaceConnectionStatusPresentation(state); + const hasStatus = presentation !== null; + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!hasStatus) { + setVisible(false); + return; + } + const timer = setTimeout(() => setVisible(true), STATUS_SHOW_DELAY_MS); + return () => clearTimeout(timer); + }, [hasStatus]); + + return visible ? presentation : null; +} + +/** + * One-shot entrance fade for the status label. Deliberately JS-driven: this can + * mount inside a native header item (RNSScreenStackHeaderSubview), where + * native-driver animated nodes blank the re-hosted view entirely. The JS driver + * updates opacity through the ordinary style path, which those subviews handle. + */ +function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boolean }) { + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const animation = Animated.timing(opacity, { + duration: FADE_IN_MS, + toValue: 1, + useNativeDriver: false, + }); + animation.start(); + return () => animation.stop(); + }, [opacity]); + + return ( + + {props.children} + + ); +} + +/** + * Renders the brand/title slot of a thread-list surface, swapping the brand + * for the workspace connection status while an environment is unavailable. + * + * Both states occupy the same slot, so connection changes never shift the + * layout below. While connected the brand renders untouched — no wrapper — + * keeping the native header item on the exact element tree that predates the + * status swap. Replaces the old WorkspaceConnectionStatus pill, which inserted + * a row above the thread list. + */ +export function WorkspaceConnectionTitle(props: { + /** Content shown while connected (brand lockup or a screen title). */ + readonly brand: ReactNode; + /** Opens environment settings. Status is not pressable when omitted. */ + readonly onPress?: () => void; + /** Fill the available row width (in-flow headers) instead of hugging content (native title slots). */ + readonly grow?: boolean; + readonly size?: "navbar" | "pageTitle"; + /** Horizontal correction so the status aligns with the brand in native title slots. */ + readonly statusOffset?: number; +}) { + const iconColor = String(useThemeColor("--color-icon-muted")); + const status = useDelayedConnectionStatus(); + const size = props.size ?? "navbar"; + + if (status === null) { + return props.grow ? ( + + {props.brand} + + ) : ( + <>{props.brand} + ); + } + + return ( + + + {status.showsProgress ? ( + + ) : ( + + )} + + {status.label} + + + + ); +} + +/** + * getCompactBrandHeaderOptions with the brand slot upgraded to the + * connection-status swap. Screens with an environment-settings callback apply + * this over the static brand options at mount. + */ +export function getConnectionAwareBrandHeaderOptions(opts: { + readonly onOpenEnvironments: () => void; + readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; + /** + * Screens whose native header shows something other than the brand lockup + * (this fork's list-mode titles: Threads / Projects / Board) pass their own + * title here. Without it the returned options overwrite the caller's + * `title`/`headerTitle` with the brand, silently dropping the mode name. + */ + readonly title?: string; + readonly brand?: ReactNode; +}): NativeStackNavigationOptions { + const title = opts.title ?? "Threads"; + if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { + return { + headerTitle: title, + headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, + title, + unstable_headerLeftItems: (): NativeStackHeaderItem[] => [ + { + element: ( + } + onPress={opts.onOpenEnvironments} + {...(opts.brand === undefined ? { statusOffset: brandTitleOffset(true) } : {})} + /> + ), + hidesSharedBackground: true, + type: "custom", + }, + ], + }; + } + + return { + headerTitle: () => ( + } + onPress={opts.onOpenEnvironments} + {...(opts.brand === undefined ? { statusOffset: brandTitleOffset(false) } : {})} + /> + ), + headerTitleStyle: opts.fallbackTitleStyle, + title, + }; +} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 83133083f59..746ed3d29ae 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -9,9 +9,14 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; +import { + pinOrderKeyBetween, + planPinnedMove, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; -import { threadEnvironment } from "../../state/threads"; +import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { vcsEnvironment } from "../../state/vcs"; import { useAtomCommand } from "../../state/use-atom-command"; import { @@ -44,6 +49,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir ); } +function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinReorder === true + ); +} + function selectionHaptic(): void { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } @@ -193,6 +205,10 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly movePinnedThread: ( + thread: EnvironmentThreadShell, + direction: "up" | "down", + ) => Promise; } { const executeAction = useThreadActionExecutor(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); @@ -390,9 +406,21 @@ export function useThreadListActions(): { return false; } selectionHaptic(); + // Same placement as web: a fresh pin takes the top of the arranged + // run. Servers that predate reordering get the bare pin (keyless). + let orderKey: string | undefined; + if (environmentSupportsPinReorder(thread.environmentId)) { + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + let firstKey: string | null = null; + for (const shell of shells) { + if (shell.pinnedAt == null || shell.pinOrderKey == null) continue; + if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey; + } + orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined; + } const result = await pinMutation({ environmentId: thread.environmentId, - input: { threadId: thread.id }, + input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) }, }); if (result._tag === "Failure") { const error = Cause.squash(result.cause); @@ -437,6 +465,85 @@ export function useThreadListActions(): { [unpinMutation], ); + // Move up / Move down for the pinned block. Computed against the CANONICAL + // keyed pinned order (not the rendered list), so the move is valid even + // while search or a project scope filters rows: the same fractional-key + // scheme web dragging uses, one write to one thread per move (plus a + // one-time section materialization when legacy keyless pins are involved). + const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { + reportFailure: false, + }); + // One move at a time: a second tap before the first write's event lands + // would plan from the same stale snapshot and silently collapse two moves + // into one — same double-dispatch guard as snoozeThread. + const movePinnedInFlightRef = useRef(false); + const movePinnedThread = useCallback( + async (thread: EnvironmentThreadShell, direction: "up" | "down") => { + if (movePinnedInFlightRef.current) return false; + if (!environmentSupportsPinReorder(thread.environmentId)) { + Alert.alert( + "Could not move thread", + "This environment's server does not support pinned reordering yet. Update the server to reorder pins.", + ); + return false; + } + const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const pinned = sortPinnedThreadsByOrderKey( + shells.filter( + (shell) => + shell.pinnedAt != null && + shell.archivedAt === null && + environmentSupportsPinReorder(shell.environmentId), + ), + ); + const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id)); + const assignments = planPinnedMove({ + orderedIds, + keysById: new Map( + pinned.map((shell) => [ + scopedThreadKey(shell.environmentId, shell.id), + shell.pinOrderKey ?? null, + ]), + ), + movedId: scopedThreadKey(thread.environmentId, thread.id), + direction, + }); + if (assignments === null || assignments.length === 0) return false; + const shellByKey = new Map( + pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), + ); + selectionHaptic(); + movePinnedInFlightRef.current = true; + try { + for (const assignment of assignments) { + const target = shellByKey.get(assignment.id); + if (target === undefined) continue; + const result = await reorderPinnedMutation({ + environmentId: target.environmentId, + input: { threadId: target.id, orderKey: assignment.orderKey }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not move thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The pinned thread could not be moved.", + ); + // No rollback: keys already written are valid orderings on their + // own (each write is a complete, consistent placement), so a + // partial materialization leaves the list sensible, not corrupt. + return false; + } + } + return true; + } finally { + movePinnedInFlightRef.current = false; + } + }, + [reorderPinnedMutation], + ); + const confirmDeleteThread = useConfirmDeleteThread(executeAction); return { @@ -448,6 +555,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + movePinnedThread, }; } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/workspace-connection-status.test.ts similarity index 72% rename from apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts rename to apps/mobile/src/features/home/workspace-connection-status.test.ts index 8c3c873cc9e..15a990bb1cb 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.test.ts @@ -4,6 +4,7 @@ import type { WorkspaceState } from "../../state/workspaceModel"; import { shouldShowWorkspaceConnectionStatus, workspaceConnectionStatusLabel, + workspaceConnectionStatusPresentation, } from "./workspace-connection-status"; function workspaceState(overrides: Partial = {}): WorkspaceState { @@ -84,4 +85,36 @@ describe("workspace connection status", () => { expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); }); + + it("presents nothing while connected", () => { + expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); + }); + + it("presents progress while reconnecting but not while offline", () => { + const reconnecting = workspaceState({ + hasConnectingEnvironment: true, + hasReadyEnvironment: false, + connectingEnvironments: [ + { + environmentId: "environment-1" as never, + environmentLabel: "Julius’s Mac mini", + displayUrl: "", + isRelayManaged: false, + connectionState: "reconnecting", + connectionError: null, + connectionErrorTraceId: null, + }, + ], + }); + expect(workspaceConnectionStatusPresentation(reconnecting)).toEqual({ + label: "Reconnecting to Julius’s Mac mini", + showsProgress: true, + }); + + const offline = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); + expect(workspaceConnectionStatusPresentation(offline)).toEqual({ + label: "You are offline", + showsProgress: false, + }); + }); }); diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index d8eed4383b1..6f9898b1bb0 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -1,5 +1,11 @@ import type { WorkspaceState } from "../../state/workspaceModel"; +export interface WorkspaceConnectionStatusPresentation { + readonly label: string; + /** True while actively working (connecting/syncing) — render a spinner. False for offline/error/idle states — render a wifi-slash icon. */ + readonly showsProgress: boolean; +} + export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { return ( state.networkStatus === "offline" || @@ -24,3 +30,17 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string { } return "Not connected"; } + +/** Header-title presentation of the connection state, or null while connected. */ +export function workspaceConnectionStatusPresentation( + state: WorkspaceState, +): WorkspaceConnectionStatusPresentation | null { + if (!shouldShowWorkspaceConnectionStatus(state)) return null; + return { + label: workspaceConnectionStatusLabel(state), + showsProgress: + state.networkStatus !== "offline" && + state.connectionError === null && + (state.connectingEnvironments.length > 0 || state.hasPendingShellSnapshot), + }; +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 98f2df4df43..68568d12186 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -17,6 +17,7 @@ import type { MenuAction } from "@react-native-menu/menu"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; 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"; @@ -78,8 +79,10 @@ import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThrea import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; -import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; +import { + getConnectionAwareBrandHeaderOptions, + WorkspaceConnectionTitle, +} from "../home/WorkspaceConnectionTitle"; import { SidebarHeaderActions } from "./sidebar-header-actions"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; @@ -231,6 +234,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + movePinnedThread, } = useThreadListActions(); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -758,6 +762,28 @@ function ThreadNavigationSidebarPane( visibleRecentEntries, ]); + const pinReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + // Canonical arranged pinned order for Move up/down flags — computed from + // all shells so search/scope filtering never disables a valid move. + const arrangedPinnedKeys = useMemo(() => { + const pinned = sortPinnedThreadsByOrderKey( + threads.filter( + (thread) => + thread.pinnedAt != null && + thread.archivedAt === null && + pinReorderEnvironmentIds.has(thread.environmentId), + ), + ); + return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); + }, [pinReorderEnvironmentIds, threads]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -884,7 +910,6 @@ function ThreadNavigationSidebarPane( settledShelfExpanded, snoozedShelfExpanded, ]); - const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listOrganization = showProjectThreadList && !threadListV2Enabled; const listMenuActions = useMemo( () => [ @@ -1259,11 +1284,20 @@ function ThreadNavigationSidebarPane( onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} + canMovePinnedUp={ + arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0 + } + canMovePinnedDown={(() => { + const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); + return index !== -1 && index < arrangedPinnedKeys.length - 1; + })()} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onMovePinnedThread={movePinnedThread} onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} @@ -1402,13 +1436,16 @@ function ThreadNavigationSidebarPane( }, [ archiveThread, + arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, + movePinnedThread, openPendingTask, + pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, projectByKey, @@ -1562,8 +1599,20 @@ function ThreadNavigationSidebarPane( + {HOME_LIST_MODE_TITLES[options.listMode]} + + ), + }), // Board columns are not one UIKit-inset scroll view — solid bar // so cards never underlap the glass nav (same as Board route / home). ...(NATIVE_LIQUID_GLASS_SUPPORTED @@ -1636,17 +1685,6 @@ function ThreadNavigationSidebarPane( scrollEventThrottle={16} showsVerticalScrollIndicator={false} style={styles.threadList} - ListHeaderComponent={ - showsConnectionStatus ? ( - - - - ) : null - } ListEmptyComponent={listEmpty} /> @@ -1744,9 +1782,21 @@ function ThreadNavigationSidebarPane( - - {HOME_LIST_MODE_TITLES[options.listMode]} - + {/* Title slot doubles as the connection status surface: while an + environment reconnects, the title fades to a status label in + place (no layout shift in the list below). Upstream's brand is + the literal "Threads"; this fork's large title tracks list mode, + so it is passed through rather than hardcoded. */} + + {HOME_LIST_MODE_TITLES[options.listMode]} + + } + /> )} - - {showsConnectionStatus ? ( - - - - ) : null} ); 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 f55580b7a4d..524e912f6e3 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -353,6 +353,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozeSupported: boolean; /** False on servers that predate thread.pin/unpin. */ readonly pinningSupported: boolean; + /** False on servers that predate thread.pin.reorder. Gates the pinned + Move up / Move down menu items. */ + readonly pinReorderSupported?: boolean; + readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; + /** Position flags for the pinned block so the menu disables the move that + would fall off the end of the list. */ + 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 @@ -381,6 +389,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onMovePinnedThread, onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; @@ -417,6 +426,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 @@ -460,12 +477,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { () => props.pinningSupported ? [ + ...(pinnedRow && props.pinReorderSupported === true + ? [ + { + id: "move-pin-up", + title: "Move up", + image: "arrow.up", + attributes: { disabled: props.canMovePinnedUp !== true }, + } satisfies MenuAction, + { + id: "move-pin-down", + title: "Move down", + image: "arrow.down", + attributes: { disabled: props.canMovePinnedDown !== true }, + } satisfies MenuAction, + ] + : []), pinnedRow ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] : [], - [pinnedRow, props.pinningSupported], + [ + pinnedRow, + props.canMovePinnedDown, + props.canMovePinnedUp, + props.pinReorderSupported, + props.pinningSupported, + ], ); const snoozableCardMenuActions = useMemo( () => [ @@ -492,6 +531,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 +549,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [ handleArchive, handleDelete, + handleMovePinnedDown, + handleMovePinnedUp, handlePin, handleSettle, handleSnooze, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 75b10daeeb6..2b981db0197 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -13,6 +13,7 @@ import { groupSortedThreadsByRecency, shouldShowRecencySectionHeaders, } from "@t3tools/client-runtime/state/thread-recency-groups"; +import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { threadMatchesAttributeQuery } from "@t3tools/shared/threadAttributeSearch"; @@ -472,8 +473,8 @@ export function buildThreadListV2Items(input: { input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; // 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. + // hand). The pin (and its pinOrderKey) survives underneath, so a woken + // thread reappears at its exact spot in the pinned block. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -533,7 +534,9 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of orderActiveThreads(pinned)) { + // Pins carry an explicit user order (#5581); only the unpinned rows follow + // the fork's grouping preference. + for (const thread of sortPinnedThreadsByOrderKey(pinned)) { items.push({ thread, variant: "card", diff --git a/apps/mobile/src/mobileSurfaceExistence.test.ts b/apps/mobile/src/mobileSurfaceExistence.test.ts index da4821e83d3..9fb44da63a5 100644 --- a/apps/mobile/src/mobileSurfaceExistence.test.ts +++ b/apps/mobile/src/mobileSurfaceExistence.test.ts @@ -75,6 +75,37 @@ describe("mobile surface existence (anti stack-drop)", () => { ); }); + it("keeps list-mode titles under the connection-status title swap", () => { + // Upstream's connection-aware header hardcodes the brand lockup and the + // literal "Threads" (#5372). This fork's headers show a list-mode title + // (Threads / Projects / Board), so every surface that adopts the swap has + // to pass its own title through — a plain adoption silently renames Board + // and Projects to "Threads", which is exactly what slipped through once. + const sidebar = NodeFS.readFileSync( + NodePath.join(root, "features/threads/ThreadNavigationSidebar.tsx"), + "utf8", + ); + const homeHeader = NodeFS.readFileSync( + NodePath.join(root, "features/home/HomeHeader.tsx"), + "utf8", + ); + + // Native header slot (iOS split) and the custom large title (Android split). + expect(sidebar).toMatch( + /getConnectionAwareBrandHeaderOptions\(\{[\s\S]*?title: HOME_LIST_MODE_TITLES\[options\.listMode\]/, + ); + expect(sidebar).toMatch( + / { const nodeKey = NodeFS.readFileSync( NodePath.join(root, "../modules/t3-markdown-text/src/markdownNodeKey.ts"), diff --git a/apps/server/package.json b/apps/server/package.json index 16106b01c23..77511ae0ab3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.31", + "version": "0.0.32", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b6eedb87e66..c697b4bd98f 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, + threadPinReorder: 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 c698cf20dba..d2b27b7b060 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -671,6 +671,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti snoozedUntil: null, snoozedAt: null, pinnedAt: null, + pinOrderKey: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -796,6 +797,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: event.payload.pinnedAt, + ...(event.payload.pinOrderKey !== undefined + ? { pinOrderKey: event.payload.pinOrderKey } + : {}), updatedAt: event.payload.updatedAt, }); return; @@ -811,6 +815,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, pinnedAt: null, + pinOrderKey: 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, + pinOrderKey: event.payload.orderKey, 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 fb6ff405a15..ea994430ad9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -88,6 +88,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + pinned_at, + pin_order_key, created_at, updated_at, deleted_at @@ -106,6 +108,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 1, 0, 0, + '2026-02-24T00:00:01.000Z', + 'gm', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -318,7 +322,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, - pinnedAt: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", titleRegeneration: null, deletedAt: null, messages: [ @@ -437,7 +442,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, - pinnedAt: null, + pinnedAt: "2026-02-24T00:00:01.000Z", + pinOrderKey: "gm", 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 a144f63ab5e..424907ef2a7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -505,6 +505,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -542,6 +543,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -581,6 +583,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1118,6 +1121,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1891,6 +1895,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], @@ -2155,6 +2160,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], @@ -2292,6 +2298,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2443,6 +2450,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2756,6 +2764,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinOrderKey: threadRow.value.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, @@ -2901,6 +2910,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, + pinOrderKey: threadRow.value.pinOrderKey ?? null, 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 9908c336752..d6592043653 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -15,6 +15,7 @@ import { ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, + ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadMessageQueuedPayload as ContractsThreadMessageQueuedPayloadSchema, ThreadQueuedMessageRemovedPayload as ContractsThreadQueuedMessageRemovedPayloadSchema, @@ -48,6 +49,7 @@ export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; +export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadMessageQueuedPayload = ContractsThreadMessageQueuedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts index 66fc23b415c..7b2b388499a 100644 --- a/apps/server/src/orchestration/decider.pinned.test.ts +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -11,6 +11,7 @@ const PINNED_AT = "1969-12-30T00:00:00.000Z"; function makeReadModel(input: { readonly pinnedAt?: string | null; + readonly pinOrderKey?: string | null; readonly archivedAt?: string | null; readonly settledOverride?: "settled" | "active" | null; readonly settledAt?: string | null; @@ -39,6 +40,7 @@ function makeReadModel(input: { snoozedUntil: input.snoozedUntil ?? null, snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null), pinnedAt: input.pinnedAt ?? null, + pinOrderKey: input.pinOrderKey ?? null, deletedAt: null, messages: [], queuedMessages: [], @@ -220,4 +222,100 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => { expect(error._tag).toBe("OrchestrationCommandInvariantError"); }), ); + + it.effect("a fresh pin carries the client's order key", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-keyed"), + threadId: ThreadId.make("thread-1"), + orderKey: "g", + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinOrderKey).toBe("g"); + } + }), + ); + + it.effect( + "re-pinning ignores the incoming order key so raced pins cannot move a placed thread", + () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-keyed-again"), + threadId: ThreadId.make("thread-1"), + orderKey: "t", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinOrderKey).toBeUndefined(); + } + }), + ); + + it.effect("reorders a pinned thread, stamping the new key", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder"), + threadId: ThreadId.make("thread-1"), + orderKey: "m", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.orderKey).toBe("m"); + // A real move stamps the command time (the test clock), not the + // thread's previous updatedAt. + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("reordering onto the same key preserves updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin.reorder", + commandId: CommandId.make("cmd-reorder-noop"), + threadId: ThreadId.make("thread-1"), + orderKey: "g", + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pin-reordered"); + if (events[0]?.type === "thread.pin-reordered") { + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + 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"), + orderKey: "m", + }, + readModel: makeReadModel({}), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c6fe019e50a..bc893897e7b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -899,6 +899,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, pinnedAt: existingPinnedAt ?? occurredAt, + // A fresh pin takes the client's slot in the arranged order; on a + // re-pin the existing key wins so raced duplicates cannot move a + // thread the user already placed. + ...(existingPinnedAt === null && command.orderKey !== undefined + ? { pinOrderKey: command.orderKey } + : {}), updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt, }, }; @@ -968,6 +974,43 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pin.reorder": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Only pinned threads have a slot in the arranged order. Rejecting + // (rather than silently pinning) keeps a raced reorder-after-unpin + // from resurrecting a pin the user just cleared. + if (thread.pinnedAt == null) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not pinned and cannot be reordered`, + }), + ); + } + // Idempotent by re-emission (see thread.settle): a duplicate drop on + // the same slot keeps the existing updatedAt so it projects as a no-op. + const keyUnchanged = thread.pinOrderKey === command.orderKey; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pin-reordered", + payload: { + threadId: command.threadId, + orderKey: command.orderKey, + updatedAt: keyUnchanged ? thread.updatedAt : occurredAt, + }, + }; + } + 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 153c9d98f66..68af8aad04b 100644 --- a/apps/server/src/orchestration/projector.pinned.test.ts +++ b/apps/server/src/orchestration/projector.pinned.test.ts @@ -79,3 +79,80 @@ it.effect("projects pin lifecycle events", () => expect(firstThread(unpinned)?.pinnedAt ?? null).toBeNull(); }), ); + +it.effect("projects pin order key lifecycle", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(firstThread(created)?.pinOrderKey ?? null).toBeNull(); + + // Fresh pin carries the client's slot in the arranged order. + const pinned = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pinned", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedAt: now, + pinOrderKey: "g", + updatedAt: now, + }, + }), + ); + expect(firstThread(pinned)?.pinOrderKey).toBe("g"); + + // Re-pins and events from pre-reorder servers omit the field entirely; + // the existing key must survive rather than being nulled out. + const repinned = yield* projectEvent( + pinned, + makeEvent({ + sequence: 3, + type: "thread.pinned", + payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now }, + }), + ); + expect(firstThread(repinned)?.pinOrderKey).toBe("g"); + + // A drag persists the new slot. + const reordered = yield* projectEvent( + repinned, + makeEvent({ + sequence: 4, + type: "thread.pin-reordered", + payload: { threadId: ThreadId.make("thread-1"), orderKey: "m", updatedAt: now }, + }), + ); + expect(firstThread(reordered)?.pinOrderKey).toBe("m"); + + // Unpin clears the slot: re-pinning is "pin again", not "restore an + // ancient position". + const unpinned = yield* projectEvent( + reordered, + makeEvent({ + sequence: 5, + type: "thread.unpinned", + payload: { threadId: ThreadId.make("thread-1"), updatedAt: now }, + }), + ); + expect(firstThread(unpinned)?.pinOrderKey).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c18e137eab3..9e5bb40c59d 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -37,6 +37,7 @@ import { ThreadRuntimeModeSetPayload, ThreadSettledPayload, ThreadPinnedPayload, + ThreadPinReorderedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -430,6 +431,7 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: payload.pinnedAt, + ...(payload.pinOrderKey !== undefined ? { pinOrderKey: payload.pinOrderKey } : {}), updatedAt: payload.updatedAt, }), })), @@ -441,6 +443,20 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { pinnedAt: null, + // Unpin clears the slot: re-pinning is "pin again", not "restore + // an ancient position". + pinOrderKey: 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, { + pinOrderKey: payload.orderKey, updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index a2094d38045..71bc9266e38 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -91,6 +91,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until, snoozed_at, pinned_at, + pin_order_key, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -119,6 +120,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, + ${row.pinOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -147,6 +149,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, + pin_order_key = excluded.pin_order_key, 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, @@ -188,6 +191,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -225,6 +229,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", + pin_order_key AS "pinOrderKey", 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/MigrationNamespaces.test.ts b/apps/server/src/persistence/MigrationNamespaces.test.ts index c02a67a2ca1..a43c72eac9d 100644 --- a/apps/server/src/persistence/MigrationNamespaces.test.ts +++ b/apps/server/src/persistence/MigrationNamespaces.test.ts @@ -7,10 +7,11 @@ import { migrationManifest } from "./Migrations.ts"; describe("migration namespaces", () => { it("keeps upstream and fork manifests in independent ledgers", () => { assert.notEqual(upstreamMigrationTable, forkMigrationTable); - assert.deepStrictEqual(migrationManifest.slice(-3), [ + assert.deepStrictEqual(migrationManifest.slice(-4), [ [35, "ProjectionThreadTitleRegeneration"], [36, "ProjectionThreadsPinned"], [37, "ProjectionTurnsKeysetIndex"], + [38, "ProjectionThreadsPinOrderKey"], ]); assert.deepStrictEqual(forkMigrationManifest, [ [1, "ProjectionQueuedMessages"], diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1f12cb89361..47f6547b8be 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,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_ProjectionThreadsPinOrderKey.ts"; /** * Migration loader with all migrations defined inline. @@ -102,6 +103,7 @@ export const migrationEntries = [ [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionThreadsPinned", Migration0036], [37, "ProjectionTurnsKeysetIndex", Migration0037], + [38, "ProjectionThreadsPinOrderKey", Migration0038], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts new file mode 100644 index 00000000000..d6735ebdbfb --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.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 === "pin_order_key")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pin_order_key TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts index 51fe300b9b5..ee700809b7b 100644 --- a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts +++ b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts @@ -59,6 +59,10 @@ layer("b18 desktop migration namespace repair", (it) => { assert.ok(names.has("title_regeneration_request_id")); assert.ok(names.has("title_regeneration_started_at")); assert.ok(names.has("pinned_at")); + // Upstream 038 is asserted in the ledger above; assert its product + // effect too, so a repair that writes the row without applying the + // ALTER cannot pass. + assert.ok(names.has("pin_order_key")); const migrations = yield* sql<{ readonly migration_id: number; @@ -82,10 +86,11 @@ layer("b18 desktop migration namespace repair", (it) => { readonly migration_id: number; readonly name: string; }>`SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id`; - assert.deepStrictEqual(upstreamMigrations.slice(-3), [ + assert.deepStrictEqual(upstreamMigrations.slice(-4), [ { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, + { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, ]); const forkMigrations = yield* sql<{ diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts index ac8df0c60af..de5d52b8d91 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts @@ -38,10 +38,11 @@ layer("fork migration namespace for a repaired database", (it) => { const backup = yield* sql` SELECT migration_id, name FROM ${sql(legacyMigrationBackupTable)} ORDER BY migration_id `; - assert.deepStrictEqual(upstream.slice(-3), [ + assert.deepStrictEqual(upstream.slice(-4), [ { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, + { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, ]); assert.deepStrictEqual(fork, [ { migration_id: 1, name: "ProjectionQueuedMessages" }, diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts index 4236c286b02..3a502604c82 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts @@ -71,10 +71,11 @@ layer("smart migration namespace repair", (it) => { const upstream = yield* sql` SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; - assert.deepStrictEqual(upstream.slice(-3), [ + assert.deepStrictEqual(upstream.slice(-4), [ { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, + { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts index 799d0e5d3a0..b2b03f93b8f 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts @@ -28,12 +28,13 @@ layer("t3vm migration namespace repair", (it) => { const upstream = yield* sql` SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; - assert.deepStrictEqual(upstream.slice(-5), [ + assert.deepStrictEqual(upstream.slice(-6), [ { migration_id: 33, name: "ProjectionThreadsSettled" }, { migration_id: 34, name: "ProjectionThreadsSnoozed" }, { migration_id: 35, name: "ProjectionThreadTitleRegeneration" }, { migration_id: 36, name: "ProjectionThreadsPinned" }, { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, + { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index aaba7ab9dbe..b3f1ab23064 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -44,6 +44,7 @@ export const ProjectionThread = Schema.Struct({ snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), + pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/apps/web/package.json b/apps/web/package.json index 4fbbb020587..cf454cc26dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.31", + "version": "0.0.32", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 94a2ed09256..7750413e73d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -41,6 +41,9 @@ import { isThreadSettledForDisplay, resolveSettledTimestamp, sortSettledThreadsForSidebarV2, + pinOrderKeyBetween, + planPinnedReorder, + sortPinnedThreadsForSidebarV2, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -1139,6 +1142,136 @@ describe("sortThreadsForSidebarV2", () => { }); }); +describe("pinOrderKeyBetween", () => { + it("produces keys that sort between their bounds", () => { + const middle = pinOrderKeyBetween(null, null)!; + const top = pinOrderKeyBetween(null, middle)!; + const bottom = pinOrderKeyBetween(middle, null)!; + expect(top < middle).toBe(true); + expect(middle < bottom).toBe(true); + + const between = pinOrderKeyBetween(top, middle)!; + expect(top < between && between < middle).toBe(true); + }); + + it("extends into new digits when bounds are adjacent", () => { + const key = pinOrderKeyBetween("g", "h")!; + expect("g" < key && key < "h").toBe(true); + }); + + it("stays strictly ordered under repeated top insertion", () => { + // Every new pin lands at the head of the arranged run; keys must keep + // sorting before the previous head without ever bottoming out. + let head: string | null = null; + const keys: string[] = []; + for (let i = 0; i < 100; i += 1) { + const key: string = pinOrderKeyBetween(null, head)!; + expect(key).not.toBeNull(); + if (head !== null) expect(key < head).toBe(true); + keys.push(key); + head = key; + } + expect(new Set(keys).size).toBe(100); + }); + + it("stays strictly ordered under repeated middle insertion", () => { + let low = pinOrderKeyBetween(null, null)!; + let high = pinOrderKeyBetween(low, null)!; + for (let i = 0; i < 100; i += 1) { + const key: string = pinOrderKeyBetween(low, high)!; + expect(low < key && key < high).toBe(true); + if (i % 2 === 0) low = key; + else high = key; + } + }); + + it("returns null for corrupt or out-of-order bounds instead of throwing", () => { + expect(pinOrderKeyBetween("z", "a")).toBeNull(); + expect(pinOrderKeyBetween("A!", null)).toBeNull(); + expect(pinOrderKeyBetween(null, "ma")).toBeNull(); + expect(pinOrderKeyBetween("m", "m")).toBeNull(); + }); +}); + +describe("planPinnedReorder", () => { + it("writes only the moved thread when neighbors are keyed", () => { + const assignments = planPinnedReorder({ + orderedIds: ["a", "c", "b"], + keysById: new Map([ + ["a", "f"], + ["b", "m"], + ["c", "t"], + ]), + movedId: "c", + }); + expect(assignments).toHaveLength(1); + expect(assignments[0]!.id).toBe("c"); + expect(assignments[0]!.orderKey > "f" && assignments[0]!.orderKey < "m").toBe(true); + }); + + it("treats list edges as open bounds", () => { + const assignments = planPinnedReorder({ + orderedIds: ["b", "a"], + keysById: new Map([ + ["a", "m"], + ["b", null], + ]), + movedId: "b", + }); + expect(assignments).toHaveLength(1); + expect(assignments[0]!.orderKey < "m").toBe(true); + }); + + it("materializes keys for the whole section when a neighbor is keyless", () => { + const assignments = planPinnedReorder({ + orderedIds: ["b", "a", "c"], + keysById: new Map([ + ["a", null], + ["b", "m"], + ["c", null], + ]), + movedId: "b", + }); + expect(assignments.map((entry) => entry.id)).toEqual(["b", "a", "c"]); + const keys = assignments.map((entry) => entry.orderKey); + expect([...keys].sort()).toEqual(keys); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe("sortPinnedThreadsForSidebarV2", () => { + const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({ + id: input.id, + createdAt: input.createdAt, + pinOrderKey: input.pinOrderKey ?? null, + }); + + it("sorts keyed threads by key ahead of keyless threads in creation order", () => { + const sorted = sortPinnedThreadsForSidebarV2([ + pinnable({ id: "keyless-old", createdAt: "2026-03-09T08:00:00.000Z" }), + pinnable({ id: "second", createdAt: "2026-03-09T09:00:00.000Z", pinOrderKey: "t" }), + pinnable({ id: "keyless-new", createdAt: "2026-03-09T12:00:00.000Z" }), + pinnable({ id: "first", createdAt: "2026-03-09T07:00:00.000Z", pinOrderKey: "g" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual([ + "first", + "second", + "keyless-new", + "keyless-old", + ]); + }); + + it("breaks equal keys by id so raced writes render identically everywhere", () => { + const sorted = sortPinnedThreadsForSidebarV2([ + pinnable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z", pinOrderKey: "m" }), + pinnable({ id: "a", createdAt: "2026-03-09T11:00:00.000Z", pinOrderKey: "m" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); +}); + describe("sortSettledThreadsForSidebarV2", () => { const settled = (input: { id: string; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 2e8c05478ee..7bf898e57d5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -985,6 +985,15 @@ export function sortThreadsForSidebarV2< ); } +// Pinned-reorder key math and the keyed sort live in client-runtime +// (state/thread-sort) so web and mobile compute identical pinned orders. +export { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, + planPinnedReorder, +} from "@t3tools/client-runtime/state/thread-sort"; +export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebarV2 } from "@t3tools/client-runtime/state/thread-sort"; + /** * 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 8ebe3596a2d..69cc3fe7bdf 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,5 +1,21 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { CSS } from "@dnd-kit/utilities"; import { canSnooze, effectiveSettled, @@ -143,6 +159,7 @@ import { hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, + planPinnedReorder, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, @@ -150,6 +167,7 @@ import { resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, + sortPinnedThreadsForSidebarV2, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, } from "./Sidebar.logic"; @@ -471,6 +489,26 @@ function SnoozePopoverButton(props: { ); } +// Subset of useSortable applied to a pinned card's root
  • . Listeners go +// on the whole card (no dedicated handle): the pointer sensor's distance +// constraint keeps plain clicks working, and we skip dnd-kit's aria +// attributes since there is no keyboard sensor and the card body already +// carries its own button semantics. +type SortablePinnedRowBag = Pick< + ReturnType, + "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" +>; + +function SortablePinnedThreadRow(props: { + id: string; + children: (bag: SortablePinnedRowBag) => ReactNode; +}) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.id, + }); + return props.children({ listeners, setNodeRef, transform, transition, isDragging }); +} + const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; @@ -491,6 +529,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // the descriptor is not loaded. Pinning itself lives in the context menu. pinningSupported: boolean; isPinned: boolean; + // Present only on pinned cards whose server supports reordering: dnd-kit + // sortable bag applied to the card root so the whole card drags (the + // pointer sensor's distance constraint keeps plain clicks working). + sortable?: SortablePinnedRowBag | undefined; // 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 @@ -1081,10 +1123,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const diff = latestTurnDiff(thread); + const sortable = props.sortable; return (
  • ()( "ThreadPinningUnsupportedError", { @@ -172,6 +186,18 @@ function clearPerThreadClientState(ref: ScopedThreadRef): void { useUiStateStore.getState().removeThread(scopedThreadKey(ref)); } +export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass()( + "ThreadPinReorderUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support reordering pinned threads yet. Update the server to reorder pins."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -195,6 +221,9 @@ export function useThreadActions() { const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false, }); + const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { + reportFailure: false, + }); const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false, }); @@ -669,7 +698,7 @@ export function useThreadActions() { ); const pinThread = useCallback( - async (target: ScopedThreadRef) => { + async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => { // Version skew: never send the command to a server that predates it. if (!readEnvironmentSupportsPinning(target.environmentId)) { return AsyncResult.failure( @@ -681,9 +710,21 @@ export function useThreadActions() { ), ); } + // Every pin path places the thread at the top of the arranged run: + // callers with a better anchor (the sidebar, which knows the displayed + // order) pass their own key; everyone else (chat header, context menus) + // gets the default so the same action never places differently. + // orderKey rides only to servers that decode it; pre-reorder servers + // get the bare pin they understand and the thread stays keyless. + const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) + ? (opts.orderKey ?? topOfPinnedRunOrderKey()) + : undefined; return pinThreadMutation({ environmentId: target.environmentId, - input: { threadId: target.threadId }, + input: { + threadId: target.threadId, + ...(orderKey !== undefined ? { orderKey } : {}), + }, }); }, [pinThreadMutation], @@ -709,6 +750,29 @@ export function useThreadActions() { [unpinThreadMutation], ); + const reorderPinnedThread = useCallback( + async (target: ScopedThreadRef, orderKey: string) => { + // Callers (the sidebar drag handler) only enable dragging on + // reorder-capable environments; this guard covers races around + // capability changes mid-drag. + if (!readEnvironmentSupportsPinReorder(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadPinReorderUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return reorderPinnedThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey }, + }); + }, + [reorderPinnedThreadMutation], + ); + const snoozeThread = useCallback( async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. @@ -878,6 +942,7 @@ export function useThreadActions() { unsnoozeThread, pinThread, unpinThread, + reorderPinnedThread, }), [ archiveThread, @@ -886,6 +951,7 @@ export function useThreadActions() { deleteThread, pinThread, renameThread, + reorderPinnedThread, settleThread, snoozeThread, unarchiveThread, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index c0018b24935..7bca3118237 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -259,6 +259,15 @@ export function readEnvironmentSupportsTitleRegeneration(environmentId: Environm ); } +/** Whether the environment's server understands thread.pin.reorder (and + orderKey on thread.pin). Same version-skew contract as settlement. */ +export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinReorder === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); } @@ -273,6 +282,10 @@ export function readThreadRefs(): ReadonlyArray { return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); } +export function readThreadShells(): ReadonlyArray { + return appAtomRegistry.get(environmentThreadShells.threadShellsAtom); +} + export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { return ( appAtomRegistry 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..99c180bafdd --- /dev/null +++ b/docs/user/thread-sidebar.md @@ -0,0 +1,13 @@ +# Organizing threads + +Pin a thread from its context menu to keep it in the 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 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 reordering is unavailable 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; +their pinned threads keep the default newest-first order below the ones you have arranged. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index f53e3d4756e..54a5144c779 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">; @@ -222,6 +223,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 8ad11939e64..9f4ec5282f0 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -15,6 +15,7 @@ import { type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, type PinThreadInput, + type ReorderPinnedThreadInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -37,6 +38,7 @@ import { setThreadInteractionMode, setThreadRuntimeMode, pinThread, + reorderPinnedThread, settleThread, snoozeThread, startThreadTurn, @@ -63,6 +65,7 @@ export type { SetThreadInteractionModeInput, SetThreadRuntimeModeInput, PinThreadInput, + ReorderPinnedThreadInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -146,6 +149,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + reorderPin: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:reorder-pin", + 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..5a2ffa442e0 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, + pinOrderKey: shell.pinOrderKey, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index de9e712963c..37c626782af 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -220,6 +220,9 @@ export function applyThreadDetailEvent( thread: { ...thread, pinnedAt: event.payload.pinnedAt, + ...(event.payload.pinOrderKey !== undefined + ? { pinOrderKey: event.payload.pinOrderKey } + : {}), updatedAt: event.payload.updatedAt, }, }; @@ -230,6 +233,17 @@ export function applyThreadDetailEvent( thread: { ...thread, pinnedAt: null, + pinOrderKey: null, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.pin-reordered": + return { + kind: "updated", + thread: { + ...thread, + pinOrderKey: event.payload.orderKey, 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..f4ea270a001 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { sortThreads, type ThreadSortInput } from "./threadSort.ts"; +import { + planPinnedMove, + sortPinnedThreadsByOrderKey, + sortThreads, + type ThreadSortInput, +} from "./threadSort.ts"; type TestThread = { readonly id: string } & ThreadSortInput; @@ -69,3 +74,69 @@ describe("sortThreads", () => { expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]); }); }); + +describe("planPinnedMove", () => { + it("moves a thread up with a single key write", () => { + const assignments = planPinnedMove({ + orderedIds: ["a", "b", "c"], + keysById: new Map([ + ["a", "f"], + ["b", "m"], + ["c", "t"], + ]), + movedId: "c", + direction: "up", + }); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe("c"); + expect(assignments![0]!.orderKey > "f" && assignments![0]!.orderKey < "m").toBe(true); + }); + + it("returns null when the move falls off the end of the list", () => { + const input = { + orderedIds: ["a", "b"], + keysById: new Map([ + ["a", "f"], + ["b", "m"], + ]), + }; + expect(planPinnedMove({ ...input, movedId: "a", direction: "up" })).toBeNull(); + expect(planPinnedMove({ ...input, movedId: "b", direction: "down" })).toBeNull(); + }); + + it("materializes keys for the whole section when a neighbor is keyless", () => { + const assignments = planPinnedMove({ + orderedIds: ["a", "b", "c"], + keysById: new Map([ + ["a", null], + ["b", "m"], + ["c", null], + ]), + movedId: "b", + direction: "up", + }); + expect(assignments).not.toBeNull(); + const keys = assignments!.map((entry) => entry.orderKey); + expect([...keys].sort()).toEqual(keys); + }); +}); + +describe("sortPinnedThreadsByOrderKey", () => { + it("breaks equal keys by id THEN environment so merged lists are stable everywhere", () => { + const sorted = sortPinnedThreadsByOrderKey([ + { + id: "thread-1", + createdAt: "2026-03-09T10:00:00.000Z", + pinOrderKey: "m", + environmentId: "env-b", + }, + { + id: "thread-1", + createdAt: "2026-03-09T11:00:00.000Z", + pinOrderKey: "m", + environmentId: "env-a", + }, + ]); + expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index aed63cd442d..9352d58dbc8 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -102,3 +102,179 @@ export function getLatestThreadForProject< )[0] ?? null ); } + +// ── Pinned reorder: fractional index keys ────────────────────────────── +// Pinned threads carry an optional pinOrderKey (a base-26 string). The +// pinned block sorts keyed threads by plain string comparison, so a drag +// (web) or Move up/down (mobile) writes ONE key to ONE thread on that +// thread's own server — neighbors, possibly living on other servers, are +// never touched, and every client connected to the same servers converges +// on the same order. +const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz"; + +function isValidPinOrderKey(key: string): boolean { + if (key.length === 0) return false; + for (const char of key) { + if (!PIN_ORDER_DIGITS.includes(char)) return false; + } + // A trailing minimum digit would leave no room to sort a key immediately + // before this one; generators never produce it, so treat it as corrupt. + return key.at(-1) !== PIN_ORDER_DIGITS[0]; +} + +/** Midpoint of two digit strings interpreted as fractions in (0, 1). + "" stands for the open bound on either side. Requires a < b. */ +function pinOrderMidpoint(a: string, b: string): string { + if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order"); + if (b !== "") { + // Recurse past the longest common prefix ("a" pads the shorter side). + let n = 0; + while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1; + if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n)); + } + const digitA = a === "" ? 0 : PIN_ORDER_DIGITS.indexOf(a.charAt(0)); + const digitB = b === "" ? PIN_ORDER_DIGITS.length : PIN_ORDER_DIGITS.indexOf(b.charAt(0)); + if (digitB - digitA > 1) { + return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2)); + } + // Consecutive leading digits: either b has spare digits to shorten into, + // or we extend a (never producing a trailing minimum digit — the base + // case midpoint("", "") is the middle of the alphabet). + if (b.length > 1) return b.charAt(0); + return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), ""); +} + +/** Key that sorts strictly between two neighbors; null bounds mean "top of + the pinned block" / "bottom of the keyed run". Returns null instead of + throwing when existing keys are corrupt or out of order — callers fall + back to rewriting the section. */ +export function pinOrderKeyBetween(before: string | null, after: string | null): string | null { + const a = before ?? ""; + const b = after ?? ""; + if (a !== "" && !isValidPinOrderKey(a)) return null; + if (b !== "" && !isValidPinOrderKey(b)) return null; + if (b !== "" && a >= b) return null; + return pinOrderMidpoint(a, b); +} + +/** Evenly spaced keys for rewriting a whole pinned section (used when a + drop lands next to keyless threads, so single-key insertion has nothing + to anchor on). Two base-26 digits give 675 slots — far beyond any real + pinned section — with monotonicity enforced as a belt-and-braces. */ +export function generateSpreadPinOrderKeys(count: number): string[] { + const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length; + const step = space / (count + 1); + const keys: string[] = []; + let previous = 0; + for (let i = 0; i < count; i += 1) { + let value = Math.max(Math.round(step * (i + 1)), previous + 1); + // Skip values whose low digit is the minimum (a trailing "a" key). + if (value % PIN_ORDER_DIGITS.length === 0) value += 1; + value = Math.min(value, space - 1); + previous = value; + keys.push( + PIN_ORDER_DIGITS.charAt(Math.floor(value / PIN_ORDER_DIGITS.length)) + + PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length), + ); + } + return keys; +} + +/** + * Assignments needed to realize a new pinned order. When the moved thread + * sits between two keyed (or absent) neighbors, this is a single write to + * the moved thread. When a neighbor is keyless (threads pinned before + * reordering shipped), the whole section gets fresh spread keys — a + * one-time materialization; every move after that is single-write. + */ +export function planPinnedReorder(input: { + /** Thread ids in the desired visual order (after the move). */ + readonly orderedIds: readonly string[]; + readonly keysById: ReadonlyMap; + readonly movedId: string; +}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { + const { orderedIds, keysById, movedId } = input; + const movedIndex = orderedIds.indexOf(movedId); + if (movedIndex === -1) return []; + const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; + const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null; + const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null; + const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null; + const beforeUsable = beforeId === null || beforeKey != null; + const afterUsable = afterId === null || afterKey != null; + if (beforeUsable && afterUsable) { + const key = pinOrderKeyBetween(beforeKey, afterKey); + if (key !== null) return [{ id: movedId, orderKey: key }]; + } + // Keyless neighbor (or corrupt keys): rewrite the section in the new order. + const keys = generateSpreadPinOrderKeys(orderedIds.length); + return orderedIds.flatMap((id, index) => { + const key = keys[index]!; + return keysById.get(id) === key ? [] : [{ id, orderKey: key }]; + }); +} + +/** + * Pinned block order: user-arranged keys first (string comparison, id + * tiebreak), then keyless threads newest-created first — so threads on + * servers that predate reordering keep the static creation order at the + * bottom of the block instead of breaking the section. + */ +export function sortPinnedThreadsByOrderKey< + T extends { + readonly id: string; + readonly createdAt: string; + readonly pinOrderKey?: string | null | undefined; + /** Thread ids are only unique within an environment, and the pinned + block merges environments — the tiebreak needs both parts or two + clients could render equal-key threads in stream-arrival order. */ + readonly environmentId?: string | undefined; + }, +>(threads: readonly T[]): T[] { + const keyed: T[] = []; + const keyless: T[] = []; + for (const thread of threads) { + (thread.pinOrderKey != null ? keyed : keyless).push(thread); + } + const identityTiebreak = (left: T, right: T) => + left.id.localeCompare(right.id) || + (left.environmentId ?? "").localeCompare(right.environmentId ?? ""); + keyed.sort((left, right) => { + const leftKey = left.pinOrderKey!; + const rightKey = right.pinOrderKey!; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right); + }); + keyless.sort((left, right) => { + const leftMs = Date.parse(left.createdAt); + const rightMs = Date.parse(right.createdAt); + return ( + (Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) || + identityTiebreak(left, right) + ); + }); + return [...keyed, ...keyless]; +} + +/** + * planPinnedReorder specialized for mobile's Move up / Move down menu + * actions: swap the moved thread with its displayed neighbor. Null when the + * move falls off either end of the list. Same single-write-per-move + * semantics as a web drag. + */ +export function planPinnedMove(input: { + /** Reorder-capable pinned thread ids in displayed order. */ + readonly orderedIds: readonly string[]; + readonly keysById: ReadonlyMap; + readonly movedId: string; + readonly direction: "up" | "down"; +}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> | null { + const { orderedIds, keysById, movedId, direction } = input; + const from = orderedIds.indexOf(movedId); + if (from === -1) return null; + const to = direction === "up" ? from - 1 : from + 1; + if (to < 0 || to >= orderedIds.length) return null; + const newOrder = [...orderedIds]; + newOrder.splice(from, 1); + newOrder.splice(to, 0, movedId); + return planPinnedReorder({ orderedIds: newOrder, keysById, movedId }); +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index c3bd819023b..357156ec039 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.31", + "version": "0.0.32", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index e9602bb7e57..74896b0f0da 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -50,6 +50,9 @@ 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 orderKey on thread.pin). + Same version-skew contract as threadSettlement. */ + threadPinReorder: 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.ts b/packages/contracts/src/orchestration.ts index c1d4c39076b..2503798581e 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -411,6 +411,11 @@ 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)), + // Fractional index for user-arranged pinned order. Keyed threads sort by + // string comparison ahead of keyless ones (which keep creation order), so + // servers never need each other's threads to agree on the merged list. + // Optional so payloads from pre-reorder servers still decode. + pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), @@ -489,6 +494,7 @@ export const OrchestrationThreadShell = Schema.Struct({ snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), @@ -764,6 +770,10 @@ const ThreadPinCommand = Schema.Struct({ type: Schema.Literal("thread.pin"), commandId: CommandId, threadId: ThreadId, + // Initial slot in the user-arranged pinned order (see ThreadPinReorderCommand). + // Optional: clients on pre-reorder servers omit it, and the pinned block + // falls back to creation order for keyless threads. + orderKey: Schema.optional(TrimmedNonEmptyString), }); const ThreadUnpinCommand = Schema.Struct({ @@ -772,6 +782,17 @@ const ThreadUnpinCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadPinReorderCommand = Schema.Struct({ + type: Schema.Literal("thread.pin.reorder"), + commandId: CommandId, + threadId: ThreadId, + // Fractional index key: pinned threads sort by plain string comparison of + // these keys, so a drag writes one key to one thread — neighbors (possibly + // on other servers) are never touched. Clients compute a key that sorts + // between the dropped position's neighbors. + orderKey: TrimmedNonEmptyString, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -983,6 +1004,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUnsnoozeCommand, ThreadPinCommand, ThreadUnpinCommand, + ThreadPinReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1014,6 +1036,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUnsnoozeCommand, ThreadPinCommand, ThreadUnpinCommand, + ThreadPinReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1168,6 +1191,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", @@ -1283,6 +1307,9 @@ export const ThreadUnsnoozedPayload = Schema.Struct({ export const ThreadPinnedPayload = Schema.Struct({ threadId: ThreadId, pinnedAt: IsoDateTime, + // Absent on re-pins of an already-pinned thread (the existing key wins) + // and on pins from clients that predate reordering. + pinOrderKey: Schema.optional(TrimmedNonEmptyString), updatedAt: IsoDateTime, }); @@ -1291,6 +1318,12 @@ export const ThreadUnpinnedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadPinReorderedPayload = Schema.Struct({ + threadId: ThreadId, + orderKey: TrimmedNonEmptyString, + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1546,6 +1579,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"),