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 (
{
- const now = `${nowMinute}:00.000Z`;
- // Snooze classification uses a REAL clock, not the quantized minute:
- // wake times are second-precise and a woken thread must not linger on
- // the shelf for the rest of the minute. snoozeWakeTick re-runs this
- // memo exactly at the next wake boundary.
- void snoozeWakeTick;
- const preciseNow = new Date().toISOString();
- const visible = threads.filter(
- (thread) =>
- thread.archivedAt === null &&
- matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds) &&
- (scopedProjectKeys === null ||
- scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) &&
- threadMatchesMine({
- claimPersonId: claimPersonIdForEnvironment(
- claimPersonIdByEnvironment,
- thread.environmentId,
- ),
- originPersonId: thread.originSource?.personId ?? null,
- participantPersonIds: (thread.participantSummaries ?? []).map(
- (participant) => participant.personId,
- ),
- mode: ownershipFilter,
- relation: ownershipRelation,
- }),
- );
- const pinned: EnvironmentThreadShell[] = [];
- const active: EnvironmentThreadShell[] = [];
- const snoozed: EnvironmentThreadShell[] = [];
- const settled: EnvironmentThreadShell[] = [];
- for (const thread of visible) {
- // Threads on servers without the settlement capability (old server,
- // or descriptor not loaded yet) never classify as settled: the user
- // could neither un-settle nor pin them, so auto-settling them would
- // strand rows in a tail with no working affordances.
- const supportsSettlement =
- serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
- true;
- const supportsSnooze =
- serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
- const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
- const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
- // Snooze temporarily suspends a pin; the pin survives and resumes on wake.
- if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
- snoozed.push(thread);
- } else if (thread.pinnedAt != null) {
- pinned.push(thread);
- } else if (
- supportsSettlement &&
- effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
- ) {
- settled.push(thread);
- } else {
- active.push(thread);
- }
+ const {
+ pinnedThreads,
+ reorderablePinnedKeys,
+ activeThreads,
+ snoozedThreads,
+ settledThreads,
+ snoozeNow,
+ } = useMemo(() => {
+ const now = `${nowMinute}:00.000Z`;
+ // Snooze classification uses a REAL clock, not the quantized minute:
+ // wake times are second-precise and a woken thread must not linger on
+ // the shelf for the rest of the minute. snoozeWakeTick re-runs this
+ // memo exactly at the next wake boundary.
+ void snoozeWakeTick;
+ const preciseNow = new Date().toISOString();
+ const visible = threads.filter(
+ (thread) =>
+ thread.archivedAt === null &&
+ matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds) &&
+ (scopedProjectKeys === null ||
+ scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) &&
+ threadMatchesMine({
+ claimPersonId: claimPersonIdForEnvironment(
+ claimPersonIdByEnvironment,
+ thread.environmentId,
+ ),
+ originPersonId: thread.originSource?.personId ?? null,
+ participantPersonIds: (thread.participantSummaries ?? []).map(
+ (participant) => participant.personId,
+ ),
+ mode: ownershipFilter,
+ relation: ownershipRelation,
+ }),
+ );
+ const pinned: EnvironmentThreadShell[] = [];
+ const active: EnvironmentThreadShell[] = [];
+ const snoozed: EnvironmentThreadShell[] = [];
+ const settled: EnvironmentThreadShell[] = [];
+ for (const thread of visible) {
+ // Threads on servers without the settlement capability (old server,
+ // or descriptor not loaded yet) never classify as settled: the user
+ // could neither un-settle nor pin them, so auto-settling them would
+ // strand rows in a tail with no working affordances.
+ const supportsSettlement =
+ serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
+ const supportsSnooze =
+ serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
+ const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
+ // Snooze temporarily suspends a pin; the pin survives and resumes on wake.
+ if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
+ snoozed.push(thread);
+ } else if (thread.pinnedAt != null) {
+ pinned.push(thread);
+ } else if (
+ supportsSettlement &&
+ effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
+ ) {
+ settled.push(thread);
+ } else {
+ active.push(thread);
}
- return {
- // Default is upstream's static creation order. Recency is the one
- // explicit alternative; neither mode changes cards or sections.
- pinnedThreads: orderForThreadGrouping(sortThreadsForSidebarV2(pinned)),
- activeThreads: orderForThreadGrouping(sortThreadsForSidebarV2(active)),
- // Soonest wake first: "what comes back next" is the shelf's question.
- snoozedThreads: snoozed.toSorted(
- (left, right) =>
- firstValidTimestampMs(left.snoozedUntil ?? null) -
- firstValidTimestampMs(right.snoozedUntil ?? null),
- ),
- settledThreads: sortSettledThreadsForSidebarV2(settled),
- snoozeNow: preciseNow,
- };
- }, [
- autoSettleAfterDays,
- changeRequestStateByKey,
- claimPersonIdByEnvironment,
- nowMinute,
- orderForThreadGrouping,
- ownershipFilter,
- ownershipRelation,
- scopedProjectKeys,
- selectedEnvironmentIds,
- serverConfigs,
- snoozeWakeTick,
- threads,
- ]);
+ }
+ return {
+ // One shared rule on every platform (see sortPinnedThreadsByOrderKey):
+ // user-arranged keys first, keyless threads in creation order below.
+ // Server capability only gates DRAGGING — it must not influence the
+ // sort, or mixed-version fleets would render different pinned orders
+ // on web and mobile from the same data. The fork's grouping
+ // preference therefore applies to the active rows, not to pins.
+ pinnedThreads: sortPinnedThreadsForSidebarV2(pinned),
+ reorderablePinnedKeys: new Set(
+ pinned
+ .filter(
+ (thread) =>
+ serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder ===
+ true,
+ )
+ .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
+ ),
+ activeThreads: orderForThreadGrouping(sortThreadsForSidebarV2(active)),
+ // Soonest wake first: "what comes back next" is the shelf's question.
+ snoozedThreads: snoozed.toSorted(
+ (left, right) =>
+ firstValidTimestampMs(left.snoozedUntil ?? null) -
+ firstValidTimestampMs(right.snoozedUntil ?? null),
+ ),
+ settledThreads: sortSettledThreadsForSidebarV2(settled),
+ snoozeNow: preciseNow,
+ };
+ }, [
+ autoSettleAfterDays,
+ changeRequestStateByKey,
+ claimPersonIdByEnvironment,
+ nowMinute,
+ orderForThreadGrouping,
+ ownershipFilter,
+ ownershipRelation,
+ scopedProjectKeys,
+ selectedEnvironmentIds,
+ serverConfigs,
+ snoozeWakeTick,
+ threads,
+ ]);
const threadSearchInputRef = useRef(null);
const [threadSearchQuery, setThreadSearchQuery] = useState("");
@@ -2419,9 +2494,70 @@ export default function SidebarV2() {
},
[unsnoozeThread],
);
+ // Drag-to-reorder for the pinned block. A drop computes ONE fractional key
+ // for the moved thread and sends it to that thread's own server (see
+ // planPinnedReorder for the keyless-neighbor materialization case). The
+ // optimistic order keeps the card where it was dropped until the
+ // confirming event round-trips; canonical order matching it releases the
+ // override, and a failed write clears it (the card snaps back) with a toast.
+ // ANY membership change (new pin, unpin, snooze/wake) also releases it:
+ // the override can't say where members it never saw belong, and holding it
+ // would misplace them and launder the stale order into later drags.
+ const pinnedDndSensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
+ );
+ const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{
+ readonly order: readonly string[];
+ /** pinOrderKey per thread as of the drop, so ANY landed write (ours
+ confirming, or a concurrent one from another client) releases the
+ override rather than fighting canonical state. */
+ readonly keysAtDrop: ReadonlyMap;
+ } | null>(null);
+ const orderedPinnedThreads = useMemo(() => {
+ if (optimisticPinnedOrder === null) return pinnedThreads;
+ return orderItemsByPreferredIds({
+ items: pinnedThreads,
+ preferredIds: optimisticPinnedOrder.order,
+ getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ });
+ }, [optimisticPinnedOrder, pinnedThreads]);
+ useEffect(() => {
+ if (optimisticPinnedOrder === null) return;
+ const canonical = pinnedThreads.filter((thread) =>
+ reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
+ );
+ const canonicalKeys = canonical.map((thread) =>
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ );
+ // The override represents one drop against one snapshot of the world.
+ // Release it as soon as the world moves on in any way: membership
+ // changed (pin/unpin/snooze/wake — the override can't say where members
+ // it never saw belong), a key changed (our write confirming, or a
+ // concurrent client's reorder that must win), or canonical already
+ // matches. Holding it longer would misplace newcomers and launder the
+ // stale order into later drags.
+ const membershipChanged =
+ canonicalKeys.length !== optimisticPinnedOrder.order.length ||
+ canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key));
+ const anyKeyLanded = canonical.some(
+ (thread, index) =>
+ optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !==
+ (thread.pinOrderKey ?? null),
+ );
+ const orderConfirmed =
+ !membershipChanged &&
+ canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]);
+ if (membershipChanged || anyKeyLanded || orderConfirmed) {
+ setOptimisticPinnedOrder(null);
+ }
+ }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]);
const attemptPin = useCallback(
(threadRef: ScopedThreadRef) => {
void (async () => {
+ // Fresh pins take the top of the arranged run: pinThread computes a
+ // key before the smallest key across ALL pinned shells — including
+ // snoozed pins hidden from this list, whose keys are still part of
+ // the run — so the new pin can't land beneath a hidden head.
const result = await pinThread(threadRef);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
@@ -2455,6 +2591,63 @@ export default function SidebarV2() {
},
[unpinThread],
);
+
+ const handlePinnedDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const activeKey = String(event.active.id);
+ const overKey = event.over === null ? null : String(event.over.id);
+ if (overKey === null || activeKey === overKey) return;
+ const reorderable = orderedPinnedThreads.filter((thread) =>
+ reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
+ );
+ const keys = reorderable.map((thread) =>
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ );
+ const fromIndex = keys.indexOf(activeKey);
+ const toIndex = keys.indexOf(overKey);
+ if (fromIndex === -1 || toIndex === -1) return;
+ const newOrder = arrayMove([...keys], fromIndex, toIndex);
+ const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread]));
+ const keysAtDrop = new Map(
+ reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]),
+ );
+ const assignments = planPinnedReorder({
+ orderedIds: newOrder,
+ keysById: keysAtDrop,
+ movedId: activeKey,
+ });
+ if (assignments.length === 0) return;
+ setOptimisticPinnedOrder({ order: newOrder, keysAtDrop });
+ void (async () => {
+ // Sequential, stop on first failure. There is deliberately no
+ // rollback: every key write is a complete, valid placement on its
+ // own, so a partial materialization leaves a sensible order (and
+ // the next drag repairs the rest) — unwinding writes across
+ // servers would trade that for real inconsistency windows.
+ for (const assignment of assignments) {
+ const thread = threadByKey.get(assignment.id);
+ if (thread === undefined) continue;
+ const result = await reorderPinnedThread(
+ scopeThreadRef(thread.environmentId, thread.id),
+ assignment.orderKey,
+ );
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ setOptimisticPinnedOrder(null);
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Failed to reorder pinned threads",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+ return;
+ }
+ }
+ })();
+ },
+ [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys],
+ );
// One snooze per thread at a time — same double-dispatch guard as settle.
const snoozingThreadKeysRef = useRef(new Set());
const performSnooze = useCallback(
@@ -3539,6 +3732,7 @@ export default function SidebarV2() {
const renderThreadRow = (
thread: EnvironmentThreadShell,
section: "pinned" | "active" | "snoozed" | "settled",
+ sortable?: SortablePinnedRowBag,
) => {
const threadKey = scopedThreadKey(
scopeThreadRef(thread.environmentId, thread.id),
@@ -3583,6 +3777,7 @@ export default function SidebarV2() {
.threadPinning === true
}
isPinned={section === "pinned"}
+ sortable={sortable}
snoozeWakeLabelText={
section === "snoozed" && thread.snoozedUntil != null
? snoozeWakeLabel(thread.snoozedUntil, {
@@ -3659,9 +3854,41 @@ export default function SidebarV2() {
// Pinned block: full cards above the inbox, closed by a
// thin divider (the pin glyphs carry the meaning, so no
// header text). Vanishes entirely at count 0.
- const items: ReactNode[] = pinnedThreads.map((thread) =>
- renderThreadRow(thread, "pinned"),
- );
+ // Rows render in the one shared pinned order; only
+ // reorder-capable rows register as sortable (legacy-server
+ // pins render in place as plain rows).
+ const items: ReactNode[] = [
+
+
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ )
+ .filter((threadKey) => reorderablePinnedKeys.has(threadKey))}
+ strategy={verticalListSortingStrategy}
+ >
+ {orderedPinnedThreads.map((thread) => {
+ const threadKey = scopedThreadKey(
+ scopeThreadRef(thread.environmentId, thread.id),
+ );
+ if (!reorderablePinnedKeys.has(threadKey)) {
+ return renderThreadRow(thread, "pinned");
+ }
+ return (
+
+ {(bag) => renderThreadRow(thread, "pinned", bag)}
+
+ );
+ })}
+
+ ,
+ ];
if (pinnedThreads.length > 0) {
items.push(
()(
"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"),